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>
@@ -1187,6 +1187,9 @@ ion-modal,method,setCurrentBreakpoint,setCurrentBreakpoint(breakpoint: number) =
|
||||
ion-modal,event,didDismiss,OverlayEventDetail<any>,true
|
||||
ion-modal,event,didPresent,void,true
|
||||
ion-modal,event,ionBreakpointDidChange,ModalBreakpointChangeEventDetail,true
|
||||
ion-modal,event,ionDragEnd,ModalDragEventDetail,true
|
||||
ion-modal,event,ionDragMove,ModalDragEventDetail,true
|
||||
ion-modal,event,ionDragStart,void,true
|
||||
ion-modal,event,ionModalDidDismiss,OverlayEventDetail<any>,true
|
||||
ion-modal,event,ionModalDidPresent,void,true
|
||||
ion-modal,event,ionModalWillDismiss,OverlayEventDetail<any>,true
|
||||
|
||||
19
core/src/components.d.ts
vendored
@@ -20,7 +20,7 @@ import { SpinnerTypes } from "./components/spinner/spinner-configs";
|
||||
import { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface";
|
||||
import { InputOtpChangeEventDetail, InputOtpCompleteEventDetail, InputOtpInputEventDetail } from "./components/input-otp/input-otp-interface";
|
||||
import { MenuChangeEventDetail, MenuCloseEventDetail, MenuType, Side } from "./components/menu/menu-interface";
|
||||
import { ModalBreakpointChangeEventDetail, ModalHandleBehavior } from "./components/modal/modal-interface";
|
||||
import { ModalBreakpointChangeEventDetail, ModalDragEventDetail, ModalHandleBehavior } from "./components/modal/modal-interface";
|
||||
import { NavComponent, NavComponentWithProps, NavOptions, RouterOutletOptions, SwipeGestureHandler, TransitionDoneFn, TransitionInstruction } from "./components/nav/nav-interface";
|
||||
import { ViewController } from "./components/nav/view-controller";
|
||||
import { PickerChangeEventDetail } from "./components/picker/picker-interfaces";
|
||||
@@ -58,7 +58,7 @@ export { SpinnerTypes } from "./components/spinner/spinner-configs";
|
||||
export { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface";
|
||||
export { InputOtpChangeEventDetail, InputOtpCompleteEventDetail, InputOtpInputEventDetail } from "./components/input-otp/input-otp-interface";
|
||||
export { MenuChangeEventDetail, MenuCloseEventDetail, MenuType, Side } from "./components/menu/menu-interface";
|
||||
export { ModalBreakpointChangeEventDetail, ModalHandleBehavior } from "./components/modal/modal-interface";
|
||||
export { ModalBreakpointChangeEventDetail, ModalDragEventDetail, ModalHandleBehavior } from "./components/modal/modal-interface";
|
||||
export { NavComponent, NavComponentWithProps, NavOptions, RouterOutletOptions, SwipeGestureHandler, TransitionDoneFn, TransitionInstruction } from "./components/nav/nav-interface";
|
||||
export { ViewController } from "./components/nav/view-controller";
|
||||
export { PickerChangeEventDetail } from "./components/picker/picker-interfaces";
|
||||
@@ -4534,6 +4534,9 @@ declare global {
|
||||
"willDismiss": OverlayEventDetail;
|
||||
"didDismiss": OverlayEventDetail;
|
||||
"ionMount": void;
|
||||
"ionDragStart": void;
|
||||
"ionDragMove": ModalDragEventDetail;
|
||||
"ionDragEnd": ModalDragEventDetail;
|
||||
}
|
||||
interface HTMLIonModalElement extends Components.IonModal, HTMLStencilElement {
|
||||
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 {
|
||||
* Emitted after the modal breakpoint has changed.
|
||||
*/
|
||||
"onIonBreakpointDidChange"?: (event: IonModalCustomEvent<ModalBreakpointChangeEventDetail>) => void;
|
||||
/**
|
||||
* Event that is emitted when the sheet modal or card modal gesture ends.
|
||||
*/
|
||||
"onIonDragEnd"?: (event: IonModalCustomEvent<ModalDragEventDetail>) => void;
|
||||
/**
|
||||
* Event that is emitted when the sheet modal or card modal gesture moves.
|
||||
*/
|
||||
"onIonDragMove"?: (event: IonModalCustomEvent<ModalDragEventDetail>) => void;
|
||||
/**
|
||||
* Event that is emitted when the sheet modal or card modal gesture starts.
|
||||
*/
|
||||
"onIonDragStart"?: (event: IonModalCustomEvent<void>) => void;
|
||||
/**
|
||||
* Emitted after the modal has dismissed.
|
||||
*/
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createGesture } from '@utils/gesture';
|
||||
import { clamp, getElementRoot, raf } from '@utils/helpers';
|
||||
import { FOCUS_TRAP_DISABLE_CLASS } from '@utils/overlays';
|
||||
|
||||
import type { Animation } from '../../../interface';
|
||||
import type { Animation, ModalDragEventDetail } from '../../../interface';
|
||||
import type { GestureDetail } from '../../../utils/gesture';
|
||||
import { getBackdropValueForSheet } from '../utils';
|
||||
|
||||
@@ -52,7 +52,10 @@ export const createSheetGesture = (
|
||||
expandToScroll: boolean,
|
||||
getCurrentBreakpoint: () => number,
|
||||
onDismiss: () => void,
|
||||
onBreakpointChange: (breakpoint: number) => void
|
||||
onBreakpointChange: (breakpoint: number) => void,
|
||||
onDragStart: () => void,
|
||||
onDragMove: (detail: ModalDragEventDetail) => void,
|
||||
onDragEnd: (detail: ModalDragEventDetail) => void
|
||||
) => {
|
||||
// Defaults for the sheet swipe animation
|
||||
const defaultBackdrop = [
|
||||
@@ -347,6 +350,8 @@ export const createSheetGesture = (
|
||||
});
|
||||
|
||||
animation.progressStart(true, 1 - currentBreakpoint);
|
||||
|
||||
onDragStart();
|
||||
};
|
||||
|
||||
const onMove = (detail: GestureDetail) => {
|
||||
@@ -423,9 +428,31 @@ export const createSheetGesture = (
|
||||
|
||||
offset = clamp(0.0001, processedStep, maxStep);
|
||||
animation.progressStep(offset);
|
||||
|
||||
const snapBreakpoint = calculateSnapBreakpoint(detail.deltaY);
|
||||
|
||||
const eventDetail: ModalDragEventDetail = {
|
||||
currentY: detail.currentY,
|
||||
deltaY: detail.deltaY,
|
||||
velocityY: detail.velocityY,
|
||||
progress: calculateProgress(detail.currentY),
|
||||
snapBreakpoint: snapBreakpoint,
|
||||
};
|
||||
|
||||
onDragMove(eventDetail);
|
||||
};
|
||||
|
||||
const onEnd = (detail: GestureDetail) => {
|
||||
const snapBreakpoint = calculateSnapBreakpoint(detail.deltaY);
|
||||
|
||||
const eventDetail: ModalDragEventDetail = {
|
||||
currentY: detail.currentY,
|
||||
deltaY: detail.deltaY,
|
||||
velocityY: detail.velocityY,
|
||||
progress: calculateProgress(detail.currentY),
|
||||
snapBreakpoint,
|
||||
};
|
||||
|
||||
/**
|
||||
* If expandToScroll is disabled, we should not allow the moveSheetToBreakpoint
|
||||
* function to be called if the user is trying to swipe content upwards and the content
|
||||
@@ -440,23 +467,13 @@ export const createSheetGesture = (
|
||||
* swap to moving on drag and if we don't swap back here then the footer will get stuck.
|
||||
*/
|
||||
swapFooterPosition('stationary');
|
||||
onDragEnd(eventDetail);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* When the gesture releases, we need to determine
|
||||
* the closest breakpoint to snap to.
|
||||
*/
|
||||
const velocity = detail.velocityY;
|
||||
const threshold = (detail.deltaY + velocity * 350) / height;
|
||||
|
||||
const diff = currentBreakpoint - threshold;
|
||||
const closest = breakpoints.reduce((a, b) => {
|
||||
return Math.abs(b - diff) < Math.abs(a - diff) ? b : a;
|
||||
});
|
||||
|
||||
moveSheetToBreakpoint({
|
||||
breakpoint: closest,
|
||||
breakpoint: snapBreakpoint,
|
||||
breakpointOffset: offset,
|
||||
canDismiss: canDismissBlocksGesture,
|
||||
|
||||
@@ -466,6 +483,8 @@ export const createSheetGesture = (
|
||||
*/
|
||||
animated: true,
|
||||
});
|
||||
|
||||
onDragEnd(eventDetail);
|
||||
};
|
||||
|
||||
const moveSheetToBreakpoint = (options: MoveSheetToBreakpointOptions) => {
|
||||
@@ -624,6 +643,112 @@ export const createSheetGesture = (
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the breakpoint based on the current deltaY.
|
||||
* This determines where the sheet should snap to when the user releases the
|
||||
* gesture.
|
||||
*
|
||||
* @param deltaY The change in Y position since the gesture started.
|
||||
* @returns The snap breakpoint value.
|
||||
*/
|
||||
const calculateSnapBreakpoint = (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);
|
||||
|
||||
/**
|
||||
* Find and return the defined breakpoint that is closest to the
|
||||
* current progress.
|
||||
*/
|
||||
const snapBreakpoint = breakpoints.reduce((a, b) => {
|
||||
return Math.abs(b - currentProgress) < Math.abs(a - currentProgress) ? b : a;
|
||||
});
|
||||
|
||||
return snapBreakpoint;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the progress of the swipe gesture.
|
||||
*
|
||||
* 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
|
||||
* @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);
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const progress = distanceFromBottom / totalDistance;
|
||||
// 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;
|
||||
};
|
||||
|
||||
const gesture = createGesture({
|
||||
el: wrapperEl,
|
||||
gestureName: 'modalSheet',
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createGesture } from '@utils/gesture';
|
||||
import { clamp, getElementRoot } from '@utils/helpers';
|
||||
import { OVERLAY_GESTURE_PRIORITY } from '@utils/overlays';
|
||||
|
||||
import type { Animation } from '../../../interface';
|
||||
import type { Animation, ModalDragEventDetail } from '../../../interface';
|
||||
import type { GestureDetail } from '../../../utils/gesture';
|
||||
import type { Style as StatusBarStyle } from '../../../utils/native/status-bar';
|
||||
import { setCardStatusBarDark, setCardStatusBarDefault } from '../utils';
|
||||
@@ -20,7 +20,10 @@ export const createSwipeToCloseGesture = (
|
||||
el: HTMLIonModalElement,
|
||||
animation: Animation,
|
||||
statusBarStyle: StatusBarStyle,
|
||||
onDismiss: () => void
|
||||
onDismiss: () => void,
|
||||
onDragStart: () => void,
|
||||
onDragMove: (detail: ModalDragEventDetail) => void,
|
||||
onDragEnd: (detail: ModalDragEventDetail) => void
|
||||
) => {
|
||||
/**
|
||||
* The step value at which a card modal
|
||||
@@ -142,6 +145,8 @@ export const createSwipeToCloseGesture = (
|
||||
}
|
||||
|
||||
animation.progressStart(true, isOpen ? 1 : 0);
|
||||
|
||||
onDragStart();
|
||||
};
|
||||
|
||||
const onMove = (detail: GestureDetail) => {
|
||||
@@ -220,6 +225,15 @@ export const createSwipeToCloseGesture = (
|
||||
}
|
||||
|
||||
lastStep = clampedStep;
|
||||
|
||||
const eventDetail: ModalDragEventDetail = {
|
||||
currentY: detail.currentY,
|
||||
deltaY: detail.deltaY,
|
||||
velocityY: detail.velocityY,
|
||||
progress: calculateProgress(el, detail.deltaY),
|
||||
};
|
||||
|
||||
onDragMove(eventDetail);
|
||||
};
|
||||
|
||||
const onEnd = (detail: GestureDetail) => {
|
||||
@@ -288,6 +302,15 @@ export const createSwipeToCloseGesture = (
|
||||
} else if (shouldComplete) {
|
||||
onDismiss();
|
||||
}
|
||||
|
||||
const eventDetail: ModalDragEventDetail = {
|
||||
currentY: detail.currentY,
|
||||
deltaY: detail.deltaY,
|
||||
velocityY: detail.velocityY,
|
||||
progress: calculateProgress(el, detail.deltaY),
|
||||
};
|
||||
|
||||
onDragEnd(eventDetail);
|
||||
};
|
||||
|
||||
const gesture = createGesture({
|
||||
@@ -307,3 +330,43 @@ export const createSwipeToCloseGesture = (
|
||||
const computeDuration = (remaining: number, velocity: number) => {
|
||||
return clamp(400, remaining / Math.abs(velocity * 1.1), 500);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the progress of the swipe gesture.
|
||||
*
|
||||
* 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 el The modal
|
||||
* @param deltaY The change in Y position (positive when dragging down, negative when dragging up)
|
||||
* @returns The progress of the swipe gesture
|
||||
*/
|
||||
const calculateProgress = (el: HTMLIonModalElement, deltaY: number): number => {
|
||||
const windowHeight = window.innerHeight;
|
||||
// Position when fully open
|
||||
const modalTop = el.getBoundingClientRect().top;
|
||||
/**
|
||||
* The distance between the top of the modal and the bottom of the screen
|
||||
* is the total distance the modal needs to travel to be fully closed.
|
||||
*/
|
||||
const totalDistance = windowHeight - modalTop;
|
||||
/**
|
||||
* The pull percentage is how far the user has swiped compared to the total
|
||||
* distance needed to close the modal.
|
||||
*/
|
||||
const pullPercentage = deltaY / totalDistance;
|
||||
/**
|
||||
* The progress is the inverse of the pull percentage because
|
||||
* 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.
|
||||
*/
|
||||
const progress = 1 - pullPercentage;
|
||||
// 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));
|
||||
};
|
||||
|
||||
@@ -47,3 +47,29 @@ export interface ModalCustomEvent extends CustomEvent {
|
||||
* The behavior setting for modals when the handle is pressed.
|
||||
*/
|
||||
export type ModalHandleBehavior = 'none' | 'cycle';
|
||||
|
||||
export interface ModalDragEventDetail {
|
||||
/**
|
||||
* The current Y coordinate of the drag event.
|
||||
*/
|
||||
currentY: number;
|
||||
/**
|
||||
* The change in Y coordinate since the last drag event.
|
||||
*/
|
||||
deltaY: number;
|
||||
/**
|
||||
* The velocity of the drag event in the Y direction.
|
||||
*/
|
||||
velocityY: number;
|
||||
/**
|
||||
* The progress of the drag event, represented as a value between 0 and 1.
|
||||
* A value of 0 means the modal is at its lowest point (fully closed),
|
||||
* while a value of 1 means the modal is at its highest point (fully open).
|
||||
*/
|
||||
progress: number;
|
||||
/**
|
||||
* The breakpoint that the sheet will snap to if the user releases
|
||||
* the gesture.
|
||||
*/
|
||||
snapBreakpoint?: number;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ import { mdLeaveAnimation } from './animations/md.leave';
|
||||
import type { MoveSheetToBreakpointOptions } from './gestures/sheet';
|
||||
import { createSheetGesture } from './gestures/sheet';
|
||||
import { createSwipeToCloseGesture, SwipeToCloseDefaults } from './gestures/swipe-to-close';
|
||||
import type { ModalBreakpointChangeEventDetail, ModalHandleBehavior } from './modal-interface';
|
||||
import type { ModalBreakpointChangeEventDetail, ModalHandleBehavior, ModalDragEventDetail } from './modal-interface';
|
||||
import { setCardStatusBarDark, setCardStatusBarDefault } from './utils';
|
||||
|
||||
// TODO(FW-2832): types
|
||||
@@ -72,6 +72,11 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
private coreDelegate: FrameworkDelegate = CoreDelegate();
|
||||
private sheetTransition?: Promise<any>;
|
||||
@State() private isSheetModal = false;
|
||||
/**
|
||||
* The breakpoint value that has been committed for a sheet modal.
|
||||
* This represents the modal's resting state when it is not being dragged
|
||||
* or animating toward a new position.
|
||||
*/
|
||||
private currentBreakpoint?: number;
|
||||
private wrapperEl?: HTMLElement;
|
||||
private backdropEl?: HTMLIonBackdropElement;
|
||||
@@ -390,6 +395,21 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
*/
|
||||
@Event() ionMount!: EventEmitter<void>;
|
||||
|
||||
/**
|
||||
* Event that is emitted when the sheet modal or card modal gesture starts.
|
||||
*/
|
||||
@Event() ionDragStart!: EventEmitter<void>;
|
||||
|
||||
/**
|
||||
* Event that is emitted when the sheet modal or card modal gesture moves.
|
||||
*/
|
||||
@Event() ionDragMove!: EventEmitter<ModalDragEventDetail>;
|
||||
|
||||
/**
|
||||
* Event that is emitted when the sheet modal or card modal gesture ends.
|
||||
*/
|
||||
@Event() ionDragEnd!: EventEmitter<ModalDragEventDetail>;
|
||||
|
||||
breakpointsChanged(breakpoints: number[] | undefined) {
|
||||
if (breakpoints !== undefined) {
|
||||
this.sortedBreakpoints = breakpoints.sort((a, b) => a - b);
|
||||
@@ -692,33 +712,15 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
|
||||
const statusBarStyle = this.statusBarStyle ?? StatusBarStyle.Default;
|
||||
|
||||
this.gesture = createSwipeToCloseGesture(el, ani, statusBarStyle, () => {
|
||||
/**
|
||||
* While the gesture animation is finishing
|
||||
* it is possible for a user to tap the backdrop.
|
||||
* This would result in the dismiss animation
|
||||
* being played again. Typically this is avoided
|
||||
* by setting `presented = false` on the overlay
|
||||
* component; however, we cannot do that here as
|
||||
* that would prevent the element from being
|
||||
* removed from the DOM.
|
||||
*/
|
||||
this.gestureAnimationDismissing = true;
|
||||
|
||||
/**
|
||||
* Reset the status bar style as the dismiss animation
|
||||
* starts otherwise the status bar will be the wrong
|
||||
* color for the duration of the dismiss animation.
|
||||
* The dismiss method does this as well, but
|
||||
* in this case it's only called once the animation
|
||||
* has finished.
|
||||
*/
|
||||
setCardStatusBarDefault(this.statusBarStyle);
|
||||
this.animation!.onFinish(async () => {
|
||||
await this.dismiss(undefined, GESTURE);
|
||||
this.gestureAnimationDismissing = false;
|
||||
});
|
||||
});
|
||||
this.gesture = createSwipeToCloseGesture(
|
||||
el,
|
||||
ani,
|
||||
statusBarStyle,
|
||||
() => this.cardOnDismiss(),
|
||||
() => this.onDragStart(),
|
||||
(detail: ModalDragEventDetail) => this.onDragMove(detail),
|
||||
(detail: ModalDragEventDetail) => this.onDragEnd(detail)
|
||||
);
|
||||
this.gesture.enable(true);
|
||||
}
|
||||
|
||||
@@ -755,7 +757,10 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
this.currentBreakpoint = breakpoint;
|
||||
this.ionBreakpointDidChange.emit({ breakpoint });
|
||||
}
|
||||
}
|
||||
},
|
||||
() => this.onDragStart(),
|
||||
(detail: ModalDragEventDetail) => this.onDragMove(detail),
|
||||
(detail: ModalDragEventDetail) => this.onDragEnd(detail)
|
||||
);
|
||||
|
||||
this.gesture = gesture;
|
||||
@@ -869,6 +874,34 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
});
|
||||
}
|
||||
|
||||
private cardOnDismiss() {
|
||||
/**
|
||||
* While the gesture animation is finishing
|
||||
* it is possible for a user to tap the backdrop.
|
||||
* This would result in the dismiss animation
|
||||
* being played again. Typically this is avoided
|
||||
* by setting `presented = false` on the overlay
|
||||
* component; however, we cannot do that here as
|
||||
* that would prevent the element from being
|
||||
* removed from the DOM.
|
||||
*/
|
||||
this.gestureAnimationDismissing = true;
|
||||
|
||||
/**
|
||||
* Reset the status bar style as the dismiss animation
|
||||
* starts otherwise the status bar will be the wrong
|
||||
* color for the duration of the dismiss animation.
|
||||
* The dismiss method does this as well, but
|
||||
* in this case it's only called once the animation
|
||||
* has finished.
|
||||
*/
|
||||
setCardStatusBarDefault(this.statusBarStyle);
|
||||
this.animation!.onFinish(async () => {
|
||||
await this.dismiss(undefined, GESTURE);
|
||||
this.gestureAnimationDismissing = false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss the modal overlay after it has been presented.
|
||||
* This is a no-op if the overlay has not been presented yet. If you want
|
||||
@@ -1335,6 +1368,18 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
this.parentRemovalObserver = undefined;
|
||||
}
|
||||
|
||||
private onDragStart() {
|
||||
this.ionDragStart.emit();
|
||||
}
|
||||
|
||||
private onDragMove(detail: ModalDragEventDetail) {
|
||||
this.ionDragMove.emit(detail);
|
||||
}
|
||||
|
||||
private onDragEnd(detail: ModalDragEventDetail) {
|
||||
this.ionDragEnd.emit(detail);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
handle,
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<h2>iOS only</h2>
|
||||
<button class="expand" id="card" onclick="presentModal(document.querySelectorAll('.ion-page')[1])">
|
||||
Card Modal
|
||||
</button>
|
||||
@@ -50,6 +51,7 @@
|
||||
>
|
||||
Card Modal Custom Radius
|
||||
</button>
|
||||
<button class="expand" id="drag-events" onclick="dragEvents()">Card Modal Drag Events</button>
|
||||
</ion-content>
|
||||
</div>
|
||||
</ion-app>
|
||||
@@ -162,6 +164,24 @@
|
||||
const modal = await createModal(presentingEl, opts);
|
||||
await modal.present();
|
||||
}
|
||||
|
||||
async function dragEvents() {
|
||||
const modal = await createModal(document.querySelectorAll('.ion-page')[1], { id: 'drag-events' });
|
||||
|
||||
modal.addEventListener('ionDragStart', (event) => {
|
||||
console.log('Drag started');
|
||||
});
|
||||
|
||||
modal.addEventListener('ionDragMove', (event) => {
|
||||
console.log('Drag moved', event.detail);
|
||||
});
|
||||
|
||||
modal.addEventListener('ionDragEnd', (event) => {
|
||||
console.log('Drag ended', event.detail);
|
||||
});
|
||||
|
||||
await modal.present();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { configs, test } from '@utils/test/playwright';
|
||||
import { configs, dragElementBy, test } from '@utils/test/playwright';
|
||||
|
||||
import { CardModalPage } from '../fixtures';
|
||||
|
||||
@@ -95,4 +95,50 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, c
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe(title('card modal: drag events'), () => {
|
||||
test('should emit ionDragStart, ionDragMove, and ionDragEnd events', async ({ page }) => {
|
||||
await page.goto('/src/components/modal/test/card', config);
|
||||
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
|
||||
await page.click('#drag-events');
|
||||
await ionModalDidPresent.next();
|
||||
|
||||
const ionDragStart = await page.spyOnEvent('ionDragStart');
|
||||
const ionDragMove = await page.spyOnEvent('ionDragMove');
|
||||
const ionDragEnd = await page.spyOnEvent('ionDragEnd');
|
||||
|
||||
const header = page.locator('.modal-card ion-header');
|
||||
|
||||
// Start the drag to verify it emits the events before the gesture ends
|
||||
await dragElementBy(header, page, 0, 50, undefined, undefined, false);
|
||||
|
||||
await ionDragStart.next();
|
||||
const dragMoveEvent = await ionDragMove.next();
|
||||
|
||||
expect(ionDragStart.length).toBe(1);
|
||||
|
||||
expect(ionDragMove.length).toBeGreaterThan(0);
|
||||
expect(Object.keys(dragMoveEvent.detail).length).toBe(4);
|
||||
|
||||
expect(ionDragEnd.length).toBe(0);
|
||||
|
||||
/**
|
||||
* Drage the modal further to verify it does:
|
||||
* - not emit the event again for `ionDragStart`
|
||||
* - emit more `ionDragMove` events
|
||||
* - emit the `ionDragEnd` event when the gesture ends
|
||||
*/
|
||||
await dragElementBy(header, page, 0, 100);
|
||||
|
||||
const dragEndEvent = await ionDragEnd.next();
|
||||
|
||||
expect(ionDragStart.length).toBe(1);
|
||||
expect(ionDragMove.length).toBeGreaterThan(0);
|
||||
|
||||
expect(ionDragEnd.length).toBe(1);
|
||||
expect(Object.keys(dragEndEvent.detail).length).toBe(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 249 KiB After Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 250 KiB After Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 196 KiB After Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 196 KiB After Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 248 KiB |
@@ -152,6 +152,8 @@
|
||||
Backdrop is inactive
|
||||
</button>
|
||||
|
||||
<button id="drag-events" onclick="dragEvents()">Drag Events</button>
|
||||
|
||||
<div class="grid">
|
||||
<div class="grid-item red"></div>
|
||||
<div class="grid-item green"></div>
|
||||
@@ -246,6 +248,27 @@
|
||||
});
|
||||
await modal.present();
|
||||
}
|
||||
|
||||
function dragEvents() {
|
||||
const modal = createModal({
|
||||
initialBreakpoint: 0.5,
|
||||
breakpoints: [0, 0.25, 0.5, 0.75, 1],
|
||||
});
|
||||
|
||||
modal.addEventListener('ionDragStart', (event) => {
|
||||
console.log('Drag started');
|
||||
});
|
||||
|
||||
modal.addEventListener('ionDragMove', (event) => {
|
||||
console.log('Drag moved', event.detail);
|
||||
});
|
||||
|
||||
modal.addEventListener('ionDragEnd', (event) => {
|
||||
console.log('Drag ended', event.detail);
|
||||
});
|
||||
|
||||
modal.present();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -353,4 +353,50 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
|
||||
await expect(dragHandle).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe(title('sheet modal: drag events'), () => {
|
||||
test('should emit ionDragStart, ionDragMove, and ionDragEnd events', async ({ page }) => {
|
||||
await page.goto('/src/components/modal/test/sheet', config);
|
||||
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
|
||||
await page.click('#drag-events');
|
||||
await ionModalDidPresent.next();
|
||||
|
||||
const ionDragStart = await page.spyOnEvent('ionDragStart');
|
||||
const ionDragMove = await page.spyOnEvent('ionDragMove');
|
||||
const ionDragEnd = await page.spyOnEvent('ionDragEnd');
|
||||
|
||||
const header = page.locator('.modal-sheet ion-header');
|
||||
|
||||
// Start the drag to verify it emits the events before the gesture ends
|
||||
await dragElementBy(header, page, 0, 50, undefined, undefined, false);
|
||||
|
||||
await ionDragStart.next();
|
||||
const dragMoveEvent = await ionDragMove.next();
|
||||
|
||||
expect(ionDragStart.length).toBe(1);
|
||||
|
||||
expect(ionDragMove.length).toBeGreaterThan(0);
|
||||
expect(Object.keys(dragMoveEvent.detail).length).toBe(5);
|
||||
|
||||
expect(ionDragEnd.length).toBe(0);
|
||||
|
||||
/**
|
||||
* Drage the modal further to verify it does:
|
||||
* - not emit the event again for `ionDragStart`
|
||||
* - emit more `ionDragMove` events
|
||||
* - emit the `ionDragEnd` event when the gesture ends
|
||||
*/
|
||||
await dragElementBy(header, page, 0, 100);
|
||||
|
||||
const dragEndEvent = await ionDragEnd.next();
|
||||
|
||||
expect(ionDragStart.length).toBe(1);
|
||||
expect(ionDragMove.length).toBeGreaterThan(0);
|
||||
|
||||
expect(ionDragEnd.length).toBe(1);
|
||||
expect(Object.keys(dragEndEvent.detail).length).toBe(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
2
core/src/interface.d.ts
vendored
@@ -17,7 +17,7 @@ export { CounterFormatter } from './components/item/item-interface';
|
||||
export { ItemSlidingCustomEvent } from './components/item-sliding/item-sliding-interface';
|
||||
export { LoadingOptions } from './components/loading/loading-interface';
|
||||
export { MenuCustomEvent, MenuI, MenuControllerI } from './components/menu/menu-interface';
|
||||
export { ModalOptions, ModalCustomEvent } from './components/modal/modal-interface';
|
||||
export { ModalOptions, ModalCustomEvent, ModalDragEventDetail } from './components/modal/modal-interface';
|
||||
export { NavDirection, NavCustomEvent } from './components/nav/nav-interface';
|
||||
export { PickerOptions, PickerColumnOption } from './components/picker-legacy/picker-interface';
|
||||
export { PopoverOptions } from './components/popover/popover-interface';
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
NgZone,
|
||||
TemplateRef,
|
||||
} from '@angular/core';
|
||||
import type { Components, ModalBreakpointChangeEventDetail } from '@ionic/core/components';
|
||||
import type { Components, ModalBreakpointChangeEventDetail, ModalDragEventDetail } from '@ionic/core/components';
|
||||
|
||||
import { ProxyCmp, proxyOutputs } from '../utils/proxy';
|
||||
|
||||
@@ -32,6 +32,18 @@ export declare interface IonModal extends Components.IonModal {
|
||||
* Emitted after the modal breakpoint has changed.
|
||||
*/
|
||||
ionBreakpointDidChange: EventEmitter<CustomEvent<ModalBreakpointChangeEventDetail>>;
|
||||
/**
|
||||
* Emitted when the sheet or card modal has started being dragged.
|
||||
*/
|
||||
ionDragStart: EventEmitter<void>;
|
||||
/**
|
||||
* Emitted while the sheet or card modal is being dragged.
|
||||
*/
|
||||
ionDragMove: EventEmitter<CustomEvent<ModalDragEventDetail>>;
|
||||
/**
|
||||
* Emitted when the sheet or card modal has finished being dragged.
|
||||
*/
|
||||
ionDragEnd: EventEmitter<CustomEvent<ModalDragEventDetail>>;
|
||||
/**
|
||||
* Emitted after the modal has presented. Shorthand for ionModalDidPresent.
|
||||
*/
|
||||
@@ -130,6 +142,9 @@ export class IonModal {
|
||||
'willPresent',
|
||||
'willDismiss',
|
||||
'didDismiss',
|
||||
'ionDragStart',
|
||||
'ionDragMove',
|
||||
'ionDragEnd',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ export {
|
||||
IonicSafeString,
|
||||
LoadingOptions,
|
||||
MenuCustomEvent,
|
||||
ModalDragEventDetail,
|
||||
NavCustomEvent,
|
||||
PickerOptions,
|
||||
PickerButton,
|
||||
|
||||
@@ -97,6 +97,7 @@ export {
|
||||
IonicSafeString,
|
||||
LoadingOptions,
|
||||
MenuCustomEvent,
|
||||
ModalDragEventDetail,
|
||||
NavCustomEvent,
|
||||
PickerOptions,
|
||||
PickerButton,
|
||||
|
||||
@@ -54,6 +54,7 @@ export {
|
||||
IonicSafeString,
|
||||
LoadingOptions,
|
||||
MenuCustomEvent,
|
||||
ModalDragEventDetail,
|
||||
ModalOptions,
|
||||
NavCustomEvent,
|
||||
PickerOptions,
|
||||
|
||||
@@ -91,6 +91,7 @@ export {
|
||||
IonicSafeString,
|
||||
LoadingOptions,
|
||||
MenuCustomEvent,
|
||||
ModalDragEventDetail,
|
||||
ModalOptions,
|
||||
NavCustomEvent,
|
||||
PickerOptions,
|
||||
|
||||
@@ -109,6 +109,11 @@ export const defineOverlayContainer = <Props extends object>(
|
||||
delete restOfProps.onDidPresent;
|
||||
delete restOfProps.onWillDismiss;
|
||||
delete restOfProps.onDidDismiss;
|
||||
if (name === "ion-modal") {
|
||||
delete restOfProps.onIonDragStart;
|
||||
delete restOfProps.onIonDragMove;
|
||||
delete restOfProps.onIonDragEnd;
|
||||
}
|
||||
|
||||
const component = slots.default && slots.default()[0];
|
||||
overlay.value = controller.create({
|
||||
@@ -174,6 +179,24 @@ export const defineOverlayContainer = <Props extends object>(
|
||||
emit("didPresent", ev);
|
||||
emit(componentName + "DidPresent", ev);
|
||||
});
|
||||
/**
|
||||
* Modal drag events:
|
||||
* Adding these ensures they are re-emitted so developers can
|
||||
* use @ionDragStart, @ionDragMove, etc. in their templates.
|
||||
*/
|
||||
if (name === "ion-modal") {
|
||||
elementRef.value.addEventListener("ionDragStart", (ev: Event) => {
|
||||
emit("ionDragStart", ev);
|
||||
});
|
||||
|
||||
elementRef.value.addEventListener("ionDragMove", (ev: Event) => {
|
||||
emit("ionDragMove", ev);
|
||||
});
|
||||
|
||||
elementRef.value.addEventListener("ionDragEnd", (ev: Event) => {
|
||||
emit("ionDragEnd", ev);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
||||