feat(modal): add bottom sheet functionality (#23828)

resolves #21039
This commit is contained in:
Liam DeBeasi
2021-08-31 15:19:19 -04:00
committed by GitHub
parent c925274c3b
commit 12216d378d
24 changed files with 1338 additions and 114 deletions

View File

@ -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

View File

@ -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.
*/

View File

@ -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 => {
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;

View File

@ -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)

View File

@ -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)

View File

@ -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]);

View File

@ -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 };
}

View File

@ -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;
};

View File

@ -18,3 +18,10 @@ export interface ModalOptions<T extends ComponentRef = ComponentRef> {
enterAnimation?: AnimationBuilder;
leaveAnimation?: AnimationBuilder;
}
export interface ModalAnimationOptions {
presentingEl?: HTMLElement;
currentBreakpoint?: number;
backdropBreakpoint?: number;
sortedBreakpoints: number[];
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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<any>;
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}
>
<ion-backdrop visible={this.showBackdrop} tappable={this.backdropDismiss} part="backdrop" />
<ion-backdrop ref={el => this.backdropEl = el} visible={this.showBackdrop} tappable={this.backdropDismiss} part="backdrop" />
{mode === 'ios' && <div class="modal-shadow"></div>}
@ -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 && <div class="modal-handle" part="handle"></div>}
<slot></slot>
</div>

View File

