Skip to content
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
14 changes: 10 additions & 4 deletions core/src/components/modal/test/can-dismiss/modal-sheet.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,11 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>

await ionHandlerDone.next();

const modal = page.locator('ion-modal');
expect(modal).not.toBe(null);
await expect(page.locator('ion-modal')).toBeVisible();
});
test('should not dismiss on swipe when not attempting to close', async ({ page }) => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
const ionDragEnd = await page.spyOnEvent('ionDragEnd');

await page.click('#sheet-can-dismiss-promise-true');

Expand All @@ -125,8 +125,14 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
const modalHeader = page.locator('#modal-header');
await dragElementBy(modalHeader, page, 0, 30);

const modal = page.locator('ion-modal');
expect(modal).not.toBe(null);
/**
* A drag this small snaps the sheet back open, so canDismiss is never
* consulted and the sheet stays where it was.
*/
const dragEndEvent = await ionDragEnd.next();

expect(dragEndEvent.detail.isDismissing).toBe(false);
await expect(page.locator('ion-modal')).toBeVisible();
});
test('should hit the dismiss threshold when swiping', async ({ page }) => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
Expand Down
14 changes: 14 additions & 0 deletions core/src/components/modal/test/sheet/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@
--height: 50%;
}

/**
* Shorter than the midpoint of the viewport, which is where the sheet
* position and the drag distance disagree the most.
*/
.short-sheet {
--height: 40%;
}

.custom-handle::part(handle) {
top: -16px;
background: rgba(255, 255, 255, 0.53);
Expand Down Expand Up @@ -131,6 +139,12 @@
<button id="custom-height-modal" onclick="presentModal({ cssClass: 'custom-height' })">
Present Sheet Modal (Custom Height)
</button>
<button
id="short-sheet"
onclick="presentModal({ cssClass: 'short-sheet', initialBreakpoint: 1, breakpoints: [0, 1] })"
>
Present Sheet Modal (Short, No Intermediate Breakpoints)
</button>
<button
id="handle-behavior-cycle-modal"
onclick="presentModal({ handleBehavior: 'cycle', initialBreakpoint: 0.25, breakpoints: [0, 0.25, 0.5, 0.75, 1] })"
Expand Down
48 changes: 48 additions & 0 deletions core/src/components/modal/test/sheet/modal.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,54 @@ configs({ modes: ['ios', 'ionic-ios'], directions: ['ltr'] }).forEach(({ title,
});
});

test.describe(title('sheet modal: no intermediate breakpoints'), () => {
test.beforeEach(async ({ page }) => {
await page.goto('/src/components/modal/test/sheet', config);

const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');

await page.click('#short-sheet');
await ionModalDidPresent.next();
});

test('should not dismiss when dragged upwards', async ({ page }) => {
const ionDragEnd = await page.spyOnEvent('ionDragEnd');

const header = page.locator('.modal-sheet ion-header');

/**
* The sheet is already fully open, so dragging up cannot take it anywhere.
* It has to stay open no matter how fast the drag was.
*/
await dragElementBy(header, page, 0, -50);

const dragEndEvent = await ionDragEnd.next();

expect(dragEndEvent.detail.isDismissing).toBe(false);
await expect(page.locator('ion-modal')).toBeVisible();
});

test('should dismiss when dragged downwards', async ({ page }) => {
const ionDragEnd = await page.spyOnEvent('ionDragEnd');
const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');

const header = page.locator('.modal-sheet ion-header');

/**
* This drag covers well under half the sheet, so snapping to the nearest
* breakpoint would reopen it. A sheet that only opens and closes has
* nowhere else to go, so it dismisses instead.
*/
await dragElementBy(header, page, 0, 30);

const dragEndEvent = await ionDragEnd.next();

expect(dragEndEvent.detail.isDismissing).toBe(true);

await ionModalDidDismiss.next();
});
});

test.describe(title('sheet modal: late breakpoints binding'), () => {
test('should not crash when swiped after breakpoints are set after the modal loads', async ({ page }) => {
const pageErrors: string[] = [];
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading