From 0f20023b058be1cdabb649c855c3b974b804940e Mon Sep 17 00:00:00 2001 From: Teemu Taskula Date: Wed, 18 Mar 2026 11:13:00 +0200 Subject: [PATCH] Improve drag end classification for smoother snapping UX --- src/constants.ts | 2 +- src/sheet.tsx | 78 ++++-------- src/snap.ts | 307 +++++++++++++++++++++++++++++++---------------- 3 files changed, 227 insertions(+), 160 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index e7f236e..d9c7666 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -16,4 +16,4 @@ export const REDUCED_MOTION_TWEEN_CONFIG: SheetTweenConfig = { export const DEFAULT_DRAG_CLOSE_THRESHOLD = 0.6; -export const DEFAULT_DRAG_VELOCITY_THRESHOLD = 500; +export const DEFAULT_DRAG_VELOCITY_THRESHOLD = 1200; diff --git a/src/sheet.tsx b/src/sheet.tsx index ef1140b..22c370c 100644 --- a/src/sheet.tsx +++ b/src/sheet.tsx @@ -30,11 +30,7 @@ import { useModalEffect } from './hooks/use-modal-effect'; import { usePreventScroll } from './hooks/use-prevent-scroll'; import { useSheetState } from './hooks/use-sheet-state'; import { useStableCallback } from './hooks/use-stable-callback'; -import { - computeSnapPoints, - handleHighVelocityDrag, - handleLowVelocityDrag, -} from './snap'; +import { classifyDragEnd, computeSnapPoints } from './snap'; import { styles } from './styles'; import { type SheetContextType, type SheetProps } from './types'; import { applyStyles, waitForElement, willOpenKeyboard } from './utils'; @@ -232,63 +228,31 @@ export const Sheet = forwardRef( const currentY = y.get(); - let yTo = 0; - - const currentSnapPoint = - currentSnap !== undefined ? getSnapPoint(currentSnap) : null; - - if (currentSnapPoint) { - const dragOffsetDirection = info.offset.y > 0 ? 'down' : 'up'; - const dragVelocityDirection = info.velocity.y > 0 ? 'down' : 'up'; - const isHighVelocity = - Math.abs(info.velocity.y) > dragVelocityThreshold; + const result = classifyDragEnd({ + y: currentY, + info, + sheetHeight, + dragCloseThreshold, + snapPoints, + dragVelocityThreshold, + }); - let result: { yTo: number; snapIndex: number | undefined }; + let yTo = result.yTo; - if (isHighVelocity) { - result = handleHighVelocityDrag({ - snapPoints, - dragDirection: dragVelocityDirection, - }); - } else { - result = handleLowVelocityDrag({ - currentSnapPoint, - currentY, - dragDirection: dragOffsetDirection, - snapPoints, - velocity: info.velocity.y, - }); - } + // If disableDismiss is true, prevent closing via gesture + if (disableDismiss && yTo + 1 >= sheetHeight) { + // Use the bottom-most open snap point + const bottomSnapPoint = snapPoints.find((s) => s.snapValue > 0); - yTo = result.yTo; - - // If disableDismiss is true, prevent closing via gesture - if (disableDismiss && yTo + 1 >= sheetHeight) { - // Use the bottom-most open snap point - const bottomSnapPoint = snapPoints.find((s) => s.snapValue > 0); - - if (bottomSnapPoint) { - yTo = bottomSnapPoint.snapValueY; - updateSnap(bottomSnapPoint.snapIndex); - } else { - // If no open snap points available, stay at current position - yTo = currentY; - } - } else if (result.snapIndex !== undefined) { - updateSnap(result.snapIndex); - } - } else if ( - info.velocity.y > dragVelocityThreshold || - currentY > sheetHeight * dragCloseThreshold - ) { - // Close the sheet if dragged past the threshold or if the velocity is high enough - // But only if disableDismiss is false - if (disableDismiss) { - // If disableDismiss, snap back to the open position - yTo = 0; + if (bottomSnapPoint) { + yTo = bottomSnapPoint.snapValueY; + updateSnap(bottomSnapPoint.snapIndex); } else { - yTo = closedY; + // If no open snap points available, stay at current position + yTo = currentY; } + } else if (result.snapIndex !== undefined) { + updateSnap(result.snapIndex); } // Update the spring value so that the sheet is animated to the snap point diff --git a/src/snap.ts b/src/snap.ts index cefdb57..db888b6 100644 --- a/src/snap.ts +++ b/src/snap.ts @@ -1,3 +1,4 @@ +import type { PanInfo } from 'motion'; import type { SheetSnapPoint } from './types'; import { isAscendingOrder } from './utils'; @@ -107,136 +108,238 @@ export function computeSnapPoints({ })); } -function findClosestSnapPoint({ - snapPoints, - currentY, -}: { - snapPoints: SheetSnapPoint[]; - currentY: number; -}) { +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} + +function sign(value: number) { + if (value > 0) return 1; + if (value < 0) return -1; + return 0; +} + +function toAscendingYSnaps(snapPoints: SheetSnapPoint[]) { + return snapPoints.slice().sort((a, b) => a.snapValueY - b.snapValueY); +} + +function findNearestSnapByY(snapPoints: SheetSnapPoint[], y: number) { return snapPoints.reduce((closest, snap) => - Math.abs(snap.snapValueY - currentY) < - Math.abs(closest.snapValueY - currentY) + Math.abs(snap.snapValueY - y) < Math.abs(closest.snapValueY - y) ? snap : closest ); } -function findNextSnapPointInDirection({ - y, - snapPoints, - dragDirection, -}: { - y: number; - snapPoints: SheetSnapPoint[]; - dragDirection: 'up' | 'down'; -}) { - // NOTE: lower Y means higher in the sheet position! - if (dragDirection === 'down') { - /** - * Example: - * - * [ - * { snapIndex: 0, snapValueY: 810 }, - * { snapIndex: 1, snapValueY: 640 }, - * { snapIndex: 2, snapValueY: 405 }, <-- next down - * ------------- Y = 60 ------------ - * { snapIndex: 3, snapValueY: 50 }, - * { snapIndex: 4, snapValueY: 0 }, - * ] - */ - return snapPoints - .slice() - .reverse() - .find((s) => s.snapValueY > y); - } else { - /** - * Example: - * [ - * { snapIndex: 0, snapValueY: 810 }, - * { snapIndex: 1, snapValueY: 640 }, - * { snapIndex: 2, snapValueY: 405 }, - * ------------- Y = 60 ------------ - * { snapIndex: 3, snapValueY: 50 }, <-- next up - * { snapIndex: 4, snapValueY: 0 }, - * ] - */ - return snapPoints.find((s) => s.snapValueY < y); +function findNearestSnapAboveY(snapPoints: SheetSnapPoint[], y: number) { + const snaps = toAscendingYSnaps(snapPoints); + + for (let i = snaps.length - 1; i >= 0; i--) { + if (snaps[i].snapValueY < y) { + return snaps[i]; + } } + + return null; } -export function handleHighVelocityDrag({ - dragDirection, - snapPoints, + +function findNearestSnapBelowY(snapPoints: SheetSnapPoint[], y: number) { + const snaps = toAscendingYSnaps(snapPoints); + + for (let i = 0; i < snaps.length; i++) { + if (snaps[i].snapValueY > y) { + return snaps[i]; + } + } + + return null; +} + +function resolveDirection({ + offsetY, + velocityY, + deltaY, }: { - dragDirection: 'up' | 'down'; - snapPoints: SheetSnapPoint[]; + offsetY: number; + velocityY: number; + deltaY: number; }) { - // Go to either the last or the first snap point depending on the direction - const bottomSnapPoint = snapPoints[0]; - const topSnapPoint = snapPoints[snapPoints.length - 1]; + if (Math.abs(velocityY) > 50) return sign(velocityY); - if (dragDirection === 'down') { - return { - yTo: bottomSnapPoint.snapValueY, - snapIndex: bottomSnapPoint.snapIndex, - }; - } - return { - yTo: topSnapPoint.snapValueY, - snapIndex: topSnapPoint.snapIndex, - }; + const offsetDirection = sign(offsetY); + if (offsetDirection !== 0) return offsetDirection; + + return sign(deltaY); } -export function handleLowVelocityDrag({ - currentSnapPoint, - currentY, - dragDirection, +/** + * Classifies the final sheet position when a drag gesture ends. + * + * Decision flow (high level): + * 1) Predict momentum endpoint from current position and velocity. + * 2) Resolve drag direction (prefer velocity, fallback to offset/delta). + * 3) Detect flick tiers: + * - strong flick -> jump fully open/closed + * - flick -> move to next snap in flick direction + * 4) For slower drags: + * - tiny drag or near-snap stickiness -> snap back to nearest snap + * - otherwise, use progress between surrounding snaps and commit threshold + * 5) Apply velocity-assisted bias to nearest predicted snap for natural feel. + * 6) Fallback to nearest snap. + * + * Special case with no snap points: + * - flick: open/close by direction + * - otherwise: open/close by predicted position vs close threshold midpoint + */ +export function classifyDragEnd({ + y, + info, + sheetHeight, snapPoints, - velocity, + dragVelocityThreshold, + dragCloseThreshold, }: { - currentSnapPoint: SheetSnapPoint; - currentY: number; - dragDirection: 'up' | 'down'; + y: number; + info: PanInfo; + sheetHeight: number; snapPoints: SheetSnapPoint[]; - velocity: number; + dragVelocityThreshold: number; + dragCloseThreshold: number; }) { - const closestSnapRelativeToCurrentY = findClosestSnapPoint({ - snapPoints, - currentY, - }); - /** - * If velocity is very low the user has stopped the sheet to a specific - * position and we should snap to the closest snap point as there is no - * "momentum" that would push the sheet further to the given direction + * Thresholds and configuration for drag classification. + * Tweak these to adjust the feel of the sheet. */ - if (Math.abs(velocity) < 20) { + // Velocity above this is considered a flick gesture. + const flickVelocity = dragVelocityThreshold; + // Velocity above this is treated as a strong flick and jumps to full open/closed. + const strongFlickVelocity = Math.max(2000, flickVelocity); + // In slow drags, crossing this portion of the current snap region commits to the next snap. + const distanceCommitRatio = 0.35; + // Time horizon (in seconds) used to estimate momentum-based end position. + const predictionTime = 0.2; + // Very small drags below this distance snap back instead of changing snap state. + const minDragDistance = 20; + // If drag ends within this many pixels from a snap, stick to that snap to reduce jitter. + const snapStickiness = 24; + + const minY = 0; + const maxY = sheetHeight; + const offsetY = info.offset.y; + const deltaY = info.delta.y; + const velocityY = info.velocity.y; + + // Step 1 — Compute predicted end position from momentum. + const predictedY = clamp(y + velocityY * predictionTime, minY, maxY); + const absVelocity = Math.abs(velocityY); + + // Step 2 + 3 — Flick detection and gesture direction resolution. + const direction = resolveDirection({ offsetY, velocityY, deltaY }); + + const isStrongFlick = absVelocity > strongFlickVelocity; + const isFlick = absVelocity > flickVelocity; + + // Step 4 — No snap points: decide between open/close only. + if (snapPoints.length === 0) { + if (isFlick) { + return { + yTo: direction < 0 ? minY : maxY, + snapIndex: undefined, + }; + } + + const midpoint = minY + (maxY - minY) * dragCloseThreshold; + return { - yTo: closestSnapRelativeToCurrentY.snapValueY, - snapIndex: closestSnapRelativeToCurrentY.snapIndex, + yTo: predictedY < midpoint ? minY : maxY, + snapIndex: undefined, }; } - /** - * If the dragging has a bit more velocity, we instead want to go to - * the next snap point in the given direction if it exists - */ - const nextSnapInDirectionRelativeToCurrentY = findNextSnapPointInDirection({ - y: currentY, - snapPoints, - dragDirection, - }); + const nearestSnap = findNearestSnapByY(snapPoints, y); + + // Slow-drag guard — tiny movement: snap back to nearest. + if (!isFlick && Math.abs(offsetY) < minDragDistance) { + return { + yTo: nearestSnap.snapValueY, + snapIndex: nearestSnap.snapIndex, + }; + } - if (nextSnapInDirectionRelativeToCurrentY) { + // Hysteresis (stickiness) — avoid jitter if already close to a snap. + if (!isFlick && Math.abs(y - nearestSnap.snapValueY) < snapStickiness) { return { - yTo: nextSnapInDirectionRelativeToCurrentY.snapValueY, - snapIndex: nextSnapInDirectionRelativeToCurrentY.snapIndex, + yTo: nearestSnap.snapValueY, + snapIndex: nearestSnap.snapIndex, }; } - // No snap point down, stay at current + // Step 5 — Strong flick overrides everything and goes fully open/closed. + if (isStrongFlick) { + const target = direction < 0 ? minY : maxY; + const targetSnap = findNearestSnapByY(snapPoints, target); + + return { + yTo: target, + snapIndex: targetSnap.snapIndex, + }; + } + + // Step 5 — Medium flick moves to the next snap in flick direction. + if (isFlick) { + const nextSnap = + direction < 0 + ? findNearestSnapAboveY(snapPoints, y) + : findNearestSnapBelowY(snapPoints, y); + + const fallbackY = direction < 0 ? minY : maxY; + const fallbackSnap = findNearestSnapByY(snapPoints, fallbackY); + const targetSnap = nextSnap ?? fallbackSnap; + + return { + yTo: targetSnap.snapValueY, + snapIndex: targetSnap.snapIndex, + }; + } + + const firstSnap = toAscendingYSnaps(snapPoints)[0]; + const lastSnap = toAscendingYSnaps(snapPoints)[snapPoints.length - 1]; + const prevSnap = findNearestSnapAboveY(snapPoints, y) ?? firstSnap; + const nextSnap = findNearestSnapBelowY(snapPoints, y) ?? lastSnap; + + if (prevSnap.snapIndex === nextSnap.snapIndex) { + return { + yTo: prevSnap.snapValueY, + snapIndex: prevSnap.snapIndex, + }; + } + + const range = nextSnap.snapValueY - prevSnap.snapValueY; + const progress = range > 0 ? (y - prevSnap.snapValueY) / range : 0; + + // Step 6 — Slow drag commit based on progress within the current snap region. + let selectedSnap = nearestSnap; + + if (direction > 0) { + selectedSnap = progress > distanceCommitRatio ? nextSnap : prevSnap; + } else if (direction < 0) { + selectedSnap = progress < 1 - distanceCommitRatio ? prevSnap : nextSnap; + } + + // Step 7 — Velocity-assisted bias toward predicted-position snap. + if (absVelocity > 200) { + const predictedSnap = findNearestSnapByY(snapPoints, predictedY); + + if ( + Math.abs(predictedSnap.snapValueY - y) < + Math.abs(selectedSnap.snapValueY - y) + ) { + selectedSnap = predictedSnap; + } + } + + // Step 8 — Final fallback. return { - yTo: currentSnapPoint.snapValueY, - snapIndex: currentSnapPoint.snapIndex, + yTo: selectedSnap.snapValueY, + snapIndex: selectedSnap.snapIndex, }; }