Skip to content

Commit 1f3f6f9

Browse files
authored
feat(react-motion): implement interruptible motions [experimental] (#33994)
1 parent 9971ca3 commit 1f3f6f9

File tree

6 files changed

+157
-7
lines changed

6 files changed

+157
-7
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "patch",
3+
"comment": "feat: experimental createInterruptablePresence()",
4+
"packageName": "@fluentui/react-motion",
5+
"email": "[email protected]",
6+
"dependentChangeType": "patch"
7+
}

packages/react-components/react-motion/library/src/factories/createPresenceComponent.ts

+46-6
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,14 @@ import { useMotionImperativeRef } from '../hooks/useMotionImperativeRef';
77
import { useMountedState } from '../hooks/useMountedState';
88
import { useIsReducedMotion } from '../hooks/useIsReducedMotion';
99
import { getChildElement } from '../utils/getChildElement';
10-
import type { MotionParam, PresenceMotion, MotionImperativeRef, PresenceMotionFn, PresenceDirection } from '../types';
10+
import type {
11+
MotionParam,
12+
PresenceMotion,
13+
MotionImperativeRef,
14+
PresenceMotionFn,
15+
PresenceDirection,
16+
AnimationHandle,
17+
} from '../types';
1118
import { useMotionBehaviourContext } from '../contexts/MotionBehaviourContext';
1219

1320
/**
@@ -72,6 +79,8 @@ export type PresenceComponent<MotionParams extends Record<string, MotionParam> =
7279
[MOTION_DEFINITION]: PresenceMotionFn<MotionParams>;
7380
};
7481

82+
const INTERRUPTABLE_MOTION_SYMBOL = Symbol.for('interruptablePresence');
83+
7584
export function createPresenceComponent<MotionParams extends Record<string, MotionParam> = {}>(
7685
value: PresenceMotion | PresenceMotionFn<MotionParams>,
7786
): PresenceComponent<MotionParams> {
@@ -143,8 +152,40 @@ export function createPresenceComponent<MotionParams extends Record<string, Moti
143152
return;
144153
}
145154

155+
let handle: AnimationHandle | undefined;
156+
157+
function cleanup() {
158+
if (!handle) {
159+
return;
160+
}
161+
162+
// Heads up!
163+
//
164+
// If the animation is interruptible & is running, we don't want to cancel it as it will be reversed in
165+
// the next effect.
166+
if (IS_EXPERIMENTAL_INTERRUPTIBLE_MOTION && handle.isRunning()) {
167+
return;
168+
}
169+
170+
handle.cancel();
171+
handleRef.current = undefined;
172+
}
173+
146174
const presenceMotion =
147175
typeof value === 'function' ? value({ element, ...optionsRef.current.params }) : (value as PresenceMotion);
176+
const IS_EXPERIMENTAL_INTERRUPTIBLE_MOTION = (
177+
presenceMotion as PresenceMotion & { [INTERRUPTABLE_MOTION_SYMBOL]?: boolean }
178+
)[INTERRUPTABLE_MOTION_SYMBOL];
179+
180+
if (IS_EXPERIMENTAL_INTERRUPTIBLE_MOTION) {
181+
handle = handleRef.current;
182+
183+
if (handle && handle.isRunning()) {
184+
handle.reverse();
185+
186+
return cleanup;
187+
}
188+
}
148189

149190
const atoms = visible ? presenceMotion.enter : presenceMotion.exit;
150191
const direction: PresenceDirection = visible ? 'enter' : 'exit';
@@ -158,13 +199,14 @@ export function createPresenceComponent<MotionParams extends Record<string, Moti
158199
handleMotionStart(direction);
159200
}
160201

161-
const handle = animateAtoms(element, atoms, { isReducedMotion: isReducedMotion() });
202+
handle = animateAtoms(element, atoms, { isReducedMotion: isReducedMotion() });
162203

163204
if (applyInitialStyles) {
164205
// Heads up!
165206
// .finish() is used in this case to skip animation and apply animation styles immediately
166207
handle.finish();
167-
return;
208+
209+
return cleanup;
168210
}
169211

170212
handleRef.current = handle;
@@ -177,9 +219,7 @@ export function createPresenceComponent<MotionParams extends Record<string, Moti
177219
handle.finish();
178220
}
179221

180-
return () => {
181-
handle.cancel();
182-
};
222+
return cleanup;
183223
},
184224
// Excluding `isFirstMount` from deps to prevent re-triggering the animation on subsequent renders
185225
// eslint-disable-next-line react-hooks/exhaustive-deps

packages/react-components/react-motion/library/src/hooks/useAnimateAtoms.ts

+24
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import * as React from 'react';
2+
3+
import { isAnimationRunning } from '../utils/isAnimationRunning';
24
import type { AnimationHandle, AtomMotion } from '../types';
35

46
export const DEFAULT_ANIMATION_OPTIONS: KeyframeEffectOptions = {
@@ -78,6 +80,9 @@ function useAnimateAtomsInSupportedEnvironment() {
7880
oncancel();
7981
});
8082
},
83+
isRunning() {
84+
return animations.some(animation => isAnimationRunning(animation));
85+
},
8186

8287
cancel: () => {
8388
animations.forEach(animation => {
@@ -99,6 +104,18 @@ function useAnimateAtomsInSupportedEnvironment() {
99104
animation.finish();
100105
});
101106
},
107+
reverse: () => {
108+
// Heads up!
109+
//
110+
// This is used for the interruptible motion. If the animation is running, we need to reverse it.
111+
//
112+
// TODO: what do with animations that have "delay"?
113+
// TODO: what do with animations that have different "durations"?
114+
115+
animations.forEach(animation => {
116+
animation.reverse();
117+
});
118+
},
102119
};
103120
},
104121
[SUPPORTS_PERSIST],
@@ -148,6 +165,10 @@ function useAnimateAtomsInTestEnvironment() {
148165
set playbackRate(rate: number) {
149166
/* no-op */
150167
},
168+
isRunning() {
169+
return false;
170+
},
171+
151172
cancel() {
152173
/* no-op */
153174
},
@@ -160,6 +181,9 @@ function useAnimateAtomsInTestEnvironment() {
160181
finish() {
161182
/* no-op */
162183
},
184+
reverse() {
185+
/* no-op */
186+
},
163187
};
164188
},
165189
[realAnimateAtoms],

