react-native-screen-transitions 3.3.0-beta.2 → 3.3.0-beta.4

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 (119) hide show
  1. package/README.md +95 -31
  2. package/lib/commonjs/shared/animation/snap-to.js +17 -10
  3. package/lib/commonjs/shared/animation/snap-to.js.map +1 -1
  4. package/lib/commonjs/shared/components/create-transition-aware-component.js +20 -18
  5. package/lib/commonjs/shared/components/create-transition-aware-component.js.map +1 -1
  6. package/lib/commonjs/shared/components/screen-container.js +68 -9
  7. package/lib/commonjs/shared/components/screen-container.js.map +1 -1
  8. package/lib/commonjs/shared/constants.js +8 -1
  9. package/lib/commonjs/shared/constants.js.map +1 -1
  10. package/lib/commonjs/shared/hooks/gestures/use-build-gestures.js +49 -39
  11. package/lib/commonjs/shared/hooks/gestures/use-build-gestures.js.map +1 -1
  12. package/lib/commonjs/shared/hooks/gestures/use-screen-gesture-handlers.js +110 -61
  13. package/lib/commonjs/shared/hooks/gestures/use-screen-gesture-handlers.js.map +1 -1
  14. package/lib/commonjs/shared/hooks/gestures/use-scroll-registry.js +67 -70
  15. package/lib/commonjs/shared/hooks/gestures/use-scroll-registry.js.map +1 -1
  16. package/lib/commonjs/shared/providers/gestures.provider.js +113 -25
  17. package/lib/commonjs/shared/providers/gestures.provider.js.map +1 -1
  18. package/lib/commonjs/shared/types/ownership.types.js +71 -0
  19. package/lib/commonjs/shared/types/ownership.types.js.map +1 -0
  20. package/lib/commonjs/shared/utils/gesture/check-gesture-activation.js +72 -128
  21. package/lib/commonjs/shared/utils/gesture/check-gesture-activation.js.map +1 -1
  22. package/lib/commonjs/shared/utils/gesture/compute-claimed-directions.js +81 -0
  23. package/lib/commonjs/shared/utils/gesture/compute-claimed-directions.js.map +1 -0
  24. package/lib/commonjs/shared/utils/gesture/determine-snap-target.js +1 -1
  25. package/lib/commonjs/shared/utils/gesture/determine-snap-target.js.map +1 -1
  26. package/lib/commonjs/shared/utils/gesture/find-collapse-target.js +48 -0
  27. package/lib/commonjs/shared/utils/gesture/find-collapse-target.js.map +1 -0
  28. package/lib/commonjs/shared/utils/gesture/resolve-ownership.js +87 -0
  29. package/lib/commonjs/shared/utils/gesture/resolve-ownership.js.map +1 -0
  30. package/lib/commonjs/shared/utils/gesture/velocity.js +16 -5
  31. package/lib/commonjs/shared/utils/gesture/velocity.js.map +1 -1
  32. package/lib/module/shared/animation/snap-to.js +16 -10
  33. package/lib/module/shared/animation/snap-to.js.map +1 -1
  34. package/lib/module/shared/components/create-transition-aware-component.js +20 -18
  35. package/lib/module/shared/components/create-transition-aware-component.js.map +1 -1
  36. package/lib/module/shared/components/screen-container.js +68 -10
  37. package/lib/module/shared/components/screen-container.js.map +1 -1
  38. package/lib/module/shared/constants.js +7 -0
  39. package/lib/module/shared/constants.js.map +1 -1
  40. package/lib/module/shared/hooks/gestures/use-build-gestures.js +49 -39
  41. package/lib/module/shared/hooks/gestures/use-build-gestures.js.map +1 -1
  42. package/lib/module/shared/hooks/gestures/use-screen-gesture-handlers.js +112 -63
  43. package/lib/module/shared/hooks/gestures/use-screen-gesture-handlers.js.map +1 -1
  44. package/lib/module/shared/hooks/gestures/use-scroll-registry.js +68 -70
  45. package/lib/module/shared/hooks/gestures/use-scroll-registry.js.map +1 -1
  46. package/lib/module/shared/providers/gestures.provider.js +113 -25
  47. package/lib/module/shared/providers/gestures.provider.js.map +1 -1
  48. package/lib/module/shared/types/ownership.types.js +67 -0
  49. package/lib/module/shared/types/ownership.types.js.map +1 -0
  50. package/lib/module/shared/utils/gesture/check-gesture-activation.js +70 -126
  51. package/lib/module/shared/utils/gesture/check-gesture-activation.js.map +1 -1
  52. package/lib/module/shared/utils/gesture/compute-claimed-directions.js +77 -0
  53. package/lib/module/shared/utils/gesture/compute-claimed-directions.js.map +1 -0
  54. package/lib/module/shared/utils/gesture/determine-snap-target.js +1 -1
  55. package/lib/module/shared/utils/gesture/determine-snap-target.js.map +1 -1
  56. package/lib/module/shared/utils/gesture/find-collapse-target.js +44 -0
  57. package/lib/module/shared/utils/gesture/find-collapse-target.js.map +1 -0
  58. package/lib/module/shared/utils/gesture/resolve-ownership.js +83 -0
  59. package/lib/module/shared/utils/gesture/resolve-ownership.js.map +1 -0
  60. package/lib/module/shared/utils/gesture/velocity.js +16 -5
  61. package/lib/module/shared/utils/gesture/velocity.js.map +1 -1
  62. package/lib/typescript/shared/animation/snap-to.d.ts.map +1 -1
  63. package/lib/typescript/shared/components/create-transition-aware-component.d.ts.map +1 -1
  64. package/lib/typescript/shared/components/screen-container.d.ts.map +1 -1
  65. package/lib/typescript/shared/constants.d.ts +6 -0
  66. package/lib/typescript/shared/constants.d.ts.map +1 -1
  67. package/lib/typescript/shared/hooks/gestures/use-build-gestures.d.ts +15 -3
  68. package/lib/typescript/shared/hooks/gestures/use-build-gestures.d.ts.map +1 -1
  69. package/lib/typescript/shared/hooks/gestures/use-screen-gesture-handlers.d.ts +52 -2
  70. package/lib/typescript/shared/hooks/gestures/use-screen-gesture-handlers.d.ts.map +1 -1
  71. package/lib/typescript/shared/hooks/gestures/use-scroll-registry.d.ts +11 -6
  72. package/lib/typescript/shared/hooks/gestures/use-scroll-registry.d.ts.map +1 -1
  73. package/lib/typescript/shared/hooks/use-backdrop-pointer-events.d.ts +1 -1
  74. package/lib/typescript/shared/hooks/use-backdrop-pointer-events.d.ts.map +1 -1
  75. package/lib/typescript/shared/providers/gestures.provider.d.ts +28 -3
  76. package/lib/typescript/shared/providers/gestures.provider.d.ts.map +1 -1
  77. package/lib/typescript/shared/types/ownership.types.d.ts +52 -0
  78. package/lib/typescript/shared/types/ownership.types.d.ts.map +1 -0
  79. package/lib/typescript/shared/types/screen.types.d.ts +22 -1
  80. package/lib/typescript/shared/types/screen.types.d.ts.map +1 -1
  81. package/lib/typescript/shared/utils/gesture/check-gesture-activation.d.ts +23 -19
  82. package/lib/typescript/shared/utils/gesture/check-gesture-activation.d.ts.map +1 -1
  83. package/lib/typescript/shared/utils/gesture/compute-claimed-directions.d.ts +23 -0
  84. package/lib/typescript/shared/utils/gesture/compute-claimed-directions.d.ts.map +1 -0
  85. package/lib/typescript/shared/utils/gesture/determine-snap-target.d.ts +5 -1
  86. package/lib/typescript/shared/utils/gesture/determine-snap-target.d.ts.map +1 -1
  87. package/lib/typescript/shared/utils/gesture/find-collapse-target.d.ts +17 -0
  88. package/lib/typescript/shared/utils/gesture/find-collapse-target.d.ts.map +1 -0
  89. package/lib/typescript/shared/utils/gesture/resolve-ownership.d.ts +36 -0
  90. package/lib/typescript/shared/utils/gesture/resolve-ownership.d.ts.map +1 -0
  91. package/lib/typescript/shared/utils/gesture/velocity.d.ts.map +1 -1
  92. package/package.json +121 -120
  93. package/src/shared/animation/snap-to.ts +17 -11
  94. package/src/shared/components/create-transition-aware-component.tsx +28 -25
  95. package/src/shared/components/screen-container.tsx +79 -12
  96. package/src/shared/constants.ts +7 -0
  97. package/src/shared/hooks/gestures/use-build-gestures.tsx +80 -44
  98. package/src/shared/hooks/gestures/use-screen-gesture-handlers.ts +147 -71
  99. package/src/shared/hooks/gestures/use-scroll-registry.tsx +94 -86
  100. package/src/shared/hooks/use-backdrop-pointer-events.ts +1 -1
  101. package/src/shared/providers/gestures.provider.tsx +168 -25
  102. package/src/shared/types/ownership.types.ts +77 -0
  103. package/src/shared/types/screen.types.ts +24 -1
  104. package/src/shared/utils/gesture/check-gesture-activation.ts +82 -116
  105. package/src/shared/utils/gesture/compute-claimed-directions.ts +93 -0
  106. package/src/shared/utils/gesture/determine-snap-target.ts +6 -2
  107. package/src/shared/utils/gesture/find-collapse-target.ts +42 -0
  108. package/src/shared/utils/gesture/resolve-ownership.ts +110 -0
  109. package/src/shared/utils/gesture/velocity.ts +16 -6
  110. package/src/shared/__tests__/bounds.store.test.ts +0 -394
  111. package/src/shared/__tests__/derivations.test.ts +0 -156
  112. package/src/shared/__tests__/determine-dismissal.test.ts +0 -111
  113. package/src/shared/__tests__/determine-snap-target.test.ts +0 -268
  114. package/src/shared/__tests__/geometry.test.ts +0 -130
  115. package/src/shared/__tests__/gesture-activation.test.ts +0 -471
  116. package/src/shared/__tests__/gesture.velocity.test.ts +0 -131
  117. package/src/shared/__tests__/history.store.test.ts +0 -550
  118. package/src/shared/__tests__/sync-routes-with-removed.test.ts +0 -137
  119. package/src/shared/__tests__/validate-snap-points.test.ts +0 -125
