Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 85 additions & 90 deletions core/src/components/modal/gestures/sheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,23 @@ import { getBackdropValueForSheet } from '../utils';

import { calculateSpringStep, canSwipeOnContent, handleCanDismiss } from './utils';

/** Gesture velocity arrives in pixels per millisecond; the thresholds below are per second. */
const MILLISECONDS_PER_SECOND = 1000;

/**
* Flick speeds fast enough to decide the outcome on their own, in pixels per
* second. Both are magnitudes. Y grows downwards, so the upward threshold is
* negated where it is compared.
*/
const DISMISS_VELOCITY = 500;
const KEEP_OPEN_VELOCITY = 400;

/**
* How far below its starting breakpoint a slow drag has to end to dismiss the
* sheet. 0.4 means the user dragged 40% of the way down from where they started.
*/
const DISMISS_PROGRESS_RATIO = 0.4;

export interface MoveSheetToBreakpointOptions {
/**
* The breakpoint value to move the sheet to.
Expand Down Expand Up @@ -85,6 +102,11 @@ export const createSheetGesture = (
const contentEl = baseEl.querySelector('ion-content');
// Cache the initial value so the gesture restores it instead of forcing scrolling on.
const initialContentScrollY = contentEl?.scrollY ?? true;
/**
* The height of the sheet the user actually sees. This comes from the wrapper
* rather than the host element, which is stretched to the full viewport no
* matter how tall the sheet itself is.
*/
const height = wrapperEl.clientHeight;
let currentBreakpoint = initialBreakpoint;
let offset = 0;
Expand Down Expand Up @@ -420,25 +442,21 @@ export const createSheetGesture = (
offset = clamp(0.0001, processedStep, maxStep);
animation.progressStep(offset);

const snapBreakpoint = usePhysicsBasedGesture
? calculateVelocitySnapBreakpoint(detail.deltaY, detail.velocityY, detail.currentY)
: calculatePositionSnapBreakpoint(detail.deltaY);
const snapBreakpoint = calculateSnapBreakpoint(detail.deltaY, detail.velocityY);

const eventDetail: ModalDragEventDetail = {
currentY: detail.currentY,
deltaY: detail.deltaY,
velocityY: detail.velocityY,
progress: calculateProgress(detail.currentY),
progress: calculateProgress(detail.deltaY),
snapBreakpoint: snapBreakpoint,
};

onDragMove(eventDetail);
};

const onEnd = (detail: GestureDetail) => {
const snapBreakpoint = usePhysicsBasedGesture
? calculateVelocitySnapBreakpoint(detail.deltaY, detail.velocityY, detail.currentY)
: calculatePositionSnapBreakpoint(detail.deltaY);
const snapBreakpoint = calculateSnapBreakpoint(detail.deltaY, detail.velocityY);

/**
* `snapBreakpoint === 0` is not enough on its own. `canDismiss: false`
Expand All @@ -454,7 +472,7 @@ export const createSheetGesture = (
currentY: detail.currentY,
deltaY: detail.deltaY,
velocityY: detail.velocityY,
progress: calculateProgress(detail.currentY),
progress: calculateProgress(detail.deltaY),
snapBreakpoint,
isDismissing,
};
Expand Down Expand Up @@ -671,6 +689,39 @@ export const createSheetGesture = (
});
};

/**
* Decides which breakpoint the sheet should settle on for the current drag.
*
* A sheet whose only breakpoints are 0 and 1 has nowhere to go on a downward
* drag except closed, so any drag the gesture recognizes closes it. Sheets
* with breakpoints in between snap to the nearest one instead, because
* dragging is how the user resizes them.
*
* Two cases opt back out of that shortcut and use the normal thresholds:
* - An upward flick, so a drag that wanders downwards before flicking back up
* reopens the sheet rather than closing it.
* - `canDismiss`, which asks the application whether closing is allowed. Going
* through the usual thresholds means a few stray pixels cannot trigger that
* callback.
*
* @param deltaY The change in Y position since the gesture started
* @param velocityY The velocity in pixels per millisecond
* @returns The snap breakpoint value
*/
const calculateSnapBreakpoint = (deltaY: number, velocityY: number): number => {
const isDraggingDown = deltaY > 0;
const isFlickingUp = velocityY * MILLISECONDS_PER_SECOND < -KEEP_OPEN_VELOCITY;
const canOnlyOpenOrClose = minBreakpoint === 0 && breakpoints.length === 2;

if (isDraggingDown && canOnlyOpenOrClose && !isFlickingUp && !canDismissBlocksGesture) {
return 0;
}

return usePhysicsBasedGesture
? calculateVelocitySnapBreakpoint(deltaY, velocityY)
: calculatePositionSnapBreakpoint(deltaY);
};

/**
* Calculates the breakpoint based on the current deltaY.
* This determines where the sheet should snap to when the user releases the
Expand All @@ -680,16 +731,7 @@ export const createSheetGesture = (
* @returns The snap breakpoint value.
*/
const calculatePositionSnapBreakpoint = (deltaY: number): number => {
/**
* Calculates the real-time vertical position of the modal.
* We combine the wrapper's current bounding box position with the
* gesture's deltaY to account for the physical movement during the drag.
*/
const currentY = wrapperEl.getBoundingClientRect().top + deltaY;
/**
* Convert that pixel position back into a 0 to 1 progress value.
*/
const currentProgress = calculateProgress(currentY);
const currentProgress = calculateProgress(deltaY);

/**
* Find and return the defined breakpoint that is closest to the
Expand All @@ -714,23 +756,22 @@ export const createSheetGesture = (
*
* @param deltaY The change in Y position since gesture started
* @param velocityY The velocity in pixels per millisecond
* @param currentY The current Y position of the gesture
* @returns The snap breakpoint value
*/
const calculateVelocitySnapBreakpoint = (deltaY: number, velocityY: number, currentY: number): number => {
const calculateVelocitySnapBreakpoint = (deltaY: number, velocityY: number): number => {
// Convert velocity from px/ms to px/s for easier threshold comparison
const velocityYPerSecond = velocityY * 1000;
const velocityYPerSecond = velocityY * MILLISECONDS_PER_SECOND;

// Calculate current progress (0 = fully closed, 1 = fully expanded)
const currentProgress = calculateProgress(currentY);
const currentProgress = calculateProgress(deltaY);

// Rule 1: Fast downward flick always dismisses
if (velocityYPerSecond > 500) {
if (velocityYPerSecond > DISMISS_VELOCITY) {
return minBreakpoint;
}

// Rule 2: Fast upward flick moves to next breakpoint above
if (velocityYPerSecond < -400) {
if (velocityYPerSecond < -KEEP_OPEN_VELOCITY) {
// Find next breakpoint above current position
const nextBreakpoint = breakpoints.find((bp) => bp > currentProgress);
// If no breakpoint above, stay at max breakpoint
Expand All @@ -743,8 +784,13 @@ export const createSheetGesture = (
const distanceBelowSnap = currentBreakpoint - currentProgress;
const percentageBelowSnap = distanceBelowSnap / currentBreakpoint;

// If dragged more than 40% below and not flicking up, dismiss
if (percentageBelowSnap > 0.4 && velocityYPerSecond <= 400) {
/**
* The velocity check reads as "not flicking up", but upward flicks are
* negative and already satisfy it. What it excludes in practice is a
* downward drag faster than 400 px/s but not fast enough to trip the
* dismissal above, which falls through to the nearest breakpoint instead.
*/
if (percentageBelowSnap > DISMISS_PROGRESS_RATIO && velocityYPerSecond <= 400) {
return 0;
}
}
Expand All @@ -754,78 +800,27 @@ export const createSheetGesture = (
};

/**
* Calculates the progress of the swipe gesture.
* Calculates how open the sheet is part way through a gesture, on the same
* 0 to 1 scale as the breakpoints: 1 is fully open and 0 is fully closed.
*
* The progress is a value between 0 and 1 that represents how far
* the swipe has progressed towards closing the modal.
*
* A value closer to 1 means the modal is closer to being opened,
* while a value closer to 0 means the modal is closer to being closed.
*
* @param currentY The current Y position of the gesture
* @param deltaY The change in Y position since the gesture started
* @returns The progress of the sheet gesture
*/
const calculateProgress = (currentY: number): number => {
const minBreakpoint = breakpoints[0];
const maxBreakpoint = breakpoints[breakpoints.length - 1];

/**
* The lowest point the sheet can be dragged to aka the point at which
* the sheet is fully closed.
*/
const maxY = convertBreakpointToY(minBreakpoint);
const calculateProgress = (deltaY: number): number => {
/**
* The highest point the sheet can be dragged to aka the point at which
* the sheet is fully open.
*/
const minY = convertBreakpointToY(maxBreakpoint);
// The total distance between the fully open and fully closed positions.
const totalDistance = maxY - minY;
// The distance from the current position to the fully closed position.
const distanceFromBottom = maxY - currentY;
/**
* The progress represents how far the sheet is from the bottom relative
* to the total distance. When the user starts swiping up, the progress
* should be close to 1, and when the user has swiped all the way down,
* the progress should be close to 0.
* Start from how open the sheet was when the gesture began, then subtract
* the fraction of the sheet's height the user has dragged. Dragging down is
* positive, so it lowers the progress.
*
* This is the inverse of the step applied to the animation in onMove, which
* keeps the breakpoint the sheet snaps to in agreement with the position it
* is drawn at.
*/
const progress = distanceFromBottom / totalDistance;
const progress = currentBreakpoint - deltaY / height;
// Round to the nearest thousandth to avoid returning very small decimal
const roundedProgress = Math.round(progress * 1000) / 1000;

return Math.max(0, Math.min(1, roundedProgress));
};

/**
* Converts a breakpoint value (0 to 1) into a pixel Y coordinate
* on the screen.
*
* @param breakpoint The breakpoint value (e.g., 0.5 for half-open)
* @returns The pixel Y coordinate on the screen
*/
const convertBreakpointToY = (breakpoint: number): number => {
const rect = baseEl.getBoundingClientRect();
const modalHeight = rect.height;
// The bottom of the screen.
const viewportBottom = window.innerHeight;
/**
* The active height is how much of the modal is actually showing
* on the screen for this specific breakpoint.
*/
const activeHeight = modalHeight * breakpoint;

/**
* To find the Y coordinate, start at the bottom of the screen
* and move up by the active height of the modal.
*
* A breakpoint of 1.0 means the active height is the full modal height
* (fully open). A breakpoint of 0.0 means the active height is 0
* (fully closed).
*
* Since screen Y coordinates get smaller as you go up, we subtract the
* active height from the viewport bottom.
*/
return viewportBottom - activeHeight;
return clamp(0, roundedProgress, 1);
};

const gesture = createGesture({
Expand Down
Loading