mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-08-16 10:01:59 +08:00
feat(toast): add swipe to dismiss functionality (#28442)
Issue number: resolves #21769 --------- <!-- Please do not submit updates to dependencies unless it fixes an issue. --> <!-- Please try to limit your pull request to one type (bugfix, feature, etc). Submit multiple pull requests if needed. --> ## What is the current behavior? <!-- Please describe the current behavior that you are modifying. --> Toast does not support swipe gestures to dismiss. ## What is the new behavior? <!-- Please describe the behavior or changes that are being added by this PR. --> - Added a `swipeGesture` property that allows users to swipe toasts closed. Note: This is a combination of previous PRs https://github.com/ionic-team/ionic-framework/pull/28380 and https://github.com/ionic-team/ionic-framework/pull/28402 ⚠️ There is a visual glitch on iOS where dragging and having the toast animate back to its opened position causes a flicker. This is an iOS 17 regression and is being tracked in https://github.com/ionic-team/ionic-framework/issues/28467. This bug has been reported to and confirmed by Apple. ## Does this introduce a breaking change? - [ ] Yes - [x] No <!-- If this introduces a breaking change, please describe the impact and migration path for existing applications below. --> ## Other information <!-- Any other information that is important to this PR such as screenshots of how the component looks before and after the change. --> ⚠️ Give co-author credit to author in https://github.com/ionic-team/ionic-framework/pull/23124 --------- Co-authored-by: evgeniy-skakun <evgeniy-skakun@users.noreply.github.com>
This commit is contained in:
@ -1447,6 +1447,7 @@ ion-toast,prop,message,IonicSafeString | string | undefined,undefined,false,fals
|
|||||||
ion-toast,prop,mode,"ios" | "md",undefined,false,false
|
ion-toast,prop,mode,"ios" | "md",undefined,false,false
|
||||||
ion-toast,prop,position,"bottom" | "middle" | "top",'bottom',false,false
|
ion-toast,prop,position,"bottom" | "middle" | "top",'bottom',false,false
|
||||||
ion-toast,prop,positionAnchor,HTMLElement | string | undefined,undefined,false,false
|
ion-toast,prop,positionAnchor,HTMLElement | string | undefined,undefined,false,false
|
||||||
|
ion-toast,prop,swipeGesture,"vertical" | undefined,undefined,false,false
|
||||||
ion-toast,prop,translucent,boolean,false,false,false
|
ion-toast,prop,translucent,boolean,false,false,false
|
||||||
ion-toast,prop,trigger,string | undefined,undefined,false,false
|
ion-toast,prop,trigger,string | undefined,undefined,false,false
|
||||||
ion-toast,method,dismiss,dismiss(data?: any, role?: string) => Promise<boolean>
|
ion-toast,method,dismiss,dismiss(data?: any, role?: string) => Promise<boolean>
|
||||||
|
12
core/src/components.d.ts
vendored
12
core/src/components.d.ts
vendored
@ -39,7 +39,7 @@ import { SelectChangeEventDetail, SelectCompareFn, SelectInterface } from "./com
|
|||||||
import { SelectPopoverOption } from "./components/select-popover/select-popover-interface";
|
import { SelectPopoverOption } from "./components/select-popover/select-popover-interface";
|
||||||
import { TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout } from "./components/tab-bar/tab-bar-interface";
|
import { TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout } from "./components/tab-bar/tab-bar-interface";
|
||||||
import { TextareaChangeEventDetail, TextareaInputEventDetail } from "./components/textarea/textarea-interface";
|
import { TextareaChangeEventDetail, TextareaInputEventDetail } from "./components/textarea/textarea-interface";
|
||||||
import { ToastButton, ToastDismissOptions, ToastLayout, ToastPosition, ToastPresentOptions } from "./components/toast/toast-interface";
|
import { ToastButton, ToastDismissOptions, ToastLayout, ToastPosition, ToastPresentOptions, ToastSwipeGestureDirection } from "./components/toast/toast-interface";
|
||||||
import { ToggleChangeEventDetail } from "./components/toggle/toggle-interface";
|
import { ToggleChangeEventDetail } from "./components/toggle/toggle-interface";
|
||||||
export { AccordionGroupChangeEventDetail } from "./components/accordion-group/accordion-group-interface";
|
export { AccordionGroupChangeEventDetail } from "./components/accordion-group/accordion-group-interface";
|
||||||
export { AnimationBuilder, AutocompleteTypes, Color, ComponentProps, ComponentRef, FrameworkDelegate, StyleEventDetail, TextFieldTypes } from "./interface";
|
export { AnimationBuilder, AutocompleteTypes, Color, ComponentProps, ComponentRef, FrameworkDelegate, StyleEventDetail, TextFieldTypes } from "./interface";
|
||||||
@ -75,7 +75,7 @@ export { SelectChangeEventDetail, SelectCompareFn, SelectInterface } from "./com
|
|||||||
export { SelectPopoverOption } from "./components/select-popover/select-popover-interface";
|
export { SelectPopoverOption } from "./components/select-popover/select-popover-interface";
|
||||||
export { TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout } from "./components/tab-bar/tab-bar-interface";
|
export { TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout } from "./components/tab-bar/tab-bar-interface";
|
||||||
export { TextareaChangeEventDetail, TextareaInputEventDetail } from "./components/textarea/textarea-interface";
|
export { TextareaChangeEventDetail, TextareaInputEventDetail } from "./components/textarea/textarea-interface";
|
||||||
export { ToastButton, ToastDismissOptions, ToastLayout, ToastPosition, ToastPresentOptions } from "./components/toast/toast-interface";
|
export { ToastButton, ToastDismissOptions, ToastLayout, ToastPosition, ToastPresentOptions, ToastSwipeGestureDirection } from "./components/toast/toast-interface";
|
||||||
export { ToggleChangeEventDetail } from "./components/toggle/toggle-interface";
|
export { ToggleChangeEventDetail } from "./components/toggle/toggle-interface";
|
||||||
export namespace Components {
|
export namespace Components {
|
||||||
interface IonAccordion {
|
interface IonAccordion {
|
||||||
@ -3172,6 +3172,10 @@ export namespace Components {
|
|||||||
* Present the toast overlay after it has been created.
|
* Present the toast overlay after it has been created.
|
||||||
*/
|
*/
|
||||||
"present": () => Promise<void>;
|
"present": () => Promise<void>;
|
||||||
|
/**
|
||||||
|
* If set to 'vertical', the Toast can be dismissed with a swipe gesture. The swipe direction is determined by the value of the `position` property: `top`: The Toast can be swiped up to dismiss. `bottom`: The Toast can be swiped down to dismiss. `middle`: The Toast can be swiped up or down to dismiss.
|
||||||
|
*/
|
||||||
|
"swipeGesture"?: ToastSwipeGestureDirection;
|
||||||
/**
|
/**
|
||||||
* If `true`, the toast will be translucent. Only applies when the mode is `"ios"` and the device supports [`backdrop-filter`](https://developer.mozilla.org/en-US/docs/Web/CSS/backdrop-filter#Browser_compatibility).
|
* If `true`, the toast will be translucent. Only applies when the mode is `"ios"` and the device supports [`backdrop-filter`](https://developer.mozilla.org/en-US/docs/Web/CSS/backdrop-filter#Browser_compatibility).
|
||||||
*/
|
*/
|
||||||
@ -7970,6 +7974,10 @@ declare namespace LocalJSX {
|
|||||||
* The element to anchor the toast's position to. Can be set as a direct reference or the ID of the element. With `position="bottom"`, the toast will sit above the chosen element. With `position="top"`, the toast will sit below the chosen element. With `position="middle"`, the value of `positionAnchor` is ignored.
|
* The element to anchor the toast's position to. Can be set as a direct reference or the ID of the element. With `position="bottom"`, the toast will sit above the chosen element. With `position="top"`, the toast will sit below the chosen element. With `position="middle"`, the value of `positionAnchor` is ignored.
|
||||||
*/
|
*/
|
||||||
"positionAnchor"?: HTMLElement | string;
|
"positionAnchor"?: HTMLElement | string;
|
||||||
|
/**
|
||||||
|
* If set to 'vertical', the Toast can be dismissed with a swipe gesture. The swipe direction is determined by the value of the `position` property: `top`: The Toast can be swiped up to dismiss. `bottom`: The Toast can be swiped down to dismiss. `middle`: The Toast can be swiped up or down to dismiss.
|
||||||
|
*/
|
||||||
|
"swipeGesture"?: ToastSwipeGestureDirection;
|
||||||
/**
|
/**
|
||||||
* If `true`, the toast will be translucent. Only applies when the mode is `"ios"` and the device supports [`backdrop-filter`](https://developer.mozilla.org/en-US/docs/Web/CSS/backdrop-filter#Browser_compatibility).
|
* If `true`, the toast will be translucent. Only applies when the mode is `"ios"` and the device supports [`backdrop-filter`](https://developer.mozilla.org/en-US/docs/Web/CSS/backdrop-filter#Browser_compatibility).
|
||||||
*/
|
*/
|
||||||
|
@ -2,6 +2,7 @@ import { getTimeGivenProgression } from '@utils/animation/cubic-bezier';
|
|||||||
import { isIonContent, findClosestIonContent, disableContentScrollY, resetContentScrollY } from '@utils/content';
|
import { isIonContent, findClosestIonContent, disableContentScrollY, resetContentScrollY } from '@utils/content';
|
||||||
import { createGesture } from '@utils/gesture';
|
import { createGesture } from '@utils/gesture';
|
||||||
import { clamp, getElementRoot } from '@utils/helpers';
|
import { clamp, getElementRoot } from '@utils/helpers';
|
||||||
|
import { OVERLAY_GESTURE_PRIORITY } from '@utils/overlays';
|
||||||
|
|
||||||
import type { Animation } from '../../../interface';
|
import type { Animation } from '../../../interface';
|
||||||
import type { GestureDetail } from '../../../utils/gesture';
|
import type { GestureDetail } from '../../../utils/gesture';
|
||||||
@ -292,7 +293,7 @@ export const createSwipeToCloseGesture = (
|
|||||||
const gesture = createGesture({
|
const gesture = createGesture({
|
||||||
el,
|
el,
|
||||||
gestureName: 'modalSwipeToClose',
|
gestureName: 'modalSwipeToClose',
|
||||||
gesturePriority: 39,
|
gesturePriority: OVERLAY_GESTURE_PRIORITY,
|
||||||
direction: 'y',
|
direction: 'y',
|
||||||
threshold: 10,
|
threshold: 10,
|
||||||
canStart,
|
canStart,
|
||||||
|
@ -4,6 +4,8 @@ import { getElementRoot } from '@utils/helpers';
|
|||||||
import type { Animation } from '../../../interface';
|
import type { Animation } from '../../../interface';
|
||||||
import type { ToastPresentOptions } from '../toast-interface';
|
import type { ToastPresentOptions } from '../toast-interface';
|
||||||
|
|
||||||
|
import { getOffsetForMiddlePosition } from './utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* iOS Toast Enter Animation
|
* iOS Toast Enter Animation
|
||||||
*/
|
*/
|
||||||
@ -22,7 +24,7 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts: ToastPresentOptions
|
|||||||
wrapperAnimation.fromTo('transform', 'translateY(-100%)', `translateY(${top})`);
|
wrapperAnimation.fromTo('transform', 'translateY(-100%)', `translateY(${top})`);
|
||||||
break;
|
break;
|
||||||
case 'middle':
|
case 'middle':
|
||||||
const topPosition = Math.floor(baseEl.clientHeight / 2 - wrapperEl.clientHeight / 2);
|
const topPosition = getOffsetForMiddlePosition(baseEl.clientHeight, wrapperEl.clientHeight);
|
||||||
wrapperEl.style.top = `${topPosition}px`;
|
wrapperEl.style.top = `${topPosition}px`;
|
||||||
wrapperAnimation.fromTo('opacity', 0.01, 1);
|
wrapperAnimation.fromTo('opacity', 0.01, 1);
|
||||||
break;
|
break;
|
||||||
|
@ -4,6 +4,8 @@ import { getElementRoot } from '@utils/helpers';
|
|||||||
import type { Animation } from '../../../interface';
|
import type { Animation } from '../../../interface';
|
||||||
import type { ToastPresentOptions } from '../toast-interface';
|
import type { ToastPresentOptions } from '../toast-interface';
|
||||||
|
|
||||||
|
import { getOffsetForMiddlePosition } from './utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MD Toast Enter Animation
|
* MD Toast Enter Animation
|
||||||
*/
|
*/
|
||||||
@ -23,7 +25,7 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts: ToastPresentOptions)
|
|||||||
wrapperAnimation.fromTo('opacity', 0.01, 1);
|
wrapperAnimation.fromTo('opacity', 0.01, 1);
|
||||||
break;
|
break;
|
||||||
case 'middle':
|
case 'middle':
|
||||||
const topPosition = Math.floor(baseEl.clientHeight / 2 - wrapperEl.clientHeight / 2);
|
const topPosition = getOffsetForMiddlePosition(baseEl.clientHeight, wrapperEl.clientHeight);
|
||||||
wrapperEl.style.top = `${topPosition}px`;
|
wrapperEl.style.top = `${topPosition}px`;
|
||||||
wrapperAnimation.fromTo('opacity', 0.01, 1);
|
wrapperAnimation.fromTo('opacity', 0.01, 1);
|
||||||
break;
|
break;
|
||||||
|
@ -88,3 +88,15 @@ function warnIfAnchorIsHidden(positionAnchor: HTMLElement, toast: HTMLElement) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the top offset required to place
|
||||||
|
* the toast in the middle of the screen.
|
||||||
|
* Only needed when position="toast".
|
||||||
|
* @param toastHeight - The height of the ion-toast element
|
||||||
|
* @param wrapperHeight - The height of the .toast-wrapper element
|
||||||
|
* inside the toast's shadow root.
|
||||||
|
*/
|
||||||
|
export const getOffsetForMiddlePosition = (toastHeight: number, wrapperHeight: number) => {
|
||||||
|
return Math.floor(toastHeight / 2 - wrapperHeight / 2);
|
||||||
|
};
|
||||||
|
298
core/src/components/toast/gestures/swipe-to-dismiss.ts
Normal file
298
core/src/components/toast/gestures/swipe-to-dismiss.ts
Normal file
@ -0,0 +1,298 @@
|
|||||||
|
import { Build } from '@stencil/core';
|
||||||
|
import { createAnimation } from '@utils/animation/animation';
|
||||||
|
import { createGesture } from '@utils/gesture';
|
||||||
|
import type { GestureDetail } from '@utils/gesture';
|
||||||
|
import { getElementRoot } from '@utils/helpers';
|
||||||
|
import { OVERLAY_GESTURE_PRIORITY } from '@utils/overlays';
|
||||||
|
|
||||||
|
import { getOffsetForMiddlePosition } from '../animations/utils';
|
||||||
|
import type { ToastAnimationPosition } from '../toast-interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a gesture that allows the Toast
|
||||||
|
* to be swiped to dismiss.
|
||||||
|
* @param el - The Toast element
|
||||||
|
* @param toastPosition - The last computed position of the Toast. This is computed in the "present" method.
|
||||||
|
* @param onDismiss - A callback to fire when the Toast was swiped to dismiss.
|
||||||
|
*/
|
||||||
|
export const createSwipeToDismissGesture = (
|
||||||
|
el: HTMLIonToastElement,
|
||||||
|
toastPosition: ToastAnimationPosition,
|
||||||
|
onDismiss: () => void
|
||||||
|
) => {
|
||||||
|
/**
|
||||||
|
* Users should swipe on the visible toast wrapper
|
||||||
|
* rather than on ion-toast which covers the entire screen.
|
||||||
|
* When testing the class instance the inner wrapper will not
|
||||||
|
* be defined. As a result, we use a placeholder element in those environments.
|
||||||
|
*/
|
||||||
|
const wrapperEl = Build.isTesting
|
||||||
|
? document.createElement('div')
|
||||||
|
: getElementRoot(el).querySelector('.toast-wrapper')!;
|
||||||
|
const hostElHeight = el.clientHeight;
|
||||||
|
const wrapperElBox = wrapperEl.getBoundingClientRect();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The maximum amount that
|
||||||
|
* the toast can be swiped. This should
|
||||||
|
* account for the wrapper element's height
|
||||||
|
* too so the toast can be swiped offscreen
|
||||||
|
* completely.
|
||||||
|
*/
|
||||||
|
let MAX_SWIPE_DISTANCE = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The step value at which a toast
|
||||||
|
* is eligible for dismissing via gesture.
|
||||||
|
*/
|
||||||
|
const DISMISS_THRESHOLD = 0.5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The middle position Toast starts 50% of the way
|
||||||
|
* through the animation, so we need to use this
|
||||||
|
* as the starting point for our step values.
|
||||||
|
*/
|
||||||
|
const STEP_OFFSET = el.position === 'middle' ? 0.5 : 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the Toast is at the top users will be
|
||||||
|
* swiping up. As a result, the delta values will be
|
||||||
|
* negative numbers which will result in negative steps
|
||||||
|
* and thresholds. As a result, we need to make those numbers
|
||||||
|
* positive.
|
||||||
|
*/
|
||||||
|
const INVERSION_FACTOR = el.position === 'top' ? -1 : 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The top offset that places the
|
||||||
|
* toast in the middle of the screen.
|
||||||
|
* Only needed when position="middle".
|
||||||
|
*/
|
||||||
|
const topPosition = getOffsetForMiddlePosition(hostElHeight, wrapperElBox.height);
|
||||||
|
const SWIPE_UP_DOWN_KEYFRAMES = [
|
||||||
|
{ offset: 0, transform: `translateY(-${topPosition + wrapperElBox.height}px)` },
|
||||||
|
{ offset: 0.5, transform: `translateY(0px)` },
|
||||||
|
{ offset: 1, transform: `translateY(${topPosition + wrapperElBox.height}px)` },
|
||||||
|
];
|
||||||
|
|
||||||
|
const swipeAnimation = createAnimation('toast-swipe-to-dismiss-animation')
|
||||||
|
.addElement(wrapperEl)
|
||||||
|
/**
|
||||||
|
* The specific value here does not actually
|
||||||
|
* matter. We just need this to be a positive
|
||||||
|
* value so the animation does not jump
|
||||||
|
* to the end when the user beings to drag.
|
||||||
|
*/
|
||||||
|
.duration(100);
|
||||||
|
|
||||||
|
switch (el.position) {
|
||||||
|
case 'middle':
|
||||||
|
MAX_SWIPE_DISTANCE = hostElHeight + wrapperElBox.height;
|
||||||
|
swipeAnimation.keyframes(SWIPE_UP_DOWN_KEYFRAMES);
|
||||||
|
/**
|
||||||
|
* Toast can be swiped up or down but
|
||||||
|
* should start in the middle of the screen.
|
||||||
|
*/
|
||||||
|
swipeAnimation.progressStart(true, 0.5);
|
||||||
|
break;
|
||||||
|
case 'top':
|
||||||
|
/**
|
||||||
|
* The bottom edge of the wrapper
|
||||||
|
* includes the distance between the top
|
||||||
|
* of the screen and the top of the wrapper
|
||||||
|
* as well as the wrapper height so the wrapper
|
||||||
|
* can be dragged fully offscreen.
|
||||||
|
*/
|
||||||
|
MAX_SWIPE_DISTANCE = wrapperElBox.bottom;
|
||||||
|
swipeAnimation.keyframes([
|
||||||
|
{ offset: 0, transform: `translateY(${toastPosition.top})` },
|
||||||
|
{ offset: 1, transform: 'translateY(-100%)' },
|
||||||
|
]);
|
||||||
|
swipeAnimation.progressStart(true, 0);
|
||||||
|
break;
|
||||||
|
case 'bottom':
|
||||||
|
default:
|
||||||
|
/**
|
||||||
|
* This computes the distance between the
|
||||||
|
* top of the wrapper and the bottom of the
|
||||||
|
* screen including the height of the wrapper
|
||||||
|
* element so it can be dragged fully offscreen.
|
||||||
|
*/
|
||||||
|
MAX_SWIPE_DISTANCE = hostElHeight - wrapperElBox.top;
|
||||||
|
swipeAnimation.keyframes([
|
||||||
|
{ offset: 0, transform: `translateY(${toastPosition.bottom})` },
|
||||||
|
{ offset: 1, transform: 'translateY(100%)' },
|
||||||
|
]);
|
||||||
|
swipeAnimation.progressStart(true, 0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const computeStep = (delta: number) => {
|
||||||
|
return (delta * INVERSION_FACTOR) / MAX_SWIPE_DISTANCE;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMove = (detail: GestureDetail) => {
|
||||||
|
const step = STEP_OFFSET + computeStep(detail.deltaY);
|
||||||
|
swipeAnimation.progressStep(step);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEnd = (detail: GestureDetail) => {
|
||||||
|
const velocity = detail.velocityY;
|
||||||
|
const threshold = ((detail.deltaY + velocity * 1000) / MAX_SWIPE_DISTANCE) * INVERSION_FACTOR;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable the gesture for the remainder of the animation.
|
||||||
|
* It will be re-enabled if the toast animates back to
|
||||||
|
* its initial presented position.
|
||||||
|
*/
|
||||||
|
gesture.enable(false);
|
||||||
|
|
||||||
|
let shouldDismiss = true;
|
||||||
|
let playTo: 0 | 1 = 1;
|
||||||
|
let step: number = 0;
|
||||||
|
let remainingDistance = 0;
|
||||||
|
|
||||||
|
if (el.position === 'middle') {
|
||||||
|
/**
|
||||||
|
* A middle positioned Toast appears
|
||||||
|
* in the middle of the screen (at animation offset 0.5).
|
||||||
|
* As a result, the threshold will be calculated relative
|
||||||
|
* to this starting position. In other words at animation offset 0.5
|
||||||
|
* the threshold will be 0. We want the middle Toast to be eligible
|
||||||
|
* for dismiss when the user has swiped either half way up or down the
|
||||||
|
* screen. As a result, we divide DISMISS_THRESHOLD in half. We also
|
||||||
|
* consider when the threshold is a negative in the event the
|
||||||
|
* user drags up (since the deltaY will also be negative).
|
||||||
|
*/
|
||||||
|
shouldDismiss = threshold >= DISMISS_THRESHOLD / 2 || threshold <= -DISMISS_THRESHOLD / 2;
|
||||||
|
/**
|
||||||
|
* Since we are replacing the keyframes
|
||||||
|
* below the animation always starts from
|
||||||
|
* the beginning of the new keyframes.
|
||||||
|
* Similarly, we are always playing to
|
||||||
|
* the end of the new keyframes.
|
||||||
|
*/
|
||||||
|
playTo = 1;
|
||||||
|
step = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Toast should animate from wherever its
|
||||||
|
* current position is to the desired end state.
|
||||||
|
*
|
||||||
|
* To begin, we get the current position of the
|
||||||
|
* Toast for its starting state.
|
||||||
|
*/
|
||||||
|
const wrapperElBox = wrapperEl.getBoundingClientRect();
|
||||||
|
const startOffset = wrapperElBox.top - topPosition;
|
||||||
|
const startPosition = `${startOffset}px`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the deltaY is negative then the user is swiping
|
||||||
|
* up, so the Toast should animate to the top of the screen.
|
||||||
|
* If the deltaY is positive then the user is swiping
|
||||||
|
* down, so the Toast should animate to the bottom of the screen.
|
||||||
|
* We also account for when the deltaY is 0, but realistically
|
||||||
|
* that should never happen because it means the user did not drag
|
||||||
|
* the toast.
|
||||||
|
*/
|
||||||
|
const offsetFactor = detail.deltaY <= 0 ? -1 : 1;
|
||||||
|
const endOffset = (topPosition + wrapperElBox.height) * offsetFactor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the Toast should dismiss
|
||||||
|
* then we need to figure out which edge of
|
||||||
|
* the screen it should animate towards.
|
||||||
|
* By default, the Toast will come
|
||||||
|
* back to its default state in the
|
||||||
|
* middle of the screen.
|
||||||
|
*/
|
||||||
|
const endPosition = shouldDismiss ? `${endOffset}px` : '0px';
|
||||||
|
|
||||||
|
const KEYFRAMES = [
|
||||||
|
{ offset: 0, transform: `translateY(${startPosition})` },
|
||||||
|
{ offset: 1, transform: `translateY(${endPosition})` },
|
||||||
|
];
|
||||||
|
|
||||||
|
swipeAnimation.keyframes(KEYFRAMES);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the remaining amount of pixels the
|
||||||
|
* toast needs to move to be fully dismissed.
|
||||||
|
*/
|
||||||
|
remainingDistance = endOffset - startOffset;
|
||||||
|
} else {
|
||||||
|
shouldDismiss = threshold >= DISMISS_THRESHOLD;
|
||||||
|
playTo = shouldDismiss ? 1 : 0;
|
||||||
|
step = computeStep(detail.deltaY);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the remaining amount of pixels the
|
||||||
|
* toast needs to move to be fully dismissed.
|
||||||
|
*/
|
||||||
|
const remainingStepAmount = shouldDismiss ? 1 - step : step;
|
||||||
|
remainingDistance = remainingStepAmount * MAX_SWIPE_DISTANCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The animation speed should depend on how quickly
|
||||||
|
* the user flicks the toast across the screen. However,
|
||||||
|
* it should be no slower than 200ms.
|
||||||
|
* We use Math.abs on the remainingDistance because that value
|
||||||
|
* can be negative when swiping up on a middle position toast.
|
||||||
|
*/
|
||||||
|
const duration = Math.min(Math.abs(remainingDistance) / Math.abs(velocity), 200);
|
||||||
|
|
||||||
|
swipeAnimation
|
||||||
|
.onFinish(
|
||||||
|
() => {
|
||||||
|
if (shouldDismiss) {
|
||||||
|
onDismiss();
|
||||||
|
swipeAnimation.destroy();
|
||||||
|
} else {
|
||||||
|
if (el.position === 'middle') {
|
||||||
|
/**
|
||||||
|
* If the toast snapped back to
|
||||||
|
* the middle of the screen we need
|
||||||
|
* to reset the keyframes
|
||||||
|
* so the toast can be swiped
|
||||||
|
* up or down again.
|
||||||
|
*/
|
||||||
|
swipeAnimation.keyframes(SWIPE_UP_DOWN_KEYFRAMES).progressStart(true, 0.5);
|
||||||
|
} else {
|
||||||
|
swipeAnimation.progressStart(true, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the toast did not dismiss then
|
||||||
|
* the user should be able to swipe again.
|
||||||
|
*/
|
||||||
|
gesture.enable(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This must be a one time callback
|
||||||
|
* otherwise a new callback will
|
||||||
|
* be added every time onEnd runs.
|
||||||
|
*/
|
||||||
|
},
|
||||||
|
{ oneTimeCallback: true }
|
||||||
|
)
|
||||||
|
.progressEnd(playTo, step, duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
const gesture = createGesture({
|
||||||
|
el: wrapperEl,
|
||||||
|
gestureName: 'toast-swipe-to-dismiss',
|
||||||
|
gesturePriority: OVERLAY_GESTURE_PRIORITY,
|
||||||
|
/**
|
||||||
|
* Toast only supports vertical swipes.
|
||||||
|
* This needs to be updated if we later
|
||||||
|
* support horizontal swipes.
|
||||||
|
*/
|
||||||
|
direction: 'y',
|
||||||
|
onMove,
|
||||||
|
onEnd,
|
||||||
|
});
|
||||||
|
|
||||||
|
return gesture;
|
||||||
|
};
|
78
core/src/components/toast/test/swipe-gesture/index.html
Normal file
78
core/src/components/toast/test/swipe-gesture/index.html
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" dir="ltr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Toast - Swipe Gesture</title>
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
||||||
|
/>
|
||||||
|
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
|
||||||
|
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
|
||||||
|
<script src="../../../../../scripts/testing/scripts.js"></script>
|
||||||
|
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<ion-app>
|
||||||
|
<ion-content class="ion-padding">
|
||||||
|
<ion-button id="top">Open Top Toast</ion-button>
|
||||||
|
<ion-button id="middle">Open Middle Toast</ion-button>
|
||||||
|
<ion-button id="bottom">Open Bottom Toast</ion-button>
|
||||||
|
|
||||||
|
<ion-button id="top-anchor">Open Top Toast with Anchor</ion-button>
|
||||||
|
<ion-button id="bottom-anchor">Open Bottom Toast with Anchor</ion-button>
|
||||||
|
|
||||||
|
<ion-fab id="bottom-fab" horizontal="end" vertical="bottom">
|
||||||
|
<ion-fab-button>
|
||||||
|
<ion-icon name="add"></ion-icon>
|
||||||
|
</ion-fab-button>
|
||||||
|
</ion-fab>
|
||||||
|
|
||||||
|
<ion-fab id="top-fab" horizontal="end" vertical="top">
|
||||||
|
<ion-fab-button>
|
||||||
|
<ion-icon name="add"></ion-icon>
|
||||||
|
</ion-fab-button>
|
||||||
|
</ion-fab>
|
||||||
|
|
||||||
|
<ion-toast
|
||||||
|
id="top-toast"
|
||||||
|
position="top"
|
||||||
|
trigger="top"
|
||||||
|
message="Hello World"
|
||||||
|
swipe-gesture="vertical"
|
||||||
|
></ion-toast>
|
||||||
|
<ion-toast
|
||||||
|
id="middle-toast"
|
||||||
|
position="middle"
|
||||||
|
trigger="middle"
|
||||||
|
message="Hello World"
|
||||||
|
swipe-gesture="vertical"
|
||||||
|
></ion-toast>
|
||||||
|
<ion-toast
|
||||||
|
id="bottom-toast"
|
||||||
|
position="bottom"
|
||||||
|
trigger="bottom"
|
||||||
|
message="Hello World"
|
||||||
|
swipe-gesture="vertical"
|
||||||
|
></ion-toast>
|
||||||
|
<ion-toast
|
||||||
|
id="bottom-anchor-toast"
|
||||||
|
position="bottom"
|
||||||
|
trigger="bottom-anchor"
|
||||||
|
message="Hello World"
|
||||||
|
swipe-gesture="vertical"
|
||||||
|
position-anchor="bottom-fab"
|
||||||
|
></ion-toast>
|
||||||
|
<ion-toast
|
||||||
|
id="top-anchor-toast"
|
||||||
|
position="top"
|
||||||
|
trigger="top-anchor"
|
||||||
|
message="Hello World"
|
||||||
|
swipe-gesture="vertical"
|
||||||
|
position-anchor="top-fab"
|
||||||
|
></ion-toast>
|
||||||
|
</ion-content>
|
||||||
|
</ion-app>
|
||||||
|
</body>
|
||||||
|
</html>
|
106
core/src/components/toast/test/swipe-gesture/toast.e2e.ts
Normal file
106
core/src/components/toast/test/swipe-gesture/toast.e2e.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import { configs, test, dragElementBy } from '@utils/test/playwright';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This behavior does not vary across modes and directions.
|
||||||
|
*/
|
||||||
|
configs({ directions: ['ltr'], modes: ['ios'] }).forEach(({ title, config }) => {
|
||||||
|
test.describe(title('toast: swipe gesture with top position'), () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('src/components/toast/test/swipe-gesture', config);
|
||||||
|
});
|
||||||
|
test('should swipe up to dismiss', async ({ page }) => {
|
||||||
|
const ionToastDidPresent = await page.spyOnEvent('ionToastDidPresent');
|
||||||
|
const ionToastDidDismiss = await page.spyOnEvent('ionToastDidDismiss');
|
||||||
|
|
||||||
|
const presentButton = page.locator('#top');
|
||||||
|
await presentButton.click();
|
||||||
|
|
||||||
|
await ionToastDidPresent.next();
|
||||||
|
|
||||||
|
const toastWrapper = page.locator('ion-toast#top-toast .toast-wrapper');
|
||||||
|
await dragElementBy(toastWrapper, page, 0, -30);
|
||||||
|
|
||||||
|
await ionToastDidDismiss.next();
|
||||||
|
});
|
||||||
|
test('should swipe up to dismiss with anchor', async ({ page }) => {
|
||||||
|
const ionToastDidPresent = await page.spyOnEvent('ionToastDidPresent');
|
||||||
|
const ionToastDidDismiss = await page.spyOnEvent('ionToastDidDismiss');
|
||||||
|
|
||||||
|
const presentButton = page.locator('#top-anchor');
|
||||||
|
await presentButton.click();
|
||||||
|
|
||||||
|
await ionToastDidPresent.next();
|
||||||
|
|
||||||
|
const toastWrapper = page.locator('ion-toast#top-anchor-toast .toast-wrapper');
|
||||||
|
await dragElementBy(toastWrapper, page, 0, -30);
|
||||||
|
|
||||||
|
await ionToastDidDismiss.next();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test.describe(title('toast: swipe gesture with bottom position'), () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('src/components/toast/test/swipe-gesture', config);
|
||||||
|
});
|
||||||
|
test('should swipe down to dismiss', async ({ page }) => {
|
||||||
|
const ionToastDidPresent = await page.spyOnEvent('ionToastDidPresent');
|
||||||
|
const ionToastDidDismiss = await page.spyOnEvent('ionToastDidDismiss');
|
||||||
|
|
||||||
|
const presentButton = page.locator('#bottom');
|
||||||
|
await presentButton.click();
|
||||||
|
|
||||||
|
await ionToastDidPresent.next();
|
||||||
|
|
||||||
|
const toastWrapper = page.locator('ion-toast#bottom-toast .toast-wrapper');
|
||||||
|
await dragElementBy(toastWrapper, page, 0, 30);
|
||||||
|
|
||||||
|
await ionToastDidDismiss.next();
|
||||||
|
});
|
||||||
|
test('should swipe down to dismiss with anchor', async ({ page }) => {
|
||||||
|
const ionToastDidPresent = await page.spyOnEvent('ionToastDidPresent');
|
||||||
|
const ionToastDidDismiss = await page.spyOnEvent('ionToastDidDismiss');
|
||||||
|
|
||||||
|
const presentButton = page.locator('#bottom-anchor');
|
||||||
|
await presentButton.click();
|
||||||
|
|
||||||
|
await ionToastDidPresent.next();
|
||||||
|
|
||||||
|
const toastWrapper = page.locator('ion-toast#bottom-anchor-toast .toast-wrapper');
|
||||||
|
await dragElementBy(toastWrapper, page, 0, 30);
|
||||||
|
|
||||||
|
await ionToastDidDismiss.next();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test.describe(title('toast: swipe gesture with middle position'), () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('src/components/toast/test/swipe-gesture', config);
|
||||||
|
});
|
||||||
|
test('should swipe down to dismiss', async ({ page }) => {
|
||||||
|
const ionToastDidPresent = await page.spyOnEvent('ionToastDidPresent');
|
||||||
|
const ionToastDidDismiss = await page.spyOnEvent('ionToastDidDismiss');
|
||||||
|
|
||||||
|
const presentButton = page.locator('#middle');
|
||||||
|
await presentButton.click();
|
||||||
|
|
||||||
|
await ionToastDidPresent.next();
|
||||||
|
|
||||||
|
const toastWrapper = page.locator('ion-toast#middle-toast .toast-wrapper');
|
||||||
|
await dragElementBy(toastWrapper, page, 0, 100);
|
||||||
|
|
||||||
|
await ionToastDidDismiss.next();
|
||||||
|
});
|
||||||
|
test('should swipe up to dismiss', async ({ page }) => {
|
||||||
|
const ionToastDidPresent = await page.spyOnEvent('ionToastDidPresent');
|
||||||
|
const ionToastDidDismiss = await page.spyOnEvent('ionToastDidDismiss');
|
||||||
|
|
||||||
|
const presentButton = page.locator('#middle');
|
||||||
|
await presentButton.click();
|
||||||
|
|
||||||
|
await ionToastDidPresent.next();
|
||||||
|
|
||||||
|
const toastWrapper = page.locator('ion-toast#middle-toast .toast-wrapper');
|
||||||
|
await dragElementBy(toastWrapper, page, 0, -100);
|
||||||
|
|
||||||
|
await ionToastDidDismiss.next();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
29
core/src/components/toast/test/toast-config.spec.ts
Normal file
29
core/src/components/toast/test/toast-config.spec.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { newSpecPage } from '@stencil/core/testing';
|
||||||
|
import { Toast } from '../toast';
|
||||||
|
import { config } from '../../../global/config';
|
||||||
|
|
||||||
|
describe('toast: duration config', () => {
|
||||||
|
it('should have duration set to 0', async () => {
|
||||||
|
const page = await newSpecPage({
|
||||||
|
components: [Toast],
|
||||||
|
html: `<ion-toast></ion-toast>`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toast = page.body.querySelector('ion-toast');
|
||||||
|
|
||||||
|
expect(toast.duration).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have duration set to 5000', async () => {
|
||||||
|
config.reset({ toastDuration: 5000 });
|
||||||
|
|
||||||
|
const page = await newSpecPage({
|
||||||
|
components: [Toast],
|
||||||
|
html: `<ion-toast></ion-toast>`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toast = page.body.querySelector('ion-toast');
|
||||||
|
|
||||||
|
expect(toast.duration).toBe(5000);
|
||||||
|
});
|
||||||
|
});
|
@ -3,6 +3,7 @@ import { newSpecPage } from '@stencil/core/testing';
|
|||||||
import { Toast } from '../toast';
|
import { Toast } from '../toast';
|
||||||
import { config } from '../../../global/config';
|
import { config } from '../../../global/config';
|
||||||
import { toastController } from '../../../utils/overlays';
|
import { toastController } from '../../../utils/overlays';
|
||||||
|
import { createAnimation } from '@utils/animation/animation';
|
||||||
|
|
||||||
describe('toast: custom html', () => {
|
describe('toast: custom html', () => {
|
||||||
it('should not allow for custom html by default', async () => {
|
it('should not allow for custom html by default', async () => {
|
||||||
@ -91,32 +92,6 @@ describe('toast: a11y smoke test', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('toast: duration config', () => {
|
|
||||||
it('should have duration set to 0', async () => {
|
|
||||||
const page = await newSpecPage({
|
|
||||||
components: [Toast],
|
|
||||||
html: `<ion-toast></ion-toast>`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const toast = page.body.querySelector('ion-toast');
|
|
||||||
|
|
||||||
expect(toast.duration).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have duration set to 5000', async () => {
|
|
||||||
config.reset({ toastDuration: 5000 });
|
|
||||||
|
|
||||||
const page = await newSpecPage({
|
|
||||||
components: [Toast],
|
|
||||||
html: `<ion-toast></ion-toast>`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const toast = page.body.querySelector('ion-toast');
|
|
||||||
|
|
||||||
expect(toast.duration).toBe(5000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('toast: htmlAttributes', () => {
|
describe('toast: htmlAttributes', () => {
|
||||||
it('should correctly inherit attributes on host', async () => {
|
it('should correctly inherit attributes on host', async () => {
|
||||||
const page = await newSpecPage({
|
const page = await newSpecPage({
|
||||||
@ -144,3 +119,99 @@ describe('toast: button cancel', () => {
|
|||||||
expect(buttonCancel.getAttribute('part')).toBe('button cancel');
|
expect(buttonCancel.getAttribute('part')).toBe('button cancel');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('toast: swipe gesture', () => {
|
||||||
|
describe('prefersSwipeGesture()', () => {
|
||||||
|
let toast: Toast;
|
||||||
|
beforeEach(() => {
|
||||||
|
toast = new Toast();
|
||||||
|
});
|
||||||
|
it('should return true if set to a valid swipe value', () => {
|
||||||
|
toast.swipeGesture = 'vertical';
|
||||||
|
expect(toast.prefersSwipeGesture()).toBe(true);
|
||||||
|
});
|
||||||
|
it('should return false if set to undefined', () => {
|
||||||
|
toast.swipeGesture = undefined;
|
||||||
|
expect(toast.prefersSwipeGesture()).toBe(false);
|
||||||
|
});
|
||||||
|
it('should return false if set to null', () => {
|
||||||
|
toast.swipeGesture = null;
|
||||||
|
expect(toast.prefersSwipeGesture()).toBe(false);
|
||||||
|
});
|
||||||
|
it('should return false if set to invalid string', () => {
|
||||||
|
toast.swipeGesture = 'limit'; // `'limit'` doesn't exist
|
||||||
|
expect(toast.prefersSwipeGesture()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('swipeGesture property', () => {
|
||||||
|
let toast: Toast;
|
||||||
|
beforeEach(() => {
|
||||||
|
toast = new Toast();
|
||||||
|
// Stub the enter animation so we aren't querying elements in the DOM that may not exist
|
||||||
|
toast.enterAnimation = () => createAnimation();
|
||||||
|
});
|
||||||
|
it('should not create a swipe gesture on present if swipeGesture is undefined', async () => {
|
||||||
|
expect(toast.gesture).toBe(undefined);
|
||||||
|
|
||||||
|
await toast.present();
|
||||||
|
|
||||||
|
expect(toast.gesture).toBe(undefined);
|
||||||
|
});
|
||||||
|
it('should create a swipe gesture on present', async () => {
|
||||||
|
toast.swipeGesture = 'vertical';
|
||||||
|
|
||||||
|
expect(toast.gesture).toBe(undefined);
|
||||||
|
|
||||||
|
await toast.present();
|
||||||
|
|
||||||
|
expect(toast.gesture).not.toBe(undefined);
|
||||||
|
});
|
||||||
|
it('should destroy a swipe gesture on dismiss', async () => {
|
||||||
|
toast.swipeGesture = 'vertical';
|
||||||
|
|
||||||
|
await toast.present();
|
||||||
|
|
||||||
|
expect(toast.gesture).not.toBe(undefined);
|
||||||
|
|
||||||
|
await toast.dismiss();
|
||||||
|
|
||||||
|
expect(toast.gesture).toBe(undefined);
|
||||||
|
});
|
||||||
|
it('should create a swipe gesture if swipeGesture is set after present', async () => {
|
||||||
|
await toast.present();
|
||||||
|
expect(toast.gesture).toBe(undefined);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually invoke the watch
|
||||||
|
* callback synchronously.
|
||||||
|
*/
|
||||||
|
toast.swipeGesture = 'vertical';
|
||||||
|
toast.swipeGestureChanged();
|
||||||
|
|
||||||
|
expect(toast.gesture).not.toBe(undefined);
|
||||||
|
});
|
||||||
|
it('should destroy a swipe gesture if swipeGesture is cleared after present', async () => {
|
||||||
|
toast.swipeGesture = 'vertical';
|
||||||
|
|
||||||
|
await toast.present();
|
||||||
|
expect(toast.gesture).not.toBe(undefined);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually invoke the watch
|
||||||
|
* callback synchronously.
|
||||||
|
*/
|
||||||
|
toast.swipeGesture = undefined;
|
||||||
|
toast.swipeGestureChanged();
|
||||||
|
|
||||||
|
expect(toast.gesture).toBe(undefined);
|
||||||
|
});
|
||||||
|
it('should not create a swipe gesture if the toast is not presented', async () => {
|
||||||
|
expect(toast.gesture).toBe(undefined);
|
||||||
|
|
||||||
|
toast.swipeGesture = 'vertical';
|
||||||
|
toast.swipeGestureChanged();
|
||||||
|
|
||||||
|
expect(toast.gesture).toBe(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@ -55,3 +55,4 @@ export interface ToastAnimationPosition {
|
|||||||
|
|
||||||
export type ToastPresentOptions = ToastPositionAlias & ToastAnimationPosition;
|
export type ToastPresentOptions = ToastPositionAlias & ToastAnimationPosition;
|
||||||
export type ToastDismissOptions = ToastPositionAlias & ToastAnimationPosition;
|
export type ToastDismissOptions = ToastPositionAlias & ToastAnimationPosition;
|
||||||
|
export type ToastSwipeGestureDirection = 'vertical';
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
import type { ComponentInterface, EventEmitter } from '@stencil/core';
|
import type { ComponentInterface, EventEmitter } from '@stencil/core';
|
||||||
import { State, Watch, Component, Element, Event, h, Host, Method, Prop } from '@stencil/core';
|
import { State, Watch, Component, Element, Event, h, Host, Method, Prop } from '@stencil/core';
|
||||||
import { ENABLE_HTML_CONTENT_DEFAULT } from '@utils/config';
|
import { ENABLE_HTML_CONTENT_DEFAULT } from '@utils/config';
|
||||||
|
import type { Gesture } from '@utils/gesture';
|
||||||
import { raf } from '@utils/helpers';
|
import { raf } from '@utils/helpers';
|
||||||
import { createLockController } from '@utils/lock-controller';
|
import { createLockController } from '@utils/lock-controller';
|
||||||
import { printIonWarning } from '@utils/logging';
|
import { printIonWarning } from '@utils/logging';
|
||||||
import {
|
import {
|
||||||
|
GESTURE,
|
||||||
createDelegateController,
|
createDelegateController,
|
||||||
createTriggerController,
|
createTriggerController,
|
||||||
dismiss,
|
dismiss,
|
||||||
@ -29,6 +31,7 @@ import { iosLeaveAnimation } from './animations/ios.leave';
|
|||||||
import { mdEnterAnimation } from './animations/md.enter';
|
import { mdEnterAnimation } from './animations/md.enter';
|
||||||
import { mdLeaveAnimation } from './animations/md.leave';
|
import { mdLeaveAnimation } from './animations/md.leave';
|
||||||
import { getAnimationPosition } from './animations/utils';
|
import { getAnimationPosition } from './animations/utils';
|
||||||
|
import { createSwipeToDismissGesture } from './gestures/swipe-to-dismiss';
|
||||||
import type {
|
import type {
|
||||||
ToastButton,
|
ToastButton,
|
||||||
ToastPosition,
|
ToastPosition,
|
||||||
@ -36,6 +39,7 @@ import type {
|
|||||||
ToastPresentOptions,
|
ToastPresentOptions,
|
||||||
ToastDismissOptions,
|
ToastDismissOptions,
|
||||||
ToastAnimationPosition,
|
ToastAnimationPosition,
|
||||||
|
ToastSwipeGestureDirection,
|
||||||
} from './toast-interface';
|
} from './toast-interface';
|
||||||
|
|
||||||
// TODO(FW-2832): types
|
// TODO(FW-2832): types
|
||||||
@ -64,6 +68,7 @@ export class Toast implements ComponentInterface, OverlayInterface {
|
|||||||
private readonly triggerController = createTriggerController();
|
private readonly triggerController = createTriggerController();
|
||||||
private customHTMLEnabled = config.get('innerHTMLTemplatesEnabled', ENABLE_HTML_CONTENT_DEFAULT);
|
private customHTMLEnabled = config.get('innerHTMLTemplatesEnabled', ENABLE_HTML_CONTENT_DEFAULT);
|
||||||
private durationTimeout?: ReturnType<typeof setTimeout>;
|
private durationTimeout?: ReturnType<typeof setTimeout>;
|
||||||
|
private gesture?: Gesture;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Holds the position of the toast calculated in the present
|
* Holds the position of the toast calculated in the present
|
||||||
@ -193,6 +198,45 @@ export class Toast implements ComponentInterface, OverlayInterface {
|
|||||||
*/
|
*/
|
||||||
@Prop() htmlAttributes?: { [key: string]: any };
|
@Prop() htmlAttributes?: { [key: string]: any };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If set to 'vertical', the Toast can be dismissed with
|
||||||
|
* a swipe gesture. The swipe direction is determined by
|
||||||
|
* the value of the `position` property:
|
||||||
|
* `top`: The Toast can be swiped up to dismiss.
|
||||||
|
* `bottom`: The Toast can be swiped down to dismiss.
|
||||||
|
* `middle`: The Toast can be swiped up or down to dismiss.
|
||||||
|
*/
|
||||||
|
@Prop() swipeGesture?: ToastSwipeGestureDirection;
|
||||||
|
@Watch('swipeGesture')
|
||||||
|
swipeGestureChanged() {
|
||||||
|
/**
|
||||||
|
* If the Toast is presented, then we need to destroy
|
||||||
|
* any actives gestures before a new gesture is potentially
|
||||||
|
* created below.
|
||||||
|
*
|
||||||
|
* If the Toast is dismissed, then no gesture should be available
|
||||||
|
* since the Toast is not visible. This case should never
|
||||||
|
* happen since the "dismiss" method handles destroying
|
||||||
|
* any active swipe gestures, but we keep this code
|
||||||
|
* around to handle the first case.
|
||||||
|
*/
|
||||||
|
this.destroySwipeGesture();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A new swipe gesture should only be created
|
||||||
|
* if the Toast is presented. If the Toast is not
|
||||||
|
* yet presented then the "present" method will
|
||||||
|
* handle calling the swipe gesture setup function.
|
||||||
|
*/
|
||||||
|
if (this.presented && this.prefersSwipeGesture()) {
|
||||||
|
/**
|
||||||
|
* If the Toast is presented then
|
||||||
|
* lastPresentedPosition is defined.
|
||||||
|
*/
|
||||||
|
this.createSwipeGesture(this.lastPresentedPosition!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If `true`, the toast will open. If `false`, the toast will close.
|
* If `true`, the toast will open. If `false`, the toast will close.
|
||||||
* Use this if you need finer grained control over presentation, otherwise
|
* Use this if you need finer grained control over presentation, otherwise
|
||||||
@ -326,6 +370,15 @@ export class Toast implements ComponentInterface, OverlayInterface {
|
|||||||
this.durationTimeout = setTimeout(() => this.dismiss(undefined, 'timeout'), this.duration);
|
this.durationTimeout = setTimeout(() => this.dismiss(undefined, 'timeout'), this.duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the Toast has a swipe gesture then we can
|
||||||
|
* create the gesture so users can swipe the
|
||||||
|
* presented Toast.
|
||||||
|
*/
|
||||||
|
if (this.prefersSwipeGesture()) {
|
||||||
|
this.createSwipeGesture(animationPosition);
|
||||||
|
}
|
||||||
|
|
||||||
unlock();
|
unlock();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -373,6 +426,13 @@ export class Toast implements ComponentInterface, OverlayInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.lastPresentedPosition = undefined;
|
this.lastPresentedPosition = undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the Toast has a swipe gesture then we can
|
||||||
|
* safely destroy it now that it is dismissed.
|
||||||
|
*/
|
||||||
|
this.destroySwipeGesture();
|
||||||
|
|
||||||
unlock();
|
unlock();
|
||||||
|
|
||||||
return dismissed;
|
return dismissed;
|
||||||
@ -486,6 +546,48 @@ export class Toast implements ComponentInterface, OverlayInterface {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new swipe gesture so Toast
|
||||||
|
* can be swiped to dismiss.
|
||||||
|
*/
|
||||||
|
private createSwipeGesture = (toastPosition: ToastAnimationPosition) => {
|
||||||
|
const gesture = (this.gesture = createSwipeToDismissGesture(this.el, toastPosition, () => {
|
||||||
|
/**
|
||||||
|
* If the gesture completed then
|
||||||
|
* we should dismiss the toast.
|
||||||
|
*/
|
||||||
|
this.dismiss(undefined, GESTURE);
|
||||||
|
}));
|
||||||
|
|
||||||
|
gesture.enable(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy an existing swipe gesture
|
||||||
|
* so Toast can no longer be swiped to dismiss.
|
||||||
|
*/
|
||||||
|
private destroySwipeGesture = () => {
|
||||||
|
const { gesture } = this;
|
||||||
|
|
||||||
|
if (gesture === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
gesture.destroy();
|
||||||
|
this.gesture = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns `true` if swipeGesture
|
||||||
|
* is configured to a value that enables the swipe behavior.
|
||||||
|
* Returns `false` otherwise.
|
||||||
|
*/
|
||||||
|
private prefersSwipeGesture = () => {
|
||||||
|
const { swipeGesture } = this;
|
||||||
|
|
||||||
|
return swipeGesture === 'vertical';
|
||||||
|
};
|
||||||
|
|
||||||
renderButtons(buttons: ToastButton[], side: 'start' | 'end') {
|
renderButtons(buttons: ToastButton[], side: 'start' | 'end') {
|
||||||
if (buttons.length === 0) {
|
if (buttons.length === 0) {
|
||||||
return;
|
return;
|
||||||
|
@ -685,6 +685,7 @@ export const safeCall = (handler: any, arg?: any) => {
|
|||||||
|
|
||||||
export const BACKDROP = 'backdrop';
|
export const BACKDROP = 'backdrop';
|
||||||
export const GESTURE = 'gesture';
|
export const GESTURE = 'gesture';
|
||||||
|
export const OVERLAY_GESTURE_PRIORITY = 39;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a delegate controller.
|
* Creates a delegate controller.
|
||||||
|
@ -2201,7 +2201,7 @@ export declare interface IonTitle extends Components.IonTitle {}
|
|||||||
|
|
||||||
|
|
||||||
@ProxyCmp({
|
@ProxyCmp({
|
||||||
inputs: ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'positionAnchor', 'translucent', 'trigger'],
|
inputs: ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'positionAnchor', 'swipeGesture', 'translucent', 'trigger'],
|
||||||
methods: ['present', 'dismiss', 'onDidDismiss', 'onWillDismiss']
|
methods: ['present', 'dismiss', 'onDidDismiss', 'onWillDismiss']
|
||||||
})
|
})
|
||||||
@Component({
|
@Component({
|
||||||
@ -2209,7 +2209,7 @@ export declare interface IonTitle extends Components.IonTitle {}
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: '<ng-content></ng-content>',
|
template: '<ng-content></ng-content>',
|
||||||
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
|
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
|
||||||
inputs: ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'positionAnchor', 'translucent', 'trigger'],
|
inputs: ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'positionAnchor', 'swipeGesture', 'translucent', 'trigger'],
|
||||||
})
|
})
|
||||||
export class IonToast {
|
export class IonToast {
|
||||||
protected el: HTMLElement;
|
protected el: HTMLElement;
|
||||||
|
@ -1915,7 +1915,7 @@ export declare interface IonTitle extends Components.IonTitle {}
|
|||||||
|
|
||||||
@ProxyCmp({
|
@ProxyCmp({
|
||||||
defineCustomElementFn: defineIonToast,
|
defineCustomElementFn: defineIonToast,
|
||||||
inputs: ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'positionAnchor', 'translucent', 'trigger'],
|
inputs: ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'positionAnchor', 'swipeGesture', 'translucent', 'trigger'],
|
||||||
methods: ['present', 'dismiss', 'onDidDismiss', 'onWillDismiss']
|
methods: ['present', 'dismiss', 'onDidDismiss', 'onWillDismiss']
|
||||||
})
|
})
|
||||||
@Component({
|
@Component({
|
||||||
@ -1923,7 +1923,7 @@ export declare interface IonTitle extends Components.IonTitle {}
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: '<ng-content></ng-content>',
|
template: '<ng-content></ng-content>',
|
||||||
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
|
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
|
||||||
inputs: ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'positionAnchor', 'translucent', 'trigger'],
|
inputs: ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'positionAnchor', 'swipeGesture', 'translucent', 'trigger'],
|
||||||
standalone: true
|
standalone: true
|
||||||
})
|
})
|
||||||
export class IonToast {
|
export class IonToast {
|
||||||
|
@ -25,7 +25,7 @@ export const IonLoading = /*@__PURE__*/ defineOverlayContainer<JSX.IonLoading>('
|
|||||||
|
|
||||||
export const IonPicker = /*@__PURE__*/ defineOverlayContainer<JSX.IonPicker>('ion-picker', defineIonPickerCustomElement, ['animated', 'backdropDismiss', 'buttons', 'columns', 'cssClass', 'duration', 'enterAnimation', 'htmlAttributes', 'isOpen', 'keyboardClose', 'leaveAnimation', 'mode', 'showBackdrop', 'trigger']);
|
export const IonPicker = /*@__PURE__*/ defineOverlayContainer<JSX.IonPicker>('ion-picker', defineIonPickerCustomElement, ['animated', 'backdropDismiss', 'buttons', 'columns', 'cssClass', 'duration', 'enterAnimation', 'htmlAttributes', 'isOpen', 'keyboardClose', 'leaveAnimation', 'mode', 'showBackdrop', 'trigger']);
|
||||||
|
|
||||||
export const IonToast = /*@__PURE__*/ defineOverlayContainer<JSX.IonToast>('ion-toast', defineIonToastCustomElement, ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'positionAnchor', 'translucent', 'trigger']);
|
export const IonToast = /*@__PURE__*/ defineOverlayContainer<JSX.IonToast>('ion-toast', defineIonToastCustomElement, ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'positionAnchor', 'swipeGesture', 'translucent', 'trigger']);
|
||||||
|
|
||||||
export const IonModal = /*@__PURE__*/ defineOverlayContainer<JSX.IonModal>('ion-modal', defineIonModalCustomElement, ['animated', 'backdropBreakpoint', 'backdropDismiss', 'breakpoints', 'canDismiss', 'enterAnimation', 'handle', 'handleBehavior', 'htmlAttributes', 'initialBreakpoint', 'isOpen', 'keepContentsMounted', 'keyboardClose', 'leaveAnimation', 'mode', 'presentingElement', 'showBackdrop', 'trigger'], true);
|
export const IonModal = /*@__PURE__*/ defineOverlayContainer<JSX.IonModal>('ion-modal', defineIonModalCustomElement, ['animated', 'backdropBreakpoint', 'backdropDismiss', 'breakpoints', 'canDismiss', 'enterAnimation', 'handle', 'handleBehavior', 'htmlAttributes', 'initialBreakpoint', 'isOpen', 'keepContentsMounted', 'keyboardClose', 'leaveAnimation', 'mode', 'presentingElement', 'showBackdrop', 'trigger'], true);
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user