@@ -1,11 +1,19 @@
1
+ /** biome-ignore-all lint/style/noNonNullAssertion: <Screen gesture is under the gesture context, so this will always exist.> */
1
2
  import { StackActions } from "@react-navigation/native";
2
3
  import { memo, useCallback } from "react";
3
4
  import { Pressable, StyleSheet, View } from "react-native";
4
- import Animated, { useAnimatedStyle } from "react-native-reanimated";
5
+ import { GestureDetector } from "react-native-gesture-handler";
6
+ import Animated, { runOnUI, useAnimatedStyle } from "react-native-reanimated";
7
+ import { DefaultSnapSpec } from "../configs/specs";
5
8
  import { NO_STYLES } from "../constants";
6
9
  import { useBackdropPointerEvents } from "../hooks/use-backdrop-pointer-events";
10
+ import { useGestureContext } from "../providers/gestures.provider";
7
11
  import { useKeys } from "../providers/screen/keys.provider";
8
12
  import { useScreenStyles } from "../providers/screen/styles.provider";
13
+ import { AnimationStore } from "../stores/animation.store";
14
+ import { GestureStore } from "../stores/gesture.store";
15
+ import { animateToProgress } from "../utils/animation/animate-to-progress";
16
+ import { findCollapseTarget } from "../utils/gesture/find-collapse-target";
9
17
 
