@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.
- package/README.md +28 -0
- package/lib/module/BottomSheetBase.js +28 -129
- package/lib/module/BottomSheetBase.js.map +1 -1
- package/lib/module/BottomSheetProvider.js.map +1 -1
- package/lib/module/bottomSheetUtils.js +55 -0
- package/lib/module/bottomSheetUtils.js.map +1 -0
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/useBottomSheetPanGesture.js +149 -0
- package/lib/module/useBottomSheetPanGesture.js.map +1 -0
- package/lib/typescript/src/BottomSheetBase.d.ts +3 -1
- package/lib/typescript/src/BottomSheetBase.d.ts.map +1 -1
- package/lib/typescript/src/BottomSheetProvider.d.ts.map +1 -1
- package/lib/typescript/src/bottomSheetUtils.d.ts +16 -0
- package/lib/typescript/src/bottomSheetUtils.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +2 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/useBottomSheetPanGesture.d.ts +20 -0
- package/lib/typescript/src/useBottomSheetPanGesture.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/BottomSheetBase.tsx +57 -162
- package/src/BottomSheetProvider.tsx +4 -0
- package/src/bottomSheetUtils.ts +82 -0
- package/src/index.tsx +2 -1
- package/src/useBottomSheetPanGesture.ts +208 -0
|
@@ -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
package/src/BottomSheetBase.tsx
CHANGED
|
@@ -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 {
|
|
17
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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((
|
|
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((
|
|
149
|
-
}, [
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
+
};
|