react-native-screen-transitions 2.3.4 → 2.4.1

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 (76) hide show
  1. package/README.md +11 -8
  2. package/lib/commonjs/__configs__/presets.js +10 -5
  3. package/lib/commonjs/__configs__/presets.js.map +1 -1
  4. package/lib/commonjs/constants.js +2 -1
  5. package/lib/commonjs/constants.js.map +1 -1
  6. package/lib/commonjs/hooks/animation/use-screen-animation.js +2 -1
  7. package/lib/commonjs/hooks/animation/use-screen-animation.js.map +1 -1
  8. package/lib/commonjs/hooks/bounds/use-bound-registry.js +10 -8
  9. package/lib/commonjs/hooks/bounds/use-bound-registry.js.map +1 -1
  10. package/lib/commonjs/hooks/gestures/use-build-gestures.js +17 -10
  11. package/lib/commonjs/hooks/gestures/use-build-gestures.js.map +1 -1
  12. package/lib/commonjs/hooks/use-stable-callback-value.js +64 -0
  13. package/lib/commonjs/hooks/use-stable-callback-value.js.map +1 -0
  14. package/lib/commonjs/stores/gestures.js +2 -1
  15. package/lib/commonjs/stores/gestures.js.map +1 -1
  16. package/lib/commonjs/utils/bounds/_utils/styles.js +58 -0
  17. package/lib/commonjs/utils/bounds/_utils/styles.js.map +1 -0
  18. package/lib/commonjs/utils/gesture/reset-gesture-values.js +14 -4
  19. package/lib/commonjs/utils/gesture/reset-gesture-values.js.map +1 -1
  20. package/lib/commonjs/utils/gesture/velocity.js +2 -2
  21. package/lib/commonjs/utils/gesture/velocity.js.map +1 -1
  22. package/lib/module/__configs__/presets.js +10 -5
  23. package/lib/module/__configs__/presets.js.map +1 -1
  24. package/lib/module/constants.js +2 -1
  25. package/lib/module/constants.js.map +1 -1
  26. package/lib/module/hooks/animation/use-screen-animation.js +2 -1
  27. package/lib/module/hooks/animation/use-screen-animation.js.map +1 -1
  28. package/lib/module/hooks/bounds/use-bound-registry.js +11 -9
  29. package/lib/module/hooks/bounds/use-bound-registry.js.map +1 -1
  30. package/lib/module/hooks/gestures/use-build-gestures.js +18 -11
  31. package/lib/module/hooks/gestures/use-build-gestures.js.map +1 -1
  32. package/lib/module/hooks/use-stable-callback-value.js +60 -0
  33. package/lib/module/hooks/use-stable-callback-value.js.map +1 -0
  34. package/lib/module/stores/gestures.js +2 -1
  35. package/lib/module/stores/gestures.js.map +1 -1
  36. package/lib/module/utils/bounds/_utils/styles.js +54 -0
  37. package/lib/module/utils/bounds/_utils/styles.js.map +1 -0
  38. package/lib/module/utils/gesture/reset-gesture-values.js +14 -4
  39. package/lib/module/utils/gesture/reset-gesture-values.js.map +1 -1
  40. package/lib/module/utils/gesture/velocity.js +2 -2
  41. package/lib/module/utils/gesture/velocity.js.map +1 -1
  42. package/lib/typescript/__configs__/presets.d.ts.map +1 -1
  43. package/lib/typescript/constants.d.ts.map +1 -1
  44. package/lib/typescript/hooks/animation/use-screen-animation.d.ts.map +1 -1
  45. package/lib/typescript/hooks/bounds/use-bound-registry.d.ts.map +1 -1
  46. package/lib/typescript/hooks/gestures/use-build-gestures.d.ts.map +1 -1
  47. package/lib/typescript/hooks/use-stable-callback-value.d.ts +13 -0
  48. package/lib/typescript/hooks/use-stable-callback-value.d.ts.map +1 -0
  49. package/lib/typescript/stores/gestures.d.ts +2 -0
  50. package/lib/typescript/stores/gestures.d.ts.map +1 -1
  51. package/lib/typescript/types/animation.d.ts +10 -9
  52. package/lib/typescript/types/animation.d.ts.map +1 -1
  53. package/lib/typescript/types/gesture.d.ts +4 -0
  54. package/lib/typescript/types/gesture.d.ts.map +1 -1
  55. package/lib/typescript/types/utils.d.ts.map +1 -1
  56. package/lib/typescript/utils/bounds/_utils/styles.d.ts +8 -0
  57. package/lib/typescript/utils/bounds/_utils/styles.d.ts.map +1 -0
  58. package/lib/typescript/utils/gesture/reset-gesture-values.d.ts.map +1 -1
  59. package/lib/typescript/utils/gesture/velocity.d.ts +1 -1
  60. package/lib/typescript/utils/gesture/velocity.d.ts.map +1 -1
  61. package/package.json +1 -1
  62. package/src/__configs__/presets.ts +23 -7
  63. package/src/__tests__/determine-dismissal.test.ts +121 -0
  64. package/src/__tests__/gesture.velocity.test.ts +138 -0
  65. package/src/constants.ts +2 -0
  66. package/src/hooks/animation/use-screen-animation.tsx +1 -0
  67. package/src/hooks/bounds/use-bound-registry.tsx +9 -13
  68. package/src/hooks/gestures/use-build-gestures.tsx +21 -37
  69. package/src/hooks/use-stable-callback-value.tsx +68 -0
  70. package/src/stores/gestures.ts +5 -0
  71. package/src/types/animation.ts +10 -9
  72. package/src/types/gesture.ts +4 -0
  73. package/src/types/utils.ts +1 -0
  74. package/src/utils/bounds/_utils/styles.ts +69 -0
  75. package/src/utils/gesture/reset-gesture-values.ts +23 -4
  76. package/src/utils/gesture/velocity.ts +1 -2