10
18
  type Props = {
11
19
  children: React.ReactNode;
@@ -15,12 +23,69 @@ export const ScreenContainer = memo(({ children }: Props) => {
15
23
  const { stylesMap } = useScreenStyles();
16
24
  const { current } = useKeys();
17
25
  const { pointerEvents, backdropBehavior } = useBackdropPointerEvents();
26
+ const gestureContext = useGestureContext();
18
27
 
19
- const isDismissable = backdropBehavior === "dismiss";
28
+ const isBackdropActive =
29
+ backdropBehavior === "dismiss" || backdropBehavior === "collapse";
30
+
31
+ const handleDismiss = useCallback(() => {
32
+ const state = current.navigation.getState();
33
+ current.navigation.dispatch({
34
+ ...StackActions.pop(),
35
+ source: current.route.key,
36
+ target: state.key,
37
+ });
38
+ }, [current]);
20
39
 
21
40
  const handleBackdropPress = useCallback(() => {
22
- current.navigation.dispatch(StackActions.pop());
23
- }, [current.navigation]);
41
+ if (backdropBehavior === "dismiss") {
42
+ handleDismiss();
43
+ return;
44
+ }
45
+
46
+ if (backdropBehavior === "collapse") {
47
+ const snapPoints = current.options.snapPoints;
48
+ const canDismiss = current.options.gestureEnabled !== false;
49
+
50
+ // No snap points → fallback to dismiss
51
+ if (!snapPoints || snapPoints.length === 0) {
52
+ handleDismiss();
53
+ return;
54
+ }
55
+
56
+ const animations = AnimationStore.getAll(current.route.key);
57
+ const gestures = GestureStore.getRouteGestures(current.route.key);
58
+ const transitionSpec = current.options.transitionSpec;
59
+
60
+ runOnUI(() => {
61
+ "worklet";
62
+ const { target, shouldDismiss } = findCollapseTarget(
63
+ animations.progress.value,
64
+ snapPoints,
65
+ canDismiss,
66
+ );
67
+
68
+ // If already dismissing, skip
69
+ if (gestures.isDismissing.value) return;
70
+
71
+ gestures.isDismissing.value = shouldDismiss ? 1 : 0;
72
+
73
+ const spec = shouldDismiss
74
+ ? transitionSpec
75
+ : {
76
+ open: transitionSpec?.expand ?? DefaultSnapSpec,
77
+ close: transitionSpec?.collapse ?? DefaultSnapSpec,
78
+ };
79
+
80
+ animateToProgress({
81
+ target,
82
+ spec,
83
+ animations,
84
+ onAnimationFinish: shouldDismiss ? handleDismiss : undefined,
85
+ });
86
+ })();
87
+ }
88
+ }, [backdropBehavior, current, handleDismiss]);
24
89
 
25
90
  const animatedContentStyle = useAnimatedStyle(() => {
26
91
  "worklet";
@@ -38,19 +103,21 @@ export const ScreenContainer = memo(({ children }: Props) => {
38
103
  <View style={styles.container} pointerEvents={pointerEvents}>
39
104
  <Pressable
40
105
  style={StyleSheet.absoluteFillObject}
41
- pointerEvents={isDismissable ? "auto" : "none"}
42
- onPress={isDismissable ? handleBackdropPress : undefined}
106
+ pointerEvents={isBackdropActive ? "auto" : "none"}
107
+ onPress={isBackdropActive ? handleBackdropPress : undefined}
43
108
  >
44
109
  <Animated.View
45
110
  style={[StyleSheet.absoluteFillObject, animatedBackdropStyle]}
46
111
  />
47
112
  </Pressable>
48
- <Animated.View
49
- style={[styles.content, animatedContentStyle]}
50
- pointerEvents={isDismissable ? "box-none" : pointerEvents}
51
- >
52
- {children}
53
- </Animated.View>
113
+ <GestureDetector gesture={gestureContext!.panGesture}>
114
+ <Animated.View
115
+ style={[styles.content, animatedContentStyle]}
116
+ pointerEvents={isBackdropActive ? "box-none" : pointerEvents}
117
+ >
118
+ {children}
119
+ </Animated.View>
120
+ </GestureDetector>
54
121
  </View>
55
122
  );
56
123
  });
@@ -93,6 +93,7 @@ export const FULLSCREEN_DIMENSIONS = (
93
93
  * Default gesture config
94
94
  */
95
95
  export const GESTURE_VELOCITY_IMPACT = 0.3;
96
+ export const SNAP_VELOCITY_IMPACT = 0.1;
96
97
  export const DEFAULT_GESTURE_DIRECTION = "horizontal";
97
98
  export const DEFAULT_GESTURE_DRIVES_PROGRESS = true;
98
99
  export const DEFAULT_GESTURE_ACTIVATION_AREA: ActivationArea = "screen";
@@ -106,3 +107,9 @@ export const FALSE = 0;
106
107
  * Small value for floating-point comparisons to handle animation/interpolation imprecision
107
108
  */
108
109
  export const EPSILON = 1e-5;
110
+
111
+ /**
112
+ * Threshold for snapping animations to target when "close enough" (1% of range).
113
+ * Prevents micro-jitter/oscillation near animation endpoints.
114
+ */
115
+ export const ANIMATION_SNAP_THRESHOLD = 0.01;
@@ -3,71 +3,115 @@ import { useCallback, useMemo, useRef } from "react";
3
3
  import { Gesture, type GestureType } from "react-native-gesture-handler";
4
4
  import type { SharedValue } from "react-native-reanimated";
5
5
  import type {
6
+ DirectionClaimMap,
6
7
  GestureContextType,
7
8
  ScrollConfig,
8
9
  } from "../../providers/gestures.provider";
9
10
  import { useKeys } from "../../providers/screen/keys.provider";
10
11
  import { GestureStore, type GestureStoreMap } from "../../stores/gesture.store";
12
+ import type { ClaimedDirections, Direction } from "../../types/ownership.types";
13
+ import { claimsAnyDirection } from "../../utils/gesture/compute-claimed-directions";
14
+ import { resolveOwnership } from "../../utils/gesture/resolve-ownership";
11
15
  import { useScreenGestureHandlers } from "./use-screen-gesture-handlers";
12
16
 
17
+ const DIRECTIONS: Direction[] = [
18
+ "vertical",
19
+ "vertical-inverted",
20
+ "horizontal",
21
+ "horizontal-inverted",
22
+ ];
23
+
24
+ /**
25
+ * Finds ancestor pan gestures that we shadow (claim the same direction).
26
+ * Used to block ancestors when child claims priority.
27
+ */
28
+ function findShadowedAncestorPanGestures(
29
+ selfClaims: ClaimedDirections,
30
+ ancestorContext: GestureContextType | null | undefined,
31
+ isIsolated: boolean,
32
+ ): GestureType[] {
33
+ const shadowedGestures: GestureType[] = [];
34
+ let ancestor = ancestorContext;
35
+
36
+ while (ancestor) {
37
+ if (ancestor.isIsolated !== isIsolated) break;
38
+
39
+ const shadowsAncestor = DIRECTIONS.some(
40
+ (dir) => selfClaims[dir] && ancestor?.claimedDirections?.[dir],
41
+ );
42
+
43
+ if (shadowsAncestor && ancestor.panGesture) {
44
+ shadowedGestures.push(ancestor.panGesture);
45
+ }
46
+
47
+ ancestor = ancestor.ancestorContext;
48
+ }
49
+
50
+ return shadowedGestures;
51
+ }
52
+
13
53
  interface BuildGesturesHookProps {
14
54
  scrollConfig: SharedValue<ScrollConfig | null>;
15
55
  ancestorContext?: GestureContextType | null;
56
+ claimedDirections: ClaimedDirections;
57
+ childDirectionClaims: SharedValue<DirectionClaimMap>;
58
+ isIsolated: boolean;
16
59
  }
17
60
 
61
+ /**
62
+ * Builds the Pan gesture for screen dismissal.
63
+ *
64
+ * Handles shadowing: when child claims same direction as ancestor,
65
+ * child's pan blocks ancestor's pan via `blocksExternalGesture()`.
66
+ *
67
+ * ScrollView coordination is handled separately by useScrollRegistry,
68
+ * which creates its own Native gesture per ScrollView.
69
+ */
18
70
  export const useBuildGestures = ({
19
71
  scrollConfig,
20
72
  ancestorContext,
73
+ claimedDirections,
74
+ childDirectionClaims,
75
+ isIsolated,
21
76
  }: BuildGesturesHookProps): {
22
77
  panGesture: GestureType;
23
78
  panGestureRef: React.MutableRefObject<GestureType | undefined>;
24
- nativeGesture: GestureType;
25
79
  gestureAnimationValues: GestureStoreMap;
26
80
  } => {
27
81
  const { current } = useKeys();
28
-
29
82
  const navState = current.navigation.getState();
30
83
 
31
84
  const isFirstScreen = useMemo(() => {
32
85
  return navState.routes.findIndex((r) => r.key === current.route.key) === 0;
33
86
  }, [navState.routes, current.route.key]);
34
87
 
35
- // Ref for external gesture coordination (e.g., swipeable lists)
36
88
  const panGestureRef = useRef<GestureType | undefined>(undefined);
37
-
38
89
  const gestureAnimationValues = GestureStore.getRouteGestures(
39
90
  current.route.key,
40
91
  );
41
92
 
42
93
  const { snapPoints } = current.options;
43
-
44
- // Dismiss gesture is controlled by gestureEnabled (disabled for first screen)
45
94
  const canDismiss = Boolean(
46
95
  isFirstScreen ? false : current.options.gestureEnabled,
47
96
  );
48
-
49
- // Snap navigation works independently - enabled when snap points exist
50
- // This matches iOS native sheet behavior where gestureEnabled: false
51
- // disables dismiss but you can still drag between detents
52
97
  const hasSnapPoints = Array.isArray(snapPoints) && snapPoints.length > 0;
53
98
  const gestureEnabled = canDismiss || hasSnapPoints;
54
99
 
100
+ const ownershipStatus = useMemo(
101
+ () => resolveOwnership(claimedDirections, ancestorContext ?? null),
102
+ [claimedDirections, ancestorContext],
103
+ );
104
+
105
+ const selfClaimsAny = claimsAnyDirection(claimedDirections);
106
+
55
107
  const handleDismiss = useCallback(() => {
56
- // If an ancestor navigator is already dismissing, skip this dismiss to
57
- // avoid racing with the ancestor
58
- if (ancestorContext?.gestureAnimationValues.isDismissing?.value) {
59
- return;
60
- }
108
+ if (ancestorContext?.gestureAnimationValues.isDismissing?.value) return;
61
109
 
62
110
  const state = current.navigation.getState();
63
-
64
111
  const routeStillPresent = state.routes.some(
65
112
  (route) => route.key === current.route.key,
66
113
  );
67
-
68
- if (!routeStillPresent) {
69
- return;
70
- }
114
+ if (!routeStillPresent) return;
71
115
 
72
116
  current.navigation.dispatch({
73
117
  ...StackActions.pop(),
@@ -81,8 +125,12 @@ export const useBuildGestures = ({
81
125
  scrollConfig,
82
126
  canDismiss,
83
127
  handleDismiss,
128
+ ownershipStatus,
84
129
  ancestorIsDismissing:
85
130
  ancestorContext?.gestureAnimationValues.isDismissing,
131
+ claimedDirections,
132
+ ancestorContext,
133
+ childDirectionClaims,
86
134
  });
87
135
 
88
136
  return useMemo(() => {
@@ -96,40 +144,27 @@ export const useBuildGestures = ({
96
144
  .onUpdate(onUpdate)
97
145
  .onEnd(onEnd);
98
146
 
99
- // Native gesture setup depends on whether this screen has gestures
100
- let nativeGesture: GestureType;
101
-
102
- if (gestureEnabled) {
103
- // This screen has gestures - set up normal pan/native relationship
104
- nativeGesture = Gesture.Native().requireExternalGestureToFail(panGesture);
105
- panGesture.blocksExternalGesture(nativeGesture);
106
- } else {
107
- // This screen has no gestures
108
- // Find nearest ancestor with gestureEnabled=true (attached pan)
109
- let activePanAncestor = ancestorContext;
110
- while (activePanAncestor && !activePanAncestor.gestureEnabled) {
111
- activePanAncestor = activePanAncestor.ancestorContext;
112
- }
113
-
114
- if (activePanAncestor?.panGesture) {
115
- // Found an ancestor with enabled pan - wait for it
116
- nativeGesture = Gesture.Native().requireExternalGestureToFail(
117
- activePanAncestor.panGesture,
118
- );
119
- } else {
120
- // No ancestor with enabled pan - plain native
121
- nativeGesture = Gesture.Native();
147
+ // Block shadowed ancestor pan gestures when we claim same directions
148
+ if (selfClaimsAny) {
149
+ const shadowedAncestorGestures = findShadowedAncestorPanGestures(
150
+ claimedDirections,
151
+ ancestorContext,
152
+ isIsolated,
153
+ );
154
+ for (const ancestorPan of shadowedAncestorGestures) {
155
+ panGesture.blocksExternalGesture(ancestorPan);
122
156
  }
123
157
  }
124
158
 
125
159
  return {
126
160
  panGesture,
127
161
  panGestureRef,
128
- nativeGesture,
129
162
  gestureAnimationValues,
130
163
  };
131
164
  }, [
132
165
  gestureEnabled,
166
+ selfClaimsAny,
167
+ claimedDirections,
133
168
  onTouchesDown,
134
169
  onTouchesMove,
135
170
  onStart,
@@ -137,5 +172,6 @@ export const useBuildGestures = ({
137
172
  onEnd,
138
173
  gestureAnimationValues,
139
174
  ancestorContext,
175
+ isIsolated,
140
176
  ]);
141
177
  };
@@ -16,17 +16,27 @@ import {
16
16
  EPSILON,
17
17
  FALSE,
18
18
  GESTURE_VELOCITY_IMPACT,
19
+ SNAP_VELOCITY_IMPACT,
19
20
  TRUE,
20
21
  } from "../../constants";
21
- import type { ScrollConfig } from "../../providers/gestures.provider";
22
+ import type {
23
+ DirectionClaimMap,
24
+ GestureContextType,
25
+ ScrollConfig,
26
+ } from "../../providers/gestures.provider";
22
27
  import { useKeys } from "../../providers/screen/keys.provider";
23
28
  import { AnimationStore } from "../../stores/animation.store";
24
29
  import { GestureStore } from "../../stores/gesture.store";
25
30
  import { GestureOffsetState } from "../../types/gesture.types";
31
+ import type {
32
+ ClaimedDirections,
33
+ Direction,
34
+ DirectionOwnership,
35
+ } from "../../types/ownership.types";
26
36
  import { animateToProgress } from "../../utils/animation/animate-to-progress";
27
37
  import {
28
38
  applyOffsetRules,
29
- checkScrollAwareActivation,
39
+ checkScrollBoundary,
30
40
  } from "../../utils/gesture/check-gesture-activation";
31
41
  import { determineDismissal } from "../../utils/gesture/determine-dismissal";
32
42
  import { determineSnapTarget } from "../../utils/gesture/determine-snap-target";
@@ -42,13 +52,66 @@ interface UseScreenGestureHandlersProps {
42
52
  ancestorIsDismissing?: SharedValue<number> | null;
43
53
  canDismiss: boolean;
44
54
  handleDismiss: () => void;
55
+ ownershipStatus: DirectionOwnership;
56
+ claimedDirections: ClaimedDirections;
57
+ ancestorContext: GestureContextType | null | undefined;
58
+ childDirectionClaims: SharedValue<DirectionClaimMap>;
45
59
  }
46
60
 
61
+ /**
62
+ * Gesture Handlers for Screen Dismissal and Snap Navigation
63
+ *
64
+ * ## Mental Model
65
+ *
66
+ * This hook implements the touch handling logic for the gesture ownership system.
67
+ * Each screen has a pan gesture handler that runs through this decision flow:
68
+ *
69
+ * ```
70
+ * onTouchesMove (for each touch move event):
71
+ * 1. ANCESTOR CHECK: If ancestor is dismissing → fail (avoid racing)
72
+ * 2. DIRECTION DETECTION: Determine swipe direction from touch delta
73
+ * 3. OWNERSHIP CHECK: Do we own this direction? (ownershipStatus)
74
+ * - "self" → continue
75
+ * - "ancestor" or null → fail (let it bubble up)
76
+ * 4. CHILD CLAIM CHECK: Has a child pre-registered a claim for this direction?
77
+ * - Yes → fail immediately (child shadows us, no delay)
78
+ * - No → continue
79
+ * 5. OFFSET THRESHOLD: Wait for sufficient touch movement
80
+ * 6. SCROLLVIEW CHECK: If touch is on ScrollView, is it at boundary?
81
+ * 7. EXPAND CHECK (snap sheets): If expanding via ScrollView, is expandViaScrollView enabled?
82
+ * 8. ACTIVATE!
83
+ * ```
84
+ *
85
+ * ## Key Concepts
86
+ *
87
+ * **Ownership**: Pre-computed at render time. "self" means this screen handles
88
+ * the direction, "ancestor" means bubble up, null means no handler exists.
89
+ *
90
+ * **Child Claims**: Registered at mount time via useEffect in gestures.provider.tsx.
91
+ * When a child shadows our direction, it pre-registers a claim so we know to defer.
92
+ * IMPORTANT: This check happens BEFORE offset threshold to ensure the parent fails
93
+ * immediately when shadowed, avoiding any perceptible delay.
94
+ * ALSO: Claims from dismissing children are ignored, allowing the parent to handle
95
+ * new gestures while the child is animating out.
96
+ *
97
+ * **ScrollView Boundaries**: Per spec, a ScrollView must be at its boundary before
98
+ * yielding to gestures. The boundary depends on sheet type:
99
+ * - Bottom sheet (vertical): scrollY = 0 (top)
100
+ * - Top sheet (vertical-inverted): scrollY >= maxY (bottom)
101
+ *
102
+ * **Snap Points**: Sheets with snapPoints claim BOTH directions on their axis
103
+ * (e.g., vertical sheet claims vertical AND vertical-inverted). This allows
104
+ * expand (drag up) and collapse/dismiss (drag down) gestures.
105
+ */
47
106
  export const useScreenGestureHandlers = ({
48
107
  scrollConfig,
49
108
  ancestorIsDismissing,
50
109
  canDismiss,
51
110
  handleDismiss,
111
+ ownershipStatus,
112
+ claimedDirections,
113
+ ancestorContext,
114
+ childDirectionClaims,
52
115
  }: UseScreenGestureHandlersProps) => {
53
116
  const dimensions = useWindowDimensions();
54
117
  const { current } = useKeys();
@@ -62,10 +125,12 @@ export const useScreenGestureHandlers = ({
62
125
  gestureDirection = DEFAULT_GESTURE_DIRECTION,
63
126
  gestureDrivesProgress = DEFAULT_GESTURE_DRIVES_PROGRESS,
64
127
  gestureVelocityImpact = GESTURE_VELOCITY_IMPACT,
128
+ snapVelocityImpact = SNAP_VELOCITY_IMPACT,
65
129
  gestureActivationArea = DEFAULT_GESTURE_ACTIVATION_AREA,
66
130
  gestureResponseDistance,
67
131
  transitionSpec,
68
132
  snapPoints: rawSnapPoints,
133
+ expandViaScrollView = true,
69
134
  } = current.options;
70
135
 
71
136
  const { hasSnapPoints, snapPoints, minSnapPoint, maxSnapPoint } = useMemo(
@@ -147,11 +212,13 @@ export const useScreenGestureHandlers = ({
147
212
  gestureOffsetState.value = GestureOffsetState.PENDING;
148
213
  });
149
214
 
215
+ const routeKey = current.route.key;
216
+
150
217
  const onTouchesMove = useStableCallbackValue(
151
218
  (e: GestureTouchEvent, manager: GestureStateManagerType) => {
152
219
  "worklet";
153
220
 
154
- // If an ancestor navigator is already dismissing via gesture, block new gestures here.
221
+ // Step 1: Ancestor dismissing check
155
222
  if (ancestorIsDismissing?.value) {
156
223
  gestureOffsetState.value = GestureOffsetState.FAILED;
157
224
  manager.fail();
@@ -177,79 +244,97 @@ export const useScreenGestureHandlers = ({
177
244
  return;
178
245
  }
179
246
 
180
- // Keep pending until thresholds are met; no eager activation.
181
247
  if (gestureAnimationValues.isDragging?.value) {
182
248
  manager.activate();
183
249
  return;
184
250
  }
185
251
 
186
- const recognizedDirection =
187
- isSwipingDown || isSwipingUp || isSwipingRight || isSwipingLeft;
188
-
189
- const scrollCfg = scrollConfig.value;
190
- const isTouchingScrollView = scrollCfg?.isTouched ?? false;
191
-
192
- if (!isTouchingScrollView) {
193
- // Early return if gesture hasn't met activation criteria
194
- const canActivate =
195
- recognizedDirection &&
196
- gestureOffsetState.value === GestureOffsetState.PASSED &&
197
- !gestureAnimationValues.isDismissing?.value;
198
-
199
- if (!canActivate) {
200
- return;
201
- }
202
-
203
- if (isSwipingDown) {
204
- gestureAnimationValues.direction.value = "vertical";
205
- } else if (isSwipingUp) {
206
- gestureAnimationValues.direction.value = "vertical-inverted";
207
- } else if (isSwipingRight) {
208
- gestureAnimationValues.direction.value = "horizontal";
209
- } else if (isSwipingLeft) {
210
- gestureAnimationValues.direction.value = "horizontal-inverted";
211
- }
252
+ // Step 2: Direction detection
253
+ let swipeDirection: Direction | null = null;
254
+ if (isSwipingDown) swipeDirection = "vertical";
255
+ else if (isSwipingUp) swipeDirection = "vertical-inverted";
256
+ else if (isSwipingRight) swipeDirection = "horizontal";
257
+ else if (isSwipingLeft) swipeDirection = "horizontal-inverted";
212
258
 
213
- manager.activate();
259
+ if (!swipeDirection) {
214
260
  return;
215
261
  }
216
262
 
217
- // Touch IS on ScrollView - apply scroll-aware rules
218
- // Snap mode: determine if sheet can still expand
219
- // Also check targetProgress - if we're already animating toward max, scroll should win
220
- const canExpandMore =
221
- hasSnapPoints &&
222
- animations.progress.value < maxSnapPoint - EPSILON &&
223
- animations.targetProgress.value < maxSnapPoint - EPSILON;
224
-
225
- const { shouldActivate, direction: activatedDirection } =
226
- checkScrollAwareActivation({
227
- swipeInfo: {
228
- isSwipingDown,
229
- isSwipingUp,
230
- isSwipingRight,
231
- isSwipingLeft,
232
- },
233
- directions,
234
- scrollConfig: scrollCfg,
235
- hasSnapPoints,
236
- canExpandMore,
237
- });
238
-
239
- if (recognizedDirection && !shouldActivate) {
263
+ // Step 3: Ownership check - fail if we don't own this direction
264
+ const ownership = ownershipStatus[swipeDirection];
265
+ if (ownership !== "self") {
240
266
  manager.fail();
241
267
  return;
242
268
  }
243
269
 
270
+ // Step 4: Child claim check - fail EARLY if a child shadows this direction
271
+ // This MUST happen before offset threshold to avoid delay when shadowing
272
+ // ALSO: Ignore claims from children that are currently dismissing
273
+ const childClaim = childDirectionClaims.value[swipeDirection];
244
274
  if (
245
- shouldActivate &&
246
- gestureOffsetState.value === GestureOffsetState.PASSED &&
247
- !gestureAnimationValues.isDismissing?.value
275
+ childClaim &&
276
+ childClaim.routeKey !== routeKey &&
277
+ !childClaim.isDismissing.value
248
278
  ) {
249
- gestureAnimationValues.direction.value = activatedDirection;
250
- manager.activate();
279
+ manager.fail();
280
+ return;
281
+ }
282
+
283
+ if (gestureOffsetState.value !== GestureOffsetState.PASSED) {
251
284
  return;
252
285
  }
286
+
287
+ // Snap sheets can interrupt their own animation; non-snap cannot
288
+ if (!hasSnapPoints && gestureAnimationValues.isDismissing?.value) {
289
+ return;
290
+ }
291
+
292
+ // Step 6: ScrollView boundary check
293
+ const scrollCfg = scrollConfig.value;
294
+ const isTouchingScrollView = scrollCfg?.isTouched ?? false;
295
+
296
+ if (isTouchingScrollView) {
297
+ const atBoundary = checkScrollBoundary(
298
+ scrollCfg,
299
+ swipeDirection,
300
+ hasSnapPoints ? directions.snapAxisInverted : undefined,
301
+ );
302
+
303
+ if (!atBoundary) {
304
+ manager.fail();
305
+ return;
306
+ }
307
+
308
+ // Step 7: Expand check for snap sheets
309
+ if (hasSnapPoints) {
310
+ const isExpandGesture =
311
+ (directions.snapAxisInverted && swipeDirection === "vertical") ||
312
+ (!directions.snapAxisInverted &&
313
+ swipeDirection === "vertical-inverted") ||
314
+ (directions.snapAxisInverted && swipeDirection === "horizontal") ||
315
+ (!directions.snapAxisInverted &&
316
+ swipeDirection === "horizontal-inverted");
317
+
318
+ if (isExpandGesture) {
319
+ if (!expandViaScrollView) {
320
+ manager.fail();
321
+ return;
322
+ }
323
+
324
+ const canExpandMore =
325
+ animations.progress.value < maxSnapPoint - EPSILON &&
326
+ animations.targetProgress.value < maxSnapPoint - EPSILON;
327
+
328
+ if (!canExpandMore) {
329
+ manager.fail();
330
+ return;
331
+ }
332
+ }
333
+ }
334
+ }
335
+
336
+ gestureAnimationValues.direction.value = swipeDirection;
337
+ manager.activate();
253
338
  },
254
339
  );
255
340
 
@@ -284,15 +369,11 @@ export const useScreenGestureHandlers = ({
284
369
  const translation = isHorizontal ? translationX : translationY;
285
370
  const dimension = isHorizontal ? width : height;
286
371
 
287
- // Map translation to progress delta:
288
- // - Positive translation (down/right) = decrease progress (dismiss)
289
- // - Negative translation (up/left) = increase progress (expand)
290
- // Inverted directions flip this behavior
372
+ // Map translation to progress: positive = dismiss, negative = expand
291
373
  const baseSign = -1;
292
374
  const sign = directions.snapAxisInverted ? -baseSign : baseSign;
293
375
  const progressDelta = (sign * translation) / dimension;
294
376
 
295
- // Use pre-computed bounds (minSnapPoint already accounts for canDismiss)
296
377
  animations.progress.value = Math.max(
297
378
  minSnapPoint,
298
379
  Math.min(maxSnapPoint, gestureStartProgress.value + progressDelta),
@@ -343,9 +424,7 @@ export const useScreenGestureHandlers = ({
343
424
  ? dimensions.width
344
425
  : dimensions.height;
345
426
 
346
- // determineSnapTarget expects positive velocity = toward dismiss (decreasing progress)
347
- // Positive velocity (down/right) = dismiss for non-inverted
348
- // Inverted directions need velocity flipped
427
+ // Normalize velocity: positive = toward dismiss
349
428
  const snapVelocity = directions.snapAxisInverted
350
429
  ? -axisVelocity
351
430
  : axisVelocity;
@@ -355,6 +434,7 @@ export const useScreenGestureHandlers = ({
355
434
  snapPoints,
356
435
  velocity: snapVelocity,
357
436
  dimension: axisDimension,
437
+ velocityFactor: snapVelocityImpact,
358
438
  canDismiss: canDismiss,
359
439
  });
360
440
 
@@ -381,9 +461,6 @@ export const useScreenGestureHandlers = ({
381
461
  dimensions,
382
462
  });
383
463
 
384
- // For snap transitions, velocity should match gesture direction
385
- // Positive gesture velocity (down/right) = collapsing (negative progress velocity)
386
- // Inverted directions flip this
387
464
  const velocitySign = directions.snapAxisInverted ? 1 : -1;
388
465
  const initialVelocity =
389
466
  velocitySign * velocity.normalize(axisVelocity, axisDimension);
@@ -404,7 +481,6 @@ export const useScreenGestureHandlers = ({
404
481
  });
405
482
 
406
483
  const shouldDismiss = result.shouldDismiss;
407
- // Without snap points, always animate to fully visible (1) when not dismissing
408
484
  const targetProgress = shouldDismiss ? 0 : 1;
409
485
 
410
486
  resetGestureValues({