diff --git a/angular/src/directives/navigation/ion-router-outlet.ts b/angular/src/directives/navigation/ion-router-outlet.ts index 9bfacd11f3..786e63b67f 100644 --- a/angular/src/directives/navigation/ion-router-outlet.ts +++ b/angular/src/directives/navigation/ion-router-outlet.ts @@ -16,6 +16,8 @@ import { RouteView, getUrl } from './stack-utils'; inputs: ['animated', 'swipeGesture'] }) export class IonRouterOutlet implements OnDestroy, OnInit { + nativeEl: HTMLIonRouterOutletElement; + private activated: ComponentRef | null = null; private activatedView: RouteView | null = null; @@ -23,7 +25,6 @@ export class IonRouterOutlet implements OnDestroy, OnInit { private _swipeGesture?: boolean; private name: string; private stackCtrl: StackController; - private nativeEl: HTMLIonRouterOutletElement; // Maintain map of activated route proxies for each component instance private proxyMap = new WeakMap(); diff --git a/core/api.txt b/core/api.txt index 3b248e57df..cf5135f220 100644 --- a/core/api.txt +++ b/core/api.txt @@ -683,7 +683,9 @@ ion-modal,prop,enterAnimation,((baseEl: any, opts?: any) => Animation) | undefin ion-modal,prop,keyboardClose,boolean,true,false,false ion-modal,prop,leaveAnimation,((baseEl: any, opts?: any) => Animation) | undefined,undefined,false,false ion-modal,prop,mode,"ios" | "md",undefined,false,false +ion-modal,prop,presentingElement,HTMLElement | undefined,undefined,false,false ion-modal,prop,showBackdrop,boolean,true,false,false +ion-modal,prop,swipeToClose,boolean,false,false,false ion-modal,method,dismiss,dismiss(data?: any, role?: string | undefined) => Promise ion-modal,method,onDidDismiss,onDidDismiss() => Promise> ion-modal,method,onWillDismiss,onWillDismiss() => Promise> diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 1e73aee79e..0eb3ce1765 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -1500,9 +1500,17 @@ export namespace Components { */ 'present': () => Promise; /** + * The element that presented the modal. This is used for card presentation effects and for stacking multiple modals on top of each other. Only applies in iOS mode. + */ + 'presentingElement'?: HTMLElement; + /** * If `true`, a backdrop will be displayed behind the modal. */ 'showBackdrop': boolean; + /** + * If `true`, the modal can be swiped to dismiss. Only applies in iOS mode. + */ + 'swipeToClose': boolean; } interface IonModalController { /** @@ -4853,9 +4861,17 @@ declare namespace LocalJSX { */ 'onIonModalWillPresent'?: (event: CustomEvent) => void; /** + * The element that presented the modal. This is used for card presentation effects and for stacking multiple modals on top of each other. Only applies in iOS mode. + */ + 'presentingElement'?: HTMLElement; + /** * If `true`, a backdrop will be displayed behind the modal. */ 'showBackdrop'?: boolean; + /** + * If `true`, the modal can be swiped to dismiss. Only applies in iOS mode. + */ + 'swipeToClose'?: boolean; } interface IonModalController {} interface IonNav { diff --git a/core/src/components/alert/alert.tsx b/core/src/components/alert/alert.tsx index e2e9f4b1a3..df54e66a46 100644 --- a/core/src/components/alert/alert.tsx +++ b/core/src/components/alert/alert.tsx @@ -1,7 +1,7 @@ import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, Watch, h } from '@stencil/core'; import { getIonMode } from '../../global/ionic-global'; -import { AlertButton, AlertInput, Animation, AnimationBuilder, CssClassMap, OverlayEventDetail, OverlayInterface } from '../../interface'; +import { AlertButton, AlertInput, AnimationBuilder, CssClassMap, OverlayEventDetail, OverlayInterface } from '../../interface'; import { BACKDROP, dismiss, eventMethod, isCancel, prepareOverlay, present, safeCall } from '../../utils/overlays'; import { sanitizeDOMString } from '../../utils/sanitization'; import { getClassMap } from '../../utils/theme'; @@ -30,7 +30,6 @@ export class Alert implements ComponentInterface, OverlayInterface { private processedButtons: AlertButton[] = []; presented = false; - animation?: Animation; mode = getIonMode(this); @Element() el!: HTMLIonAlertElement; diff --git a/core/src/components/content/content.scss b/core/src/components/content/content.scss index 1978ae4074..31230f505a 100644 --- a/core/src/components/content/content.scss +++ b/core/src/components/content/content.scss @@ -120,6 +120,8 @@ } .transition-effect { + + display: none; position: absolute; /* stylelint-disable property-blacklist */ diff --git a/core/src/components/loading/loading.tsx b/core/src/components/loading/loading.tsx index 63a1c69e97..c83c15575c 100644 --- a/core/src/components/loading/loading.tsx +++ b/core/src/components/loading/loading.tsx @@ -2,7 +2,7 @@ import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Meth import { config } from '../../global/config'; import { getIonMode } from '../../global/ionic-global'; -import { Animation, AnimationBuilder, OverlayEventDetail, OverlayInterface, SpinnerTypes } from '../../interface'; +import { AnimationBuilder, OverlayEventDetail, OverlayInterface, SpinnerTypes } from '../../interface'; import { BACKDROP, dismiss, eventMethod, prepareOverlay, present } from '../../utils/overlays'; import { sanitizeDOMString } from '../../utils/sanitization'; import { getClassMap } from '../../utils/theme'; @@ -27,7 +27,6 @@ export class Loading implements ComponentInterface, OverlayInterface { private durationTimeout: any; presented = false; - animation?: Animation; mode = getIonMode(this); @Element() el!: HTMLIonLoadingElement; diff --git a/core/src/components/modal/animations/ios.enter.ts b/core/src/components/modal/animations/ios.enter.ts index a2a58beed5..c262e3a39d 100644 --- a/core/src/components/modal/animations/ios.enter.ts +++ b/core/src/components/modal/animations/ios.enter.ts @@ -1,90 +1,53 @@ import { Animation } from '../../../interface'; import { createAnimation } from '../../../utils/animation/animation'; +import { SwipeToCloseDefaults } from '../gestures/swipe-to-close'; /** - * iOS Modal Enter Animation + * iOS Modal Enter Animation for the Card presentation style */ -export const iosEnterAnimation = (baseEl: HTMLElement): Animation => { - const baseAnimation = createAnimation(); - const backdropAnimation = createAnimation(); - const wrapperAnimation = createAnimation(); - - backdropAnimation +export const iosEnterAnimation = ( + baseEl: HTMLElement, + presentingEl?: HTMLElement, + ): Animation => { + // The top translate Y for the presenting element + const backdropAnimation = createAnimation() .addElement(baseEl.querySelector('ion-backdrop')!) .fromTo('opacity', 0.01, 'var(--backdrop-opacity)'); - wrapperAnimation + const wrapperAnimation = createAnimation() .addElement(baseEl.querySelector('.modal-wrapper')!) .beforeStyles({ 'opacity': 1 }) .fromTo('transform', 'translateY(100%)', 'translateY(0%)'); - return baseAnimation + const baseAnimation = createAnimation() .addElement(baseEl) - .easing('cubic-bezier(0.36,0.66,0.04,1)') - .duration(400) + .easing('cubic-bezier(0.32,0.72,0,1)') + .duration(500) .beforeAddClass('show-modal') .addAnimation([backdropAnimation, wrapperAnimation]); + + if (presentingEl) { + const modalTransform = (presentingEl.tagName === 'ION-MODAL' && (presentingEl as HTMLIonModalElement).presentingElement !== undefined) ? 40 : 0; + const bodyEl = document.body; + const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE; + const finalTransform = `translateY(${-modalTransform}px) scale(${toPresentingScale})`; + + const presentingAnimation = createAnimation() + .beforeStyles({ + 'transform': 'translateY(0)' + }) + .afterStyles({ + 'transform': finalTransform + }) + .beforeAddWrite(() => bodyEl.style.setProperty('background-color', 'black')) + .addElement(presentingEl) + .keyframes([ + { offset: 0, transform: 'translateY(0px) scale(1)', 'border-radius': '0px' }, + { offset: 1, transform: finalTransform, 'border-radius': '10px 10px 0 0' } + ]); + + baseAnimation.addAnimation(presentingAnimation); + } + + return baseAnimation; }; - -/** - * Animations for modals - */ -// export function modalSlideIn(rootEl: HTMLElement) { - -// } - -// export class ModalSlideOut { -// constructor(el: HTMLElement) { -// let backdrop = new Animation(this.plt, el.querySelector('ion-backdrop')); -// let wrapperEle = el.querySelector('.modal-wrapper'); -// let wrapperEleRect = wrapperEle.getBoundingClientRect(); -// let wrapper = new Animation(this.plt, wrapperEle); - -// // height of the screen - top of the container tells us how much to scoot it down -// // so it's off-screen -// wrapper.fromTo('translateY', '0px', `${this.plt.height() - wrapperEleRect.top}px`); -// backdrop.fromTo('opacity', 0.4, 0.0); - -// this -// .element(this.leavingView.pageRef()) -// .easing('ease-out') -// .duration(250) -// .add(backdrop) -// .add(wrapper); -// } -// } - -// export class ModalMDSlideIn { -// constructor(el: HTMLElement) { -// const backdrop = new Animation(this.plt, el.querySelector('ion-backdrop')); -// const wrapper = new Animation(this.plt, el.querySelector('.modal-wrapper')); - -// backdrop.fromTo('opacity', 0.01, 0.4); -// wrapper.fromTo('translateY', '40px', '0px'); -// wrapper.fromTo('opacity', 0.01, 1); - -// const DURATION = 280; -// const EASING = 'cubic-bezier(0.36,0.66,0.04,1)'; -// this.element(this.enteringView.pageRef()).easing(EASING).duration(DURATION) -// .add(backdrop) -// .add(wrapper); -// } -// } - -// export class ModalMDSlideOut { -// constructor(el: HTMLElement) { -// const backdrop = new Animation(this.plt, el.querySelector('ion-backdrop')); -// const wrapper = new Animation(this.plt, el.querySelector('.modal-wrapper')); - -// backdrop.fromTo('opacity', 0.4, 0.0); -// wrapper.fromTo('translateY', '0px', '40px'); -// wrapper.fromTo('opacity', 0.99, 0); - -// this -// .element(this.leavingView.pageRef()) -// .duration(200) -// .easing('cubic-bezier(0.47,0,0.745,0.715)') -// .add(wrapper) -// .add(backdrop); -// } -// } diff --git a/core/src/components/modal/animations/ios.leave.ts b/core/src/components/modal/animations/ios.leave.ts index ec4dd6a1fc..927bf37457 100644 --- a/core/src/components/modal/animations/ios.leave.ts +++ b/core/src/components/modal/animations/ios.leave.ts @@ -1,28 +1,55 @@ import { Animation } from '../../../interface'; import { createAnimation } from '../../../utils/animation/animation'; +import { SwipeToCloseDefaults } from '../gestures/swipe-to-close'; /** * iOS Modal Leave Animation */ -export const iosLeaveAnimation = (baseEl: HTMLElement): Animation => { - const baseAnimation = createAnimation(); - const backdropAnimation = createAnimation(); - const wrapperAnimation = createAnimation(); - const wrapperEl = baseEl.querySelector('.modal-wrapper'); - const wrapperElRect = wrapperEl!.getBoundingClientRect(); +export const iosLeaveAnimation = ( + baseEl: HTMLElement, + presentingEl?: HTMLElement, + duration = 500 + ): Animation => { - backdropAnimation + const backdropAnimation = createAnimation() .addElement(baseEl.querySelector('ion-backdrop')!) .fromTo('opacity', 'var(--backdrop-opacity)', 0.0); - wrapperAnimation - .addElement(wrapperEl!) + const wrapperAnimation = createAnimation() + .addElement(baseEl.querySelector('.modal-wrapper')!) .beforeStyles({ 'opacity': 1 }) - .fromTo('transform', 'translateY(0%)', `translateY(${(baseEl.ownerDocument as any).defaultView.innerHeight - wrapperElRect.top}px)`); + .fromTo('transform', `translateY(0%)`, 'translateY(100%)'); - return baseAnimation + const baseAnimation = createAnimation() .addElement(baseEl) - .easing('ease-out') - .duration(250) + .easing('cubic-bezier(0.32,0.72,0,1)') + .duration(duration) .addAnimation([backdropAnimation, wrapperAnimation]); + + if (presentingEl) { + const modalTransform = (presentingEl.tagName === 'ION-MODAL' && (presentingEl as HTMLIonModalElement).presentingElement !== undefined) ? 40 : 0; + const bodyEl = document.body; + const currentPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE; + const presentingAnimation = createAnimation() + .addElement(presentingEl) + .beforeClearStyles(['transform']) + .afterClearStyles(['transform']) + .onFinish(currentStep => { + // only reset background color if this is the last card-style modal + if (currentStep !== 1) { return; } + + const numModals = Array.from(bodyEl.querySelectorAll('ion-modal')).filter(m => m.presentingElement !== undefined).length; + if (numModals <= 1) { + bodyEl.style.setProperty('background-color', ''); + } + }) + .keyframes([ + { offset: 0, transform: `translateY(${-modalTransform}px) scale(${currentPresentingScale})`, 'border-radius': '10px 10px 0 0' }, + { offset: 1, transform: 'translateY(0px) scale(1)', 'border-radius': '0px' } + ]); + + baseAnimation.addAnimation(presentingAnimation); + } + + return baseAnimation; }; diff --git a/core/src/components/modal/gestures/swipe-to-close.ts b/core/src/components/modal/gestures/swipe-to-close.ts new file mode 100644 index 0000000000..d9ba18a4d9 --- /dev/null +++ b/core/src/components/modal/gestures/swipe-to-close.ts @@ -0,0 +1,97 @@ +import { Animation } from '../../../interface'; +import { getTimeGivenProgression } from '../../../utils/animation/cubic-bezier'; +import { GestureDetail, createGesture } from '../../../utils/gesture'; +import { clamp } from '../../../utils/helpers'; + +// Defaults for the card swipe animation +export const SwipeToCloseDefaults = { + MIN_BACKDROP_OPACITY: 0.4, + MIN_PRESENTING_SCALE: 0.95, + MIN_Y_CARD: 44, + MIN_Y_FULLSCREEN: 0, + MIN_PRESENTING_Y: 0 +}; + +export const createSwipeToCloseGesture = ( + el: HTMLIonModalElement, + animation: Animation, + onDismiss: () => void +) => { + const height = el.offsetHeight; + let isOpen = false; + + const canStart = (detail: GestureDetail) => { + const target = detail.event.target as HTMLElement | null; + + if (target === null || + !(target as any).closest) { + return true; + } + + const content = target.closest('ion-content'); + if (content === null) { + return true; + } + // Target is in the content so we don't start the gesture. + // We could be more nuanced here and allow it for content that + // does not need to scroll. + return false; + }; + + const onStart = () => { + animation.progressStart(true, (isOpen) ? 1 : 0); + }; + + const onMove = (detail: GestureDetail) => { + const step = detail.deltaY / height; + if (step < 0) { return; } + + animation.progressStep(step); + }; + + const onEnd = (detail: GestureDetail) => { + const velocity = detail.velocityY; + const step = detail.deltaY / height; + if (step < 0) { return; } + + const threshold = (detail.deltaY + velocity * 1000) / height; + + const shouldComplete = threshold >= 0.5; + let newStepValue = (shouldComplete) ? -0.001 : 0.001; + + if (!shouldComplete) { + animation.easing('cubic-bezier(1, 0, 0.68, 0.28)'); + newStepValue += getTimeGivenProgression([0, 0], [1, 0], [0.68, 0.28], [1, 1], step)[0]; + } else { + animation.easing('cubic-bezier(0.32, 0.72, 0, 1)'); + newStepValue += getTimeGivenProgression([0, 0], [0.32, 0.72], [0, 1], [1, 1], step)[0]; + } + + const duration = (shouldComplete) ? computeDuration(step * height, velocity) : computeDuration((1 - step) * height, velocity); + isOpen = shouldComplete; + + animation + .onFinish(() => { + if (shouldComplete) { + onDismiss(); + } + }) + .progressEnd((shouldComplete) ? 1 : 0, newStepValue, duration); + }; + + return createGesture({ + el, + gestureName: 'modalSwipeToClose', + gesturePriority: 40, + direction: 'y', + threshold: 10, + canStart, + onStart, + onMove, + onEnd + }); +}; + +const computeDuration = (remaining: number, velocity: number) => { + return clamp(100, remaining / Math.abs(velocity * 1.1), 400); +}; diff --git a/core/src/components/modal/modal-interface.ts b/core/src/components/modal/modal-interface.ts index a7b1e0ed8e..dc007b1996 100644 --- a/core/src/components/modal/modal-interface.ts +++ b/core/src/components/modal/modal-interface.ts @@ -3,11 +3,13 @@ import { AnimationBuilder, ComponentProps, ComponentRef, FrameworkDelegate, Mode export interface ModalOptions { component: T; componentProps?: ComponentProps; + presentingElement?: HTMLElement; showBackdrop?: boolean; backdropDismiss?: boolean; cssClass?: string | string[]; delegate?: FrameworkDelegate; animated?: boolean; + swipeToClose?: boolean; mode?: Mode; keyboardClose?: boolean; diff --git a/core/src/components/modal/modal.ios.scss b/core/src/components/modal/modal.ios.scss index 540e32529b..469bbf2dac 100644 --- a/core/src/components/modal/modal.ios.scss +++ b/core/src/components/modal/modal.ios.scss @@ -18,3 +18,12 @@ // hidden by default to prevent flickers, the animation will show it @include transform(translate3d(0, 100%, 0)); } + +:host(.modal-card) { + align-items: flex-end; +} + +:host(.modal-card) .modal-wrapper { + @include border-radius($modal-ios-border-radius, $modal-ios-border-radius, 0, 0); + height: calc(100% - 40px); +} \ No newline at end of file diff --git a/core/src/components/modal/modal.ios.vars.scss b/core/src/components/modal/modal.ios.vars.scss index d228b75d63..666be8fb9e 100644 --- a/core/src/components/modal/modal.ios.vars.scss +++ b/core/src/components/modal/modal.ios.vars.scss @@ -8,3 +8,5 @@ $modal-ios-background-color: $background-color !default; /// @prop - Border radius for the modal $modal-ios-border-radius: 10px !default; + +$modal-ios-card-border-radius: 10px !default; diff --git a/core/src/components/modal/modal.md.scss b/core/src/components/modal/modal.md.scss index a32d493717..7288a831d0 100644 --- a/core/src/components/modal/modal.md.scss +++ b/core/src/components/modal/modal.md.scss @@ -20,4 +20,4 @@ @include transform(translate3d(0, 40px, 0)); opacity: .01; -} +} \ No newline at end of file diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index 714b55aab3..676a8bfeb6 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -1,9 +1,9 @@ import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, h } from '@stencil/core'; import { getIonMode } from '../../global/ionic-global'; -import { Animation, AnimationBuilder, ComponentProps, ComponentRef, FrameworkDelegate, OverlayEventDetail, OverlayInterface } from '../../interface'; +import { Animation, AnimationBuilder, ComponentProps, ComponentRef, FrameworkDelegate, Gesture, OverlayEventDetail, OverlayInterface } from '../../interface'; import { attachComponent, detachComponent } from '../../utils/framework-delegate'; -import { BACKDROP, dismiss, eventMethod, prepareOverlay, present } from '../../utils/overlays'; +import { BACKDROP, activeAnimations, dismiss, eventMethod, prepareOverlay, present } from '../../utils/overlays'; import { getClassMap } from '../../utils/theme'; import { deepReady } from '../../utils/transition'; @@ -11,6 +11,7 @@ import { iosEnterAnimation } from './animations/ios.enter'; import { iosLeaveAnimation } from './animations/ios.leave'; import { mdEnterAnimation } from './animations/md.enter'; import { mdLeaveAnimation } from './animations/md.leave'; +import { createSwipeToCloseGesture } from './gestures/swipe-to-close'; /** * @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use. @@ -24,11 +25,13 @@ import { mdLeaveAnimation } from './animations/md.leave'; scoped: true }) export class Modal implements ComponentInterface, OverlayInterface { + private gesture?: Gesture; + // Reference to the user's provided modal content private usersElement?: HTMLElement; presented = false; - animation: Animation | undefined; + animation?: Animation; mode = getIonMode(this); @Element() el!: HTMLIonModalElement; @@ -85,6 +88,17 @@ export class Modal implements ComponentInterface, OverlayInterface { */ @Prop() animated = true; + /** + * If `true`, the modal can be swiped to dismiss. Only applies in iOS mode. + */ + @Prop() swipeToClose = false; + + /** + * The element that presented the modal. This is used for card presentation effects + * and for stacking multiple modals on top of each other. Only applies in iOS mode. + */ + @Prop() presentingElement?: HTMLElement; + /** * Emitted after the modal has presented. */ @@ -127,7 +141,21 @@ export class Modal implements ComponentInterface, OverlayInterface { }; this.usersElement = await attachComponent(this.delegate, container, this.component, ['ion-page'], componentProps); await deepReady(this.usersElement); - return present(this, 'modalEnter', iosEnterAnimation, mdEnterAnimation); + await present(this, 'modalEnter', iosEnterAnimation, mdEnterAnimation, this.presentingElement); + + const mode = getIonMode(this); + if (this.swipeToClose && mode === 'ios') { + // All of the elements needed for the swipe gesture + // should be in the DOM and referenced by now, except + // for the presenting el + const ani = this.animation = iosLeaveAnimation(this.el, this.presentingElement); + this.gesture = createSwipeToCloseGesture( + this.el, + ani, + () => this.dismiss(undefined, 'gesture') + ); + this.gesture.enable(true); + } } /** @@ -138,10 +166,21 @@ export class Modal implements ComponentInterface, OverlayInterface { */ @Method() async dismiss(data?: any, role?: string): Promise { - const dismissed = await dismiss(this, data, role, 'modalLeave', iosLeaveAnimation, mdLeaveAnimation); + const iosAni = (this.animation === undefined || (role === BACKDROP || role === undefined)) ? iosLeaveAnimation : undefined; + const enteringAnimation = activeAnimations.get(this) || []; + const dismissed = await dismiss(this, data, role, 'modalLeave', iosAni, mdLeaveAnimation, this.presentingElement); + if (dismissed) { await detachComponent(this.delegate, this.usersElement); + if (this.animation) { + this.animation.destroy(); + } + + enteringAnimation.forEach(ani => ani.destroy()); } + + this.animation = undefined; + return dismissed; } @@ -194,6 +233,7 @@ export class Modal implements ComponentInterface, OverlayInterface { aria-modal="true" class={{ [mode]: true, + [`modal-card`]: this.presentingElement !== undefined, ...getClassMap(this.cssClass) }} style={{ @@ -209,10 +249,7 @@ export class Modal implements ComponentInterface, OverlayInterface { diff --git a/core/src/components/modal/readme.md b/core/src/components/modal/readme.md index dd636eb40a..02ab708d6f 100644 --- a/core/src/components/modal/readme.md +++ b/core/src/components/modal/readme.md @@ -153,6 +153,42 @@ import { EventModalModule } from '../modals/event/event.module'; export class CalendarComponentModule {} ``` +### Swipeable Modals + +Modals in iOS mode have the ability to be presented in a card-style and swiped to close. The card-style presentation and swipe to close gesture are not mutually exclusive, meaning you can pick and choose which features you want to use. For example, you can have a card-style modal that cannot be swiped or a full sized modal that can be swiped. + +```javascript +import { IonRouterOutlet } from '@ionic/angular'; + +constructor(private routerOutlet: IonRouterOutlet) {} + +async presentModal() { + const modal = await this.modalController.create({ + component: ModalPage, + swipeToClose: true, + presentingElement: this.routerOutlet.nativeEl + }); + return await modal.present(); +} +``` + +In most scenarios, using the `ion-router-outlet` element as the `presentingElement` is fine. In cases where you are presenting a card-style modal from within another modal, you should pass in the top-most `ion-modal` element as the `presentingElement`. + +```javascript +import { ModalController } from '@ionic/angular'; + +constructor(private modalCtrl: ModalController) {} + +async presentModal() { + const modal = await this.modalController.create({ + component: ModalPage, + swipeToClose: true, + presentingElement: await this.modalCtrl.getTop() // Get the top-most ion-modal + }); + return await modal.present(); +} +``` + ### Javascript @@ -235,6 +271,27 @@ console.log(data); ``` +### Swipeable Modals + +Modals in iOS mode have the ability to be presented in a card-style and swiped to close. The card-style presentation and swipe to close gesture are not mutually exclusive, meaning you can pick and choose which features you want to use. For example, you can have a card-style modal that cannot be swiped or a full sized modal that can be swiped. + +```javascript +const modalElement = document.createElement('ion-modal'); +modalElement.component = 'modal-page'; +modalElement.swipeToClose = true; +modalElement.presentingElement = document.querySelector('ion-nav'); +``` + +In most scenarios, using the `ion-nav` element as the `presentingElement` is fine. In cases where you are presenting a card-style modal from within a modal, you should pass in the top-most `ion-modal` element as the `presentingElement`. + +```javascript +const modalElement = document.createElement('ion-modal'); +modalElement.component = 'modal-page'; +modalElement.swipeToClose = true; +modalElement.presentingElement = await modalController.getTop(); // Get the top-most ion-modal +``` + + ### React ```tsx @@ -256,6 +313,43 @@ export const ModalExample: React.FC = () => { }; ``` +### Swipeable Modals + +Modals in iOS mode have the ability to be presented in a card-style and swiped to close. The card-style presentation and swipe to close gesture are not mutually exclusive, meaning you can pick and choose which features you want to use. For example, you can have a card-style modal that cannot be swiped or a full sized modal that can be swiped. + +```tsx + setShowModal(false)}> +

This is modal content

+ setShowModal(false)}>Close Modal +
+``` + +In most scenarios, setting a ref on `IonPage` and passing that ref's `current` value to `presentingElement` is fine. In cases where you are presenting a card-style modal from within another modal, you should pass in the top-most `ion-modal` ref as the `presentingElement`. + +```tsx + setShowModal(false)}> +

