@seed-design/stackflow 1.1.19 → 1.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/lib/_virtual/_rolldown/runtime.cjs +34 -0
  2. package/lib/_virtual/_rolldown/runtime.js +14 -0
  3. package/lib/components/AppBar/AppBar.cjs +88 -87
  4. package/lib/components/AppBar/AppBar.d.ts +3 -0
  5. package/lib/components/AppBar/AppBar.js +86 -84
  6. package/lib/components/AppBar/AppBar.namespace.cjs +20 -15
  7. package/lib/components/AppBar/AppBar.namespace.d.ts +2 -2
  8. package/lib/components/AppBar/AppBar.namespace.js +15 -1
  9. package/lib/components/AppBar/index.cjs +17 -17
  10. package/lib/components/AppBar/index.d.ts +2 -2
  11. package/lib/components/AppBar/index.js +3 -3
  12. package/lib/components/AppScreen/AppScreen.cjs +46 -61
  13. package/lib/components/AppScreen/AppScreen.js +45 -57
  14. package/lib/components/AppScreen/AppScreen.namespace.cjs +17 -13
  15. package/lib/components/AppScreen/AppScreen.namespace.js +12 -1
  16. package/lib/components/AppScreen/index.cjs +14 -15
  17. package/lib/components/AppScreen/index.js +3 -3
  18. package/lib/components/index.cjs +30 -25
  19. package/lib/components/index.js +5 -6
  20. package/lib/hooks/index.cjs +3 -9
  21. package/lib/hooks/index.js +2 -1
  22. package/lib/hooks/useActivityZIndexBase.cjs +12 -11
  23. package/lib/hooks/useActivityZIndexBase.js +11 -7
  24. package/lib/index.cjs +41 -31
  25. package/lib/index.js +8 -10
  26. package/lib/plugin.cjs +16 -16
  27. package/lib/plugin.js +14 -12
  28. package/lib/primitive/AppBar/AppBar.cjs +41 -33
  29. package/lib/primitive/AppBar/AppBar.js +40 -29
  30. package/lib/primitive/AppBar/AppBar.namespace.cjs +16 -12
  31. package/lib/primitive/AppBar/AppBar.namespace.js +11 -1
  32. package/lib/primitive/AppBar/anatomy.cjs +18 -0
  33. package/lib/primitive/AppBar/anatomy.d.ts +15 -0
  34. package/lib/primitive/AppBar/anatomy.js +18 -0
  35. package/lib/primitive/AppBar/index.cjs +13 -14
  36. package/lib/primitive/AppBar/index.js +3 -3
  37. package/lib/primitive/AppBar/useAppBar.cjs +29 -36
  38. package/lib/primitive/AppBar/useAppBar.js +28 -32
  39. package/lib/primitive/AppBar/useAppBarContext.cjs +11 -18
  40. package/lib/primitive/AppBar/useAppBarContext.js +10 -14
  41. package/lib/primitive/AppScreen/AppScreen.cjs +49 -52
  42. package/lib/primitive/AppScreen/AppScreen.js +48 -48
  43. package/lib/primitive/AppScreen/AppScreen.namespace.cjs +16 -12
  44. package/lib/primitive/AppScreen/AppScreen.namespace.js +11 -1
  45. package/lib/primitive/AppScreen/anatomy.cjs +17 -0
  46. package/lib/primitive/AppScreen/anatomy.d.ts +14 -0
  47. package/lib/primitive/AppScreen/anatomy.js +17 -0
  48. package/lib/primitive/AppScreen/index.cjs +15 -16
  49. package/lib/primitive/AppScreen/index.js +4 -4
  50. package/lib/primitive/AppScreen/useAppScreen.cjs +60 -63
  51. package/lib/primitive/AppScreen/useAppScreen.js +59 -59
  52. package/lib/primitive/AppScreen/useAppScreenContext.cjs +11 -18
  53. package/lib/primitive/AppScreen/useAppScreenContext.js +10 -14
  54. package/lib/primitive/GlobalInteraction/GlobalInteraction.cjs +21 -26
  55. package/lib/primitive/GlobalInteraction/GlobalInteraction.js +20 -22
  56. package/lib/primitive/GlobalInteraction/animation.cjs +303 -0
  57. package/lib/primitive/GlobalInteraction/animation.d.ts +16 -0
  58. package/lib/primitive/GlobalInteraction/animation.js +299 -0
  59. package/lib/primitive/GlobalInteraction/constants.cjs +9 -0
  60. package/lib/primitive/GlobalInteraction/constants.d.ts +19 -0
  61. package/lib/primitive/GlobalInteraction/constants.js +8 -0
  62. package/lib/primitive/GlobalInteraction/dom.cjs +147 -0
  63. package/lib/primitive/GlobalInteraction/dom.d.ts +44 -0
  64. package/lib/primitive/GlobalInteraction/dom.js +140 -0
  65. package/lib/primitive/GlobalInteraction/index.cjs +5 -11
  66. package/lib/primitive/GlobalInteraction/index.js +3 -2
  67. package/lib/primitive/GlobalInteraction/useGlobalInteraction.cjs +220 -133
  68. package/lib/primitive/GlobalInteraction/useGlobalInteraction.d.ts +10 -26
  69. package/lib/primitive/GlobalInteraction/useGlobalInteraction.js +219 -129
  70. package/lib/primitive/GlobalInteraction/useGlobalInteractionContext.cjs +11 -18
  71. package/lib/primitive/GlobalInteraction/useGlobalInteractionContext.js +10 -14
  72. package/lib/primitive/GlobalInteraction/useSwipeBack.cjs +71 -55
  73. package/lib/primitive/GlobalInteraction/useSwipeBack.d.ts +19 -2
  74. package/lib/primitive/GlobalInteraction/useSwipeBack.js +70 -51
  75. package/lib/primitive/index.cjs +55 -27
  76. package/lib/primitive/index.js +26 -9
  77. package/lib/primitive/private/useElementOffset.cjs +29 -51
  78. package/lib/primitive/private/useElementOffset.js +27 -28
  79. package/lib/primitive/private/useTopActivity.cjs +22 -31
  80. package/lib/primitive/private/useTopActivity.js +21 -27
  81. package/lib/utils/createStyleContext.cjs +91 -74
  82. package/lib/utils/createStyleContext.js +89 -70
  83. package/lib/utils/styled.cjs +13 -20
  84. package/lib/utils/styled.js +12 -16
  85. package/package.json +4 -4
  86. package/src/components/AppBar/AppBar.namespace.ts +2 -0
  87. package/src/components/AppBar/AppBar.tsx +29 -3
  88. package/src/components/AppBar/index.ts +2 -0
  89. package/src/primitive/AppBar/AppBar.tsx +4 -1
  90. package/src/primitive/AppBar/anatomy.ts +16 -0
  91. package/src/primitive/AppBar/useAppBar.tsx +2 -1
  92. package/src/primitive/AppScreen/anatomy.ts +15 -0
  93. package/src/primitive/AppScreen/useAppScreen.tsx +5 -4
  94. package/src/primitive/GlobalInteraction/animation.ts +447 -0
  95. package/src/primitive/GlobalInteraction/constants.ts +24 -0
  96. package/src/primitive/GlobalInteraction/dom.ts +211 -0
  97. package/src/primitive/GlobalInteraction/useGlobalInteraction.ts +302 -129
  98. package/src/primitive/GlobalInteraction/useSwipeBack.ts +64 -26