@@ -0,0 +1,138 @@
1
+ import { describe, expect, it, mock } from "bun:test";
2
+
3
+ mock.module("react-native", () => ({}));
4
+ mock.module("react-native-gesture-handler", () => ({}));
5
+ mock.module("react-native-reanimated", () => ({
6
+ clamp: (value: number, lower: number, upper: number) =>
7
+ Math.min(Math.max(value, lower), upper),
8
+ }));
9
+
10
+ const { velocity } = await import("../utils/gesture/velocity");
11
+
12
+ type Directions = {
13
+ horizontal: boolean;
14
+ horizontalInverted: boolean;
15
+ vertical: boolean;
16
+ verticalInverted: boolean;
17
+ };
18
+
19
+ type GestureEventInit = {
20
+ translationX?: number;
21
+ translationY?: number;
22
+ velocityX?: number;
23
+ velocityY?: number;
24
+ };
25
+
26
+ const createAnimations = (progress: number) =>
27
+ ({
28
+ progress: { value: progress },
29
+ closing: { value: 0 },
30
+ animating: { value: 0 },
31
+ }) as const;
32
+
33
+ const createEvent = ({
34
+ translationX = 0,
35
+ translationY = 0,
36
+ velocityX = 0,
37
+ velocityY = 0,
38
+ }: GestureEventInit) =>
39
+ ({ translationX, translationY, velocityX, velocityY }) as any;
40
+
41
+ const createDirections = (overrides: Partial<Directions> = {}) => ({
42
+ horizontal: false,
43
+ horizontalInverted: false,
44
+ vertical: false,
45
+ verticalInverted: false,
46
+ ...overrides,
47
+ });
48
+
49
+ describe("velocity.normalize", () => {
50
+ it("clamps values to the configured range", () => {
51
+ expect(velocity.normalize(6400, 320)).toBeCloseTo(3.2, 5);
52
+ expect(velocity.normalize(-6400, 320)).toBeCloseTo(-3.2, 5);
53
+ });
54
+ });
55
+
56
+ describe("velocity.calculateProgressVelocity", () => {
57
+ const dimensions = { width: 320, height: 640 };
58
+
59
+ it("returns positive magnitude when progressing toward open target", () => {
60
+ const animations = createAnimations(0.25);
61
+ const event = createEvent({
62
+ translationX: 48,
63
+ translationY: 6,
64
+ velocityX: 800,
65
+ });
66
+
67
+ const result = velocity.calculateProgressVelocity({
68
+ animations: animations as any,
69
+ shouldDismiss: false,
70
+ event,
71
+ dimensions,
72
+ directions: createDirections({ horizontal: true }),
73
+ });
74
+
75
+ expect(result).toBeCloseTo(2.5, 5);
76
+ });
77
+
78
+ it("prefers the axis with greater normalized translation", () => {
79
+ const animations = createAnimations(0.8);
80
+ const event = createEvent({
81
+ translationX: 24,
82
+ translationY: -140,
83
+ velocityX: 120,
84
+ velocityY: -900,
85
+ });
86
+
87
+ const result = velocity.calculateProgressVelocity({
88
+ animations: animations as any,
89
+ shouldDismiss: true,
90
+ event,
91
+ dimensions,
92
+ directions: createDirections({ horizontal: true, verticalInverted: true }),
93
+ });
94
+
95
+ expect(result).toBeLessThan(0);
96
+ expect(Math.abs(result)).toBeCloseTo(1.406, 3);
97
+ });
98
+
99
+ it("caps the returned magnitude using clamp", () => {
100
+ const animations = createAnimations(0.5);
101
+ const event = createEvent({
102
+ translationX: 10,
103
+ velocityX: 5000,
104
+ });
105
+
106
+ const result = velocity.calculateProgressVelocity({
107
+ animations: animations as any,
108
+ shouldDismiss: false,
109
+ event,
110
+ dimensions,
111
+ directions: createDirections({ horizontal: true }),
112
+ });
113
+
114
+ expect(result).toBeCloseTo(3.2, 5);
115
+ });
116
+ });
117
+
118
+ describe("velocity.shouldPassDismissalThreshold", () => {
119
+ const width = 320;
120
+
121
+ it("returns true once translation alone crosses half the screen", () => {
122
+ expect(
123
+ velocity.shouldPassDismissalThreshold(170, 0, width, 0.3),
124
+ ).toBe(true);
125
+ });
126
+
127
+ it("combines translation with weighted velocity", () => {
128
+ expect(
129
+ velocity.shouldPassDismissalThreshold(40, 2500, width, 0.5),
130
+ ).toBe(true);
131
+ });
132
+
133
+ it("returns false when movement is negligible", () => {
134
+ expect(
135
+ velocity.shouldPassDismissalThreshold(0, 0, width, 0.3),
136
+ ).toBe(false);
137
+ });
138
+ });
package/src/constants.ts CHANGED
@@ -33,6 +33,7 @@ export const DEFAULT_SCREEN_TRANSITION_STATE: ScreenTransitionState =
33
33
  normalizedY: 0,
