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:
Liam DeBeasi
2023-11-13 12:14:29 -05:00
committed by GitHub
parent 0ae327f0e0
commit 30c21aab3e
17 changed files with 748 additions and 36 deletions

View File

@ -1,10 +1,12 @@
import type { ComponentInterface, EventEmitter } 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 type { Gesture } from '@utils/gesture';
import { raf } from '@utils/helpers';
import { createLockController } from '@utils/lock-controller';
import { printIonWarning } from '@utils/logging';
import {
GESTURE,
createDelegateController,
createTriggerController,
dismiss,
@ -29,6 +31,7 @@ import { iosLeaveAnimation } from './animations/ios.leave';
import { mdEnterAnimation } from './animations/md.enter';
import { mdLeaveAnimation } from './animations/md.leave';
import { getAnimationPosition } from './animations/utils';
import { createSwipeToDismissGesture } from './gestures/swipe-to-dismiss';
import type {
ToastButton,
ToastPosition,
@ -36,6 +39,7 @@ import type {
ToastPresentOptions,
ToastDismissOptions,
ToastAnimationPosition,
ToastSwipeGestureDirection,
} from './toast-interface';
// TODO(FW-2832): types
@ -64,6 +68,7 @@ export class Toast implements ComponentInterface, OverlayInterface {
private readonly triggerController = createTriggerController();
private customHTMLEnabled = config.get('innerHTMLTemplatesEnabled', ENABLE_HTML_CONTENT_DEFAULT);
private durationTimeout?: ReturnType<typeof setTimeout>;
private gesture?: Gesture;
/**
* 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 };
/**
* 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.
* 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);
}
/**
* 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();
}
@ -373,6 +426,13 @@ export class Toast implements ComponentInterface, OverlayInterface {
}
this.lastPresentedPosition = undefined;
/**
* If the Toast has a swipe gesture then we can
* safely destroy it now that it is dismissed.
*/
this.destroySwipeGesture();
unlock();
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') {
if (buttons.length === 0) {
return;