This is modal content

+ setShow2ndModal(true)}>Show 2nd Modal + setShowModal(false)}>Close Modal +
+ setShow2ndModal(false)}> +

This is more modal content

+ setShow2ndModal(false)}>Close Modal +
+``` + ### Vue @@ -326,18 +420,20 @@ export default { ## Properties -| Property | Attribute | Description | Type | Default | -| ------------------------ | ------------------ | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ----------- | -| `animated` | `animated` | If `true`, the modal will animate. | `boolean` | `true` | -| `backdropDismiss` | `backdrop-dismiss` | If `true`, the modal will be dismissed when the backdrop is clicked. | `boolean` | `true` | -| `component` _(required)_ | `component` | The component to display inside of the modal. | `Function \| HTMLElement \| null \| string` | `undefined` | -| `componentProps` | -- | The data to pass to the modal component. | `undefined \| { [key: string]: any; }` | `undefined` | -| `cssClass` | `css-class` | Additional classes to apply for custom CSS. If multiple classes are provided they should be separated by spaces. | `string \| string[] \| undefined` | `undefined` | -| `enterAnimation` | -- | Animation to use when the modal is presented. | `((baseEl: any, opts?: any) => Animation) \| undefined` | `undefined` | -| `keyboardClose` | `keyboard-close` | If `true`, the keyboard will be automatically dismissed when the overlay is presented. | `boolean` | `true` | -| `leaveAnimation` | -- | Animation to use when the modal is dismissed. | `((baseEl: any, opts?: any) => Animation) \| undefined` | `undefined` | -| `mode` | `mode` | The mode determines which platform styles to use. | `"ios" \| "md"` | `undefined` | -| `showBackdrop` | `show-backdrop` | If `true`, a backdrop will be displayed behind the modal. | `boolean` | `true` | +| Property | Attribute | Description | Type | Default | +| ------------------------ | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ----------- | +| `animated` | `animated` | If `true`, the modal will animate. | `boolean` | `true` | +| `backdropDismiss` | `backdrop-dismiss` | If `true`, the modal will be dismissed when the backdrop is clicked. | `boolean` | `true` | +| `component` _(required)_ | `component` | The component to display inside of the modal. | `Function \| HTMLElement \| null \| string` | `undefined` | +| `componentProps` | -- | The data to pass to the modal component. | `undefined \| { [key: string]: any; }` | `undefined` | +| `cssClass` | `css-class` | Additional classes to apply for custom CSS. If multiple classes are provided they should be separated by spaces. | `string \| string[] \| undefined` | `undefined` | +| `enterAnimation` | -- | Animation to use when the modal is presented. | `((baseEl: any, opts?: any) => Animation) \| undefined` | `undefined` | +| `keyboardClose` | `keyboard-close` | If `true`, the keyboard will be automatically dismissed when the overlay is presented. | `boolean` | `true` | +| `leaveAnimation` | -- | Animation to use when the modal is dismissed. | `((baseEl: any, opts?: any) => Animation) \| undefined` | `undefined` | +| `mode` | `mode` | The mode determines which platform styles to use. | `"ios" \| "md"` | `undefined` | +| `presentingElement` | -- | The element that presented the modal. This is used for card presentation effects and for stacking multiple modals on top of each other. Only applies in iOS mode. | `HTMLElement \| undefined` | `undefined` | +| `showBackdrop` | `show-backdrop` | If `true`, a backdrop will be displayed behind the modal. | `boolean` | `true` | +| `swipeToClose` | `swipe-to-close` | If `true`, the modal can be swiped to dismiss. Only applies in iOS mode. | `boolean` | `false` | ## Events diff --git a/core/src/components/modal/test/basic/index.html b/core/src/components/modal/test/basic/index.html index 9a278a970e..d5d727842c 100644 --- a/core/src/components/modal/test/basic/index.html +++ b/core/src/components/modal/test/basic/index.html @@ -23,23 +23,26 @@ - - - Modal - Basic - - +
+ + + Modal - Basic + + - -

- Present modal -

-

- Present and close modal -

-

- Present and close modal (crash) -

-
+ +

+ Present modal +

+

+ Present and close modal +

+

+ Present and close modal (crash) +

+
+
+
@@ -77,8 +80,9 @@ } async function presentModal() { + const presentingEl = document.querySelectorAll('.ion-page')[1]; const modal = createModal(); - await modal.present(); + await modal.present(presentingEl); } async function presentCloseModal() { const modal = createModal(); diff --git a/core/src/components/modal/test/spec/e2e.ts b/core/src/components/modal/test/spec/e2e.ts new file mode 100644 index 0000000000..3962b3759b --- /dev/null +++ b/core/src/components/modal/test/spec/e2e.ts @@ -0,0 +1,11 @@ +import { testModal } from '../test.utils'; + +const DIRECTORY = 'spec'; + +test('modal: card', async () => { + await testModal(DIRECTORY, '#card-modal'); +}); + +test('modal:rtl: card', async () => { + await testModal(DIRECTORY, '#card-modal', true); +}); diff --git a/core/src/components/modal/test/spec/index.html b/core/src/components/modal/test/spec/index.html new file mode 100644 index 0000000000..583e1e1f38 --- /dev/null +++ b/core/src/components/modal/test/spec/index.html @@ -0,0 +1,478 @@ + + + + + + Modal - Spec + + + + + + + + + + + + +
+ + + + + + + + Favorites + + + + + + +
+ + +
+ + + + + diff --git a/core/src/components/modal/usage/angular.md b/core/src/components/modal/usage/angular.md index aa8e255e54..d8b16df1e0 100644 --- a/core/src/components/modal/usage/angular.md +++ b/core/src/components/modal/usage/angular.md @@ -129,4 +129,40 @@ import { EventModalModule } from '../modals/event/event.module'; }) export class CalendarComponentModule {} -``` \ No newline at end of file +``` + +### Swipeable Modals + +Modals in iOS mode have the ability to be presented in a card-style and swiped to close. The card-style presentation and swipe to close gesture are not mutually exclusive, meaning you can pick and choose which features you want to use. For example, you can have a card-style modal that cannot be swiped or a full sized modal that can be swiped. + +```javascript +import { IonRouterOutlet } from '@ionic/angular'; + +constructor(private routerOutlet: IonRouterOutlet) {} + +async presentModal() { + const modal = await this.modalController.create({ + component: ModalPage, + swipeToClose: true, + presentingElement: this.routerOutlet.nativeEl + }); + return await modal.present(); +} +``` + +In most scenarios, using the `ion-router-outlet` element as the `presentingElement` is fine. In cases where you are presenting a card-style modal from within another modal, you should pass in the top-most `ion-modal` element as the `presentingElement`. + +```javascript +import { ModalController } from '@ionic/angular'; + +constructor(private modalCtrl: ModalController) {} + +async presentModal() { + const modal = await this.modalController.create({ + component: ModalPage, + swipeToClose: true, + presentingElement: await this.modalCtrl.getTop() // Get the top-most ion-modal + }); + return await modal.present(); +} +``` diff --git a/core/src/components/modal/usage/javascript.md b/core/src/components/modal/usage/javascript.md index f272e0ffe7..52229bfdbf 100644 --- a/core/src/components/modal/usage/javascript.md +++ b/core/src/components/modal/usage/javascript.md @@ -75,4 +75,25 @@ After being dismissed, the data can be read in through the `onWillDismiss` or `o ```javascript const { data } = await modalElement.onWillDismiss(); console.log(data); -``` \ No newline at end of file +``` + + +### Swipeable Modals + +Modals in iOS mode have the ability to be presented in a card-style and swiped to close. The card-style presentation and swipe to close gesture are not mutually exclusive, meaning you can pick and choose which features you want to use. For example, you can have a card-style modal that cannot be swiped or a full sized modal that can be swiped. + +```javascript +const modalElement = document.createElement('ion-modal'); +modalElement.component = 'modal-page'; +modalElement.swipeToClose = true; +modalElement.presentingElement = document.querySelector('ion-nav'); +``` + +In most scenarios, using the `ion-nav` element as the `presentingElement` is fine. In cases where you are presenting a card-style modal from within a modal, you should pass in the top-most `ion-modal` element as the `presentingElement`. + +```javascript +const modalElement = document.createElement('ion-modal'); +modalElement.component = 'modal-page'; +modalElement.swipeToClose = true; +modalElement.presentingElement = await modalController.getTop(); // Get the top-most ion-modal +``` diff --git a/core/src/components/modal/usage/react.md b/core/src/components/modal/usage/react.md index edfccdffd8..e4aaf7231a 100644 --- a/core/src/components/modal/usage/react.md +++ b/core/src/components/modal/usage/react.md @@ -16,3 +16,40 @@ export const ModalExample: React.FC = () => { ); }; ``` + +### Swipeable Modals + +Modals in iOS mode have the ability to be presented in a card-style and swiped to close. The card-style presentation and swipe to close gesture are not mutually exclusive, meaning you can pick and choose which features you want to use. For example, you can have a card-style modal that cannot be swiped or a full sized modal that can be swiped. + +```tsx + setShowModal(false)}> +

This is modal content

+ setShowModal(false)}>Close Modal +
+``` + +In most scenarios, setting a ref on `IonPage` and passing that ref's `current` value to `presentingElement` is fine. In cases where you are presenting a card-style modal from within another modal, you should pass in the top-most `ion-modal` ref as the `presentingElement`. + +```tsx + setShowModal(false)}> +

This is modal content

+ setShow2ndModal(true)}>Show 2nd Modal + setShowModal(false)}>Close Modal +
+ setShow2ndModal(false)}> +

