fix(animation): play method resolves when animation is stopped (#28264)

Issue number: N/A

---------

<!-- 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. -->

When trying to fix
https://github.com/ionic-team/ionic-framework/issues/20092, I discovered
that
ac2c8e6c22/core/src/components/menu/menu.tsx (L483)
was never resolving when the animation was aborted in
ac2c8e6c22/core/src/components/menu/menu.tsx (L699).
This can happen if `menu.disabled` is set to `true` mid-animation.

In order to fix the menu bug, I need this promise to resolve when the
animation is stopped.

## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->

- The `play` method now correctly resolves when the animation is
cancelled.

## 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. -->

The `play` method resolves when a particular run of the animation is
finished. The `stop` method ensures that this run never finishes which
is why I've chosen to have `play` resolve. Note that `onFinish`
callbacks should not be fired because the animation run did not
complete.
This commit is contained in:
Liam DeBeasi
2023-10-09 11:16:39 -04:00
committed by GitHub
parent d5f0c776df
commit e6031fbef0
2 changed files with 89 additions and 1 deletions

View File

@ -30,6 +30,8 @@ interface AnimationOnFinishCallback {
o?: AnimationCallbackOptions; o?: AnimationCallbackOptions;
} }
type AnimationOnStopCallback = AnimationOnFinishCallback;
export const createAnimation = (animationId?: string): Animation => { export const createAnimation = (animationId?: string): Animation => {
let _delay: number | undefined; let _delay: number | undefined;
let _duration: number | undefined; let _duration: number | undefined;
@ -63,6 +65,7 @@ export const createAnimation = (animationId?: string): Animation => {
const id: string | undefined = animationId; const id: string | undefined = animationId;
const onFinishCallbacks: AnimationOnFinishCallback[] = []; const onFinishCallbacks: AnimationOnFinishCallback[] = [];
const onFinishOneTimeCallbacks: AnimationOnFinishCallback[] = []; const onFinishOneTimeCallbacks: AnimationOnFinishCallback[] = [];
const onStopOneTimeCallbacks: AnimationOnStopCallback[] = [];
const elements: HTMLElement[] = []; const elements: HTMLElement[] = [];
const childAnimations: Animation[] = []; const childAnimations: Animation[] = [];
const stylesheets: HTMLElement[] = []; const stylesheets: HTMLElement[] = [];
@ -134,6 +137,35 @@ export const createAnimation = (animationId?: string): Animation => {
return numAnimationsRunning !== 0 && !paused; return numAnimationsRunning !== 0 && !paused;
}; };
/**
* @internal
* Remove a callback from a chosen callback array
* @param callbackToRemove: A reference to the callback that should be removed
* @param callbackObjects: An array of callbacks that callbackToRemove should be removed from.
*/
const clearCallback = (
callbackToRemove: AnimationLifecycle,
callbackObjects: AnimationOnFinishCallback[] | AnimationOnStopCallback[]
) => {
const index = callbackObjects.findIndex((callbackObject) => callbackObject.c === callbackToRemove);
if (index > -1) {
callbackObjects.splice(index, 1);
}
};
/**
* @internal
* Add a callback to be fired when an animation is stopped/cancelled.
* @param callback: A reference to the callback that should be fired
* @param opts: Any options associated with this particular callback
*/
const onStop = (callback: AnimationLifecycle, opts?: AnimationCallbackOptions) => {
onStopOneTimeCallbacks.push({ c: callback, o: opts });
return ani;
};
const onFinish = (callback: AnimationLifecycle, opts?: AnimationCallbackOptions) => { const onFinish = (callback: AnimationLifecycle, opts?: AnimationCallbackOptions) => {
const callbacks = opts?.oneTimeCallback ? onFinishOneTimeCallbacks : onFinishCallbacks; const callbacks = opts?.oneTimeCallback ? onFinishOneTimeCallbacks : onFinishCallbacks;
callbacks.push({ c: callback, o: opts }); callbacks.push({ c: callback, o: opts });
@ -953,7 +985,34 @@ export const createAnimation = (animationId?: string): Animation => {
shouldCalculateNumAnimations = false; shouldCalculateNumAnimations = false;
} }
onFinish(() => resolve(), { oneTimeCallback: true }); /**
* When one of these callbacks fires we
* need to clear the other's callback otherwise
* you can potentially get these callbacks
* firing multiple times if the play method
* is subsequently called.
* Example:
* animation.play() (onStop and onFinish callbacks are registered)
* animation.stop() (onStop callback is fired, onFinish is not)
* animation.play() (onStop and onFinish callbacks are registered)
* Total onStop callbacks: 1
* Total onFinish callbacks: 2
*/
const onStopCallback = () => {
clearCallback(onFinishCallback, onFinishOneTimeCallbacks);
resolve();
};
const onFinishCallback = () => {
clearCallback(onStopCallback, onStopOneTimeCallbacks);
resolve();
};
/**
* The play method resolves when an animation
* run either finishes or is cancelled.
*/
onFinish(onFinishCallback, { oneTimeCallback: true });
onStop(onStopCallback, { oneTimeCallback: true });
childAnimations.forEach((animation) => { childAnimations.forEach((animation) => {
animation.play(); animation.play();
@ -969,6 +1028,14 @@ export const createAnimation = (animationId?: string): Animation => {
}); });
}; };
/**
* Stops an animation and resets it state to the
* beginning. This does not fire any onFinish
* callbacks because the animation did not finish.
* However, since the animation was not destroyed
* (i.e. the animation could run again) we do not
* clear the onFinish callbacks.
*/
const stop = () => { const stop = () => {
childAnimations.forEach((animation) => { childAnimations.forEach((animation) => {
animation.stop(); animation.stop();
@ -980,6 +1047,9 @@ export const createAnimation = (animationId?: string): Animation => {
} }
resetFlags(); resetFlags();
onStopOneTimeCallbacks.forEach((onStopCallback) => onStopCallback.c(0, ani));
onStopOneTimeCallbacks.length = 0;
}; };
const from = (property: string, value: any) => { const from = (property: string, value: any) => {

View File

@ -4,6 +4,24 @@ import { processKeyframes } from '../animation-utils';
import { getTimeGivenProgression } from '../cubic-bezier'; import { getTimeGivenProgression } from '../cubic-bezier';
describe('Animation Class', () => { describe('Animation Class', () => {
describe('play()', () => {
it('should resolve when the animation is cancelled', async () => {
// Tell Jest to expect 1 assertion for async code
expect.assertions(1);
const el = document.createElement('div');
const animation = createAnimation()
.addElement(el)
.fromTo('transform', 'translateX(0px)', 'translateX(100px)')
.duration(100000);
const animationPromise = animation.play();
animation.stop();
// Expect that the promise resolves and returns undefined
expect(animationPromise).resolves.toEqual(undefined);
});
});
describe('isRunning()', () => { describe('isRunning()', () => {
let animation: Animation; let animation: Animation;
beforeEach(() => { beforeEach(() => {