From 12216d378df091e16fd77d271b107e819278481c Mon Sep 17 00:00:00 2001 From: Liam DeBeasi Date: Tue, 31 Aug 2021 15:19:19 -0400 Subject: [PATCH] feat(modal): add bottom sheet functionality (#23828) resolves #21039 --- core/api.txt | 5 + core/src/components.d.ts | 32 ++ .../components/modal/animations/ios.enter.ts | 35 ++- .../components/modal/animations/ios.leave.ts | 30 +- .../components/modal/animations/md.enter.ts | 36 ++- .../components/modal/animations/md.leave.ts | 43 +-- core/src/components/modal/animations/sheet.ts | 59 ++++ core/src/components/modal/gestures/sheet.ts | 208 +++++++++++++ core/src/components/modal/modal-interface.ts | 7 + core/src/components/modal/modal.ios.scss | 10 + core/src/components/modal/modal.scss | 35 +++ core/src/components/modal/modal.tsx | 125 +++++++- core/src/components/modal/readme.md | 294 ++++++++++++++++-- core/src/components/modal/test/sheet/e2e.ts | 45 +++ .../components/modal/test/sheet/index.html | 181 +++++++++++ core/src/components/modal/usage/angular.md | 30 +- core/src/components/modal/usage/javascript.md | 11 +- core/src/components/modal/usage/react.md | 66 +++- core/src/components/modal/usage/stencil.md | 55 +++- core/src/components/modal/usage/vue.md | 69 +++- core/src/components/modal/utils.ts | 48 +++ core/src/css/core.scss | 4 +- core/src/utils/animation/animation.ts | 23 +- core/src/utils/overlays.ts | 1 - 24 files changed, 1338 insertions(+), 114 deletions(-) create mode 100644 core/src/components/modal/animations/sheet.ts create mode 100644 core/src/components/modal/gestures/sheet.ts create mode 100644 core/src/components/modal/test/sheet/e2e.ts create mode 100644 core/src/components/modal/test/sheet/index.html create mode 100644 core/src/components/modal/utils.ts diff --git a/core/api.txt b/core/api.txt index 3715f0cebe..4dae8d6d4f 100644 --- a/core/api.txt +++ b/core/api.txt @@ -758,8 +758,12 @@ ion-menu-toggle,prop,menu,string | undefined,undefined,false,false ion-modal,shadow ion-modal,prop,animated,boolean,true,false,false +ion-modal,prop,backdropBreakpoint,number,0,false,false ion-modal,prop,backdropDismiss,boolean,true,false,false +ion-modal,prop,breakpoints,number[] | undefined,undefined,false,false ion-modal,prop,enterAnimation,((baseEl: any, opts?: any) => Animation) | undefined,undefined,false,false +ion-modal,prop,handle,boolean | undefined,undefined,false,false +ion-modal,prop,initialBreakpoint,number | undefined,undefined,false,false ion-modal,prop,isOpen,boolean,false,false,false ion-modal,prop,keyboardClose,boolean,true,false,false ion-modal,prop,leaveAnimation,((baseEl: any, opts?: any) => Animation) | undefined,undefined,false,false @@ -794,6 +798,7 @@ ion-modal,css-prop,--min-width ion-modal,css-prop,--width ion-modal,part,backdrop ion-modal,part,content +ion-modal,part,handle ion-nav,shadow ion-nav,prop,animated,boolean,true,false,false diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 5b7ac19d42..2d066c9894 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -1465,10 +1465,18 @@ export namespace Components { * If `true`, the modal will animate. */ "animated": boolean; + /** + * A decimal value between 0 and 1 that indicates the point at which the backdrop will begin to fade in when using a sheet modal. Prior to this point, the backdrop will be hidden and the content underneath the sheet can be interacted with. This value must also be listed in the `breakpoints` array. + */ + "backdropBreakpoint": number; /** * If `true`, the modal will be dismissed when the backdrop is clicked. */ "backdropDismiss": boolean; + /** + * The breakpoints to use when creating a sheet modal. Each value in the array must be a decimal between 0 and 1 where 0 indicates the modal is fully closed and 1 indicates the modal is fully open. Values are relative to the height of the modal, not the height of the screen. One of the values in this array must be the value of the `initialBreakpoint` property. For example: [0, .25, .5, 1] + */ + "breakpoints"?: number[]; /** * The component to display inside of the modal. */ @@ -1492,6 +1500,14 @@ export namespace Components { * Animation to use when the modal is presented. */ "enterAnimation"?: AnimationBuilder; + /** + * The horizontal line that displays at the top of a sheet modal. It is `true` by default when setting the `breakpoints` and `initialBreakpoint` properties. + */ + "handle"?: boolean; + /** + * A decimal value between 0 and 1 that indicates the initial point the modal will open at when creating a sheet modal. This value must also be listed in the `breakpoints` array. + */ + "initialBreakpoint"?: number; /** * If `true`, the modal will open. If `false`, the modal will close. Use this if you need finer grained control over presentation, otherwise just use the modalController or the `trigger` property. Note: `isOpen` will not automatically be set back to `false` when the modal dismisses. You will need to do that in your code. */ @@ -5061,10 +5077,18 @@ declare namespace LocalJSX { * If `true`, the modal will animate. */ "animated"?: boolean; + /** + * A decimal value between 0 and 1 that indicates the point at which the backdrop will begin to fade in when using a sheet modal. Prior to this point, the backdrop will be hidden and the content underneath the sheet can be interacted with. This value must also be listed in the `breakpoints` array. + */ + "backdropBreakpoint"?: number; /** * If `true`, the modal will be dismissed when the backdrop is clicked. */ "backdropDismiss"?: boolean; + /** + * The breakpoints to use when creating a sheet modal. Each value in the array must be a decimal between 0 and 1 where 0 indicates the modal is fully closed and 1 indicates the modal is fully open. Values are relative to the height of the modal, not the height of the screen. One of the values in this array must be the value of the `initialBreakpoint` property. For example: [0, .25, .5, 1] + */ + "breakpoints"?: number[]; /** * The component to display inside of the modal. */ @@ -5082,6 +5106,14 @@ declare namespace LocalJSX { * Animation to use when the modal is presented. */ "enterAnimation"?: AnimationBuilder; + /** + * The horizontal line that displays at the top of a sheet modal. It is `true` by default when setting the `breakpoints` and `initialBreakpoint` properties. + */ + "handle"?: boolean; + /** + * A decimal value between 0 and 1 that indicates the initial point the modal will open at when creating a sheet modal. This value must also be listed in the `breakpoints` array. + */ + "initialBreakpoint"?: number; /** * If `true`, the modal will open. If `false`, the modal will close. Use this if you need finer grained control over presentation, otherwise just use the modalController or the `trigger` property. Note: `isOpen` will not automatically be set back to `false` when the modal dismisses. You will need to do that in your code. */ diff --git a/core/src/components/modal/animations/ios.enter.ts b/core/src/components/modal/animations/ios.enter.ts index 30955ad429..201cfe0cce 100644 --- a/core/src/components/modal/animations/ios.enter.ts +++ b/core/src/components/modal/animations/ios.enter.ts @@ -1,30 +1,43 @@ -import { Animation } from '../../../interface'; +import { Animation, ModalAnimationOptions } from '../../../interface'; import { createAnimation } from '../../../utils/animation/animation'; import { getElementRoot } from '../../../utils/helpers'; import { SwipeToCloseDefaults } from '../gestures/swipe-to-close'; +import { createSheetEnterAnimation } from './sheet'; + +const createEnterAnimation = () => { + const backdropAnimation = createAnimation() + .fromTo('opacity', 0.01, 'var(--backdrop-opacity)'); + + const wrapperAnimation = createAnimation() + .fromTo('transform', 'translateY(100vh)', 'translateY(0vh)'); + + return { backdropAnimation, wrapperAnimation }; +} + /** * iOS Modal Enter Animation for the Card presentation style */ export const iosEnterAnimation = ( - baseEl: HTMLElement, - presentingEl?: HTMLElement, - ): Animation => { + baseEl: HTMLElement, + opts: ModalAnimationOptions, +): Animation => { + const { presentingEl, currentBreakpoint } = opts; const root = getElementRoot(baseEl); - const backdropAnimation = createAnimation() + const { wrapperAnimation, backdropAnimation } = currentBreakpoint !== undefined ? createSheetEnterAnimation(opts) : createEnterAnimation(); + + backdropAnimation .addElement(root.querySelector('ion-backdrop')!) - .fromTo('opacity', 0.01, 'var(--backdrop-opacity)') .beforeStyles({ 'pointer-events': 'none' }) .afterClearStyles(['pointer-events']); - const wrapperAnimation = createAnimation() + wrapperAnimation .addElement(root.querySelectorAll('.modal-wrapper, .modal-shadow')!) - .beforeStyles({ 'opacity': 1 }) - .fromTo('transform', 'translateY(100vh)', 'translateY(0vh)'); + .beforeStyles({ 'opacity': 1 }); - const baseAnimation = createAnimation() + const baseAnimation = createAnimation('entering-base') .addElement(baseEl) .easing('cubic-bezier(0.32,0.72,0,1)') .duration(500) @@ -48,7 +61,7 @@ export const iosEnterAnimation = ( /** * Fallback for browsers that does not support `max()` (ex: Firefox) * No need to worry about statusbar padding since engines like Gecko - * are not used as the engine for standlone Cordova/Capacitor apps + * are not used as the engine for standalone Cordova/Capacitor apps */ const transformOffset = (!CSS.supports('width', 'max(0px, 1px)')) ? '30px' : 'max(30px, var(--ion-safe-area-top))'; const modalTransform = hasCardModal ? '-10px' : transformOffset; diff --git a/core/src/components/modal/animations/ios.leave.ts b/core/src/components/modal/animations/ios.leave.ts index 54606dd0c6..0077cd212e 100644 --- a/core/src/components/modal/animations/ios.leave.ts +++ b/core/src/components/modal/animations/ios.leave.ts @@ -1,27 +1,39 @@ -import { Animation } from '../../../interface'; +import { Animation, ModalAnimationOptions } from '../../../interface'; import { createAnimation } from '../../../utils/animation/animation'; import { getElementRoot } from '../../../utils/helpers'; import { SwipeToCloseDefaults } from '../gestures/swipe-to-close'; +import { createSheetLeaveAnimation } from './sheet'; + +const createLeaveAnimation = () => { + const backdropAnimation = createAnimation() + .fromTo('opacity', 'var(--backdrop-opacity)', 0); + + const wrapperAnimation = createAnimation() + .fromTo('transform', 'translateY(0vh)', 'translateY(100vh)'); + + return { backdropAnimation, wrapperAnimation }; +} + /** * iOS Modal Leave Animation */ export const iosLeaveAnimation = ( baseEl: HTMLElement, - presentingEl?: HTMLElement, + opts: ModalAnimationOptions, duration = 500 ): Animation => { + const { presentingEl, currentBreakpoint } = opts; const root = getElementRoot(baseEl); - const backdropAnimation = createAnimation() - .addElement(root.querySelector('ion-backdrop')!) - .fromTo('opacity', 'var(--backdrop-opacity)', 0.0); + const { wrapperAnimation, backdropAnimation } = currentBreakpoint !== undefined ? createSheetLeaveAnimation(opts) : createLeaveAnimation(); - const wrapperAnimation = createAnimation() + backdropAnimation.addElement(root.querySelector('ion-backdrop')!) + + wrapperAnimation .addElement(root.querySelectorAll('.modal-wrapper, .modal-shadow')!) - .beforeStyles({ 'opacity': 1 }) - .fromTo('transform', 'translateY(0vh)', 'translateY(100vh)'); + .beforeStyles({ 'opacity': 1 }); - const baseAnimation = createAnimation() + const baseAnimation = createAnimation('leaving-base') .addElement(baseEl) .easing('cubic-bezier(0.32,0.72,0,1)') .duration(duration) diff --git a/core/src/components/modal/animations/md.enter.ts b/core/src/components/modal/animations/md.enter.ts index fa0d87cbf1..971a17d5d8 100644 --- a/core/src/components/modal/animations/md.enter.ts +++ b/core/src/components/modal/animations/md.enter.ts @@ -1,32 +1,44 @@ -import { Animation } from '../../../interface'; +import { Animation, ModalAnimationOptions } from '../../../interface'; import { createAnimation } from '../../../utils/animation/animation'; import { getElementRoot } from '../../../utils/helpers'; +import { createSheetEnterAnimation } from './sheet'; + +const createEnterAnimation = () => { + const backdropAnimation = createAnimation() + .fromTo('opacity', 0.01, 'var(--backdrop-opacity)'); + + const wrapperAnimation = createAnimation() + .keyframes([ + { offset: 0, opacity: 0.01, transform: 'translateY(40px)' }, + { offset: 1, opacity: 1, transform: `translateY(0px)` } + ]); + + return { backdropAnimation, wrapperAnimation }; +} + /** * Md Modal Enter Animation */ -export const mdEnterAnimation = (baseEl: HTMLElement): Animation => { +export const mdEnterAnimation = ( + baseEl: HTMLElement, + opts: ModalAnimationOptions +): Animation => { + const { currentBreakpoint } = opts; const root = getElementRoot(baseEl); - const baseAnimation = createAnimation(); - const backdropAnimation = createAnimation(); - const wrapperAnimation = createAnimation(); + const { wrapperAnimation, backdropAnimation } = currentBreakpoint !== undefined ? createSheetEnterAnimation(opts) : createEnterAnimation(); backdropAnimation .addElement(root.querySelector('ion-backdrop')!) - .fromTo('opacity', 0.01, 'var(--backdrop-opacity)') .beforeStyles({ 'pointer-events': 'none' }) .afterClearStyles(['pointer-events']); wrapperAnimation - .addElement(root.querySelector('.modal-wrapper')!) - .keyframes([ - { offset: 0, opacity: 0.01, transform: 'translateY(40px)' }, - { offset: 1, opacity: 1, transform: 'translateY(0px)' } - ]); + .addElement(root.querySelector('.modal-wrapper')!); - return baseAnimation + return createAnimation() .addElement(baseEl) .easing('cubic-bezier(0.36,0.66,0.04,1)') .duration(280) diff --git a/core/src/components/modal/animations/md.leave.ts b/core/src/components/modal/animations/md.leave.ts index e1ffe22671..b16755a472 100644 --- a/core/src/components/modal/animations/md.leave.ts +++ b/core/src/components/modal/animations/md.leave.ts @@ -1,30 +1,37 @@ -import { Animation } from '../../../interface'; +import { Animation, ModalAnimationOptions } from '../../../interface'; import { createAnimation } from '../../../utils/animation/animation'; import { getElementRoot } from '../../../utils/helpers'; +import { createSheetLeaveAnimation } from './sheet'; + +const createLeaveAnimation = () => { + const backdropAnimation = createAnimation() + .fromTo('opacity', 'var(--backdrop-opacity)', 0); + + const wrapperAnimation = createAnimation() + .keyframes([ + { offset: 0, opacity: 0.99, transform: `translateY(0px)` }, + { offset: 1, opacity: 0, transform: 'translateY(40px)' } + ]); + + return { backdropAnimation, wrapperAnimation }; +} + /** * Md Modal Leave Animation */ -export const mdLeaveAnimation = (baseEl: HTMLElement): Animation => { +export const mdLeaveAnimation = ( + baseEl: HTMLElement, + opts: ModalAnimationOptions +): Animation => { + const { currentBreakpoint } = opts; const root = getElementRoot(baseEl); - const baseAnimation = createAnimation(); - const backdropAnimation = createAnimation(); - const wrapperAnimation = createAnimation(); - const wrapperEl = root.querySelector('.modal-wrapper')!; + const { wrapperAnimation, backdropAnimation } = currentBreakpoint !== undefined ? createSheetLeaveAnimation(opts) : createLeaveAnimation(); - backdropAnimation - .addElement(root.querySelector('ion-backdrop')!) - .fromTo('opacity', 'var(--backdrop-opacity)', 0.0); + backdropAnimation.addElement(root.querySelector('ion-backdrop')!); + wrapperAnimation.addElement(root.querySelector('.modal-wrapper')!); - wrapperAnimation - .addElement(wrapperEl) - .keyframes([ - { offset: 0, opacity: 0.99, transform: 'translateY(0px)' }, - { offset: 1, opacity: 0, transform: 'translateY(40px)' } - ]); - - return baseAnimation - .addElement(baseEl) + return createAnimation() .easing('cubic-bezier(0.47,0,0.745,0.715)') .duration(200) .addAnimation([backdropAnimation, wrapperAnimation]); diff --git a/core/src/components/modal/animations/sheet.ts b/core/src/components/modal/animations/sheet.ts new file mode 100644 index 0000000000..a44d44aa68 --- /dev/null +++ b/core/src/components/modal/animations/sheet.ts @@ -0,0 +1,59 @@ +import { ModalAnimationOptions } from '../../../interface'; +import { createAnimation } from '../../../utils/animation/animation'; +import { getBackdropValueForSheet } from '../utils'; + +export const createSheetEnterAnimation = (opts: ModalAnimationOptions) => { + const { currentBreakpoint, backdropBreakpoint } = opts; + + /** + * If the backdropBreakpoint is undefined, then the backdrop + * should always fade in. If the backdropBreakpoint came before the + * current breakpoint, then the backdrop should be fading in. + */ + const shouldShowBackdrop = backdropBreakpoint === undefined || backdropBreakpoint < currentBreakpoint!; + const initialBackdrop = shouldShowBackdrop ? `calc(var(--backdrop-opacity) * ${currentBreakpoint!})` : '0.01'; + + const backdropAnimation = createAnimation('backdropAnimation') + .fromTo('opacity', 0, initialBackdrop); + + const wrapperAnimation = createAnimation('wrapperAnimation') + .keyframes([ + { offset: 0, opacity: 1, transform: 'translateY(100%)' }, + { offset: 1, opacity: 1, transform: `translateY(${100 - (currentBreakpoint! * 100)}%)` } + ]); + + return { wrapperAnimation, backdropAnimation }; +} + +export const createSheetLeaveAnimation = (opts: ModalAnimationOptions) => { + const { currentBreakpoint, backdropBreakpoint, sortedBreakpoints } = opts; + + /** + * Backdrop does not always fade in from 0 to 1 if backdropBreakpoint + * is defined, so we need to account for that offset by figuring out + * what the current backdrop value should be. + */ + const maxBreakpoint = sortedBreakpoints![sortedBreakpoints.length - 1]; + const backdropValue = `calc(var(--backdrop-opacity) * ${getBackdropValueForSheet(currentBreakpoint!, maxBreakpoint, backdropBreakpoint!)})`; + const defaultBackdrop = [ + { offset: 0, opacity: backdropValue }, + { offset: 1, opacity: 0 } + ] + + const customBackdrop = [ + { offset: 0, opacity: backdropValue }, + { offset: backdropBreakpoint!, opacity: 0 }, + { offset: 1, opacity: 0 } + ] + + const backdropAnimation = createAnimation('backdropAnimation') + .keyframes(backdropBreakpoint !== 0 ? customBackdrop : defaultBackdrop); + + const wrapperAnimation = createAnimation('wrapperAnimation') + .keyframes([ + { offset: 0, opacity: 1, transform: `translateY(${100 - (currentBreakpoint! * 100)}%)` }, + { offset: 1, opacity: 1, transform: `translateY(100%)` } + ]); + + return { wrapperAnimation, backdropAnimation }; +} diff --git a/core/src/components/modal/gestures/sheet.ts b/core/src/components/modal/gestures/sheet.ts new file mode 100644 index 0000000000..ea0510cda0 --- /dev/null +++ b/core/src/components/modal/gestures/sheet.ts @@ -0,0 +1,208 @@ +import { Animation } from '../../../interface'; +import { GestureDetail, createGesture } from '../../../utils/gesture'; +import { clamp, raf } from '../../../utils/helpers'; +import { getBackdropValueForSheet } from '../utils'; + +export const createSheetGesture = ( + baseEl: HTMLIonModalElement, + backdropEl: HTMLIonBackdropElement, + wrapperEl: HTMLElement, + initialBreakpoint: number, + backdropBreakpoint: number, + animation: Animation, + breakpoints: number[] = [], + onDismiss: () => void, + onBreakpointChange: (breakpoint: number) => void +) => { + // Defaults for the sheet swipe animation + const defaultBackdrop = [ + { offset: 0, opacity: 'var(--backdrop-opacity)' }, + { offset: 1, opacity: 0.01 } + ] + + const customBackdrop = [ + { offset: 0, opacity: 'var(--backdrop-opacity)' }, + { offset: backdropBreakpoint, opacity: 0 }, + { offset: 1, opacity: 0 } + ] + + const SheetDefaults = { + WRAPPER_KEYFRAMES: [ + { offset: 0, transform: 'translateY(0%)' }, + { offset: 1, transform: 'translateY(100%)' } + ], + BACKDROP_KEYFRAMES: (backdropBreakpoint !== 0) ? customBackdrop : defaultBackdrop + }; + + const contentEl = baseEl.querySelector('ion-content'); + const height = wrapperEl.clientHeight; + let currentBreakpoint = initialBreakpoint; + let offset = 0; + const wrapperAnimation = animation.childAnimations.find(ani => ani.id === 'wrapperAnimation'); + const backdropAnimation = animation.childAnimations.find(ani => ani.id === 'backdropAnimation'); + const maxBreakpoint = breakpoints[breakpoints.length - 1]; + + /** + * After the entering animation completes, + * we need to set the animation to go from + * offset 0 to offset 1 so that users can + * swipe in any direction. We then set the + * animation offset to the current breakpoint + * so there is no flickering. + */ + if (wrapperAnimation && backdropAnimation) { + wrapperAnimation.keyframes([...SheetDefaults.WRAPPER_KEYFRAMES]); + backdropAnimation.keyframes([...SheetDefaults.BACKDROP_KEYFRAMES]); + animation.progressStart(true, 1 - currentBreakpoint); + + const backdropEnabled = currentBreakpoint >= backdropBreakpoint + backdropEl.style.setProperty('pointer-events', backdropEnabled ? 'auto' : 'none'); + } + + if (contentEl && currentBreakpoint !== maxBreakpoint) { + contentEl.scrollY = false; + } + + const canStart = (detail: GestureDetail) => { + /** + * If the sheet is fully expanded and + * the user is swiping on the content, + * the gesture should not start to + * allow for scrolling on the content. + */ + const content = (detail.event.target! as HTMLElement).closest('ion-content'); + + if (currentBreakpoint === 1 && content) { + return false; + } + + return true; + }; + + const onStart = () => { + /** + * If swiping on the content + * we should disable scrolling otherwise + * the sheet will expand and the content will scroll. + */ + if (contentEl) { + contentEl.scrollY = false; + } + + animation.progressStart(true, 1 - currentBreakpoint); + }; + + const onMove = (detail: GestureDetail) => { + /** + * Given the change in gesture position on the Y axis, + * compute where the offset of the animation should be + * relative to where the user dragged. + */ + const initialStep = 1 - currentBreakpoint; + offset = clamp(0.0001, initialStep + (detail.deltaY / height), 0.9999); + animation.progressStep(offset); + }; + + const onEnd = (detail: GestureDetail) => { + /** + * When the gesture releases, we need to determine + * the closest breakpoint to snap to. + */ + const velocity = detail.velocityY; + const threshold = (detail.deltaY + velocity * 100) / height; + const diff = currentBreakpoint - threshold; + + const closest = breakpoints.reduce((a, b) => { + return Math.abs(b - diff) < Math.abs(a - diff) ? b : a; + }); + + const shouldRemainOpen = closest !== 0; + currentBreakpoint = 0; + + /** + * Update the animation so that it plays from + * the last offset to the closest snap point. + */ + if (wrapperAnimation && backdropAnimation) { + wrapperAnimation.keyframes([ + { offset: 0, transform: `translateY(${offset * 100}%)` }, + { offset: 1, transform: `translateY(${(1 - closest) * 100}%)` } + ]); + + backdropAnimation.keyframes([ + { offset: 0, opacity: `calc(var(--backdrop-opacity) * ${getBackdropValueForSheet(1 - offset, maxBreakpoint, backdropBreakpoint)})` }, + { offset: 1, opacity: `calc(var(--backdrop-opacity) * ${getBackdropValueForSheet(closest, maxBreakpoint, backdropBreakpoint)})` } + ]); + + animation.progressStep(0); + } + + /** + * Gesture should remain disabled until the + * snapping animation completes. + */ + gesture.enable(false); + + animation + .onFinish(() => { + if (shouldRemainOpen) { + + /** + * Once the snapping animation completes, + * we need to reset the animation to go + * from 0 to 1 so users can swipe in any direction. + * We then set the animation offset to the current + * breakpoint so that it starts at the snapped position. + */ + if (wrapperAnimation && backdropAnimation) { + raf(() => { + wrapperAnimation.keyframes([...SheetDefaults.WRAPPER_KEYFRAMES]); + backdropAnimation.keyframes([...SheetDefaults.BACKDROP_KEYFRAMES]); + animation.progressStart(true, 1 - closest); + currentBreakpoint = closest; + onBreakpointChange(currentBreakpoint); + + /** + * If the sheet is fully expanded, we can safely + * enable scrolling again. + */ + if (contentEl && currentBreakpoint === breakpoints[breakpoints.length - 1]) { + contentEl.scrollY = true; + } + + const backdropEnabled = currentBreakpoint >= backdropBreakpoint; + backdropEl.style.setProperty('pointer-events', backdropEnabled ? 'auto' : 'none'); + + gesture.enable(true); + }); + } else { + gesture.enable(true); + } + } + + /** + * This must be a one time callback + * otherwise a new callback will + * be added every time onEnd runs. + */ + }, { oneTimeCallback: true }) + .progressEnd(1, 0, 500); + + if (!shouldRemainOpen) { + onDismiss(); + } + }; + + const gesture = createGesture({ + el: wrapperEl, + gestureName: 'modalSheet', + gesturePriority: 40, + direction: 'y', + threshold: 10, + canStart, + onStart, + onMove, + onEnd + }); + return gesture; +}; diff --git a/core/src/components/modal/modal-interface.ts b/core/src/components/modal/modal-interface.ts index dc007b1996..5656b4963a 100644 --- a/core/src/components/modal/modal-interface.ts +++ b/core/src/components/modal/modal-interface.ts @@ -18,3 +18,10 @@ export interface ModalOptions { enterAnimation?: AnimationBuilder; leaveAnimation?: AnimationBuilder; } + +export interface ModalAnimationOptions { + presentingEl?: HTMLElement; + currentBreakpoint?: number; + backdropBreakpoint?: number; + sortedBreakpoints: number[]; +} diff --git a/core/src/components/modal/modal.ios.scss b/core/src/components/modal/modal.ios.scss index 85f5a46a41..a5536aa1a6 100644 --- a/core/src/components/modal/modal.ios.scss +++ b/core/src/components/modal/modal.ios.scss @@ -19,6 +19,9 @@ @include transform(translate3d(0, 100%, 0)); } +// iOS Card Modal +// -------------------------------------------------- + @media screen and (max-width: 767px) { @supports (width: max(0px, 1px)) { :host(.modal-card) { @@ -68,3 +71,10 @@ box-shadow: var(--box-shadow); } } + +// iOS Sheet Modal +// -------------------------------------------------- + +:host(.modal-sheet) .modal-wrapper { + @include border-radius($modal-ios-border-radius, $modal-ios-border-radius, 0, 0); +} diff --git a/core/src/components/modal/modal.scss b/core/src/components/modal/modal.scss index 8730b7c024..c917100070 100644 --- a/core/src/components/modal/modal.scss +++ b/core/src/components/modal/modal.scss @@ -65,6 +65,9 @@ .modal-shadow { @include border-radius(var(--border-radius)); + position: absolute; + bottom: 0; + width: var(--width); min-width: var(--min-width); max-width: var(--max-width); @@ -107,3 +110,35 @@ --height: #{$modal-inset-height-large}; } } + +// Sheet Modal +// -------------------------------------------------- + +.modal-handle { + @include position(14px, 0px, null, 0px); + @include border-radius(8px, 8px, 8px, 8px); + @include margin(null, auto, null, auto); + + position: absolute; + + width: 36px; + height: 5px; + + /** + * This allows the handle to appear + * on top of user content in WebKit. + */ + transform: translateZ(0); + + background: var(--ion-color-step-350, #c0c0be); + + z-index: 11; +} + +/** + * Ensure that the sheet modal does not + * completely cover the content. + */ +:host(.modal-sheet) { + --height: calc(100% - 10px); +} diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index e22db547a7..d4e757b9e7 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -13,6 +13,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 { createSheetGesture } from './gestures/sheet'; import { createSwipeToCloseGesture } from './gestures/swipe-to-close'; /** @@ -22,6 +23,7 @@ import { createSwipeToCloseGesture } from './gestures/swipe-to-close'; * * @part backdrop - The `ion-backdrop` element. * @part content - The wrapper element for the default slot. + * @part handle - The handle that is displayed at the top of the sheet modal when `handle="true"`. */ @Component({ tag: 'ion-modal', @@ -38,6 +40,11 @@ export class Modal implements ComponentInterface, OverlayInterface { private coreDelegate: FrameworkDelegate = CoreDelegate(); private currentTransition?: Promise; private destroyTriggerInteraction?: () => void; + private isSheetModal = false; + private currentBreakpoint?: number; + private wrapperEl?: HTMLElement; + private backdropEl?: HTMLIonBackdropElement; + private sortedBreakpoints: number[] = []; private inline = false; private workingDelegate?: FrameworkDelegate; @@ -75,6 +82,40 @@ export class Modal implements ComponentInterface, OverlayInterface { */ @Prop() leaveAnimation?: AnimationBuilder; + /** + * The breakpoints to use when creating a sheet modal. Each value in the + * array must be a decimal between 0 and 1 where 0 indicates the modal is fully + * closed and 1 indicates the modal is fully open. Values are relative + * to the height of the modal, not the height of the screen. One of the values in this + * array must be the value of the `initialBreakpoint` property. + * For example: [0, .25, .5, 1] + */ + @Prop() breakpoints?: number[]; + + /** + * A decimal value between 0 and 1 that indicates the + * initial point the modal will open at when creating a + * sheet modal. This value must also be listed in the + * `breakpoints` array. + */ + @Prop() initialBreakpoint?: number; + + /** + * A decimal value between 0 and 1 that indicates the + * point at which the backdrop will begin to fade in + * when using a sheet modal. Prior to this point, the + * backdrop will be hidden and the content underneath + * the sheet can be interacted with. This value must + * also be listed in the `breakpoints` array. + */ + @Prop() backdropBreakpoint = 0; + + /** + * The horizontal line that displays at the top of a sheet modal. It is `true` by default when + * setting the `breakpoints` and `initialBreakpoint` properties. + */ + @Prop() handle?: boolean; + /** * The component to display inside of the modal. * @internal @@ -206,11 +247,18 @@ export class Modal implements ComponentInterface, OverlayInterface { } componentWillLoad() { + const { breakpoints, initialBreakpoint } = this; + /** * If user has custom ID set then we should * not assign the default incrementing ID. */ this.modalId = (this.el.hasAttribute('id')) ? this.el.getAttribute('id')! : `ion-modal-${this.modalIndex}`; + this.isSheetModal = breakpoints !== undefined && initialBreakpoint !== undefined; + + if (breakpoints !== undefined && initialBreakpoint !== undefined && !breakpoints.includes(initialBreakpoint)) { + console.warn('[Ionic Warning]: Your breakpoints array must include the initialBreakpoint value.') + } } componentDidLoad() { @@ -315,15 +363,17 @@ export class Modal implements ComponentInterface, OverlayInterface { writeTask(() => this.el.classList.add('show-modal')); - this.currentTransition = present(this, 'modalEnter', iosEnterAnimation, mdEnterAnimation, this.presentingElement); + this.currentTransition = present(this, 'modalEnter', iosEnterAnimation, mdEnterAnimation, { presentingEl: this.presentingElement, currentBreakpoint: this.initialBreakpoint, backdropBreakpoint: this.backdropBreakpoint }); await this.currentTransition; - this.currentTransition = undefined; - - if (this.swipeToClose) { + if (this.isSheetModal) { + this.initSheetGesture(); + } else if (this.swipeToClose) { this.initSwipeToClose(); } + + this.currentTransition = undefined; } private initSwipeToClose() { @@ -333,7 +383,7 @@ export class Modal implements ComponentInterface, OverlayInterface { // should be in the DOM and referenced by now, except // for the presenting el const animationBuilder = this.leaveAnimation || config.get('modalLeave', iosLeaveAnimation); - const ani = this.animation = animationBuilder(this.el, this.presentingElement); + const ani = this.animation = animationBuilder(this.el, { presentingEl: this.presentingElement }); this.gesture = createSwipeToCloseGesture( this.el, ani, @@ -354,6 +404,53 @@ export class Modal implements ComponentInterface, OverlayInterface { this.gestureAnimationDismissing = false; }); }, + + ); + this.gesture.enable(true); + } + + private initSheetGesture() { + const { wrapperEl, initialBreakpoint, backdropBreakpoint } = this; + + if (!wrapperEl || initialBreakpoint === undefined) { + return; + } + + const animationBuilder = this.enterAnimation || config.get('modalEnter', iosEnterAnimation); + const ani: Animation = this.animation = animationBuilder(this.el, { presentingEl: this.presentingElement, currentBreakpoint: initialBreakpoint, backdropBreakpoint }); + + ani.progressStart(true, 1); + + const sortedBreakpoints = this.sortedBreakpoints = (this.breakpoints?.sort((a, b) => a - b)) || []; + + this.gesture = createSheetGesture( + this.el, + this.backdropEl!, + wrapperEl, + initialBreakpoint, + backdropBreakpoint, + ani, + sortedBreakpoints, + () => { + /** + * 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; + this.animation!.onFinish(async () => { + await this.dismiss(undefined, 'gesture'); + this.gestureAnimationDismissing = false; + }); + }, + (breakpoint: number) => { + this.currentBreakpoint = breakpoint; + } ); this.gesture.enable(true); } @@ -384,7 +481,7 @@ export class Modal implements ComponentInterface, OverlayInterface { const enteringAnimation = activeAnimations.get(this) || []; - this.currentTransition = dismiss(this, data, role, 'modalLeave', iosLeaveAnimation, mdLeaveAnimation, this.presentingElement); + this.currentTransition = dismiss(this, data, role, 'modalLeave', iosLeaveAnimation, mdLeaveAnimation, { presentingEl: this.presentingElement, currentBreakpoint: this.currentBreakpoint || this.initialBreakpoint, sortedBreakpoints: this.sortedBreakpoints, backdropBreakpoint: this.backdropBreakpoint }); const dismissed = await this.currentTransition; @@ -394,12 +491,15 @@ export class Modal implements ComponentInterface, OverlayInterface { if (this.animation) { this.animation.destroy(); } + if (this.gesture) { + this.gesture.destroy(); + } enteringAnimation.forEach(ani => ani.destroy()); } - this.animation = undefined; this.currentTransition = undefined; + this.animation = undefined; return dismissed; } @@ -445,6 +545,10 @@ export class Modal implements ComponentInterface, OverlayInterface { } render() { + const { handle, isSheetModal, presentingElement } = this; + + const showHandle = handle || isSheetModal; + const mode = getIonMode(this); const { presented, modalId } = this; @@ -455,7 +559,8 @@ export class Modal implements ComponentInterface, OverlayInterface { tabindex="-1" class={{ [mode]: true, - [`modal-card`]: this.presentingElement !== undefined && mode === 'ios', + [`modal-card`]: presentingElement !== undefined && mode === 'ios', + [`modal-sheet`]: isSheetModal, 'overlay-hidden': true, 'modal-interactive': presented, ...getClassMap(this.cssClass) @@ -471,7 +576,7 @@ export class Modal implements ComponentInterface, OverlayInterface { onIonModalWillDismiss={this.onLifecycle} onIonModalDidDismiss={this.onLifecycle} > - + this.backdropEl = el} visible={this.showBackdrop} tappable={this.backdropDismiss} part="backdrop" /> {mode === 'ios' && } @@ -479,7 +584,9 @@ export class Modal implements ComponentInterface, OverlayInterface { role="dialog" class="modal-wrapper ion-overlay-wrapper" part="content" + ref={el => this.wrapperEl = el} > + {showHandle && } diff --git a/core/src/components/modal/readme.md b/core/src/components/modal/readme.md index 41d7bb3ef5..784ba9e24d 100644 --- a/core/src/components/modal/readme.md +++ b/core/src/components/modal/readme.md @@ -38,6 +38,30 @@ If you need fine grained control over when the modal is presented and dismissed, We typically recommend that you write your modals inline as it streamlines the amount of code in your application. You should only use the `modalController` for complex use cases where writing a modal inline is impractical. +## Card Modal + +Developers can create a card modal effect where the modal appears as a card stacked on top of your app's main content. To create a card modal, developers need to set the `presentingElement` property and the `swipeToClose` properties on `ion-modal`. + +The `presentingElement` property accepts a reference to the element that should display under your modal. This is typically a reference to `ion-router-outlet`. + +The `swipeToClose` property can be used to control whether or not the card modal can be swiped to close. + +See [Usage](#usage) for examples on how to use the sheet modal. + +## Sheet Modal + +Developers can create a sheet modal effect similar to the drawer components available in maps applications. To create a sheet modal, developers need to set the `breakpoints` and `initialBreakpoint` properties on `ion-modal`. + +The `breakpoints` property accepts an array which states each breakpoint that the sheet can snap to when swiped. A `breakpoints` property of `[0, 0.5, 1]` would indicate that the sheet can be swiped to 0% of the screen height, 50% of the screen height, and 100% of the screen height. When the modal is swiped to 0% of the screen height, the modal will be automatically dismissed. + +The `initialBreakpoint` property is required so that the sheet modal knows which breakpoint to start at when presenting. The `initalBreakpoint` value must also exist in the `breakpoints` array. Given a `breakpoints` value of `[0, 0.5, 1]`, an `initialBreakpoint` value of `0.5` would be valid as `0.5` is in the `breakpoints` array. An `initialBreakpoint` value of `0.25` would not be valid as `0.25` does not exist in the `breakpoints` array. + +The `backdropBreakpoint` property can be used to customize the point at which the `ion-backdrop` will begin to fade in. This is useful when creating interfaces that have content underneath the sheet that should remain interactive. A common use case is a sheet modal that overlays a map where the map is interactive until the sheet is fully expanded. + +See [Usage](#usage) for examples on how to use the sheet modal. + +> Note: The `swipeToClose` property has no effect when using a sheet modal as sheet modals must be swipeable in order to be usable. + ## Interfaces Below you will find all of the options available to you when using the `modalController`. These options should be supplied when calling `modalController.create()`. @@ -264,7 +288,7 @@ import { EventModalModule } from '../modals/event/event.module'; export class CalendarComponentModule {} ``` -### Swipeable Modals +### Card 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. @@ -306,6 +330,34 @@ async presentModal() { } ``` +### Sheet Modals + +**Controller** +```javascript +import { IonRouterOutlet } from '@ionic/angular'; + +constructor(private routerOutlet: IonRouterOutlet) {} + +async presentModal() { + const modal = await this.modalController.create({ + component: ModalPage, + initialBreakpoint: 0.5, + breakpoints: [0, 0.5, 1] + }); + return await modal.present(); +} +``` + + +**Inline** +```html + + + + + +``` + ### Style Placement @@ -397,7 +449,7 @@ console.log(data); ``` -### Swipeable Modals +### Card 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. @@ -421,6 +473,15 @@ modalElement.swipeToClose = true; modalElement.presentingElement = await modalController.getTop(); // Get the top-most ion-modal ``` +### Sheet Modals + +```javascript +const modalElement = document.createElement('ion-modal'); +modalElement.component = 'modal-page'; +modalElement.initialBreakpoint = 0.5; +modalElement.breakpoints = [0, 0.5, 1]; +``` + ### React @@ -507,7 +568,7 @@ export const ModalExample: React.FC = () => { }; ``` -### Swipeable Modals +### Card 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. @@ -538,18 +599,18 @@ const Home: React.FC = ({ router }) => { const [showModal, setShowModal] = useState(false); return ( - ... - - setShowModal(false)}> -

This is modal content

-
- - ... + + + setShowModal(false)}> +