This is more modal content

+ setShow2ndModal(false)}>Close Modal +
+``` diff --git a/core/src/components/nav/nav.tsx b/core/src/components/nav/nav.tsx index ad0a107acb..982971ed06 100644 --- a/core/src/components/nav/nav.tsx +++ b/core/src/components/nav/nav.tsx @@ -986,7 +986,7 @@ export class Nav implements NavOutlet { newStepValue += getTimeGivenProgression([0, 0], [0.32, 0.72], [0, 1], [1, 1], stepValue)[0]; } - (this.sbAni as Animation).progressEnd(shouldComplete ? 1 : 0, newStepValue, dur); + this.sbAni.progressEnd(shouldComplete ? 1 : 0, newStepValue, dur); } } diff --git a/core/src/components/picker/picker.tsx b/core/src/components/picker/picker.tsx index b2edec2968..186b427613 100644 --- a/core/src/components/picker/picker.tsx +++ b/core/src/components/picker/picker.tsx @@ -1,7 +1,7 @@ import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, State, h } from '@stencil/core'; import { getIonMode } from '../../global/ionic-global'; -import { Animation, AnimationBuilder, CssClassMap, OverlayEventDetail, OverlayInterface, PickerButton, PickerColumn } from '../../interface'; +import { AnimationBuilder, CssClassMap, OverlayEventDetail, OverlayInterface, PickerButton, PickerColumn } from '../../interface'; import { BACKDROP, dismiss, eventMethod, isCancel, prepareOverlay, present, safeCall } from '../../utils/overlays'; import { getClassMap } from '../../utils/theme'; @@ -24,8 +24,6 @@ export class Picker implements ComponentInterface, OverlayInterface { mode = getIonMode(this); - animation?: Animation; - @Element() el!: HTMLIonPickerElement; @State() presented = false; diff --git a/core/src/components/popover/popover.tsx b/core/src/components/popover/popover.tsx index b72d26ce91..6f9011672e 100644 --- a/core/src/components/popover/popover.tsx +++ b/core/src/components/popover/popover.tsx @@ -1,7 +1,7 @@ import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, h } from '@stencil/core'; import { getIonMode } from '../../global/ionic-global'; -import { Animation, AnimationBuilder, ComponentProps, ComponentRef, FrameworkDelegate, OverlayEventDetail, OverlayInterface } from '../../interface'; +import { AnimationBuilder, ComponentProps, ComponentRef, FrameworkDelegate, OverlayEventDetail, OverlayInterface } from '../../interface'; import { attachComponent, detachComponent } from '../../utils/framework-delegate'; import { BACKDROP, dismiss, eventMethod, prepareOverlay, present } from '../../utils/overlays'; import { getClassMap } from '../../utils/theme'; @@ -28,7 +28,6 @@ export class Popover implements ComponentInterface, OverlayInterface { private usersElement?: HTMLElement; presented = false; - animation?: Animation; mode = getIonMode(this); @Element() el!: HTMLIonPopoverElement; diff --git a/core/src/components/router-outlet/route-outlet.tsx b/core/src/components/router-outlet/route-outlet.tsx index 018d99011a..ac53234fe6 100644 --- a/core/src/components/router-outlet/route-outlet.tsx +++ b/core/src/components/router-outlet/route-outlet.tsx @@ -96,7 +96,7 @@ export class RouterOutlet implements ComponentInterface, NavOutlet { newStepValue += getTimeGivenProgression([0, 0], [0.32, 0.72], [0, 1], [1, 1], step)[0]; } - (this.ani as Animation).progressEnd(shouldComplete ? 1 : 0, newStepValue, dur); + this.ani.progressEnd(shouldComplete ? 1 : 0, newStepValue, dur); } } diff --git a/core/src/components/toast/toast.tsx b/core/src/components/toast/toast.tsx index 0abf142385..e84987a332 100644 --- a/core/src/components/toast/toast.tsx +++ b/core/src/components/toast/toast.tsx @@ -1,7 +1,7 @@ import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, h } from '@stencil/core'; import { getIonMode } from '../../global/ionic-global'; -import { Animation, AnimationBuilder, Color, CssClassMap, OverlayEventDetail, OverlayInterface, ToastButton } from '../../interface'; +import { AnimationBuilder, Color, CssClassMap, OverlayEventDetail, OverlayInterface, ToastButton } from '../../interface'; import { dismiss, eventMethod, isCancel, prepareOverlay, present, safeCall } from '../../utils/overlays'; import { sanitizeDOMString } from '../../utils/sanitization'; import { createColorClasses, getClassMap } from '../../utils/theme'; @@ -27,7 +27,6 @@ export class Toast implements ComponentInterface, OverlayInterface { private durationTimeout: any; presented = false; - animation?: Animation; mode = getIonMode(this); @Element() el!: HTMLIonToastElement; diff --git a/core/src/utils/overlays-interface.ts b/core/src/utils/overlays-interface.ts index e4f3ddcda8..24b8b5ae4b 100644 --- a/core/src/utils/overlays-interface.ts +++ b/core/src/utils/overlays-interface.ts @@ -1,7 +1,7 @@ import { EventEmitter } from '@stencil/core'; import { HTMLStencilElement } from '@stencil/core/internal'; -import { Animation, AnimationBuilder, Mode } from '../interface'; +import { AnimationBuilder, Mode } from '../interface'; export interface OverlayEventDetail { data?: T; @@ -15,7 +15,6 @@ export interface OverlayInterface { keyboardClose: boolean; overlayIndex: number; presented: boolean; - animation?: Animation; enterAnimation?: AnimationBuilder; leaveAnimation?: AnimationBuilder; diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts index b0aaeb09fa..33e386dc87 100644 --- a/core/src/utils/overlays.ts +++ b/core/src/utils/overlays.ts @@ -1,8 +1,10 @@ import { config } from '../global/config'; -import { ActionSheetOptions, AlertOptions, AnimationBuilder, BackButtonEvent, HTMLIonOverlayElement, IonicConfig, LoadingOptions, ModalOptions, OverlayInterface, PickerOptions, PopoverOptions, ToastOptions } from '../interface'; +import { ActionSheetOptions, AlertOptions, Animation, AnimationBuilder, BackButtonEvent, HTMLIonOverlayElement, IonicConfig, LoadingOptions, ModalOptions, OverlayInterface, PickerOptions, PopoverOptions, ToastOptions } from '../interface'; let lastId = 0; +export const activeAnimations = new WeakMap(); + const createController = (tagName: string) => { return { create(options: Opts): Promise { @@ -140,7 +142,7 @@ export const dismiss = async ( data: any | undefined, role: string | undefined, name: keyof IonicConfig, - iosLeaveAnimation: AnimationBuilder, + iosLeaveAnimation: AnimationBuilder | undefined, mdLeaveAnimation: AnimationBuilder, opts?: any ): Promise => { @@ -156,9 +158,13 @@ export const dismiss = async ( ? overlay.leaveAnimation : config.get(name, overlay.mode === 'ios' ? iosLeaveAnimation : mdLeaveAnimation); - await overlayAnimation(overlay, animationBuilder, overlay.el, opts); + if (animationBuilder !== undefined) { + await overlayAnimation(overlay, animationBuilder, overlay.el, opts); + } overlay.didDismiss.emit({ data, role }); + activeAnimations.delete(overlay); + } catch (err) { console.error(err); } @@ -177,19 +183,12 @@ const overlayAnimation = async ( baseEl: any, opts: any ): Promise => { - if (overlay.animation) { - overlay.animation.destroy(); - overlay.animation = undefined; - return false; - } // Make overlay visible in case it's hidden baseEl.classList.remove('overlay-hidden'); const aniRoot = baseEl.shadowRoot || overlay.el; - const animation = animationBuilder(aniRoot, opts); - overlay.animation = animation; if (!overlay.animated || !config.getBoolean('animated', true)) { animation.duration(0); } @@ -203,9 +202,11 @@ const overlayAnimation = async ( }); } + const activeAni = activeAnimations.get(overlay) || []; + activeAnimations.set(overlay, [...activeAni, animation]); + await animation.play(); - overlay.animation = undefined; return true; }; diff --git a/core/src/utils/transition/ios.transition.ts b/core/src/utils/transition/ios.transition.ts index 2cd3ee8b7b..2a3328e9a3 100644 --- a/core/src/utils/transition/ios.transition.ts +++ b/core/src/utils/transition/ios.transition.ts @@ -256,8 +256,8 @@ export const iosTransitionAnimation = (navEl: HTMLElement, opts: TransitionOptio enteringTransitionEffect .addElement(enteringTransitionEffectEl) - .beforeStyles({ opacity: '1' }) - .afterStyles({ opacity: '' }); + .beforeStyles({ opacity: '1', display: 'block' }) + .afterStyles({ opacity: '', display: '' }); enteringTransitionCover .addElement(enteringTransitionCoverEl!) // REVIEW @@ -406,8 +406,8 @@ export const iosTransitionAnimation = (navEl: HTMLElement, opts: TransitionOptio leavingTransitionEffect .addElement(leavingTransitionEffectEl) - .beforeStyles({ opacity: '1' }) - .afterStyles({ opacity: '' }); + .beforeStyles({ opacity: '1', display: 'block' }) + .afterStyles({ opacity: '', display: '' }); leavingTransitionCover .addElement(leavingTransitionCoverEl!) // REVIEW