Skip to content

Commit a1ce19b

Browse files
committed
Improve mobile drawer swipe gesture with live feedback
Refactors the mobile navigation drawer to provide live feedback during swipe gestures, including velocity and position-based snapping, direction locking, and improved overlay handling. This enhances the user experience by making the drawer feel more responsive and intuitive on touch devices.
1 parent f17abb4 commit a1ce19b

File tree

1 file changed

+128
-40
lines changed

1 file changed

+128
-40
lines changed

src/lib/components/MobileNav.svelte

Lines changed: 128 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import { shareModal } from "$lib/stores/shareModal";
2424
import { loading } from "$lib/stores/loading";
2525
import { requireAuthUser } from "$lib/utils/auth";
26+
2627
interface Props {
2728
title: string | undefined;
2829
children?: import("svelte").Snippet;
@@ -44,16 +45,6 @@
4445
// Define the width for the drawer (less than 100% to create the gap)
4546
const drawerWidthPercentage = 85;
4647
47-
const tween = Spring.of(
48-
() => {
49-
if (isOpen) {
50-
return 0 as number;
51-
}
52-
return -100 as number;
53-
},
54-
{ stiffness: 0.2, damping: 0.8 }
55-
);
56-
5748
$effect(() => {
5849
title ??= "New Chat";
5950
});
@@ -78,48 +69,146 @@
7869
isOpen = false;
7970
}
8071
81-
// Swipe gesture support for opening/closing the nav
72+
// Swipe gesture support for opening/closing the nav with live feedback
73+
// Thresholds from vaul drawer library
74+
const VELOCITY_THRESHOLD = 0.4; // px/ms - if exceeded, snap in swipe direction
75+
const CLOSE_THRESHOLD = 0.25; // 25% position threshold
76+
const DIRECTION_LOCK_THRESHOLD = 10; // px - movement needed to lock direction
77+
8278
let touchstart: Touch | null = null;
83-
let touchend: Touch | null = null;
79+
let dragStartTime: number = 0;
80+
let isDragging = $state(false);
81+
let dragOffset = $state(-100); // percentage: -100 (closed) to 0 (open)
82+
let dragStartedOpen = false;
83+
84+
// Direction lock: null = undecided, 'horizontal' = drawer drag, 'vertical' = scroll
85+
let directionLock: "horizontal" | "vertical" | null = null;
86+
let potentialDrag = false;
8487
85-
function checkDirection() {
86-
if (!touchstart || !touchend) return;
88+
// Spring target: follows dragOffset during drag, follows isOpen after drag ends
89+
const springTarget = $derived(isDragging ? dragOffset : isOpen ? 0 : -100);
90+
const tween = Spring.of(() => springTarget, { stiffness: 0.2, damping: 0.8 });
8791
88-
const screenWidth = window.innerWidth;
89-
const swipeDistance = touchend.screenX - touchstart.screenX;
90-
const absSwipeDistance = Math.abs(swipeDistance);
92+
function onTouchStart(e: TouchEvent) {
93+
const touch = e.changedTouches[0];
94+
touchstart = touch;
95+
dragStartTime = Date.now();
96+
directionLock = null;
9197
92-
// Only trigger if swipe is significant (1/8 of screen width)
93-
if (absSwipeDistance < screenWidth / 8) return;
98+
const drawerWidth = window.innerWidth * (drawerWidthPercentage / 100);
99+
const touchOnDrawer = isOpen && touch.clientX < drawerWidth;
94100
95-
// Swipe right from left edge (within 40px) -> open
96-
if (touchstart.clientX < 40 && swipeDistance > 0 && !isOpen) {
97-
isOpen = true;
98-
}
99-
// Swipe left while open -> close
100-
else if (swipeDistance < 0 && isOpen) {
101-
isOpen = false;
101+
// Potential drag scenarios - never start isDragging until direction is locked
102+
// Exception: overlay tap (no scroll content, so no direction conflict)
103+
if (!isOpen && touch.clientX < 40) {
104+
// Opening gesture - wait for direction lock before starting drag
105+
potentialDrag = true;
106+
dragStartedOpen = false;
107+
} else if (isOpen && !touchOnDrawer) {
108+
// Touch on overlay - can start immediately (no scroll conflict)
109+
potentialDrag = true;
110+
isDragging = true;
111+
dragStartedOpen = true;
112+
dragOffset = 0;
113+
directionLock = "horizontal";
114+
} else if (isOpen && touchOnDrawer) {
115+
// Touch on drawer content - wait for direction lock
116+
potentialDrag = true;
117+
dragStartedOpen = true;
102118
}
103119
}
104120
105-
function onTouchStart(e: TouchEvent) {
106-
touchstart = e.changedTouches[0];
121+
function onTouchMove(e: TouchEvent) {
122+
if (!touchstart || !potentialDrag) return;
123+
124+
const touch = e.changedTouches[0];
125+
const deltaX = touch.clientX - touchstart.clientX;
126+
const deltaY = touch.clientY - touchstart.clientY;
127+
128+
// Determine direction lock if not yet decided
129+
if (directionLock === null) {
130+
const absX = Math.abs(deltaX);
131+
const absY = Math.abs(deltaY);
132+
133+
if (absX > DIRECTION_LOCK_THRESHOLD || absY > DIRECTION_LOCK_THRESHOLD) {
134+
if (absX > absY) {
135+
// Horizontal movement - commit to drawer drag
136+
directionLock = "horizontal";
137+
isDragging = true;
138+
dragOffset = dragStartedOpen ? 0 : -100;
139+
} else {
140+
// Vertical movement - abort potential drag, let content scroll
141+
directionLock = "vertical";
142+
potentialDrag = false;
143+
return;
144+
}
145+
} else {
146+
return;
147+
}
148+
}
149+
150+
if (directionLock !== "horizontal") return;
151+
152+
const drawerWidth = window.innerWidth * (drawerWidthPercentage / 100);
153+
154+
if (dragStartedOpen) {
155+
dragOffset = Math.max(-100, Math.min(0, (deltaX / drawerWidth) * 100));
156+
} else {
157+
dragOffset = Math.max(-100, Math.min(0, -100 + (deltaX / drawerWidth) * 100));
158+
}
107159
}
108160
109161
function onTouchEnd(e: TouchEvent) {
110-
touchend = e.changedTouches[0];
111-
checkDirection();
162+
if (!potentialDrag) return;
163+
164+
if (!isDragging || !touchstart) {
165+
resetDragState();
166+
return;
167+
}
168+
169+
const touch = e.changedTouches[0];
170+
const timeTaken = Date.now() - dragStartTime;
171+
const distMoved = touch.clientX - touchstart.clientX;
172+
const velocity = Math.abs(distMoved) / timeTaken;
173+
174+
// Determine snap direction based on velocity first, then position
175+
if (velocity > VELOCITY_THRESHOLD) {
176+
isOpen = distMoved > 0;
177+
} else {
178+
const openThreshold = -100 + CLOSE_THRESHOLD * 100;
179+
isOpen = dragOffset > openThreshold;
180+
}
181+
182+
resetDragState();
183+
}
184+
185+
function onTouchCancel() {
186+
if (isDragging) {
187+
isOpen = dragStartedOpen;
188+
}
189+
resetDragState();
190+
}
191+
192+
function resetDragState() {
193+
isDragging = false;
194+
potentialDrag = false;
195+
touchstart = null;
196+
directionLock = null;
112197
}
113198
114199
onMount(() => {
115-
window.addEventListener("touchstart", onTouchStart);
116-
window.addEventListener("touchend", onTouchEnd);
200+
window.addEventListener("touchstart", onTouchStart, { passive: true });
201+
window.addEventListener("touchmove", onTouchMove, { passive: true });
202+
window.addEventListener("touchend", onTouchEnd, { passive: true });
203+
window.addEventListener("touchcancel", onTouchCancel, { passive: true });
117204
});
118205
119206
onDestroy(() => {
120207
if (browser) {
121208
window.removeEventListener("touchstart", onTouchStart);
209+
window.removeEventListener("touchmove", onTouchMove);
122210
window.removeEventListener("touchend", onTouchEnd);
211+
window.removeEventListener("touchcancel", onTouchCancel);
123212
}
124213
});
125214
</script>
@@ -170,23 +259,22 @@
170259
</div>
171260
</nav>
172261

173-
<!-- Mobile drawer overlay - shows when drawer is open -->
174-
{#if isOpen}
262+
<!-- Mobile drawer overlay - shows when drawer is open or dragging -->
263+
{#if isOpen || isDragging}
175264
<button
176265
type="button"
177266
class="fixed inset-0 z-20 cursor-default bg-black/30 md:hidden"
178-
style="opacity: {Math.max(0, Math.min(1, (100 + tween.current) / 100))};"
267+
style="opacity: {Math.max(0, Math.min(1, (100 + tween.current) / 100))}; will-change: opacity;"
179268
onclick={closeDrawer}
180269
aria-label="Close mobile navigation"
181270
></button>
182271
{/if}
183272

184273
<nav
185-
style="transform: translateX({Math.max(
186-
-100,
187-
Math.min(0, tween.current)
188-
)}%); width: {drawerWidthPercentage}%;"
189-
class:shadow-[5px_0_15px_0_rgba(0,0,0,0.3)]={isOpen}
274+
style="transform: translateX({isDragging
275+
? dragOffset
276+
: tween.current}%); width: {drawerWidthPercentage}%; will-change: transform;"
277+
class:shadow-[5px_0_15px_0_rgba(0,0,0,0.3)]={isOpen || isDragging}
190278
class="fixed bottom-0 left-0 top-0 z-30 grid max-h-dvh grid-cols-1
191279
grid-rows-[auto,1fr,auto,auto] rounded-r-xl bg-white pt-4 dark:bg-gray-900 md:hidden"
192280
>

0 commit comments

Comments
 (0)