This is modal content

+
+
+
); }; @@ -581,6 +642,46 @@ In most scenarios, setting a ref on `IonRouterOutlet` and passing that ref's `cu ``` +### Sheet Modals + +```tsx +const App: React.FC = () => { + const routerRef = useRef(null); + + return ( + + + + } exact={true} /> + + + + ) +}; + +... + +const Home: React.FC = () => { + const [showModal, setShowModal] = useState(false); + + return ( + + + setShowModal(false)}> +

This is modal content

+
+
+
+ ); +}; + +``` + + ### Stencil ```tsx @@ -692,7 +793,7 @@ const { data } = await modal.onWillDismiss(); console.log(data); ``` -### Swipeable Modals +### Card 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. @@ -738,6 +839,59 @@ async presentModal() { ``` +### Sheet Modals + +**Controller** +```tsx +import { Component, Element, h } from '@stencil/core'; + +import { modalController } from '@ionic/core'; + +@Component({ + tag: 'modal-example', + styleUrl: 'modal-example.css' +}) +export class ModalExample { + @Element() el: any; + + async presentModal() { + const modal = await modalController.create({ + component: 'page-modal', + initialBreakpoint: 0.5, + breakpoints: [0, 0.5, 1] + + }); + await modal.present(); + } +} +``` + +**Inline** +```tsx +import { Component, State, h } from '@stencil/core'; + +@Component({ + tag: 'modal-example', + styleUrl: 'modal-example.css' +}) +export class ModalExample { + @State() isModalOpen: boolean = false; + + render() { + return [ + + + + ] + } +} +``` + + ### Vue ```html @@ -836,7 +990,7 @@ export default defineComponent({ > If you need a wrapper element inside of your modal component, we recommend using an `` so that the component dimensions are still computed properly. -### Swipeable Modals +### Card 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. @@ -877,23 +1031,94 @@ export default defineComponent({ ``` +### Sheet Modals + +**Controller** +```html + + + +``` + +**Inline** +```html + + + +``` + ## 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` | -| `enterAnimation` | -- | Animation to use when the modal is presented. | `((baseEl: any, opts?: any) => Animation) \| undefined` | `undefined` | -| `isOpen` | `is-open` | If `true`, the modal will open. If `false`, the modal will close. Use this if you need finer grained control over presentation, otherwise just use the modalController or the `trigger` property. Note: `isOpen` will not automatically be set back to `false` when the modal dismisses. You will need to do that in your code. | `boolean` | `false` | -| `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` | -| `trigger` | `trigger` | An ID corresponding to the trigger element that causes the modal to open when clicked. | `string \| undefined` | `undefined` | +| Property | Attribute | Description | Type | Default | +| -------------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ----------- | +| `animated` | `animated` | If `true`, the modal will animate. | `boolean` | `true` | +| `backdropBreakpoint` | `backdrop-breakpoint` | A decimal value between 0 and 1 that indicates the point at which the backdrop will begin to fade in when using a sheet modal. Prior to this point, the backdrop will be hidden and the content underneath the sheet can be interacted with. This value must also be listed in the `breakpoints` array. | `number` | `0` | +| `backdropDismiss` | `backdrop-dismiss` | If `true`, the modal will be dismissed when the backdrop is clicked. | `boolean` | `true` | +| `breakpoints` | -- | The breakpoints to use when creating a sheet modal. Each value in the array must be a decimal between 0 and 1 where 0 indicates the modal is fully closed and 1 indicates the modal is fully open. Values are relative to the height of the modal, not the height of the screen. One of the values in this array must be the value of the `initialBreakpoint` property. For example: [0, .25, .5, 1] | `number[] \| undefined` | `undefined` | +| `enterAnimation` | -- | Animation to use when the modal is presented. | `((baseEl: any, opts?: any) => Animation) \| undefined` | `undefined` | +| `handle` | `handle` | The horizontal line that displays at the top of a sheet modal. It is `true` by default when setting the `breakpoints` and `initialBreakpoint` properties. | `boolean \| undefined` | `undefined` | +| `initialBreakpoint` | `initial-breakpoint` | A decimal value between 0 and 1 that indicates the initial point the modal will open at when creating a sheet modal. This value must also be listed in the `breakpoints` array. | `number \| undefined` | `undefined` | +| `isOpen` | `is-open` | If `true`, the modal will open. If `false`, the modal will close. Use this if you need finer grained control over presentation, otherwise just use the modalController or the `trigger` property. Note: `isOpen` will not automatically be set back to `false` when the modal dismisses. You will need to do that in your code. | `boolean` | `false` | +| `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` | +| `trigger` | `trigger` | An ID corresponding to the trigger element that causes the modal to open when clicked. | `string \| undefined` | `undefined` | ## Events @@ -962,10 +1187,11 @@ Type: `Promise` ## Shadow Parts -| Part | Description | -| ------------ | ----------------------------------------- | -| `"backdrop"` | The `ion-backdrop` element. | -| `"content"` | The wrapper element for the default slot. | +| Part | Description | +| ------------ | -------------------------------------------------------------------------------- | +| `"backdrop"` | The `ion-backdrop` element. | +| `"content"` | The wrapper element for the default slot. | +| `"handle"` | The handle that is displayed at the top of the sheet modal when `handle="true"`. | ## CSS Custom Properties diff --git a/core/src/components/modal/test/sheet/e2e.ts b/core/src/components/modal/test/sheet/e2e.ts new file mode 100644 index 0000000000..3dd80c6406 --- /dev/null +++ b/core/src/components/modal/test/sheet/e2e.ts @@ -0,0 +1,45 @@ +import { newE2EPage } from '@stencil/core/testing'; +import { testModal } from '../test.utils'; + +const DIRECTORY = 'sheet'; + +test('modal: sheet', async () => { + await testModal(DIRECTORY, '#sheet-modal'); +}); + +test('modal:rtl: sheet', async () => { + await testModal(DIRECTORY, '#sheet-modal', true); +}); + +test.only('modal - open', async () => { + const screenshotCompares = []; + const page = await newE2EPage({ url: '/src/components/modal/test/sheet?ionic:_testing=true' }); + + await page.click('#sheet-modal'); + + const modal = await page.find('ion-modal'); + await modal.waitForVisible(); + + screenshotCompares.push(await page.compareScreenshot()); + + await modal.callMethod('dismiss'); + await modal.waitForNotVisible(); + + screenshotCompares.push(await page.compareScreenshot('dismiss')); + + await page.click('#sheet-modal'); + + const modalAgain = await page.find('ion-modal'); + await modalAgain.waitForVisible(); + + screenshotCompares.push(await page.compareScreenshot()); + + await modalAgain.callMethod('dismiss'); + await modalAgain.waitForNotVisible(); + + screenshotCompares.push(await page.compareScreenshot('dismiss')); + + for (const screenshotCompare of screenshotCompares) { + expect(screenshotCompare).toMatchScreenshot(); + } +}); diff --git a/core/src/components/modal/test/sheet/index.html b/core/src/components/modal/test/sheet/index.html new file mode 100644 index 0000000000..5b4dee2ad6 --- /dev/null +++ b/core/src/components/modal/test/sheet/index.html @@ -0,0 +1,181 @@ + + + + + + Modal - Sheet + + + + + + + + + + + + +
+ + + Modal - Sheet + + + + + Present Sheet Modal + Present Sheet Modal (Custom Breakpoints) + Present Sheet Modal (Custom Backdrop Breakpoint) + Present Sheet Modal (Custom Height) + Present Sheet Modal (Custom Handle) + +
+
+
+
+
+
+
+
+
+
+ +
+
+ +
+ + + + diff --git a/core/src/components/modal/usage/angular.md b/core/src/components/modal/usage/angular.md index d56f93527e..677ce6f043 100644 --- a/core/src/components/modal/usage/angular.md +++ b/core/src/components/modal/usage/angular.md @@ -128,7 +128,7 @@ import { EventModalModule } from '../modals/event/event.module'; export class CalendarComponentModule {} ``` -### Swipeable Modals +### Card 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. @@ -170,6 +170,34 @@ async presentModal() { } ``` +### Sheet Modals + +**Controller** +```javascript +import { IonRouterOutlet } from '@ionic/angular'; + +constructor(private routerOutlet: IonRouterOutlet) {} + +async presentModal() { + const modal = await this.modalController.create({ + component: ModalPage, + initialBreakpoint: 0.5, + breakpoints: [0, 0.5, 1] + }); + return await modal.present(); +} +``` + + +**Inline** +```html + + + + + +``` + ### Style Placement diff --git a/core/src/components/modal/usage/javascript.md b/core/src/components/modal/usage/javascript.md index f85fe8cc80..0b2641e87b 100644 --- a/core/src/components/modal/usage/javascript.md +++ b/core/src/components/modal/usage/javascript.md @@ -82,7 +82,7 @@ console.log(data); ``` -### Swipeable Modals +### Card 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. @@ -105,3 +105,12 @@ modalElement.cssClass = 'my-custom-class'; modalElement.swipeToClose = true; modalElement.presentingElement = await modalController.getTop(); // Get the top-most ion-modal ``` + +### Sheet Modals + +```javascript +const modalElement = document.createElement('ion-modal'); +modalElement.component = 'modal-page'; +modalElement.initialBreakpoint = 0.5; +modalElement.breakpoints = [0, 0.5, 1]; +``` \ No newline at end of file diff --git a/core/src/components/modal/usage/react.md b/core/src/components/modal/usage/react.md index 702e17f681..057baa740f 100644 --- a/core/src/components/modal/usage/react.md +++ b/core/src/components/modal/usage/react.md @@ -81,7 +81,7 @@ export const ModalExample: React.FC = () => { }; ``` -### Swipeable Modals +### Card 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. @@ -112,18 +112,18 @@ const Home: React.FC = ({ router }) => { const [showModal, setShowModal] = useState(false); return ( - ... - - setShowModal(false)}> -