34
34
  isDismissing: 0,
35
35
  isDragging: 0,
36
+ direction: null,
36
37
  },
37
38
  bounds: {} as Record<string, BoundEntry>,
38
39
  route: {} as RouteProp<ParamListBase>,
@@ -54,6 +55,7 @@ export const EMPTY_BOUND_HELPER_RESULT_RAW = Object.freeze({
54
55
  });
55
56
  export const ENTER_RANGE = [0, 1] as const;
56
57
  export const EXIT_RANGE = [1, 2] as const;
58
+
57
59
  export const FULLSCREEN_DIMENSIONS = (
58
60
  dimensions: ScaledSize,
59
61
  ): MeasuredDimensions => {
@@ -45,6 +45,7 @@ const unwrap = (
45
45
  normalizedY: s.gesture.normalizedY.value,
46
46
  isDismissing: s.gesture.isDismissing.value,
47
47
  isDragging: s.gesture.isDragging.value,
48
+ direction: s.gesture.direction.value,
48
49
  },
49
50
  bounds: Bounds.getBounds(key) || NO_BOUNDS_MAP,
50
51
  route: s.route,
@@ -18,9 +18,10 @@ import {
18
18
  import type { SharedValue } from "react-native-reanimated/lib/typescript/commonTypes";
19
19
  import { useKeys } from "../../providers/keys";
20
20
  import { Bounds } from "../../stores/bounds";
21
- import { flattenStyle } from "../../utils/bounds/_utils/flatten-styles";
22
21
  import { isBoundsEqual } from "../../utils/bounds/_utils/is-bounds-equal";
22
+ import { prepareStyleForBounds } from "../../utils/bounds/_utils/styles";
23
23
  import useStableCallback from "../use-stable-callback";
24
+ import useStableCallbackValue from "../use-stable-callback-value";
24
25
 
25
26
  interface BoundMeasurerHookProps {
26
27
  sharedBoundTag?: string;
@@ -48,17 +49,18 @@ export const useBoundsRegistry = ({
48
49
  onPress,
49
50
  }: BoundMeasurerHookProps) => {
50
51
  const { previous, current, next } = useKeys();
52
+ const preparedStyles = useMemo(() => prepareStyleForBounds(style), [style]);
51
53
 
52
54
  const ROOT_MEASUREMENT_SIGNAL = useContext(MeasurementUpdateContext);
53
55
  const ROOT_SIGNAL = useSharedValue(0);
54
56
  const IS_ROOT = !ROOT_MEASUREMENT_SIGNAL;
55
57
 
56
- const emitUpdate = useCallback(() => {
58
+ const emitUpdate = useStableCallbackValue(() => {
57
59
  "worklet";
58
60
  if (IS_ROOT) ROOT_SIGNAL.value = ROOT_SIGNAL.value + 1;
59
- }, [IS_ROOT, ROOT_SIGNAL]);
61
+ });
60
62
 
61
- const maybeMeasureAndStore = useCallback(
63
+ const maybeMeasureAndStore = useStableCallbackValue(
62
64
  ({ onPress, skipMarkingActive }: MaybeMeasureAndStoreParams) => {
63
65
  "worklet";
64
66
  // Currently, there's no necessity to measure when the current route is blurred ( could potentially change in the future )
@@ -86,18 +88,17 @@ export const useBoundsRegistry = ({
86
88
 
87
89
  emitUpdate();
88
90
 
89
- Bounds.setBounds(key, sharedBoundTag, measured, flattenStyle(style));
91
+ Bounds.setBounds(key, sharedBoundTag, measured, preparedStyles);
90
92
  if (!skipMarkingActive) {
91
93
  Bounds.setRouteActive(key, sharedBoundTag);
92
94
  }
93
95
 
94
96
  if (onPress) runOnJS(onPress)();
95
97
  },
96
- [sharedBoundTag, animatedRef, current.route.key, style, emitUpdate, next],
97
98
  );
98
99
 
99
100
  const hasMeasuredOnLayout = useSharedValue(false);
100
- const handleInitialLayout = useCallback(() => {
101
+ const handleInitialLayout = useStableCallbackValue(() => {
101
102
  "worklet";
102
103
 
103
104
  const prevKey = previous?.route.key;
@@ -113,12 +114,7 @@ export const useBoundsRegistry = ({
113
114
  // Should not measure again while in transition
114
115
  hasMeasuredOnLayout.value = true;
115
116
  }
116
- }, [
117
- maybeMeasureAndStore,
118
- sharedBoundTag,
119
- previous?.route.key,
120
- hasMeasuredOnLayout,
121
- ]);
117
+ });
122
118
 
123
119
  const captureActiveOnPress = useStableCallback(() => {
124
120
  if (!sharedBoundTag) {
@@ -1,4 +1,4 @@
1
- import { useCallback, useMemo } from "react";
1
+ import { useMemo } from "react";
2
2
  import { useWindowDimensions } from "react-native";
3
3
  import {
4
4
  Gesture,
@@ -26,7 +26,7 @@ import { useKeys } from "../../providers/keys";
26
26
  import { Animations } from "../../stores/animations";
27
27
  import { Gestures } from "../../stores/gestures";
28
28
  import { NavigatorDismissState } from "../../stores/navigator-dismiss-state";
29
- import { GestureOffsetState } from "../../types/gesture";
29
+ import { type GestureDirection, GestureOffsetState } from "../../types/gesture";
30
30
  import { startScreenTransition } from "../../utils/animation/start-screen-transition";
31
31
  import { applyOffsetRules } from "../../utils/gesture/check-gesture-activation";
32
32
  import { determineDismissal } from "../../utils/gesture/determine-dismissal";
@@ -34,6 +34,7 @@ import { mapGestureToProgress } from "../../utils/gesture/map-gesture-to-progres
34
34
  import { resetGestureValues } from "../../utils/gesture/reset-gesture-values";
35
35
  import { velocity } from "../../utils/gesture/velocity";
36
36
  import useStableCallback from "../use-stable-callback";
37
+ import useStableCallbackValue from "../use-stable-callback-value";
37
38
 
38
39
  interface BuildGesturesHookProps {
39
40
  scrollConfig: SharedValue<ScrollConfig | null>;
@@ -98,17 +99,14 @@ export const useBuildGestures = ({
98
99
  NavigatorDismissState.remove(key);
99
100
  });
100
101
 
101
- const onTouchesDown = useCallback(
102
- (e: GestureTouchEvent) => {
103
- "worklet";
104
- const firstTouch = e.changedTouches[0];
105
- initialTouch.value = { x: firstTouch.x, y: firstTouch.y };
106
- gestureOffsetState.value = GestureOffsetState.PENDING;
107
- },
108
- [initialTouch, gestureOffsetState],
109
- );
102
+ const onTouchesDown = useStableCallbackValue((e: GestureTouchEvent) => {
103
+ "worklet";
104
+ const firstTouch = e.changedTouches[0];
105
+ initialTouch.value = { x: firstTouch.x, y: firstTouch.y };
106
+ gestureOffsetState.value = GestureOffsetState.PENDING;
107
+ });
110
108
 
111
- const onTouchesMove = useCallback(
109
+ const onTouchesMove = useStableCallbackValue(
112
110
  (e: GestureTouchEvent, manager: GestureStateManagerType) => {
113
111
  "worklet";
114
112
 
@@ -151,18 +149,24 @@ export const useBuildGestures = ({
151
149
  const scrollCfg = scrollConfig.value;
152
150
 
153
151
  let shouldActivate = false;
152
+ let activatedDirection: GestureDirection | null = null;
153
+
154
154
  if (recognizedDirection) {
155
155
  if (directions.vertical && isSwipingDown) {
156
156
  shouldActivate = scrollCfg ? scrollCfg.y <= 0 : true;
157
+ if (shouldActivate) activatedDirection = "vertical";
157
158
  }
158
159
  if (directions.horizontal && isSwipingRight) {
159
160
  shouldActivate = scrollCfg ? scrollCfg.x <= 0 : true;
161
+ if (shouldActivate) activatedDirection = "horizontal";
160
162
  }
161
163
  if (directions.verticalInverted && isSwipingUp) {
162
164
  shouldActivate = scrollCfg ? scrollCfg.y >= maxScrollY : true;
165
+ if (shouldActivate) activatedDirection = "vertical-inverted";
163
166
  }
164
167
  if (directions.horizontalInverted && isSwipingLeft) {
165
168
  shouldActivate = scrollCfg ? scrollCfg.x >= maxScrollX : true;
169
+ if (shouldActivate) activatedDirection = "horizontal-inverted";
166
170
  }
167
171
  }
168
172
 
@@ -176,29 +180,20 @@ export const useBuildGestures = ({
176
180
  gestureOffsetState.value === GestureOffsetState.PASSED &&
177
181
  !gestures.isDismissing?.value
178
182
  ) {
183
+ gestures.direction.value = activatedDirection;
179
184
  manager.activate();
180
185
  return;
181
186
  }
182
187
  },
183
- [
184
- initialTouch,
185
- scrollConfig,
186
- gestures,
187
- directions,
188
- gestureOffsetState,
189
- dimensions,
190
- gestureActivationArea,
191
- gestureResponseDistance,
192
- ],
193
188
  );
194
189
 
195
- const onStart = useCallback(() => {
190
+ const onStart = useStableCallbackValue(() => {
196
191
  "worklet";
197
192
  gestures.isDragging.value = 1;
198
193
  gestures.isDismissing.value = 0;
199
- }, [gestures]);
194
+ });
200
195
 
201
- const onUpdate = useCallback(
196
+ const onUpdate = useStableCallbackValue(
202
197
  (event: GestureUpdateEvent<PanGestureHandlerEventPayload>) => {
203
198
  "worklet";
204
199
 
@@ -263,10 +258,9 @@ export const useBuildGestures = ({
263
258
  animations.progress.value = 1 - gestureProgress;
264
259
  }
265
260
  },
266
- [dimensions, gestures, animations, gestureDrivesProgress, directions],
267
261
  );
268
262
 
269
- const onEnd = useCallback(
263
+ const onEnd = useStableCallbackValue(
270
264
  (event: GestureStateChangeEvent<PanGestureHandlerEventPayload>) => {
271
265
  "worklet";
272
266
 
@@ -307,16 +301,6 @@ export const useBuildGestures = ({
307
301
  initialVelocity,
308
302
  });
309
303
  },
310
- [
311
- dimensions,
312
- animations,
313
- transitionSpec,
314
- setNavigatorDismissal,
315
- handleDismiss,
316
- gestures,
317
- directions,
318
- gestureVelocityImpact,
319
- ],
320
304
  );
321
305
 
322
306
  return useMemo(() => {
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Credit:
3
+ * https://github.com/MatiPl01/react-native-sortables/blob/main/packages/react-native-sortables/src/integrations/reanimated/hooks/useStableCallbackValue.ts
4
+ */
5
+ import { useCallback, useEffect, useState } from "react";
6
+ import {
7
+ isWorkletFunction,
8
+ makeMutable,
9
+ runOnJS,
10
+ } from "react-native-reanimated";
11
+
12
+ // biome-ignore lint/suspicious/noExplicitAny: <Does not matter>
13
+ type AnyFunction = (...args: Array<any>) => any;
14
+
15
+ function useMutableValue<T>(initialValue: T) {
16
+ return useState(() => makeMutable(initialValue))[0];
17
+ }
18
+
19
+ // We cannot store a function as a SharedValue because reanimated will treat
20
+ // it as an animation and will try to execute the animation when assigned
21
+ // to the SharedValue. Since we want the function to be treated as a value,
22
+ // we wrap it in an object and store the object in the SharedValue.
23
+ type WrappedCallback<C extends AnyFunction> = {
24
+ call: C;
25
+ };
26
+
27
+ const wrap = <C extends AnyFunction>(callback: C): WrappedCallback<C> => {
28
+ if (isWorkletFunction(callback)) {
29
+ return { call: callback };
30
+ }
31
+ return {
32
+ call: ((...args: Parameters<C>) => {
33
+ "worklet";
34
+ runOnJS(callback)(...args);
35
+ }) as C,
36
+ };
37
+ };
38
+
39
+ /** Creates a stable worklet callback that can be called from the UI runtime
40
+ * @param callback The JavaScript or worklet function to be called
41
+ * @returns A worklet function that can be safely called from the UI thread
42
+ * @default Behavior:
43
+ * - If passed a regular JS function, calls it on the JS thread using runOnJS
44
+ * - If passed a worklet function, calls it directly on the UI thread
45
+ * @important The returned function maintains a stable reference and properly handles
46
+ * thread execution based on the input callback type
47
+ */
48
+ export default function useStableCallbackValue<C extends AnyFunction>(
49
+ callback?: C,
50
+ ) {
51
+ const stableCallback = useMutableValue<null | WrappedCallback<C>>(null);
52
+
53
+ useEffect(() => {
54
+ if (callback) {
55
+ stableCallback.value = wrap(callback);
56
+ } else {
57
+ stableCallback.value = null;
58
+ }
59
+ }, [callback, stableCallback]);
60
+
61
+ return useCallback(
62
+ (...args: Parameters<C>) => {
63
+ "worklet";
64
+ stableCallback.value?.call(...args);
65
+ },
66
+ [stableCallback],
67
+ );
68
+ }
@@ -1,4 +1,5 @@
1
1
  import { makeMutable, type SharedValue } from "react-native-reanimated";
2
+ import type { GestureDirection } from "../types/gesture";
2
3
  import type { ScreenKey } from "../types/navigator";
3
4
 
4
5
  export type GestureKey =
@@ -16,6 +17,7 @@ export type GestureMap = {
16
17
  normalizedY: SharedValue<number>;
17
18
  isDismissing: SharedValue<number>;
18
19
  isDragging: SharedValue<number>;
20
+ direction: SharedValue<Omit<GestureDirection, "bidirectional"> | null>;
19
21
  };
20
22
 
21
23
  const store: Record<ScreenKey, GestureMap> = {};
@@ -30,6 +32,9 @@ function ensure(routeKey: ScreenKey): GestureMap {
30
32
  normalizedY: makeMutable(0),
31
33
  isDismissing: makeMutable(0),
32
34
  isDragging: makeMutable(0),
35
+ direction: makeMutable<Omit<GestureDirection, "bidirectional"> | null>(
36
+ null,
37
+ ),
33
38
  };
34
39
  store[routeKey] = bag;
35
40
  }
@@ -18,10 +18,11 @@ export type ScreenTransitionState = {
18
18
  };
19
19
 
20
20
  export interface ScreenInterpolationProps {
21
- /** Values for the screen that is the focus of the transition (e.g., the one opening). */
21
+ /** Values for the screen that came before the current one in the navigation stack. */
22
22
  previous: ScreenTransitionState | undefined;
23
+ /** Values for the current screen being interpolated. */
23
24
  current: ScreenTransitionState;
24
- /** Values for the screen immediately behind the current one in the screen. */
25
+ /** Values for the screen that comes after the current one in the navigation stack. */
25
26
  next: ScreenTransitionState | undefined;
26
27
  /** Layout measurements for the screen. */
27
28
  layouts: {
@@ -33,19 +34,19 @@ export interface ScreenInterpolationProps {
33
34
  };
34
35
  /** The safe area insets for the screen. */
35
36
  insets: EdgeInsets;
36
- /** The id of the active bound. */
37
+ /** The ID of the currently active shared bound (e.g., 'a' when Transition.Pressable has sharedBoundTag='a'). */
37
38
  activeBoundId: string;
38
- /** Whether the screen is focused. */
39
+ /** Whether the current screen is the focused (topmost) screen in the stack. */
39
40
  focused: boolean;
40
- /** The progress of the screen transitions (0-2). */
41
+ /** Combined progress of current and next screen transitions, ranging from 0-2. */
41
42
  progress: number;
42
- /** A function that returns a bounds builder for the screen. */
43
+ /** Function that provides access to bounds builders for creating shared element transitions. */
43
44
  bounds: BoundsAccessor;
44
- /** The active screen between current and next. */
45
+ /** The screen state that is currently driving the transition (either current or next, whichever is focused). */
45
46
  active: ScreenTransitionState;
46
- /** Whether the active screen is transitioning. */
47
+ /** Whether the active screen is currently transitioning (either being dragged or animating). */
47
48
  isActiveTransitioning: boolean;
48
- /** Whether the active screen is dismissing. */
49
+ /** Whether the active screen is in the process of being dismissed/closed. */
49
50
  isDismissing: boolean;
50
51
  }
51
52
 
@@ -49,4 +49,8 @@ export type GestureValues = {
49
49
  * A flag indicating if the screen is in the process of dismissing.
50
50
  */
51
51
  isDismissing: number;
52
+ /**
53
+ * The initial direction that activated the gesture.
54
+ */
55
+ direction: Omit<GestureDirection, "bidirectional"> | null;
52
56
  };
@@ -1,3 +1,4 @@
1
+ // biome-ignore lint/suspicious/noExplicitAny: <>
1
2
  export type Any = any;
2
3
 
3
4
  export type Complete<T> = { [K in keyof T]-?: Exclude<T[K], undefined> };
@@ -0,0 +1,69 @@
1
+ import type { ImageStyle, StyleProp, TextStyle, ViewStyle } from "react-native";
2
+ import { isSharedValue } from "react-native-reanimated";
3
+ import type { Any } from "../../../types/utils";
4
+
5
+ type AnyStyle = ViewStyle | TextStyle | ImageStyle;
6
+ type StyleValue = StyleProp<AnyStyle>;
7
+ type PlainStyleObject = Record<string, Any>;
8
+
9
+ function mergeStyleArrays<T extends StyleValue>(style: T): T {
10
+ "worklet";
11
+
12
+ // Early returns for non-objects
13
+ if (style === null || style === undefined || typeof style !== "object") {
14
+ return style;
15
+ }
16
+
17
+ // If not an array, return as-is
18
+ if (!Array.isArray(style)) {
19
+ return style;
20
+ }
21
+
22
+ // Merge array of styles into single object
23
+ const merged: PlainStyleObject = {};
24
+ for (let i = 0; i < style.length; i++) {
25
+ const currentStyle = mergeStyleArrays(style[i] as StyleValue);
26
+ if (currentStyle && typeof currentStyle === "object") {
27
+ Object.assign(merged, currentStyle);
28
+ }
29
+ }
30
+ return merged as T;
31
+ }
32
+
33
+ function stripNonSerializable<T>(value: T): T | undefined {
34
+ if (isSharedValue(value)) return value;
35
+
36
+ if (Array.isArray(value)) {
37
+ return value.map(stripNonSerializable) as T;
38
+ }
39
+
40
+ if (value && typeof value === "object") {
41
+ const cleaned: PlainStyleObject = {};
42
+ for (const key in value) {
43
+ if (key === "current") continue;
44
+
45
+ const cleanedValue = stripNonSerializable(value[key]);
46
+ if (cleanedValue !== undefined) {
47
+ cleaned[key] = cleanedValue;
48
+ }
49
+ }
50
+ return cleaned as T;
51
+ }
52
+
53
+ if (typeof value === "function") {
54
+ return undefined;
55
+ }
56
+
57
+ return value;
58
+ }
59
+
60
+ export function prepareStyleForBounds(
61
+ style: StyleValue | undefined,
62
+ ): PlainStyleObject {
63
+ if (!style) return {};
64
+
65
+ const flattened = mergeStyleArrays(style);
66
+ const serializable = stripNonSerializable(flattened);
67
+
68
+ return serializable || {};
69
+ }
@@ -31,6 +31,7 @@ export const resetGestureValues = ({
31
31
  const nx =
32
32
  gestures.normalizedX.value ||
33
33
  event.translationX / Math.max(1, dimensions.width);
34
+
34
35
  const ny =
35
36
  gestures.normalizedY.value ||
36
37
  event.translationY / Math.max(1, dimensions.height);
@@ -38,11 +39,29 @@ export const resetGestureValues = ({
38
39
  const vxTowardZero = velocity.calculateRestoreVelocity(nx, vxNorm);
39
40
  const vyTowardZero = velocity.calculateRestoreVelocity(ny, vyNorm);
40
41
 
41
- gestures.x.value = animate(0, { ...spec, velocity: vxTowardZero });
42
- gestures.y.value = animate(0, { ...spec, velocity: vyTowardZero });
42
+ let remainingAnimations = 4;
43
+
44
+ const onFinish = (finished: boolean | undefined) => {
45
+ "worklet";
46
+ if (!finished) return;
47
+ remainingAnimations -= 1;
48
+ if (remainingAnimations === 0) {
49
+ gestures.direction.value = null;
50
+ }
51
+ };
43
52
 
44
- gestures.normalizedX.value = animate(0, { ...spec, velocity: vxTowardZero });
45
- gestures.normalizedY.value = animate(0, { ...spec, velocity: vyTowardZero });
53
+ gestures.x.value = animate(0, { ...spec, velocity: vxTowardZero }, onFinish);
54
+ gestures.y.value = animate(0, { ...spec, velocity: vyTowardZero }, onFinish);
55
+ gestures.normalizedX.value = animate(
56
+ 0,
57
+ { ...spec, velocity: vxTowardZero },
58
+ onFinish,
59
+ );
60
+ gestures.normalizedY.value = animate(
61
+ 0,
62
+ { ...spec, velocity: vyTowardZero },
63
+ onFinish,
64
+ );
46
65
  gestures.isDragging.value = 0;
47
66
  gestures.isDismissing.value = Number(shouldDismiss);
48
67
  };
@@ -41,11 +41,10 @@ const normalize = (velocityPixelsPerSecond: number, screenSize: number) => {
41
41
  const calculateRestoreVelocity = (
42
42
  currentValueNormalized: number,
43
43
  baseVelocityNormalized: number,
44
- threshold: number = NEAR_ZERO_THRESHOLD,
45
44
  ) => {
46
45
  "worklet";
47
46
 
48
- if (Math.abs(currentValueNormalized) < threshold) return 0;
47
+ if (Math.abs(currentValueNormalized) < NEAR_ZERO_THRESHOLD) return 0;
49
48
 
50
49
  const directionTowardZero = Math.sign(currentValueNormalized) || 1;
51
50
  const clampedVelocity = Math.min(Math.abs(baseVelocityNormalized), 1);