react-native-screen-transitions 2.3.3 → 2.4.0

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 (79) hide show
  1. package/README.md +34 -15
  2. package/lib/commonjs/__configs__/presets.js +10 -5
  3. package/lib/commonjs/__configs__/presets.js.map +1 -1
  4. package/lib/commonjs/components/controllers/screen-lifecycle.js +5 -8
  5. package/lib/commonjs/components/controllers/screen-lifecycle.js.map +1 -1
  6. package/lib/commonjs/components/create-transition-aware-component.js +2 -2
  7. package/lib/commonjs/components/create-transition-aware-component.js.map +1 -1
  8. package/lib/commonjs/constants.js +2 -1
  9. package/lib/commonjs/constants.js.map +1 -1
  10. package/lib/commonjs/hooks/animation/use-screen-animation.js +2 -1
  11. package/lib/commonjs/hooks/animation/use-screen-animation.js.map +1 -1
  12. package/lib/commonjs/hooks/bounds/use-bound-registry.js +18 -14
  13. package/lib/commonjs/hooks/bounds/use-bound-registry.js.map +1 -1
  14. package/lib/commonjs/hooks/gestures/use-build-gestures.js +17 -10
  15. package/lib/commonjs/hooks/gestures/use-build-gestures.js.map +1 -1
  16. package/lib/commonjs/hooks/use-stable-callback-value.js +64 -0
  17. package/lib/commonjs/hooks/use-stable-callback-value.js.map +1 -0
  18. package/lib/commonjs/stores/gestures.js +2 -1
  19. package/lib/commonjs/stores/gestures.js.map +1 -1
  20. package/lib/commonjs/utils/bounds/_utils/styles.js +58 -0
  21. package/lib/commonjs/utils/bounds/_utils/styles.js.map +1 -0
  22. package/lib/commonjs/utils/gesture/reset-gesture-values.js +14 -4
  23. package/lib/commonjs/utils/gesture/reset-gesture-values.js.map +1 -1
  24. package/lib/module/__configs__/presets.js +10 -5
  25. package/lib/module/__configs__/presets.js.map +1 -1
  26. package/lib/module/components/controllers/screen-lifecycle.js +5 -8
  27. package/lib/module/components/controllers/screen-lifecycle.js.map +1 -1
  28. package/lib/module/components/create-transition-aware-component.js +2 -2
  29. package/lib/module/components/create-transition-aware-component.js.map +1 -1
  30. package/lib/module/constants.js +2 -1
  31. package/lib/module/constants.js.map +1 -1
  32. package/lib/module/hooks/animation/use-screen-animation.js +2 -1
  33. package/lib/module/hooks/animation/use-screen-animation.js.map +1 -1
  34. package/lib/module/hooks/bounds/use-bound-registry.js +19 -15
  35. package/lib/module/hooks/bounds/use-bound-registry.js.map +1 -1
  36. package/lib/module/hooks/gestures/use-build-gestures.js +18 -11
  37. package/lib/module/hooks/gestures/use-build-gestures.js.map +1 -1
  38. package/lib/module/hooks/use-stable-callback-value.js +60 -0
  39. package/lib/module/hooks/use-stable-callback-value.js.map +1 -0
  40. package/lib/module/stores/gestures.js +2 -1
  41. package/lib/module/stores/gestures.js.map +1 -1
  42. package/lib/module/utils/bounds/_utils/styles.js +54 -0
  43. package/lib/module/utils/bounds/_utils/styles.js.map +1 -0
  44. package/lib/module/utils/gesture/reset-gesture-values.js +14 -4
  45. package/lib/module/utils/gesture/reset-gesture-values.js.map +1 -1
  46. package/lib/typescript/__configs__/presets.d.ts.map +1 -1
  47. package/lib/typescript/components/controllers/screen-lifecycle.d.ts.map +1 -1
  48. package/lib/typescript/constants.d.ts.map +1 -1
  49. package/lib/typescript/hooks/animation/use-screen-animation.d.ts.map +1 -1
  50. package/lib/typescript/hooks/bounds/use-bound-registry.d.ts +1 -1
  51. package/lib/typescript/hooks/bounds/use-bound-registry.d.ts.map +1 -1
  52. package/lib/typescript/hooks/gestures/use-build-gestures.d.ts.map +1 -1
  53. package/lib/typescript/hooks/use-stable-callback-value.d.ts +13 -0
  54. package/lib/typescript/hooks/use-stable-callback-value.d.ts.map +1 -0
  55. package/lib/typescript/stores/gestures.d.ts +2 -0
  56. package/lib/typescript/stores/gestures.d.ts.map +1 -1
  57. package/lib/typescript/types/animation.d.ts +10 -9
  58. package/lib/typescript/types/animation.d.ts.map +1 -1
  59. package/lib/typescript/types/gesture.d.ts +4 -0
  60. package/lib/typescript/types/gesture.d.ts.map +1 -1
  61. package/lib/typescript/utils/bounds/_utils/styles.d.ts +7 -0
  62. package/lib/typescript/utils/bounds/_utils/styles.d.ts.map +1 -0
  63. package/lib/typescript/utils/gesture/reset-gesture-values.d.ts.map +1 -1
  64. package/package.json +1 -1
  65. package/src/__configs__/presets.ts +23 -7
  66. package/src/__tests__/determine-dismissal.test.ts +121 -0
  67. package/src/__tests__/gesture.velocity.test.ts +138 -0
  68. package/src/components/controllers/screen-lifecycle.tsx +5 -7
  69. package/src/components/create-transition-aware-component.tsx +2 -2
  70. package/src/constants.ts +2 -0
  71. package/src/hooks/animation/use-screen-animation.tsx +1 -0
  72. package/src/hooks/bounds/use-bound-registry.tsx +23 -23
  73. package/src/hooks/gestures/use-build-gestures.tsx +21 -37
  74. package/src/hooks/use-stable-callback-value.tsx +68 -0
  75. package/src/stores/gestures.ts +5 -0
  76. package/src/types/animation.ts +10 -9
  77. package/src/types/gesture.ts +4 -0
  78. package/src/utils/bounds/_utils/styles.ts +68 -0
  79. package/src/utils/gesture/reset-gesture-values.ts +22 -4