@@ -1,6 +1,22 @@
1
- import { useCallbackRef } from "@radix-ui/react-use-callback-ref";
2
- import { useCallback, useMemo, useRef, useState } from "react";
1
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react";
3
2
  import { useTopActivity } from "../private/useTopActivity";
3
+ import {
4
+ type TransitionStyle,
5
+ type TransitionTargets,
6
+ findTransitionTargets,
7
+ readTransitionStyle,
8
+ applySwipeStyles,
9
+ clearAllStyles,
10
+ setIdlePositions,
11
+ setPostExitPositions,
12
+ } from "./dom";
13
+ import {
14
+ cancelAll,
15
+ animateTransition,
16
+ animateSwipeComplete,
17
+ animateSwipeCancel,
18
+ scrubAppBarBackground,
19
+ } from "./animation";
4
20
 
5
21
  export type SwipeBackState = "idle" | "swiping" | "canceling" | "completing";
6
22
 
@@ -14,22 +30,18 @@ export interface SwipeBackContext {
14
30
  velocity: number;
15
31
  }
16
32
 
17
- export interface SwipeBackProps {
33
+ export interface SwipeBackThresholds {
18
34
  /**
19
- * The threshold to determine whether the swipe back is intentional.
35
+ * The threshold to determine whether the swipe back is intentional, by displacement ratio.
20
36
  * @default 0.4
21
37
  */
22
- swipeBackDisplacementRatioThreshold?: number;
38
+ displacementRatioThreshold?: number;
23
39
 
24
40
  /**
25
- * The threshold to determine whether the swipe back is intentional.
41
+ * The threshold to determine whether the swipe back is intentional, by velocity.
26
42
  * @default 1
27
43
  */
28
- swipeBackVelocityThreshold?: number;
29
-
30
- onSwipeBackStart?: () => void;
31
- onSwipeBackMove?: (props: { displacement: number; displacementRatio: number }) => void;
32
- onSwipeBackEnd?: (props: { swiped: boolean }) => void;
44
+ velocityThreshold?: number;
33
45
  }
34
46
 
35
47
  export interface StartSwipeBackProps {
@@ -42,147 +54,308 @@ export interface MoveSwipeBackProps {
42
54
  t: number;
43
55
  }
44
56
 
45
- // biome-ignore lint/suspicious/noEmptyInterface: intentionally empty for future extension
46
- export interface EndSwipeBackProps {}
57
+ const IDLE_CONTEXT: SwipeBackContext = Object.freeze({
58
+ x0: 0,
59
+ t0: 0,
60
+ displacement: 0,
61
+ displacementRatio: 0,
62
+ velocity: 0,
63
+ });
64
+
65
+ const DEFAULT_DISPLACEMENT_RATIO_THRESHOLD = 0.4;
66
+ const DEFAULT_VELOCITY_THRESHOLD = 1;
47
67
 
48
68
  export function useGlobalInteraction() {
49
- const [swipeBackState, setSwipeBackState] = useState<SwipeBackState>("idle");
50
-
51
- const swipeBackContextRef = useRef<SwipeBackContext>({
52
- x0: 0,
53
- t0: 0,
54
- displacement: 0,
55
- displacementRatio: 0,
56
- velocity: 0,
57
- });
69
+ const swipeBackStateRef = useRef<SwipeBackState>("idle");
70
+
71
+ const swipeBackContextRef = useRef<SwipeBackContext>({ ...IDLE_CONTEXT });
58
72
  const stackRef = useRef<HTMLDivElement>(null);
59
73
 
60
- const setSwipeBackContext = useCallback((ctx: SwipeBackContext) => {
61
- swipeBackContextRef.current = ctx;
62
- stackRef.current?.style.setProperty(
63
- "--swipe-back-displacement",
64
- `${ctx.displacement.toString()}px`,
65
- );
66
- stackRef.current?.style.setProperty(
67
- "--swipe-back-displacement-ratio",
68
- ctx.displacementRatio.toString(),
69
- );
74
+ // Cached transition targets — populated once per gesture/transition
75
+ const cachedTargetsRef = useRef<TransitionTargets | null>(null);
76
+
77
+ // Running WAAPI animations — for cancellation on new transitions
78
+ const runningAnimsRef = useRef<Animation[]>([]);
79
+
80
+ // Current scrub animation on the top AppBar background element. Retained
81
+ // across touchmoves so setKeyframes can replace it in place — recreating
82
+ // on every move would cancel-then-recreate and flicker.
83
+ const appBarBgScrubAnimRef = useRef<Animation | null>(null);
84
+
85
+ // Transition style of the top activity at swipe start. Non-iOS styles
86
+ // (fadeFromBottomAndroid / fadeIn) do not track finger displacement —
87
+ // their exit is driven by stackflow's normal exit-active lifecycle.
88
+ const swipeStyleRef = useRef<TransitionStyle | null>(null);
89
+
90
+ // Defer push animation one frame so stackflow has committed the new top
91
+ // activity's DOM (data-activity-is-top + layer/appBar subtree) before we
92
+ // query targets. Running sync in useLayoutEffect turned out to race with
93
+ // stackflow's internal subscription updates, leaving targets empty and
94
+ // the enter animation never firing.
95
+ const pendingPushRAFRef = useRef<number | null>(null);
96
+
97
+ // Skip the next exit-active transition after swipe completing,
98
+ // because stackflow fires exit-active as part of its normal lifecycle
99
+ // but we've already animated the exit via WAAPI.
100
+ const skipNextExitRef = useRef(false);
101
+
102
+ /** Cancel any running WAAPI animations and clear the ref. */
103
+ const stopRunningAnims = useCallback(() => {
104
+ cancelAll(runningAnimsRef.current);
105
+ runningAnimsRef.current = [];
70
106
  }, []);
71
107
 
72
- const getSwipeBackEvents = useCallback(
73
- (props: SwipeBackProps) => {
74
- const {
75
- swipeBackDisplacementRatioThreshold: displacementRatioThreshold = 0.4,
76
- swipeBackVelocityThreshold: velocityThreshold = 1,
77
- } = props;
78
- const onSwipeStart = useCallbackRef(props.onSwipeBackStart);
79
- const onSwipeMove = useCallbackRef(props.onSwipeBackMove);
80
- const onSwipeEnd = useCallbackRef(props.onSwipeBackEnd);
81
-
82
- const startSwipeBack = useCallback(
83
- ({ x0, t0 }: StartSwipeBackProps) => {
84
- setSwipeBackContext({
85
- x0,
86
- t0,
87
- displacement: 0,
88
- displacementRatio: 0,
89
- velocity: 0,
90
- });
91
- setSwipeBackState((prev) => (prev === "swiping" ? prev : "swiping"));
92
- onSwipeStart?.();
93
- },
94
- [onSwipeStart],
95
- );
108
+ /** Cancel the in-place scrub animation on the AppBar background. */
109
+ const stopAppBarBgScrub = useCallback(() => {
110
+ appBarBgScrubAnimRef.current?.cancel();
111
+ appBarBgScrubAnimRef.current = null;
112
+ }, []);
96
113
 
97
- const moveSwipeBack = useCallback(
98
- ({ x, t }: MoveSwipeBackProps) => {
99
- const displacement = Math.max(0, x - swipeBackContextRef.current.x0);
100
- const displacementRatio = displacement / window.innerWidth;
101
- const velocity = displacement / (t - swipeBackContextRef.current.t0);
102
- setSwipeBackContext({
103
- ...swipeBackContextRef.current,
104
- displacement,
105
- displacementRatio,
106
- velocity,
107
- });
108
- setSwipeBackState((prev) => (prev === "swiping" ? prev : "swiping"));
109
- onSwipeMove?.({ displacement, displacementRatio });
110
- },
111
- [onSwipeMove],
112
- );
114
+ /** Cancel a pending deferred push animation, if any. */
115
+ const cancelPendingPushRAF = useCallback(() => {
116
+ if (pendingPushRAFRef.current !== null) {
117
+ cancelAnimationFrame(pendingPushRAFRef.current);
118
+ pendingPushRAFRef.current = null;
119
+ }
120
+ }, []);
113
121
 
114
- const endSwipeBack = useCallback(
115
- (_: EndSwipeBackProps) => {
116
- const swiped =
117
- swipeBackContextRef.current.displacementRatio > displacementRatioThreshold ||
118
- swipeBackContextRef.current.velocity > velocityThreshold;
119
-
120
- if (swiped) {
121
- stackRef.current?.style.setProperty("--swipe-back-target", "100%");
122
- setSwipeBackState("completing");
123
- } else {
124
- stackRef.current?.style.setProperty("--swipe-back-target", "0");
125
- setSwipeBackState("canceling");
126
- }
122
+ /** Update swipe-back-state attribute on the stack DOM element. */
123
+ const setSwipeBackState = useCallback((state: SwipeBackState) => {
124
+ swipeBackStateRef.current = state;
125
+ if (stackRef.current) {
126
+ stackRef.current.dataset["swipeBackState"] = state;
127
+ }
128
+ }, []);
129
+
130
+ const startSwipeBack = useCallback(
131
+ ({ x0, t0 }: StartSwipeBackProps) => {
132
+ // Cancel pending push rAF and any running animations
133
+ cancelPendingPushRAF();
134
+ stopRunningAnims();
135
+ stopAppBarBgScrub();
136
+
137
+ swipeBackContextRef.current = { ...IDLE_CONTEXT, x0, t0 };
127
138
 
128
- onSwipeEnd?.({ swiped });
129
- },
130
- [onSwipeEnd, displacementRatioThreshold, velocityThreshold],
139
+ // Cache target elements and transition style once per gesture
140
+ if (stackRef.current) {
141
+ cachedTargetsRef.current = findTransitionTargets(stackRef.current);
142
+ swipeStyleRef.current = readTransitionStyle(stackRef.current);
143
+ }
144
+
145
+ setSwipeBackState("swiping");
146
+ },
147
+ [cancelPendingPushRAF, stopRunningAnims, stopAppBarBgScrub, setSwipeBackState],
148
+ );
149
+
150
+ const moveSwipeBack = useCallback(({ x, t }: MoveSwipeBackProps): SwipeBackContext => {
151
+ const ctx = swipeBackContextRef.current;
152
+ const displacement = Math.max(0, x - ctx.x0);
153
+ const displacementRatio = displacement / window.innerWidth;
154
+ const velocity = displacement / (t - ctx.t0);
155
+
156
+ // Mutate in place — this runs at 60fps inside touchmove rAF.
157
+ ctx.displacement = displacement;
158
+ ctx.displacementRatio = displacementRatio;
159
+ ctx.velocity = velocity;
160
+
161
+ // Only iOS slide tracks finger displacement; other styles stay put
162
+ // and let stackflow's own exit transition play on pop.
163
+ const targets = cachedTargetsRef.current;
164
+ if (targets && swipeStyleRef.current === "slideFromRightIOS") {
165
+ applySwipeStyles(targets, displacement, displacementRatio);
166
+ appBarBgScrubAnimRef.current = scrubAppBarBackground(
167
+ targets.topAppBarBackground,
168
+ `translate3d(${displacement}px, 0, 0)`,
169
+ appBarBgScrubAnimRef.current,
131
170
  );
171
+ }
132
172
 
133
- const reset = useCallback(() => {
134
- setSwipeBackContext({
135
- x0: 0,
136
- t0: 0,
137
- displacement: 0,
138
- displacementRatio: 0,
139
- velocity: 0,
140
- });
141
- stackRef.current?.style.setProperty("--swipe-back-target", "0");
173
+ return ctx;
174
+ }, []);
175
+
176
+ const endSwipeBack = useCallback(
177
+ (thresholds: SwipeBackThresholds = {}): boolean => {
178
+ const {
179
+ displacementRatioThreshold = DEFAULT_DISPLACEMENT_RATIO_THRESHOLD,
180
+ velocityThreshold = DEFAULT_VELOCITY_THRESHOLD,
181
+ } = thresholds;
182
+
183
+ const ctx = swipeBackContextRef.current;
184
+ const swiped =
185
+ ctx.displacementRatio > displacementRatioThreshold || ctx.velocity > velocityThreshold;
186
+
187
+ const targets = cachedTargetsRef.current;
188
+ if (!targets) {
142
189
  setSwipeBackState("idle");
143
- }, []);
144
-
145
- return useMemo(
146
- () => ({
147
- startSwipeBack,
148
- moveSwipeBack,
149
- endSwipeBack,
150
- reset,
151
- }),
152
- [startSwipeBack, moveSwipeBack, endSwipeBack, reset],
153
- );
190
+ return swiped;
191
+ }
192
+
193
+ // Non-iOS styles do not track finger motion — let stackflow's normal
194
+ // exit-active lifecycle drive the pop animation when `swiped` is true.
195
+ if (swipeStyleRef.current !== "slideFromRightIOS") {
196
+ cachedTargetsRef.current = null;
197
+ swipeStyleRef.current = null;
198
+ setSwipeBackState("idle");
199
+ return swiped;
200
+ }
201
+
202
+ // Keep swipe-time inline styles intact — WAAPI keyframe[0] matches the
203
+ // current displacement, and the inline values cover the 1-frame gap
204
+ // before the animation's first paint. Clearing here would cause a
205
+ // single-frame snap to default (transform: 0) on heavy main-thread
206
+ // sessions where reflow can interleave between this clear and animate().
207
+ stopAppBarBgScrub();
208
+
209
+ const onFinish = (animations: Animation[], pin: () => void) => {
210
+ // Always pin + cancel: skipping these leaves inline styles from the
211
+ // mid-gesture snapshot on screen when the animation gets cancelled
212
+ // by a newer transition (the cancel itself drops fill:forwards, so
213
+ // the stale inline styles become visible).
214
+ // Set inline styles BEFORE cancel to prevent flash.
215
+ pin();
216
+ cancelAll(animations);
217
+ // Only reset the running ref / per-gesture caches if no newer
218
+ // transition has claimed them — otherwise we'd clobber the next
219
+ // gesture's state.
220
+ if (runningAnimsRef.current === animations) {
221
+ runningAnimsRef.current = [];
222
+ cachedTargetsRef.current = null;
223
+ swipeStyleRef.current = null;
224
+ setSwipeBackState("idle");
225
+ }
226
+ };
227
+
228
+ if (swiped) {
229
+ setSwipeBackState("completing");
230
+ // Mark to skip the next exit-active from stackflow
231
+ skipNextExitRef.current = true;
232
+ const { animations, finished } = animateSwipeComplete(targets, ctx.displacement);
233
+ runningAnimsRef.current = animations;
234
+ finished.then(() => onFinish(animations, () => setPostExitPositions(targets)));
235
+ } else {
236
+ setSwipeBackState("canceling");
237
+ const { animations, finished } = animateSwipeCancel(targets, ctx.displacement);
238
+ runningAnimsRef.current = animations;
239
+ finished.then(() => onFinish(animations, () => setIdlePositions(targets)));
240
+ }
241
+
242
+ return swiped;
154
243
  },
155
- [setSwipeBackContext],
244
+ [setSwipeBackState, stopAppBarBgScrub],
156
245
  );
157
246
 
247
+ const resetSwipeBack = useCallback(() => {
248
+ stopRunningAnims();
249
+ stopAppBarBgScrub();
250
+ if (cachedTargetsRef.current) {
251
+ clearAllStyles(cachedTargetsRef.current);
252
+ }
253
+ cachedTargetsRef.current = null;
254
+ swipeStyleRef.current = null;
255
+ swipeBackContextRef.current = { ...IDLE_CONTEXT };
256
+ setSwipeBackState("idle");
257
+ }, [stopRunningAnims, stopAppBarBgScrub, setSwipeBackState]);
258
+
158
259
  const topActivity = useTopActivity();
159
260
 
261
+ // ── WAAPI push/pop transitions triggered by stackflow state changes ──
262
+ const prevTransitionStateRef = useRef<string>(topActivity.transitionState);
263
+
264
+ useLayoutEffect(() => {
265
+ const prev = prevTransitionStateRef.current;
266
+ const next = topActivity.transitionState;
267
+ prevTransitionStateRef.current = next;
268
+
269
+ const stackEl = stackRef.current;
270
+ if (!stackEl) return;
271
+
272
+ const swipeState = swipeBackStateRef.current;
273
+
274
+ if (next === "enter-active" && prev !== "enter-active") {
275
+ if (swipeState !== "idle") return;
276
+
277
+ stopRunningAnims();
278
+ cancelPendingPushRAF();
279
+ // Defer one frame so stackflow's new top activity subtree is reliably
280
+ // observable via data-activity-is-top. Sync dispatch inside
281
+ // useLayoutEffect raced with stackflow subscription updates and left
282
+ // findTransitionTargets empty on push.
283
+ pendingPushRAFRef.current = requestAnimationFrame(() => {
284
+ pendingPushRAFRef.current = null;
285
+ const style = readTransitionStyle(stackEl);
286
+ const targets = findTransitionTargets(stackEl);
287
+ const { animations, finished } = animateTransition(targets, "push", style);
288
+ runningAnimsRef.current = animations;
289
+ finished.then(() => {
290
+ // Always pin idle inline styles + cancel before checking the ref.
291
+ // Skipping these on cancel-by-newer-transition would leave the
292
+ // mid-flight inline styles (or none at all) on screen.
293
+ setIdlePositions(targets, style);
294
+ cancelAll(animations);
295
+ if (runningAnimsRef.current === animations) {
296
+ runningAnimsRef.current = [];
297
+ }
298
+ });
299
+ });
300
+ }
301
+
302
+ if (next === "exit-active" && prev !== "exit-active") {
303
+ if (skipNextExitRef.current) {
304
+ skipNextExitRef.current = false;
305
+ return;
306
+ }
307
+
308
+ if (swipeState !== "idle") return;
309
+
310
+ cancelPendingPushRAF();
311
+ stopRunningAnims();
312
+ const style = readTransitionStyle(stackEl);
313
+ const targets = findTransitionTargets(stackEl);
314
+ const { animations, finished } = animateTransition(targets, "pop", style);
315
+ runningAnimsRef.current = animations;
316
+ finished.then(() => {
317
+ // Always pin post-exit inline styles + cancel. If we bail here on
318
+ // cancel-by-newer-transition, the previous transition's idle inline
319
+ // styles (e.g. behind layer at -30%) stay on screen.
320
+ setPostExitPositions(targets, style);
321
+ cancelAll(animations);
322
+ if (runningAnimsRef.current === animations) {
323
+ runningAnimsRef.current = [];
324
+ }
325
+ });
326
+ }
327
+ }, [topActivity.transitionState, stopRunningAnims, cancelPendingPushRAF]);
328
+
329
+ // Cancel any pending push rAF and running animations on unmount so
330
+ // late-firing finished handlers can't run against a torn-down stack.
331
+ useEffect(() => {
332
+ return () => {
333
+ cancelPendingPushRAF();
334
+ stopRunningAnims();
335
+ stopAppBarBgScrub();
336
+ };
337
+ }, [cancelPendingPushRAF, stopRunningAnims, stopAppBarBgScrub]);
338
+
160
339
  const stackProps = useMemo(
161
- () => ({
162
- "data-swipe-back-state": swipeBackState,
163
- "data-global-transition-state": topActivity.transitionState,
164
- "data-top-activity-type": topActivity.activityType,
165
- "data-top-transition-style": topActivity.transitionStyle,
166
- }),
167
- [
168
- swipeBackState,
169
- topActivity.transitionState,
170
- topActivity.activityType,
171
- topActivity.transitionStyle,
172
- ],
173
- ) as React.HTMLAttributes<HTMLElement>;
340
+ () =>
341
+ ({
342
+ "data-swipe-back-state": swipeBackStateRef.current,
343
+ "data-global-transition-state": topActivity.transitionState,
344
+ "data-top-activity-type": topActivity.activityType,
345
+ "data-top-transition-style": topActivity.transitionStyle,
346
+ }) as React.HTMLAttributes<HTMLElement>,
347
+ [topActivity.transitionState, topActivity.activityType, topActivity.transitionStyle],
348
+ );
174
349
 
175
350
  return useMemo(
176
351
  () => ({
177
352
  stackRef,
178
- swipeBackContextRef,
179
- swipeBackState,
180
- setSwipeBackState,
181
- setSwipeBackContext,
182
- getSwipeBackEvents,
183
-
353
+ startSwipeBack,
354
+ moveSwipeBack,
355
+ endSwipeBack,
356
+ resetSwipeBack,
184
357
  stackProps,
185
358
  }),
186
- [swipeBackState, setSwipeBackContext, getSwipeBackEvents, stackProps],
359
+ [startSwipeBack, moveSwipeBack, endSwipeBack, resetSwipeBack, stackProps],
187
360
  );
188
361
  }
@@ -1,56 +1,94 @@
1
- import { useEffect, useMemo } from "react";
2
- import type { SwipeBackProps } from "./useGlobalInteraction";
1
+ import { useCallbackRef } from "@radix-ui/react-use-callback-ref";
2
+ import { useEffect, useMemo, useRef } from "react";
3
3
  import { useGlobalInteractionContext } from "./useGlobalInteractionContext";
4
4
 
5
- export interface UseSwipeBackProps extends SwipeBackProps {}
5
+ export interface UseSwipeBackProps {
6
+ /**
7
+ * The threshold to determine whether the swipe back is intentional, by displacement ratio.
8
+ * @default 0.4
9
+ */
10
+ swipeBackDisplacementRatioThreshold?: number;
11
+
12
+ /**
13
+ * The threshold to determine whether the swipe back is intentional, by velocity.
14
+ * @default 1
15
+ */
16
+ swipeBackVelocityThreshold?: number;
17
+
18
+ onSwipeBackStart?: () => void;
19
+ onSwipeBackMove?: (props: { displacement: number; displacementRatio: number }) => void;
20
+ onSwipeBackEnd?: (props: { swiped: boolean }) => void;
21
+ }
6
22
 
7
23
  export function useSwipeBack(props: UseSwipeBackProps) {
8
- const globalInteraction = useGlobalInteractionContext();
9
- const events = globalInteraction.getSwipeBackEvents(props);
24
+ const {
25
+ swipeBackDisplacementRatioThreshold: displacementRatioThreshold,
26
+ swipeBackVelocityThreshold: velocityThreshold,
27
+ } = props;
28
+
29
+ const onSwipeStart = useCallbackRef(props.onSwipeBackStart);
30
+ const onSwipeMove = useCallbackRef(props.onSwipeBackMove);
31
+ const onSwipeEnd = useCallbackRef(props.onSwipeBackEnd);
32
+
33
+ const { startSwipeBack, moveSwipeBack, endSwipeBack, resetSwipeBack } =
34
+ useGlobalInteractionContext();
35
+
36
+ const rAFLockRef = useRef(false);
10
37
 
11
38
  useEffect(() => {
12
39
  return () => {
13
- events.reset();
40
+ resetSwipeBack();
14
41
  };
15
- }, [events]);
42
+ }, [resetSwipeBack]);
16
43
 
17
44
  return useMemo(
18
45
  () => ({
19
46
  activityProps: {
20
47
  "data-swipe-back": "",
21
48
  } as React.HTMLAttributes<HTMLDivElement>,
22
- layerProps: {
23
- onAnimationEnd: (e) => {
24
- if (e.target === e.currentTarget) {
25
- events.reset();
26
- }
27
- },
28
- onTransitionEnd: (e) => {
29
- if (e.target === e.currentTarget) {
30
- events.reset();
31
- }
32
- },
33
- } as React.HTMLAttributes<HTMLDivElement>,
49
+ layerProps: {} as React.HTMLAttributes<HTMLDivElement>,
34
50
  edgeProps: {
35
51
  tabIndex: -1,
36
- onTouchStart: (e) => {
52
+ onTouchStart: (e: React.TouchEvent) => {
37
53
  const x0 = e.touches[0].clientX;
38
54
  const t0 = Date.now();
39
- events.startSwipeBack({ x0, t0 });
55
+ startSwipeBack({ x0, t0 });
56
+ onSwipeStart();
40
57
  },
41
- onTouchMove: (e) => {
58
+ onTouchMove: (e: React.TouchEvent) => {
59
+ // rAF lock: process at most once per animation frame
60
+ if (rAFLockRef.current) return;
61
+ rAFLockRef.current = true;
42
62
  const x = e.touches[0].clientX;
43
63
  const t = Date.now();
44
- events.moveSwipeBack({ x, t });
64
+ requestAnimationFrame(() => {
65
+ const ctx = moveSwipeBack({ x, t });
66
+ rAFLockRef.current = false;
67
+ onSwipeMove({
68
+ displacement: ctx.displacement,
69
+ displacementRatio: ctx.displacementRatio,
70
+ });
71
+ });
45
72
  },
46
73
  onTouchEnd: () => {
47
- events.endSwipeBack({});
74
+ const swiped = endSwipeBack({ displacementRatioThreshold, velocityThreshold });
75
+ onSwipeEnd({ swiped });
48
76
  },
49
77
  onTouchCancel: () => {
50
- events.endSwipeBack({});
78
+ const swiped = endSwipeBack({ displacementRatioThreshold, velocityThreshold });
79
+ onSwipeEnd({ swiped });
51
80
  },
52
81
  } as React.HTMLAttributes<HTMLElement>,
53
82
  }),
54
- [events],
83
+ [
84
+ startSwipeBack,
85
+ moveSwipeBack,
86
+ endSwipeBack,
87
+ onSwipeStart,
88
+ onSwipeMove,
89
+ onSwipeEnd,
90
+ displacementRatioThreshold,
91
+ velocityThreshold,
92
+ ],
55
93
  );
56
94
  }