@swmansion/react-native-bottom-sheet 0.3.0 → 0.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.
@@ -0,0 +1,20 @@
1
+ import type { PanGesture } from 'react-native-gesture-handler';
2
+ import { type AnimatedRef, type SharedValue } from 'react-native-reanimated';
3
+ interface BottomSheetPanGestureParams {
4
+ animationTarget: SharedValue<number>;
5
+ translateY: SharedValue<number>;
6
+ sheetHeight: SharedValue<number>;
7
+ detentsValue: SharedValue<number[]>;
8
+ isDraggableValue: SharedValue<boolean[]>;
9
+ currentIndex: SharedValue<number>;
10
+ scrollOffset: SharedValue<number>;
11
+ hasScrollable: SharedValue<boolean>;
12
+ isScrollableGestureActive: SharedValue<boolean>;
13
+ isScrollableLocked: SharedValue<boolean>;
14
+ scrollableRef: AnimatedRef<any>;
15
+ handleIndexChange: (nextIndex: number) => void;
16
+ animateToIndex: (targetIndex: number, velocity?: number) => void;
17
+ }
18
+ export declare const useBottomSheetPanGesture: ({ animationTarget, translateY, sheetHeight, detentsValue, isDraggableValue, currentIndex, scrollOffset, hasScrollable, isScrollableGestureActive, isScrollableLocked, scrollableRef, handleIndexChange, animateToIndex, }: BottomSheetPanGestureParams) => PanGesture;
19
+ export {};
20
+ //# sourceMappingURL=useBottomSheetPanGesture.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useBottomSheetPanGesture.d.ts","sourceRoot":"","sources":["../../../src/useBottomSheetPanGesture.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,8BAA8B,CAAC;AAG/D,OAAO,EAGL,KAAK,WAAW,EAChB,KAAK,WAAW,EAEjB,MAAM,yBAAyB,CAAC;AAIjC,UAAU,2BAA2B;IACnC,eAAe,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACrC,UAAU,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAChC,WAAW,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACjC,YAAY,EAAE,WAAW,CAAC,MAAM,EAAE,CAAC,CAAC;IACpC,gBAAgB,EAAE,WAAW,CAAC,OAAO,EAAE,CAAC,CAAC;IACzC,YAAY,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAClC,YAAY,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAClC,aAAa,EAAE,WAAW,CAAC,OAAO,CAAC,CAAC;IACpC,yBAAyB,EAAE,WAAW,CAAC,OAAO,CAAC,CAAC;IAChD,kBAAkB,EAAE,WAAW,CAAC,OAAO,CAAC,CAAC;IACzC,aAAa,EAAE,WAAW,CAAC,GAAG,CAAC,CAAC;IAChC,iBAAiB,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/C,cAAc,EAAE,CAAC,WAAW,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;CAClE;AAED,eAAO,MAAM,wBAAwB,GAAI,2NActC,2BAA2B,KAAG,UAoKhC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swmansion/react-native-bottom-sheet",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Provides bottom-sheet components for React Native.",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",
@@ -4,8 +4,6 @@ import type { LayoutChangeEvent } from 'react-native';
4
4
  import { Pressable, StyleSheet, View, useWindowDimensions } from 'react-native';
5
5
  import type { SharedValue, WithSpringConfig } from 'react-native-reanimated';