@@ -173,7 +173,6 @@ export const ElasticCard = (
173
173
  /**
174
174
  * Applies to both screens ( previous and incoming)
175
175
  */
176
-
177
176
  const scale = interpolate(progress, [0, 1, 2], [0, 1, 0.8]);
178
177
 
179
178
  // applies to current screen
@@ -367,16 +366,33 @@ export const SharedAppleMusic = (
367
366
 
368
367
  const normX = active.gesture.normalizedX;
369
368
  const normY = active.gesture.normalizedY;
369
+ const initialDirection = active.gesture.direction;
370
370
 
371
371
  /**
372
372
  * ===============================
373
373
  * Animations for both bounds
374
374
  * ===============================
375
375
  */
376
- const dragX = interpolate(normX, [0, 1], [0, screen.width * 0.8]);
377
- const dragY = interpolate(normY, [0, 1], [0, screen.height * 0.8]);
378
- const dragXScale = interpolate(normX, [0, 1], [1, 0.75]);
379
- const dragYScale = interpolate(normY, [0, 1], [1, 0.75]);
376
+ const xResistance = initialDirection === "horizontal" ? 0.7 : 0.4;
377
+ const yResistance = initialDirection === "vertical" ? 0.7 : 0.4;
378
+
379
+ const xScaleOuput = initialDirection === "horizontal" ? [1, 0.5] : [1, 1];
380
+ const yScaleOuput = initialDirection === "vertical" ? [1, 0.5] : [1, 1];
381
+
382
+ const dragX = interpolate(
383
+ normX,
384
+ [-1, 0, 1],
385
+ [-screen.width * xResistance, 0, screen.width * xResistance],
386
+ "clamp",
387
+ );
388
+ const dragY = interpolate(
389
+ normY,
390
+ [-1, 0, 1],
391
+ [-screen.height * yResistance, 0, screen.height * yResistance],
392
+ "clamp",
393
+ );
394
+ const dragXScale = interpolate(normX, [0, 1], xScaleOuput);
395
+ const dragYScale = interpolate(normY, [0, 1], yScaleOuput);
380
396
 
381
397
  const boundValues = bounds({
382
398
  method: focused ? "content" : "transform",
@@ -387,8 +403,8 @@ export const SharedAppleMusic = (
387
403
 
388
404
  const opacity = interpolate(
389
405
  progress,
390
- [0, 0.35, 1, 1.25, 2],
391
- [0, 1, 1, 1, 0],
406
+ [0, 0.25, 1.25, 2],
407
+ [0, 1, 1, 0],
392
408
  "clamp",
393
409
  );
394
410
 
@@ -0,0 +1,121 @@
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 { determineDismissal } = await import(
11
+ "../utils/gesture/determine-dismissal"
12
+ );
13
+
14
+ describe("determineDismissal", () => {
15
+ const dimensions = { width: 320, height: 640 };
16
+
17
+ it("dismisses when horizontal translation exceeds the threshold", () => {
18
+ const { shouldDismiss } = determineDismissal({
19
+ event: {
20
+ translationX: 170,
21
+ translationY: 0,
22
+ velocityX: 0,
23
+ velocityY: 0,
24
+ },
25
+ directions: {
26
+ vertical: false,
27
+ verticalInverted: false,
28
+ horizontal: true,
29
+ horizontalInverted: false,
30
+ },
31
+ dimensions,
32
+ gestureVelocityImpact: 0.3,
33
+ });
34
+
35
+ expect(shouldDismiss).toBe(true);
36
+ });
37
+
38
+ it("ignores movement in disallowed directions", () => {
39
+ const { shouldDismiss } = determineDismissal({
40
+ event: {
41
+ translationX: 200,
42
+ translationY: 0,
43
+ velocityX: 0,
44
+ velocityY: 0,
45
+ },
46
+ directions: {
47
+ vertical: true,
48
+ verticalInverted: false,
49
+ horizontal: false,
50
+ horizontalInverted: false,
51
+ },
52
+ dimensions,
53
+ gestureVelocityImpact: 0.3,
54
+ });
55
+
56
+ expect(shouldDismiss).toBe(false);
57
+ });
58
+
59
+ it("dismisses vertical gestures when velocity pushes the projection past the threshold", () => {
60
+ const { shouldDismiss } = determineDismissal({
61
+ event: {
62
+ translationX: 0,
63
+ translationY: 40,
64
+ velocityX: 0,
65
+ velocityY: 1800,
66
+ },
67
+ directions: {
68
+ vertical: true,
69
+ verticalInverted: false,
70
+ horizontal: false,
71
+ horizontalInverted: false,
72
+ },
73
+ dimensions,
74
+ gestureVelocityImpact: 0.3,
75
+ });
76
+
77
+ expect(shouldDismiss).toBe(true);
78
+ });
79
+
80
+ it("respects inverted horizontal directions", () => {
81
+ const { shouldDismiss } = determineDismissal({
82
+ event: {
83
+ translationX: -160,
84
+ translationY: 0,
85
+ velocityX: -700,
86
+ velocityY: 0,
87
+ },
88
+ directions: {
89
+ vertical: false,
90
+ verticalInverted: false,
91
+ horizontal: false,
92
+ horizontalInverted: true,
93
+ },
94
+ dimensions,
95
+ gestureVelocityImpact: 0.25,
96
+ });
97
+
98
+ expect(shouldDismiss).toBe(true);
99
+ });
100
+
101
+ it("returns false when movement never exceeds the composite threshold", () => {
102
+ const { shouldDismiss } = determineDismissal({
103
+ event: {
104
+ translationX: 30,
105
+ translationY: 0,
106
+ velocityX: 100,
107
+ velocityY: 0,
108
+ },
109
+ directions: {
110
+ vertical: false,
111
+ verticalInverted: false,
112
+ horizontal: true,
113
+ horizontalInverted: false,
114
+ },
115
+ dimensions,
116
+ gestureVelocityImpact: 0.2,
117
+ });
118
+
119
+ expect(shouldDismiss).toBe(false);
120
+ });
121
+ });
@@ -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
+ });
@@ -22,14 +22,12 @@ export const ScreenLifecycleController = ({
22
22
  const key = current.navigation.getParent()?.getState().key;
23
23
  const requestedDismissOnNavigator = NavigatorDismissState.get(key);
24
24
 
25
- // Don't run e.preventDefault when the dismissal was on the local root
26
- if (requestedDismissOnNavigator) {
27
- resetStoresForScreen(current);
28
- return;
29
- }
25
+ const isEnabled = current.options.enableTransitions;
26
+ const isRequestedDismissOnNavigator = requestedDismissOnNavigator;
27
+ const isFirstScreen = current.navigation.getState().index === 0;
30
28
 
31
- // Don't run e.preventDefault when this is the first screen of the stack
32
- if (current.navigation.getState().index === 0) {
29
+ // If transitions are disabled, or the dismissal was on the local root, or this is the first screen of the stack, reset the stores
30
+ if (!isEnabled || isRequestedDismissOnNavigator || isFirstScreen) {
33
31
  resetStoresForScreen(current);
34
32
  return;
35
33
  }
@@ -62,7 +62,7 @@ export function createTransitionAwareComponent<P extends object>(
62
62
  });
63
63
 
64
64
  const {
65
- handleTransitionLayout,
65
+ handleInitialLayout,
66
66
  captureActiveOnPress,
67
67
  MeasurementSyncProvider,
68
68
  } = useBoundsRegistry({
@@ -79,7 +79,7 @@ export function createTransitionAwareComponent<P extends object>(
79
79
  ref={animatedRef}
80
80
  style={[style, associatedStyles]}
81
81
  onPress={captureActiveOnPress}
82
- onLayout={runOnUI(handleTransitionLayout)}
82
+ onLayout={runOnUI(handleInitialLayout)}
83
83
  collapsable={!sharedBoundTag}
84
84
  >
85
85
  {children}
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,18 +18,23 @@ 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;
27
28
  animatedRef: AnimatedRef<View>;
28
-
29
29
  style: StyleProps;
30
30
  onPress?: ((...args: unknown[]) => void) | undefined;
31
31
  }
32
32
 
33
+ interface MaybeMeasureAndStoreParams {
34
+ onPress?: ((...args: unknown[]) => void) | undefined;
35
+ skipMarkingActive?: boolean;
36
+ }
37
+
33
38
  interface MeasurementUpdateContextType {
34
39
  updateSignal: SharedValue<number>;
35
40
  }
@@ -43,28 +48,23 @@ export const useBoundsRegistry = ({
43
48
  style,
44
49
  onPress,
45
50
  }: BoundMeasurerHookProps) => {
46
- const { previous, current } = useKeys();
51
+ const { previous, current, next } = useKeys();
52
+ const preparedStyles = useMemo(() => prepareStyleForBounds(style), [style]);
47
53
 
48
54
  const ROOT_MEASUREMENT_SIGNAL = useContext(MeasurementUpdateContext);
49
55
  const ROOT_SIGNAL = useSharedValue(0);
50
56
  const IS_ROOT = !ROOT_MEASUREMENT_SIGNAL;
51
- const hasMeasured = useSharedValue(false);
52
57
 
53
- const emitUpdate = useCallback(() => {
58
+ const emitUpdate = useStableCallbackValue(() => {
54
59
  "worklet";
55
60
  if (IS_ROOT) ROOT_SIGNAL.value = ROOT_SIGNAL.value + 1;
56
- }, [IS_ROOT, ROOT_SIGNAL]);
57
-
58
- const maybeMeasureAndStore = useCallback(
59
- ({
60
- onPress,
61
- skipMarkingActive,
62
- }: {
63
- onPress?: () => void;
64
- skipMarkingActive?: boolean;
65
- }) => {
61
+ });
62
+
63
+ const maybeMeasureAndStore = useStableCallbackValue(
64
+ ({ onPress, skipMarkingActive }: MaybeMeasureAndStoreParams) => {
66
65
  "worklet";
67
- if (!sharedBoundTag) return;
66
+ // Currently, there's no necessity to measure when the current route is blurred ( could potentially change in the future )
67
+ if (!sharedBoundTag || next) return;
68
68
 
69
69
  const measured = measure(animatedRef);
70
70
 
@@ -88,21 +88,21 @@ export const useBoundsRegistry = ({
88
88
 
89
89
  emitUpdate();
90
90
 
91
- Bounds.setBounds(key, sharedBoundTag, measured, flattenStyle(style));
91
+ Bounds.setBounds(key, sharedBoundTag, measured, preparedStyles);
92
92
  if (!skipMarkingActive) {
93
93
  Bounds.setRouteActive(key, sharedBoundTag);
94
94
  }
95
95
 
96
96
  if (onPress) runOnJS(onPress)();
97
97
  },
98
- [sharedBoundTag, animatedRef, current.route.key, style, emitUpdate],
99
98
  );
100
99
 
101
- const handleTransitionLayout = useCallback(() => {
100
+ const hasMeasuredOnLayout = useSharedValue(false);
101
+ const handleInitialLayout = useStableCallbackValue(() => {
102
102
  "worklet";
103
103
 
104
104
  const prevKey = previous?.route.key;
105
- if (!sharedBoundTag || hasMeasured.value || !prevKey) {
105
+ if (!sharedBoundTag || hasMeasuredOnLayout.value || !prevKey) {
106
106
  return;
107
107
  }
108
108
 
@@ -112,9 +112,9 @@ export const useBoundsRegistry = ({
112
112
  // Should skip mark active if we are in a transition
113
113
  maybeMeasureAndStore({ skipMarkingActive: true });
114
114
  // Should not measure again while in transition
115
- hasMeasured.value = true;
115
+ hasMeasuredOnLayout.value = true;
116
116
  }
117
- }, [maybeMeasureAndStore, sharedBoundTag, previous?.route.key, hasMeasured]);
117
+ });
118
118
 
119
119
  const captureActiveOnPress = useStableCallback(() => {
120
120
  if (!sharedBoundTag) {
@@ -152,7 +152,7 @@ export const useBoundsRegistry = ({
152
152
  );
153
153
 
154
154
  return {
155
- handleTransitionLayout,
155
+ handleInitialLayout,
156
156
  captureActiveOnPress,
157
157
  MeasurementSyncProvider,
158
158
  };
@@ -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
+ }