Skip to content

Commit d29ac71

Browse files
thetaPCShaneKbrandyscarney
authored
feat(modal): add drag events for sheet and card modals (#30962)
Issue number: internal --------- ## What is the current behavior? The sheet and card modal can be dragged to view content. However, there are no events that determine when drag has started or ended. ## What is the new behavior? - Added drag events for sheet and card modal: `ionDragStart`, `ionDragMove`, `ionDragEnd` - Added a drag interface - Added tests ## Does this introduce a breaking change? - [ ] Yes - [x] No --------- Co-authored-by: Shane <shane@shanessite.net> Co-authored-by: Brandy Smith <brandyscarney@users.noreply.github.com>
1 parent 5bcf921 commit d29ac71

29 files changed

+505
-51
lines changed

core/api.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1187,6 +1187,9 @@ ion-modal,method,setCurrentBreakpoint,setCurrentBreakpoint(breakpoint: number) =
11871187
ion-modal,event,didDismiss,OverlayEventDetail<any>,true
11881188
ion-modal,event,didPresent,void,true
11891189
ion-modal,event,ionBreakpointDidChange,ModalBreakpointChangeEventDetail,true
1190+
ion-modal,event,ionDragEnd,ModalDragEventDetail,true
1191+
ion-modal,event,ionDragMove,ModalDragEventDetail,true
1192+
ion-modal,event,ionDragStart,void,true
11901193
ion-modal,event,ionModalDidDismiss,OverlayEventDetail<any>,true
11911194
ion-modal,event,ionModalDidPresent,void,true
11921195
ion-modal,event,ionModalWillDismiss,OverlayEventDetail<any>,true

core/src/components.d.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { SpinnerTypes } from "./components/spinner/spinner-configs";
2020
import { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface";
2121
import { InputOtpChangeEventDetail, InputOtpCompleteEventDetail, InputOtpInputEventDetail } from "./components/input-otp/input-otp-interface";
2222
import { MenuChangeEventDetail, MenuCloseEventDetail, MenuType, Side } from "./components/menu/menu-interface";
23-
import { ModalBreakpointChangeEventDetail, ModalHandleBehavior } from "./components/modal/modal-interface";
23+
import { ModalBreakpointChangeEventDetail, ModalDragEventDetail, ModalHandleBehavior } from "./components/modal/modal-interface";
2424
import { NavComponent, NavComponentWithProps, NavOptions, RouterOutletOptions, SwipeGestureHandler, TransitionDoneFn, TransitionInstruction } from "./components/nav/nav-interface";
2525
import { ViewController } from "./components/nav/view-controller";
2626
import { PickerChangeEventDetail } from "./components/picker/picker-interfaces";
@@ -58,7 +58,7 @@ export { SpinnerTypes } from "./components/spinner/spinner-configs";
5858
export { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface";
5959
export { InputOtpChangeEventDetail, InputOtpCompleteEventDetail, InputOtpInputEventDetail } from "./components/input-otp/input-otp-interface";
6060
export { MenuChangeEventDetail, MenuCloseEventDetail, MenuType, Side } from "./components/menu/menu-interface";
61-
export { ModalBreakpointChangeEventDetail, ModalHandleBehavior } from "./components/modal/modal-interface";
61+
export { ModalBreakpointChangeEventDetail, ModalDragEventDetail, ModalHandleBehavior } from "./components/modal/modal-interface";
6262
export { NavComponent, NavComponentWithProps, NavOptions, RouterOutletOptions, SwipeGestureHandler, TransitionDoneFn, TransitionInstruction } from "./components/nav/nav-interface";
6363
export { ViewController } from "./components/nav/view-controller";
6464
export { PickerChangeEventDetail } from "./components/picker/picker-interfaces";
@@ -4534,6 +4534,9 @@ declare global {
45344534
"willDismiss": OverlayEventDetail;
45354535
"didDismiss": OverlayEventDetail;
45364536
"ionMount": void;
4537+
"ionDragStart": void;
4538+
"ionDragMove": ModalDragEventDetail;
4539+
"ionDragEnd": ModalDragEventDetail;
45374540
}
45384541
interface HTMLIonModalElement extends Components.IonModal, HTMLStencilElement {
45394542
addEventListener<K extends keyof HTMLIonModalElementEventMap>(type: K, listener: (this: HTMLIonModalElement, ev: IonModalCustomEvent<HTMLIonModalElementEventMap[K]>) => any, options?: boolean | AddEventListenerOptions): void;
@@ -7350,6 +7353,18 @@ declare namespace LocalJSX {
73507353
* Emitted after the modal breakpoint has changed.
73517354
*/
73527355
"onIonBreakpointDidChange"?: (event: IonModalCustomEvent<ModalBreakpointChangeEventDetail>) => void;
7356+
/**
7357+
* Event that is emitted when the sheet modal or card modal gesture ends.
7358+
*/
7359+
"onIonDragEnd"?: (event: IonModalCustomEvent<ModalDragEventDetail>) => void;
7360+
/**
7361+
* Event that is emitted when the sheet modal or card modal gesture moves.
7362+
*/
7363+
"onIonDragMove"?: (event: IonModalCustomEvent<ModalDragEventDetail>) => void;
7364+
/**
7365+
* Event that is emitted when the sheet modal or card modal gesture starts.
7366+
*/
7367+
"onIonDragStart"?: (event: IonModalCustomEvent<void>) => void;
73537368
/**
73547369
* Emitted after the modal has dismissed.
73557370
*/

core/src/components/modal/gestures/sheet.ts

Lines changed: 140 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { createGesture } from '@utils/gesture';
33
import { clamp, getElementRoot, raf } from '@utils/helpers';
44
import { FOCUS_TRAP_DISABLE_CLASS } from '@utils/overlays';
55

6-
import type { Animation } from '../../../interface';
6+
import type { Animation, ModalDragEventDetail } from '../../../interface';
77
import type { GestureDetail } from '../../../utils/gesture';
88
import { getBackdropValueForSheet } from '../utils';
99

@@ -52,7 +52,10 @@ export const createSheetGesture = (
5252
expandToScroll: boolean,
5353
getCurrentBreakpoint: () => number,
5454
onDismiss: () => void,
55-
onBreakpointChange: (breakpoint: number) => void
55+
onBreakpointChange: (breakpoint: number) => void,
56+
onDragStart: () => void,
57+
onDragMove: (detail: ModalDragEventDetail) => void,
58+
onDragEnd: (detail: ModalDragEventDetail) => void
5659
) => {
5760
// Defaults for the sheet swipe animation
5861
const defaultBackdrop = [
@@ -347,6 +350,8 @@ export const createSheetGesture = (
347350
});
348351

349352
animation.progressStart(true, 1 - currentBreakpoint);
353+
354+
onDragStart();
350355
};
351356

352357
const onMove = (detail: GestureDetail) => {
@@ -423,9 +428,31 @@ export const createSheetGesture = (
423428

424429
offset = clamp(0.0001, processedStep, maxStep);
425430
animation.progressStep(offset);
431+
432+
const snapBreakpoint = calculateSnapBreakpoint(detail.deltaY);
433+
434+
const eventDetail: ModalDragEventDetail = {
435+
currentY: detail.currentY,
436+
deltaY: detail.deltaY,
437+
velocityY: detail.velocityY,
438+
progress: calculateProgress(detail.currentY),
439+
snapBreakpoint: snapBreakpoint,
440+
};
441+
442+
onDragMove(eventDetail);
426443
};
427444

428445
const onEnd = (detail: GestureDetail) => {
446+
const snapBreakpoint = calculateSnapBreakpoint(detail.deltaY);
447+
448+
const eventDetail: ModalDragEventDetail = {
449+
currentY: detail.currentY,
450+
deltaY: detail.deltaY,
451+
velocityY: detail.velocityY,
452+
progress: calculateProgress(detail.currentY),
453+
snapBreakpoint,
454+
};
455+
429456
/**
430457
* If expandToScroll is disabled, we should not allow the moveSheetToBreakpoint
431458
* function to be called if the user is trying to swipe content upwards and the content
@@ -440,23 +467,13 @@ export const createSheetGesture = (
440467
* swap to moving on drag and if we don't swap back here then the footer will get stuck.
441468
*/
442469
swapFooterPosition('stationary');
470+
onDragEnd(eventDetail);
471+
443472
return;
444473
}
445474

446-
/**
447-
* When the gesture releases, we need to determine
448-
* the closest breakpoint to snap to.
449-
*/
450-
const velocity = detail.velocityY;
451-
const threshold = (detail.deltaY + velocity * 350) / height;
452-
453-
const diff = currentBreakpoint - threshold;
454-
const closest = breakpoints.reduce((a, b) => {
455-
return Math.abs(b - diff) < Math.abs(a - diff) ? b : a;
456-
});
457-
458475
moveSheetToBreakpoint({
459-
breakpoint: closest,
476+
breakpoint: snapBreakpoint,
460477
breakpointOffset: offset,
461478
canDismiss: canDismissBlocksGesture,
462479

@@ -466,6 +483,8 @@ export const createSheetGesture = (
466483
*/
467484
animated: true,
468485
});
486+
487+
onDragEnd(eventDetail);
469488
};
470489

471490
const moveSheetToBreakpoint = (options: MoveSheetToBreakpointOptions) => {
@@ -624,6 +643,112 @@ export const createSheetGesture = (
624643
});
625644
};
626645

646+
/**
647+
* Calculates the breakpoint based on the current deltaY.
648+
* This determines where the sheet should snap to when the user releases the
649+
* gesture.
650+
*
651+
* @param deltaY The change in Y position since the gesture started.
652+
* @returns The snap breakpoint value.
653+
*/
654+
const calculateSnapBreakpoint = (deltaY: number): number => {
655+
/**
656+
* Calculates the real-time vertical position of the modal.
657+
* We combine the wrapper's current bounding box position with the
658+
* gesture's deltaY to account for the physical movement during the drag.
659+
*/
660+
const currentY = wrapperEl.getBoundingClientRect().top + deltaY;
661+
/**
662+
* Convert that pixel position back into a 0 to 1 progress value.
663+
*/
664+
const currentProgress = calculateProgress(currentY);
665+
666+
/**
667+
* Find and return the defined breakpoint that is closest to the
668+
* current progress.
669+
*/
670+
const snapBreakpoint = breakpoints.reduce((a, b) => {
671+
return Math.abs(b - currentProgress) < Math.abs(a - currentProgress) ? b : a;
672+
});
673+
674+
return snapBreakpoint;
675+
};
676+
677+
/**
678+
* Calculates the progress of the swipe gesture.
679+
*
680+
* The progress is a value between 0 and 1 that represents how far
681+
* the swipe has progressed towards closing the modal.
682+
*
683+
* A value closer to 1 means the modal is closer to being opened,
684+
* while a value closer to 0 means the modal is closer to being closed.
685+
*
686+
* @param currentY The current Y position of the gesture
687+
* @returns The progress of the sheet gesture
688+
*/
689+
const calculateProgress = (currentY: number): number => {
690+
const minBreakpoint = breakpoints[0];
691+
const maxBreakpoint = breakpoints[breakpoints.length - 1];
692+
693+
/**
694+
* The lowest point the sheet can be dragged to aka the point at which
695+
* the sheet is fully closed.
696+
*/
697+
const maxY = convertBreakpointToY(minBreakpoint);
698+
/**
699+
* The highest point the sheet can be dragged to aka the point at which
700+
* the sheet is fully open.
701+
*/
702+
const minY = convertBreakpointToY(maxBreakpoint);
703+
// The total distance between the fully open and fully closed positions.
704+
const totalDistance = maxY - minY;
705+
// The distance from the current position to the fully closed position.
706+
const distanceFromBottom = maxY - currentY;
707+
/**
708+
* The progress represents how far the sheet is from the bottom relative
709+
* to the total distance. When the user starts swiping up, the progress
710+
* should be close to 1, and when the user has swiped all the way down,
711+
* the progress should be close to 0.
712+
*/
713+
const progress = distanceFromBottom / totalDistance;
714+
// Round to the nearest thousandth to avoid returning very small decimal
715+
const roundedProgress = Math.round(progress * 1000) / 1000;
716+
717+
return Math.max(0, Math.min(1, roundedProgress));
718+
};
719+
720+
/**
721+
* Converts a breakpoint value (0 to 1) into a pixel Y coordinate
722+
* on the screen.
723+
*
724+
* @param breakpoint The breakpoint value (e.g., 0.5 for half-open)
725+
* @returns The pixel Y coordinate on the screen
726+
*/
727+
const convertBreakpointToY = (breakpoint: number): number => {
728+
const rect = baseEl.getBoundingClientRect();
729+
const modalHeight = rect.height;
730+
// The bottom of the screen.
731+
const viewportBottom = window.innerHeight;
732+
/**
733+
* The active height is how much of the modal is actually showing
734+
* on the screen for this specific breakpoint.
735+
*/
736+
const activeHeight = modalHeight * breakpoint;
737+
738+
/**
739+
* To find the Y coordinate, start at the bottom of the screen
740+
* and move up by the active height of the modal.
741+
*
742+
* A breakpoint of 1.0 means the active height is the full modal height
743+
* (fully open). A breakpoint of 0.0 means the active height is 0
744+
* (fully closed).
745+
*
746+
* Since screen Y coordinates get smaller as you go up, we subtract the
747+
* active height from the viewport bottom.
748+
*/
749+
return viewportBottom - activeHeight;
750+
};
751+
627752
const gesture = createGesture({
628753
el: wrapperEl,
629754
gestureName: 'modalSheet',

core/src/components/modal/gestures/swipe-to-close.ts

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { createGesture } from '@utils/gesture';
44
import { clamp, getElementRoot } from '@utils/helpers';
55
import { OVERLAY_GESTURE_PRIORITY } from '@utils/overlays';
66

7-
import type { Animation } from '../../../interface';
7+
import type { Animation, ModalDragEventDetail } from '../../../interface';
88
import type { GestureDetail } from '../../../utils/gesture';
99
import type { Style as StatusBarStyle } from '../../../utils/native/status-bar';
1010
import { setCardStatusBarDark, setCardStatusBarDefault } from '../utils';
@@ -20,7 +20,10 @@ export const createSwipeToCloseGesture = (
2020
el: HTMLIonModalElement,
2121
animation: Animation,
2222
statusBarStyle: StatusBarStyle,
23-
onDismiss: () => void
23+
onDismiss: () => void,
24+
onDragStart: () => void,
25+
onDragMove: (detail: ModalDragEventDetail) => void,
26+
onDragEnd: (detail: ModalDragEventDetail) => void
2427
) => {
2528
/**
2629
* The step value at which a card modal
@@ -142,6 +145,8 @@ export const createSwipeToCloseGesture = (
142145
}
143146

144147
animation.progressStart(true, isOpen ? 1 : 0);
148+
149+
onDragStart();
145150
};
146151

147152
const onMove = (detail: GestureDetail) => {
@@ -220,6 +225,15 @@ export const createSwipeToCloseGesture = (
220225
}
221226

222227
lastStep = clampedStep;
228+
229+
const eventDetail: ModalDragEventDetail = {
230+
currentY: detail.currentY,
231+
deltaY: detail.deltaY,
232+
velocityY: detail.velocityY,
233+
progress: calculateProgress(el, detail.deltaY),
234+
};
235+
236+
onDragMove(eventDetail);
223237
};
224238

225239
const onEnd = (detail: GestureDetail) => {
@@ -288,6 +302,15 @@ export const createSwipeToCloseGesture = (
288302
} else if (shouldComplete) {
289303
onDismiss();
290304
}
305+
306+
const eventDetail: ModalDragEventDetail = {
307+
currentY: detail.currentY,
308+
deltaY: detail.deltaY,
309+
velocityY: detail.velocityY,
310+
progress: calculateProgress(el, detail.deltaY),
311+
};
312+
313+
onDragEnd(eventDetail);
291314
};
292315

293316
const gesture = createGesture({
@@ -307,3 +330,43 @@ export const createSwipeToCloseGesture = (
307330
const computeDuration = (remaining: number, velocity: number) => {
308331
return clamp(400, remaining / Math.abs(velocity * 1.1), 500);
309332
};
333+
334+
/**
335+
* Calculates the progress of the swipe gesture.
336+
*
337+
* The progress is a value between 0 and 1 that represents how far
338+
* the swipe has progressed towards closing the modal.
339+
*
340+
* A value closer to 1 means the modal is closer to being opened,
341+
* while a value closer to 0 means the modal is closer to being closed.
342+
*
343+
* @param el The modal
344+
* @param deltaY The change in Y position (positive when dragging down, negative when dragging up)
345+
* @returns The progress of the swipe gesture
346+
*/
347+
const calculateProgress = (el: HTMLIonModalElement, deltaY: number): number => {
348+
const windowHeight = window.innerHeight;
349+
// Position when fully open
350+
const modalTop = el.getBoundingClientRect().top;
351+
/**
352+
* The distance between the top of the modal and the bottom of the screen
353+
* is the total distance the modal needs to travel to be fully closed.
354+
*/
355+
const totalDistance = windowHeight - modalTop;
356+
/**
357+
* The pull percentage is how far the user has swiped compared to the total
358+
* distance needed to close the modal.
359+
*/
360+
const pullPercentage = deltaY / totalDistance;
361+
/**
362+
* The progress is the inverse of the pull percentage because
363+
* when the user starts swiping up, the progress should be close to 1,
364+
* and when the user has swiped all the way down, the progress should be
365+
* close to 0.
366+
*/
367+
const progress = 1 - pullPercentage;
368+
// Round to the nearest thousandth to avoid returning very small decimal
369+
const roundedProgress = Math.round(progress * 1000) / 1000;
370+
371+
return Math.max(0, Math.min(1, roundedProgress));
372+
};

0 commit comments

Comments
 (0)