packages/react-components/react-motion/library/src/types.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,9 @@ export type PresenceMotionFn<MotionParams extends Record<string, MotionParam> =
3131

3232
// ---
3333

34-
export type AnimationHandle = Pick<Animation, 'cancel' | 'finish' | 'pause' | 'play' | 'playbackRate'> & {
34+
export type AnimationHandle = Pick<Animation, 'cancel' | 'finish' | 'pause' | 'play' | 'playbackRate' | 'reverse'> & {
3535
setMotionEndCallbacks: (onfinish: () => void, oncancel: () => void) => void;
36+
isRunning: () => boolean;
3637
};
3738

3839
export type MotionImperativeRef = {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { isAnimationRunning } from './isAnimationRunning';
2+
3+
// Heads up!
4+
// Unit tests are funny as JSDOM doesn't support the Web Animation API
5+
6+
function createModernAnimationMock(playState: Animation['playState'], overallProgress: number): Animation {
7+
return {
8+
playState,
9+
overallProgress,
10+
} as Partial<Animation> as Animation;
11+
}
12+
13+
function createLegacyAnimationMock(
14+
playState: Animation['playState'],
15+
currentTime: number,
16+
duration: number,
17+
): Animation {
18+
return {
19+
playState,
20+
currentTime,
21+
effect: {
22+
getTiming: () => ({
23+
duration,
24+
}),
25+
},
26+
} as Partial<Animation> as Animation;
27+
}
28+
29+
describe('isAnimationRunning', () => {
30+
it('returns "true" if the animation is running & started', () => {
31+
expect(isAnimationRunning(createModernAnimationMock('running', 0.5))).toBe(true);
32+
});
33+
34+
it('returns "false" if the animation is running & not started yet', () => {
35+
expect(isAnimationRunning(createModernAnimationMock('running', 0))).toBe(false);
36+
expect(isAnimationRunning(createModernAnimationMock('running', 1))).toBe(false);
37+
});
38+
39+
it('returns "false" if the animation is not running', () => {
40+
expect(isAnimationRunning(createModernAnimationMock('paused', 0.5))).toBe(false);
41+
expect(isAnimationRunning(createModernAnimationMock('finished', 1))).toBe(false);
42+
expect(isAnimationRunning(createModernAnimationMock('idle', 0))).toBe(false);
43+
});
44+
45+
it('fallbacks to "currentTime" is "overallProgress" is not supported', () => {
46+
expect(isAnimationRunning(createLegacyAnimationMock('running', 500, 1000))).toBe(true);
47+
48+
expect(isAnimationRunning(createLegacyAnimationMock('running', 0, 1000))).toBe(false);
49+
expect(isAnimationRunning(createLegacyAnimationMock('running', 1000, 1000))).toBe(false);
50+
});
51+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* Checks if the animation is running at the moment.
3+
*/
4+
export function isAnimationRunning(animation: Animation & { readonly overallProgress?: number | null }) {
5+
if (animation.playState === 'running') {
6+
// Heads up!
7+
//
8+
// There is an edge case where the animation is running, but the overall progress is 0 or 1. In this case, we
9+
// consider the animation to be not running. If it will be reversed it will flip from 1 to 0, and we will observe a
10+
// glitch.
11+
12+
// "overallProgress" is not supported in all browsers, so we need to check if it exists.
13+
// We will fall back to the currentTime and duration if "overallProgress" is not supported.
14+
if (animation.overallProgress !== undefined) {
15+
const overallProgress = animation.overallProgress ?? 0;
16+
17+
return overallProgress > 0 && overallProgress < 1;
18+
}
19+
20+
const currentTime = Number(animation.currentTime ?? 0);
21+
const totalTime = Number(animation.effect?.getTiming().duration ?? 0);
22+
23+
return currentTime > 0 && currentTime < totalTime;
24+
}
25+
26+
return false;
27+
}

0 commit comments

Comments
 (0)