6
6
  import Animated, {
7
- measure,
8
- scrollTo,
9
7
  useAnimatedRef,
10
8
  useAnimatedReaction,
11
9
  useAnimatedStyle,
@@ -13,13 +11,20 @@ import Animated, {
13
11
  useSharedValue,
14
12
  withSpring,
15
13
  } from 'react-native-reanimated';
16
- import { scheduleOnRN, scheduleOnUI } from 'react-native-worklets';
17
- import { Gesture, GestureDetector } from 'react-native-gesture-handler';
14
+ import { scheduleOnUI } from 'react-native-worklets';
15
+ import { GestureDetector } from 'react-native-gesture-handler';
18
16
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
19
17
  import { Portal } from './BottomSheetProvider';
20
18
  import { BottomSheetContextProvider } from './BottomSheetContext';
21
-
22
- export type Detent = number | 'max';
19
+ import {
20
+ clampIndex,
21
+ isDetentProgrammatic,
22
+ resolveDetent,
23
+ } from './bottomSheetUtils';
24
+ import type { Detent } from './bottomSheetUtils';
25
+ import { useBottomSheetPanGesture } from './useBottomSheetPanGesture';
26
+ export type { Detent, DetentValue } from './bottomSheetUtils';
27
+ export { programmatic } from './bottomSheetUtils';
23
28
 
24
29
  export interface BottomSheetCommonProps {
25
30
  children: ReactNode;
@@ -48,25 +53,6 @@ const DEFAULT_CLOSE_ANIMATION_CONFIG: WithSpringConfig = {
48
53
  overshootClamping: true,
49
54
  };
50
55
 
51
- const VELOCITY_THRESHOLD = 800;
52
-
53
- const resolveDetent = (
54
- detent: Detent,
55
- contentHeight: number,
56
- maxHeight: number
57
- ) => {
58
- if (typeof detent === 'number') return detent;
59
- if (detent === 'max') {
60
- return contentHeight > 0 ? Math.min(contentHeight, maxHeight) : maxHeight;
61
- }
62
- throw new Error(`Invalid detent: \`${detent}\`.`);
63
- };
64
-
65
- const clampIndex = (index: number, detentCount: number) => {
66
- if (detentCount <= 0) return 0;
67
- return Math.min(Math.max(index, 0), detentCount - 1);
68
- };
69
-
70
56
  const DefaultScrim = ({ progress }: { progress: SharedValue<number> }) => {
71
57
  const style = useAnimatedStyle(() => ({ opacity: progress.value }));
72
58
  return (
@@ -96,14 +82,18 @@ export const BottomSheetBase = ({
96
82
  const maxHeight = screenHeight - insets.top;
97
83
  const resolvedIndex = clampIndex(index, detents.length);
98
84
  const [contentHeight, setContentHeight] = useState(0);
85
+
99
86
  if (detents.length === 0) {
100
87
  throw new Error('detents must include at least one value.');
101
88
  }
102
- const normalizedDetents = detents.map((point) => {
103
- const resolved = resolveDetent(point, contentHeight, maxHeight);
89
+
90
+ const normalizedDetents = detents.map((detent) => {
91
+ const resolved = resolveDetent(detent, contentHeight, maxHeight);
104
92
  return Math.max(0, Math.min(resolved, maxHeight));
105
93
  });
94
+ const isDraggable = detents.map((detent) => !isDetentProgrammatic(detent));
106
95
  const initialMaxSnap = Math.max(0, ...normalizedDetents);
96
+
107
97
  const translateY = useSharedValue(initialMaxSnap);
108
98
  const animationTarget = useSharedValue(NaN);
109
99
  const sheetHeight = useSharedValue(initialMaxSnap);
@@ -112,41 +102,50 @@ export const BottomSheetBase = ({
112
102
  const isScrollableGestureActive = useSharedValue(false);
113
103
  const isScrollableLocked = useSharedValue(false);
114
104
  const scrollableRef = useAnimatedRef();
115
- const isDraggingSheet = useSharedValue(false);
116
- const isDraggingFromScrollable = useSharedValue(false);
117
- const panStartY = useSharedValue(0);
118
- const panActivated = useSharedValue(false);
119
- const dragStartTranslateY = useSharedValue(0);
120
- const isTouchWithinScrollable = useSharedValue(false);
105
+
121
106
  const detentsValue = useSharedValue(normalizedDetents);
107
+ const isDraggableValue = useSharedValue(isDraggable);
122
108
  const firstNonzeroDetent = useSharedValue(
123
- normalizedDetents.find((d) => d > 0) ?? 0
109
+ normalizedDetents.find((detent) => detent > 0) ?? 0
124
110
  );
125
111
  const currentIndex = useSharedValue(resolvedIndex);
126
112
  const internalPosition = useDerivedValue(() =>
127
113
  Math.max(0, sheetHeight.value - translateY.value)
128
114
  );
115
+
129
116
  useAnimatedReaction(
130
117
  () => internalPosition.value,
131
118
  (value) => {
132
119
  if (externalPosition !== undefined) externalPosition.set(value);
133
120
  }
134
121
  );
122
+
135
123
  const scrimProgress = useDerivedValue(() => {
136
124
  const target = firstNonzeroDetent.value;
137
125
  if (target <= 0) return 0;
138
126
  const progress = internalPosition.value / target;
139
127
  return Math.min(1, Math.max(0, progress));
140
128
  });
129
+
141
130
  const handleIndexChange = (nextIndex: number) => {
142
131
  onIndexChange?.(nextIndex);
143
132
  };
133
+
144
134
  useEffect(() => {
145
135
  const maxSnap = Math.max(0, ...normalizedDetents);
146
136
  detentsValue.set(normalizedDetents);
137
+ isDraggableValue.set(isDraggable);
147
138
  sheetHeight.set(maxSnap);
148
- firstNonzeroDetent.set(normalizedDetents.find((d) => d > 0) ?? 0);
149
- }, [normalizedDetents, sheetHeight, detentsValue, firstNonzeroDetent]);
139
+ firstNonzeroDetent.set(normalizedDetents.find((detent) => detent > 0) ?? 0);
140
+ }, [
141
+ normalizedDetents,
142
+ isDraggable,
143
+ sheetHeight,
144
+ detentsValue,
145
+ isDraggableValue,
146
+ firstNonzeroDetent,
147
+ ]);
148
+
150
149
  const animateToIndex = useCallback(
151
150
  (targetIndex: number, velocity?: number) => {
152
151
  'worklet';
@@ -174,135 +173,27 @@ export const BottomSheetBase = ({
174
173
  translateY,
175
174
  ]
176
175
  );
176
+
177
177
  useEffect(() => {
178
178
  scheduleOnUI(animateToIndex, resolvedIndex);
179
179
  }, [animateToIndex, resolvedIndex, normalizedDetents]);
180
- const panGesture = Gesture.Pan()
181
- .manualActivation(true)
182
- .onTouchesDown((event) => {
183
- 'worklet';
184
- panActivated.set(false);
185
- isDraggingSheet.set(false);
186
- isDraggingFromScrollable.set(false);
187
- isScrollableLocked.set(false);
188
- isTouchWithinScrollable.set(false);
189
- const touch = event.changedTouches[0] ?? event.allTouches[0];
190
- if (touch !== undefined) {
191
- panStartY.set(touch.absoluteY);
192
- if (hasScrollable.value) {
193
- const layout = measure(scrollableRef);
194
- if (layout !== null) {
195
- const withinX =
196
- touch.absoluteX >= layout.pageX &&
197
- touch.absoluteX <= layout.pageX + layout.width;
198
- const withinY =
199
- touch.absoluteY >= layout.pageY &&
200
- touch.absoluteY <= layout.pageY + layout.height;
201
- isTouchWithinScrollable.set(withinX && withinY);
202
- }
203
- }
204
- }
205
- })
206
- .onTouchesMove((event, stateManager) => {
207
- 'worklet';
208
- if (panActivated.value) return;
209
- const touch = event.changedTouches[0] ?? event.allTouches[0];
210
- if (!touch) return;
211
- const deltaY = touch.absoluteY - panStartY.value;
212
- if (
213
- hasScrollable.value &&
214
- scrollOffset.value > 0 &&
215
- isTouchWithinScrollable.value
216
- ) {
217
- return;
218
- }
219
- if (deltaY > 0 || translateY.value > 0) {
220
- panActivated.set(true);
221
- stateManager.activate();
222
- }
223
- })
224
- .onBegin(() => {
225
- 'worklet';
226
- animationTarget.set(NaN);
227
- isDraggingSheet.set(false);
228
- isDraggingFromScrollable.set(false);
229
- dragStartTranslateY.set(translateY.value);
230
- })
231
- .onUpdate((event) => {
232
- 'worklet';
233
- if (isDraggingSheet.value) {
234
- if (isDraggingFromScrollable.value) {
235
- scrollTo(scrollableRef, 0, 0, false);
236
- }
237
- } else {
238
- const isDraggingDown = event.translationY > 0;
239
- const canStartDrag =
240
- !hasScrollable.value ||
241
- scrollOffset.value <= 0 ||
242
- !isTouchWithinScrollable.value;
243
- if (!canStartDrag || (!isDraggingDown && translateY.value <= 0)) {
244
- return;
245
- }
246
- const isScrollableActive =
247
- hasScrollable.value && isScrollableGestureActive.value;
248
- isDraggingSheet.set(true);
249
- isDraggingFromScrollable.set(
250
- isScrollableActive && isTouchWithinScrollable.value
251
- );
252
- isScrollableLocked.set(hasScrollable.value);
253
- if (isTouchWithinScrollable.value && hasScrollable.value) {
254
- scrollTo(scrollableRef, 0, 0, false);
255
- }
256
- }
257
- const nextTranslate = Math.min(
258
- Math.max(dragStartTranslateY.value + event.translationY, 0),
259
- sheetHeight.value
260
- );
261
- translateY.set(nextTranslate);
262
- })
263
- .onEnd((event) => {
264
- 'worklet';
265
- const wasDragging = isDraggingSheet.value;
266
- isScrollableLocked.set(false);
267
- isDraggingSheet.set(false);
268
- if (!wasDragging) {
269
- animateToIndex(currentIndex.value);
270
- return;
271
- }
272
- const maxSnap = sheetHeight.value;
273
- const allPositions = detentsValue.value.map((point, snapIndex) => ({
274
- index: snapIndex,
275
- translateY: maxSnap - point,
276
- }));
277
- const currentTranslate = translateY.value;
278
- const velocityY = event.velocityY;
279
- let targetIndex = currentIndex.value;
280
- let minDistance = Infinity;
281
- for (const pos of allPositions) {
282
- const distance = Math.abs(currentTranslate - pos.translateY);
283
- if (distance < minDistance) {
284
- minDistance = distance;
285
- targetIndex = pos.index;
286
- }
287
- }
288
- if (Math.abs(velocityY) > VELOCITY_THRESHOLD) {
289
- if (velocityY > 0) {
290
- const lower = allPositions
291
- .filter((pos) => pos.translateY > currentTranslate + 1)
292
- .sort((a, b) => a.translateY - b.translateY)[0];
293
- if (lower !== undefined) targetIndex = lower.index;
294
- } else {
295
- const upper = allPositions
296
- .filter((pos) => pos.translateY < currentTranslate - 1)
297
- .sort((a, b) => b.translateY - a.translateY)[0];
298
- if (upper !== undefined) targetIndex = upper.index;
299
- }
300
- }
301
- const hasIndexChanged = targetIndex !== currentIndex.value;
302
- if (hasIndexChanged) scheduleOnRN(handleIndexChange, targetIndex);
303
- const shouldApplyVelocity = hasIndexChanged && Number.isFinite(velocityY);
304
- animateToIndex(targetIndex, shouldApplyVelocity ? velocityY : undefined);
305
- });
180
+
181
+ const panGesture = useBottomSheetPanGesture({
182
+ animationTarget,
183
+ translateY,
184
+ sheetHeight,
185
+ detentsValue,
186
+ isDraggableValue,
187
+ currentIndex,
188
+ scrollOffset,
189
+ hasScrollable,
190
+ isScrollableGestureActive,
191
+ isScrollableLocked,
192
+ scrollableRef,
193
+ handleIndexChange,
194
+ animateToIndex,
195
+ });
196
+
306
197
  const handleSentinelLayout = (event: LayoutChangeEvent) => {
307
198
  setContentHeight(event.nativeEvent.layout.y);
308
199
  };
@@ -312,6 +203,7 @@ export const BottomSheetBase = ({
312
203
  handleIndexChange(closedIndex);
313
204
  scheduleOnUI(animateToIndex, closedIndex);
314
205
  };
206
+
315
207
  const wrapperStyle = useAnimatedStyle(() => ({
316
208
  transform: [{ translateY: translateY.value }],
317
209
  height: sheetHeight.value,
@@ -325,6 +217,7 @@ export const BottomSheetBase = ({
325
217
  } else if (modal) {
326
218
  scrimElement = <DefaultScrim progress={scrimProgress} />;
327
219
  }
220
+
328
221
  const sheetContent = (
329
222
  <BottomSheetContextProvider
330
223
  value={{
@@ -361,6 +254,7 @@ export const BottomSheetBase = ({
361
254
  </Animated.View>
362
255
  </BottomSheetContextProvider>
363
256
  );
257
+
364
258
  const sheetContainer = (
365
259
  <Animated.View
366
260
  style={StyleSheet.absoluteFill}
@@ -375,5 +269,6 @@ export const BottomSheetBase = ({
375
269
  </Animated.View>
376
270
  );
377
271
  if (modal) return <Portal>{sheetContainer}</Portal>;
272
+
378
273
  return sheetContainer;
379
274
  };
@@ -24,6 +24,7 @@ const PortalHost = () => {
24
24
  useEffect(() => {
25
25
  return context.subscribe(forceRender);
26
26
  }, [context]);
27
+
27
28
  return Array.from(context.getPortals().entries()).map(([key, element]) => (
28
29
  <View key={key} style={StyleSheet.absoluteFill} pointerEvents="box-none">
29
30
  {element}
@@ -56,6 +57,7 @@ export const BottomSheetProvider = ({ children }: { children: ReactNode }) => {
56
57
  getPortals: () => portals,
57
58
  };
58
59
  });
60
+
59
61
  return (
60
62
  <PortalContext.Provider value={context}>
61
63
  {children}
@@ -69,8 +71,10 @@ export const Portal = ({ children }: { children: ReactNode }) => {
69
71
  if (context === null) {
70
72
  throw new Error('`Portal` must be used within `BottomSheetProvider`.');
71
73
  }
74
+
72
75
  const { addPortal, removePortal } = context;
73
76
  const id = useId();
77
+
74
78
  useEffect(() => {
75
79
  addPortal(id, children);
76
80
  }, [id, children, addPortal]);
@@ -0,0 +1,82 @@
1
+ export type DetentValue = number | 'max';
2
+
3
+ export type Detent =
4
+ | DetentValue
5
+ | { value: DetentValue; programmatic?: boolean };
6
+
7
+ export const programmatic = (value: DetentValue): Detent => ({
8
+ value,
9
+ programmatic: true,
10
+ });
11
+
12
+ export const detentValue = (detent: Detent): DetentValue => {
13
+ if (typeof detent === 'object' && detent !== null) return detent.value;
14
+ return detent;
15
+ };
16
+
17
+ export const isDetentProgrammatic = (detent: Detent): boolean => {
18
+ if (typeof detent === 'object' && detent !== null) {
19
+ return detent.programmatic === true;
20
+ }
21
+ return false;
22
+ };
23
+
24
+ const VELOCITY_THRESHOLD = 800;
25
+
26
+ export const findSnapTarget = (
27
+ currentTranslate: number,
28
+ velocityY: number,
29
+ currentIndex: number,
30
+ allPositions: { index: number; translateY: number; isDraggable: boolean }[]
31
+ ) => {
32
+ 'worklet';
33
+ const draggablePositions = allPositions.filter(
34
+ (position) => position.isDraggable
35
+ );
36
+ const effectivePositions =
37
+ draggablePositions.length > 0 ? draggablePositions : allPositions;
38
+
39
+ let targetIndex = currentIndex;
40
+ let minDistance = Infinity;
41
+
42
+ for (const position of effectivePositions) {
43
+ const distance = Math.abs(currentTranslate - position.translateY);
44
+ if (distance < minDistance) {
45
+ minDistance = distance;
46
+ targetIndex = position.index;
47
+ }
48
+ }
49
+
50
+ if (Math.abs(velocityY) > VELOCITY_THRESHOLD) {
51
+ if (velocityY > 0) {
52
+ const lowerPosition = effectivePositions
53
+ .filter((position) => position.translateY > currentTranslate + 1)
54
+ .sort((a, b) => a.translateY - b.translateY)[0];
55
+ if (lowerPosition !== undefined) targetIndex = lowerPosition.index;
56
+ } else {
57
+ const upperPosition = effectivePositions
58
+ .filter((position) => position.translateY < currentTranslate - 1)
59
+ .sort((a, b) => b.translateY - a.translateY)[0];
60
+ if (upperPosition !== undefined) targetIndex = upperPosition.index;
61
+ }
62
+ }
63
+ return targetIndex;
64
+ };
65
+
66
+ export const resolveDetent = (
67
+ detent: Detent,
68
+ contentHeight: number,
69
+ maxHeight: number
70
+ ) => {
71
+ const detentValueInput = detentValue(detent);
72
+ if (typeof detentValueInput === 'number') return detentValueInput;
73
+ if (detentValueInput === 'max') {
74
+ return contentHeight > 0 ? Math.min(contentHeight, maxHeight) : maxHeight;
75
+ }
76
+ throw new Error(`Invalid detent: \`${detentValueInput}\`.`);
77
+ };
78
+
79
+ export const clampIndex = (index: number, detentCount: number) => {
80
+ if (detentCount <= 0) return 0;
81
+ return Math.min(Math.max(index, 0), detentCount - 1);
82
+ };
package/src/index.tsx CHANGED
@@ -13,4 +13,5 @@ export type {
13
13
  BottomSheetScrollViewMethods,
14
14
  BottomSheetScrollViewProps,
15
15
  } from './BottomSheetScrollView';
16
- export type { Detent } from './BottomSheetBase';
16
+ export type { Detent, DetentValue } from './BottomSheetBase';
17
+ export { programmatic } from './BottomSheetBase';
@@ -0,0 +1,208 @@
1
+ import type { PanGesture } from 'react-native-gesture-handler';
2
+ import { Gesture } from 'react-native-gesture-handler';
3
+ import { scheduleOnRN } from 'react-native-worklets';
4
+ import {
5
+ measure,
6
+ scrollTo,
7
+ type AnimatedRef,
8
+ type SharedValue,
9
+ useSharedValue,
10
+ } from 'react-native-reanimated';
11
+
12
+ import { findSnapTarget } from './bottomSheetUtils';
13
+
14
+ interface BottomSheetPanGestureParams {
15
+ animationTarget: SharedValue<number>;
16
+ translateY: SharedValue<number>;
17
+ sheetHeight: SharedValue<number>;
18
+ detentsValue: SharedValue<number[]>;
19
+ isDraggableValue: SharedValue<boolean[]>;
20
+ currentIndex: SharedValue<number>;
21
+ scrollOffset: SharedValue<number>;
22
+ hasScrollable: SharedValue<boolean>;
23
+ isScrollableGestureActive: SharedValue<boolean>;
24
+ isScrollableLocked: SharedValue<boolean>;
25
+ scrollableRef: AnimatedRef<any>;
26
+ handleIndexChange: (nextIndex: number) => void;
27
+ animateToIndex: (targetIndex: number, velocity?: number) => void;
28
+ }
29
+
30
+ export const useBottomSheetPanGesture = ({
31
+ animationTarget,
32
+ translateY,
33
+ sheetHeight,
34
+ detentsValue,
35
+ isDraggableValue,
36
+ currentIndex,
37
+ scrollOffset,
38
+ hasScrollable,
39
+ isScrollableGestureActive,
40
+ isScrollableLocked,
41
+ scrollableRef,
42
+ handleIndexChange,
43
+ animateToIndex,
44
+ }: BottomSheetPanGestureParams): PanGesture => {
45
+ const isDraggingSheet = useSharedValue(false);
46
+ const isDraggingFromScrollable = useSharedValue(false);
47
+ const panStartY = useSharedValue(0);
48
+ const panActivated = useSharedValue(false);
49
+ const dragStartTranslateY = useSharedValue(0);
50
+ const isTouchWithinScrollable = useSharedValue(false);
51
+
52
+ return Gesture.Pan()
53
+ .manualActivation(true)
54
+ .onTouchesDown((event) => {
55
+ 'worklet';
56
+ panActivated.set(false);
57
+ isDraggingSheet.set(false);
58
+ isDraggingFromScrollable.set(false);
59
+ isScrollableLocked.set(false);
60
+ isTouchWithinScrollable.set(false);
61
+ const touch = event.changedTouches[0] ?? event.allTouches[0];
62
+ if (touch !== undefined) {
63
+ panStartY.set(touch.absoluteY);
64
+ if (hasScrollable.value) {
65
+ const layout = measure(scrollableRef);
66
+ if (layout !== null) {
67
+ const withinX =
68
+ touch.absoluteX >= layout.pageX &&
69
+ touch.absoluteX <= layout.pageX + layout.width;
70
+ const withinY =
71
+ touch.absoluteY >= layout.pageY &&
72
+ touch.absoluteY <= layout.pageY + layout.height;
73
+ isTouchWithinScrollable.set(withinX && withinY);
74
+ }
75
+ }
76
+ }
77
+ })
78
+ .onTouchesMove((event, stateManager) => {
79
+ 'worklet';
80
+ if (panActivated.value) return;
81
+ const touch = event.changedTouches[0] ?? event.allTouches[0];
82
+ if (!touch) return;
83
+ const deltaY = touch.absoluteY - panStartY.value;
84
+ if (
85
+ hasScrollable.value &&
86
+ scrollOffset.value > 0 &&
87
+ isTouchWithinScrollable.value
88
+ ) {
89
+ return;
90
+ }
91
+ if (deltaY > 0 || translateY.value > 0) {
92
+ panActivated.set(true);
93
+ stateManager.activate();
94
+ }
95
+ })
96
+ .onBegin(() => {
97
+ 'worklet';
98
+ animationTarget.set(NaN);
99
+ isDraggingSheet.set(false);
100
+ isDraggingFromScrollable.set(false);
101
+ dragStartTranslateY.set(translateY.value);
102
+ })
103
+ .onUpdate((event) => {
104
+ 'worklet';
105
+ if (isDraggingSheet.value) {
106
+ if (isDraggingFromScrollable.value) {
107
+ scrollTo(scrollableRef, 0, 0, false);
108
+ }
109
+ } else {
110
+ const isDraggingDown = event.translationY > 0;
111
+ const canStartDrag =
112
+ !hasScrollable.value ||
113
+ scrollOffset.value <= 0 ||
114
+ !isTouchWithinScrollable.value;
115
+ if (!canStartDrag || (!isDraggingDown && translateY.value <= 0)) {
116
+ return;
117
+ }
118
+ const isScrollableActive =
119
+ hasScrollable.value && isScrollableGestureActive.value;
120
+ isDraggingSheet.set(true);
121
+ isDraggingFromScrollable.set(
122
+ isScrollableActive && isTouchWithinScrollable.value
123
+ );
124
+ dragStartTranslateY.set(translateY.value - event.translationY);
125
+ isScrollableLocked.set(hasScrollable.value);
126
+ if (isTouchWithinScrollable.value && hasScrollable.value) {
127
+ scrollTo(scrollableRef, 0, 0, false);
128
+ }
129
+ }
130
+ const rawTranslate = dragStartTranslateY.value + event.translationY;
131
+ const nextTranslate = Math.min(
132
+ Math.max(rawTranslate, 0),
133
+ sheetHeight.value
134
+ );
135
+ translateY.set(nextTranslate);
136
+ if (
137
+ isDraggingSheet.value &&
138
+ rawTranslate < 0 &&
139
+ isTouchWithinScrollable.value &&
140
+ hasScrollable.value
141
+ ) {
142
+ isDraggingSheet.set(false);
143
+ isScrollableLocked.set(false);
144
+ const resolvedDetentValues = detentsValue.value;
145
+ const draggable = isDraggableValue.value;
146
+ let targetSnapIndex = -1;
147
+ let targetSnapValue = -1;
148
+ for (let i = resolvedDetentValues.length - 1; i >= 0; i--) {
149
+ const detentValue = resolvedDetentValues[i];
150
+ if (
151
+ detentValue !== undefined &&
152
+ (draggable[i] ?? true) &&
153
+ detentValue > targetSnapValue
154
+ ) {
155
+ targetSnapValue = detentValue;
156
+ targetSnapIndex = i;
157
+ }
158
+ }
159
+ if (targetSnapIndex === -1) {
160
+ const maxSnap = sheetHeight.value;
161
+ for (let i = resolvedDetentValues.length - 1; i >= 0; i--) {
162
+ if (resolvedDetentValues[i] === maxSnap) {
163
+ targetSnapIndex = i;
164
+ break;
165
+ }
166
+ }
167
+ }
168
+ if (targetSnapIndex !== -1) {
169
+ if (targetSnapIndex !== currentIndex.value) {
170
+ scheduleOnRN(handleIndexChange, targetSnapIndex);
171
+ }
172
+ animateToIndex(targetSnapIndex);
173
+ }
174
+ }
175
+ })
176
+ .onEnd((event) => {
177
+ 'worklet';
178
+ const wasDragging = isDraggingSheet.value;
179
+ isScrollableLocked.set(false);
180
+ isDraggingSheet.set(false);
181
+ animationTarget.set(NaN);
182
+ if (!wasDragging) {
183
+ animateToIndex(currentIndex.value);
184
+ return;
185
+ }
186
+ const maxSnap = sheetHeight.value;
187
+ const draggable = isDraggableValue.value;
188
+ const allPositions = detentsValue.value.map((detentValue, snapIndex) => ({
189
+ index: snapIndex,
190
+ translateY: maxSnap - detentValue,
191
+ isDraggable: draggable[snapIndex] ?? true,
192
+ }));
193
+ const targetIndex = findSnapTarget(
194
+ translateY.value,
195
+ event.velocityY,
196
+ currentIndex.value,
197
+ allPositions
198
+ );
199
+ const hasIndexChanged = targetIndex !== currentIndex.value;
200
+ if (hasIndexChanged) scheduleOnRN(handleIndexChange, targetIndex);
201
+ const shouldApplyVelocity =
202
+ hasIndexChanged && Number.isFinite(event.velocityY);
203
+ animateToIndex(
204
+ targetIndex,
205
+ shouldApplyVelocity ? event.velocityY : undefined
206
+ );
207
+ });
208
+ };