diff --git a/core/src/components/modal/gestures/sheet.ts b/core/src/components/modal/gestures/sheet.ts index 42a739ff558..19fb7fa7602 100644 --- a/core/src/components/modal/gestures/sheet.ts +++ b/core/src/components/modal/gestures/sheet.ts @@ -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. @@ -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; @@ -420,15 +442,13 @@ 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, }; @@ -436,9 +456,7 @@ export const createSheetGesture = ( }; 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` @@ -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, }; @@ -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 @@ -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 @@ -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 @@ -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; } } @@ -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({ diff --git a/core/src/components/modal/test/can-dismiss/modal-sheet.e2e.ts b/core/src/components/modal/test/can-dismiss/modal-sheet.e2e.ts index 865b5b5a8de..2be21e2c03b 100644 --- a/core/src/components/modal/test/can-dismiss/modal-sheet.e2e.ts +++ b/core/src/components/modal/test/can-dismiss/modal-sheet.e2e.ts @@ -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'); @@ -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'); diff --git a/core/src/components/modal/test/sheet/index.html b/core/src/components/modal/test/sheet/index.html index 4a8d3f5b666..f78a4a93108 100644 --- a/core/src/components/modal/test/sheet/index.html +++ b/core/src/components/modal/test/sheet/index.html @@ -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); @@ -131,6 +139,12 @@ +