@ -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
<ion-modal [isOpen]="isModalOpen" [initialBreakpoint]="0.5" [breakpoints]="[0, 0.5, 1]">
<ng-template>
<modal-page></modal-page>
</ng-template>
</ion-modal>
```
### 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,8 +599,8 @@ const Home: React.FC<HomePageProps> = ({ router }) => {
const [showModal, setShowModal] = useState(false);
return (
...
<IonPage>
<IonContent>
<IonModal
isOpen={showModal}
cssClass='my-custom-class'
@ -548,8 +609,8 @@ const Home: React.FC<HomePageProps> = ({ router }) => {
onDidDismiss={() => setShowModal(false)}>
<p>This is modal content</p>
</IonModal>
...
</IonContent>
</IonPage>
);
};
@ -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<HTMLIonRouterOutletElement | null>(null);
return (
<IonApp>
<IonReactRouter>
<IonRouterOutlet ref={routerRef}>
<Route path="/home" render={() => <Home router={routerRef.current} />} exact={true} />
</IonRouterOutlet>
</IonReactRouter>
</IonApp>
)
};
...
const Home: React.FC = () => {
const [showModal, setShowModal] = useState(false);
return (
<IonPage>
<IonContent>
<IonModal
isOpen={showModal}
initialBreakpoint={0.5}
breakpoints={[0, 0.5, 1]}
onDidDismiss={() => setShowModal(false)}>
<p>This is modal content</p>
</IonModal>
</IonContent>
</IonPage>
);
};
```
### 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 [
<ion-modal
isOpen={isModalOpen}
initialBreakpoint={0.5}
breakpoints={[0, 0.5, 1]}
>
<page-modal></page-modal>
<ion-modal>
]
}
}
```
### Vue
```html
@ -836,7 +990,7 @@ export default defineComponent({
> If you need a wrapper element inside of your modal component, we recommend using an `<ion-page>` 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,15 +1031,86 @@ export default defineComponent({
</script>
```
### Sheet Modals
**Controller**
```html
<template>
<ion-page>
<ion-content class="ion-padding">
<ion-button @click="openModal()">Open Modal</ion-button>
</ion-content>
</ion-page>
</template>
<script>
import { IonButton, IonContent, IonPage, modalController } from '@ionic/vue';
import Modal from './modal.vue'
export default {
components: { IonButton, IonContent, IonPage },
methods: {
async openModal() {
const modal = await modalController
.create({
component: Modal,
initialBreakpoint: 0.5,
breakpoints: [0, 0.5, 1]
})
return modal.present();
},
},
}
</script>
```
**Inline**
```html
<template>
<ion-page>
<ion-content>
<ion-button @click="setOpen(true)">Show Modal</ion-button>
<ion-modal
:is-open="isOpenRef"
:initial-breakpoint="0.5"
:breakpoints="[0, 0.5, 1]"
@didDismiss="setOpen(false)"
>
<Modal></Modal>
</ion-modal>
</ion-content>
</ion-page>
</template>
<script lang="ts">
import { IonModal, IonButton, IonContent, IonPage } from '@ionic/vue';
import { defineComponent, ref } from 'vue';
import Modal from './modal.vue'
export default defineComponent({
components: { IonModal, IonButton, Modal, IonContent, IonPage },
setup() {
const isOpenRef = ref(false);
const setOpen = (state: boolean) => isOpenRef.value = state;
return { isOpenRef, setOpen }
}
});
</script>
```
## Properties
| 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` |
@ -963,9 +1188,10 @@ Type: `Promise<void>`
## Shadow Parts
| 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

View File

@ -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();
}
});

View File

@ -0,0 +1,181 @@
<!DOCTYPE html>
<html dir="ltr">
<head>
<meta charset="UTF-8">
<title>Modal - Sheet</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet">
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet">
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
<style>
:root {
--ion-safe-area-top: 20px;
--ion-safe-area-bottom: 20px;
}
.custom-height {
--height: 50%;
}
.custom-handle::part(handle) {
top: -16px;
background: rgba(255, 255, 255, 0.53);
}
.custom-handle::part(content) {
overflow: visible;
}
.red {
background-color: #ea445a;
}
.green {
background-color: #76d672;
}
.blue {
background-color: #3478f6;
}
.yellow {
background-color: #ffff80;
}
.pink {
background-color: #ff6b86;
}
.purple {
background-color: #7e34f6;
}
.black {
background-color: #000;
}
.orange {
background-color: #f69234;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 10px;
padding: 10px;
}
.grid-item {
height: 200px;
}
</style>
</head>
<body>
<ion-app>
<div class="ion-page">
<ion-header>
<ion-toolbar>
<ion-title>Modal - Sheet</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-button id="sheet-modal" onclick="presentModal()">Present Sheet Modal</ion-button>
<ion-button id="sheet-modal" onclick="presentModal({ initialBreakpoint: 0.5, breakpoints: [0, 0.5, 1] })">Present Sheet Modal (Custom Breakpoints)</ion-button>
<ion-button id="sheet-modal" onclick="presentModal({ backdropBreakpoint: 0.5 })">Present Sheet Modal (Custom Backdrop Breakpoint)</ion-button>
<ion-button id="sheet-modal" onclick="presentModal({ cssClass: 'custom-height' })">Present Sheet Modal (Custom Height)</ion-button>
<ion-button id="sheet-modal" onclick="presentModal({ cssClass: 'custom-handle' })">Present Sheet Modal (Custom Handle)</ion-button>
<div class="grid">
<div class="grid-item red"></div>
<div class="grid-item green"></div>
<div class="grid-item blue"></div>
<div class="grid-item yellow"></div>
<div class="grid-item pink"></div>
<div class="grid-item purple"></div>
<div class="grid-item black"></div>
<div class="grid-item orange"></div>
</div>
</ion-content>
</div>
</ion-app>
<script>
window.addEventListener("ionModalDidDismiss", function (e) { console.log('DidDismiss', e) })
window.addEventListener("ionModalWillDismiss", function (e) { console.log('WillDismiss', e) })
function createModal(options) {
let items = '';
for (var i = 0; i < 25; i++ ) {
items += `<ion-item>Item ${i}</ion-item>`;
}
// create component to open
const element = document.createElement('div');
element.innerHTML = `
<ion-header>
<ion-toolbar>
<ion-title>Super Modal</ion-title>
<ion-buttons slot="end">
<ion-button class="dismiss">Dismiss Modal</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-list>
${items}
</ion-list>
</ion-content>
`;
let extraOptions = {
initialBreakpoint: 0.25,
breakpoints: [0, 0.25, .5, .75, 1]
};
if (options) {
extraOptions = {
...extraOptions,
...options
}
}
// present the modal
const modalElement = Object.assign(document.createElement('ion-modal'), {
component: element,
...extraOptions
});
// listen for close event
const button = element.querySelector('ion-button');
button.addEventListener('click', () => {
modalElement.dismiss();
});
document.body.appendChild(modalElement);
return modalElement;
}
async function presentModal(options) {
const modal = createModal(options);
await modal.present();
}
async function presentCardModal() {
const presentingEl = document.querySelectorAll('.ion-page')[1];
const modal = createModal('card', {
presentingElement: presentingEl
});
await modal.present();
}
</script>
</body>
</html>

View File

@ -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
<ion-modal [isOpen]="isModalOpen" [initialBreakpoint]="0.5" [breakpoints]="[0, 0.5, 1]">
<ng-template>
<modal-page></modal-page>
</ng-template>
</ion-modal>
```
### Style Placement

View File

@ -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];
```

View File

@ -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,8 +112,8 @@ const Home: React.FC<HomePageProps> = ({ router }) => {
const [showModal, setShowModal] = useState(false);
return (
...
<IonPage>
<IonContent>
<IonModal
isOpen={showModal}
cssClass='my-custom-class'
@ -122,8 +122,8 @@ const Home: React.FC<HomePageProps> = ({ router }) => {
onDidDismiss={() => setShowModal(false)}>
<p>This is modal content</p>
</IonModal>
...
</IonContent>
</IonPage>
);
};
@ -153,3 +153,43 @@ In most scenarios, setting a ref on `IonRouterOutlet` and passing that ref's `cu
<IonButton onClick={() => setShow2ndModal(false)}>Close Modal</IonButton>
</IonModal>
```
### Sheet Modals
```tsx
const App: React.FC = () => {
const routerRef = useRef<HTMLIonRouterOutletElement | null>(null);
return (
<IonApp>
<IonReactRouter>
<IonRouterOutlet ref={routerRef}>
<Route path="/home" render={() => <Home router={routerRef.current} />} exact={true} />
</IonRouterOutlet>
</IonReactRouter>
</IonApp>
)
};
...
const Home: React.FC = () => {
const [showModal, setShowModal] = useState(false);
return (
<IonPage>
<IonContent>
<IonModal
isOpen={showModal}
initialBreakpoint={0.5}
breakpoints={[0, 0.5, 1]}
onDidDismiss={() => setShowModal(false)}>
<p>This is modal content</p>
</IonModal>
</IonContent>
</IonPage>
);
};
```

View File

@ -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 [
<ion-modal
isOpen={isModalOpen}
initialBreakpoint={0.5}
breakpoints={[0, 0.5, 1]}
>
<page-modal></page-modal>
<ion-modal>
]
}
}
```

View File

@ -94,7 +94,7 @@ export default defineComponent({
> If you need a wrapper element inside of your modal component, we recommend using an `<ion-page>` 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.
@ -134,3 +134,70 @@ export default defineComponent({
});
</script>
```
### Sheet Modals
**Controller**
```html
<template>
<ion-page>
<ion-content class="ion-padding">
<ion-button @click="openModal()">Open Modal</ion-button>
</ion-content>
</ion-page>
</template>
<script>
import { IonButton, IonContent, IonPage, modalController } from '@ionic/vue';
import Modal from './modal.vue'
export default {
components: { IonButton, IonContent, IonPage },
methods: {
async openModal() {
const modal = await modalController
.create({
component: Modal,
initialBreakpoint: 0.5,
breakpoints: [0, 0.5, 1]
})
return modal.present();
},
},
}
</script>
```
**Inline**
```html
<template>
<ion-page>
<ion-content>
<ion-button @click="setOpen(true)">Show Modal</ion-button>
<ion-modal
:is-open="isOpenRef"
:initial-breakpoint="0.5"
:breakpoints="[0, 0.5, 1]"
@didDismiss="setOpen(false)"
>
<Modal></Modal>
</ion-modal>
</ion-content>
</ion-page>
</template>
<script lang="ts">
import { IonModal, IonButton, IonContent, IonPage } from '@ionic/vue';
import { defineComponent, ref } from 'vue';
import Modal from './modal.vue'
export default defineComponent({
components: { IonModal, IonButton, Modal, IonContent, IonPage },
setup() {
const isOpenRef = ref(false);
const setOpen = (state: boolean) => isOpenRef.value = state;
return { isOpenRef, setOpen }
}
});
</script>
```

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
};

View File

@ -376,7 +376,6 @@ export const present = async (
if (completed) {
overlay.didPresent.emit();
overlay.didPresentShorthand?.emit();
}
/**