This is modal content

-
- - ... + + + setShowModal(false)}> +

This is modal content

+
+
+
); }; @@ -153,3 +153,43 @@ In most scenarios, setting a ref on `IonRouterOutlet` and passing that ref's `cu setShow2ndModal(false)}>Close Modal ``` + + +### Sheet Modals + +```tsx +const App: React.FC = () => { + const routerRef = useRef(null); + + return ( + + + + } exact={true} /> + + + + ) +}; + +... + +const Home: React.FC = () => { + const [showModal, setShowModal] = useState(false); + + return ( + + + setShowModal(false)}> +

This is modal content

+
+
+
+ ); +}; + +``` diff --git a/core/src/components/modal/usage/stencil.md b/core/src/components/modal/usage/stencil.md index 93bcdbe842..2e62abdc89 100644 --- a/core/src/components/modal/usage/stencil.md +++ b/core/src/components/modal/usage/stencil.md @@ -107,7 +107,7 @@ const { data } = await modal.onWillDismiss(); console.log(data); ``` -### Swipeable Modals +### Card 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. @@ -151,3 +151,56 @@ async presentModal() { await modal.present(); } ``` + + +### Sheet Modals + +**Controller** +```tsx +import { Component, Element, h } from '@stencil/core'; + +import { modalController } from '@ionic/core'; + +@Component({ + tag: 'modal-example', + styleUrl: 'modal-example.css' +}) +export class ModalExample { + @Element() el: any; + + async presentModal() { + const modal = await modalController.create({ + component: 'page-modal', + initialBreakpoint: 0.5, + breakpoints: [0, 0.5, 1] + + }); + await modal.present(); + } +} +``` + +**Inline** +```tsx +import { Component, State, h } from '@stencil/core'; + +@Component({ + tag: 'modal-example', + styleUrl: 'modal-example.css' +}) +export class ModalExample { + @State() isModalOpen: boolean = false; + + render() { + return [ + + + + ] + } +} +``` diff --git a/core/src/components/modal/usage/vue.md b/core/src/components/modal/usage/vue.md index 86d06cc394..890b442773 100644 --- a/core/src/components/modal/usage/vue.md +++ b/core/src/components/modal/usage/vue.md @@ -94,7 +94,7 @@ export default defineComponent({ > If you need a wrapper element inside of your modal component, we recommend using an `` so that the component dimensions are still computed properly. -### Swipeable Modals +### Card 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. @@ -133,4 +133,71 @@ export default defineComponent({ } }); +``` + +### Sheet Modals + +**Controller** +```html + + + +``` + +**Inline** +```html + + + ``` \ No newline at end of file diff --git a/core/src/components/modal/utils.ts b/core/src/components/modal/utils.ts new file mode 100644 index 0000000000..31ce2f7216 --- /dev/null +++ b/core/src/components/modal/utils.ts @@ -0,0 +1,48 @@ +/** + * Use y = mx + b to + * figure out the backdrop value + * at a particular x coordinate. This + * is useful when the backdrop does + * not begin to fade in until after + * the 0 breakpoint. + */ +export const getBackdropValueForSheet = (x: number, maxBreakpoint: number, backdropBreakpoint: number) => { + + /** + * We will use these points: + * (backdropBreakpoint, 0) + * (maxBreakpoint, 1) + * We know that at the beginning breakpoint, + * the backdrop will be hidden. We also + * know that at the maxBreakpoint, the backdrop + * must be fully visible. + * m = (y2 - y1) / (x2 - x1) + * + * This is simplified from: + * m = (1 - 0) / (maxBreakpoint - backdropBreakpoint) + */ + const slope = 1 / (maxBreakpoint - backdropBreakpoint); + + /** + * From here, compute b which is + * the backdrop opacity if the offset + * is 0. If the backdrop does not + * begin to fade in until after the + * 0 breakpoint, this b value will be + * negative. This is fine as we never pass + * b directly into the animation keyframes. + * b = y - mx + * Use a known point: (backdropBreakpoint, 0) + * This is simplified from: + * b = 0 - (backdropBreakpoint * slope) + */ + const b = -(backdropBreakpoint * slope); + + /** + * Finally, we can now determine the + * backdrop offset given an arbitrary + * gesture offset. + */ + + return (x * slope) + b; +} diff --git a/core/src/css/core.scss b/core/src/css/core.scss index 7d6b53015e..7e33f1f4d6 100644 --- a/core/src/css/core.scss +++ b/core/src/css/core.scss @@ -40,6 +40,7 @@ body.backdrop-no-scroll { * padding though because of the safe area. */ html.ios ion-modal.modal-card ion-header ion-toolbar:first-of-type, +html.ios ion-modal.modal-sheet ion-header ion-toolbar:first-of-type, html.ios ion-modal ion-footer ion-toolbar:first-of-type { padding-top: 6px; } @@ -49,7 +50,8 @@ html.ios ion-modal ion-footer ion-toolbar:first-of-type { * bottom of the header. We accomplish this by targeting * the last toolbar in the header. */ -html.ios ion-modal.modal-card ion-header ion-toolbar:last-of-type { +html.ios ion-modal.modal-card ion-header ion-toolbar:last-of-type, +html.ios ion-modal.modal-sheet ion-header ion-toolbar:last-of-type { padding-bottom: 6px; } diff --git a/core/src/utils/animation/animation.ts b/core/src/utils/animation/animation.ts index 6c1de02b82..7c6ffb5a5a 100644 --- a/core/src/utils/animation/animation.ts +++ b/core/src/utils/animation/animation.ts @@ -409,11 +409,31 @@ export const createAnimation = (animationId?: string): Animation => { }; const keyframes = (keyframeValues: AnimationKeyFrames) => { + const different = _keyframes !== keyframeValues; _keyframes = keyframeValues; + if (different) { + updateKeyframes(_keyframes); + } + return ani; }; + const updateKeyframes = (keyframeValues: AnimationKeyFrames) => { + if (supportsWebAnimations) { + getWebAnimations().forEach(animation => { + if (animation.effect.setKeyframes) { + animation.effect.setKeyframes(keyframeValues); + } else { + const newEffect = new KeyframeEffect(animation.effect.target, keyframeValues, animation.effect.getTiming()); + animation.effect = newEffect; + } + }); + } else { + initializeCSSAnimation(); + } + }; + /** * Run all "before" animation hooks. */ @@ -668,9 +688,8 @@ export const createAnimation = (animationId?: string): Animation => { if (!initialized) { initializeAnimation(); - } else { - update(false, true, step); } + update(false, true, step); return ani; }; diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts index fa87ae8c4d..1397b0c1ae 100644 --- a/core/src/utils/overlays.ts +++ b/core/src/utils/overlays.ts @@ -376,7 +376,6 @@ export const present = async ( if (completed) { overlay.didPresent.emit(); overlay.didPresentShorthand?.emit(); - } /**