@react-native-ama/bottom-sheet 2.0.0-beta.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,502 @@
1
+ import * as React from "react";
2
+ import type { PropsWithChildren } from "react";
3
+ import {
4
+ Dimensions,
5
+ KeyboardAvoidingView,
6
+ LayoutChangeEvent,
7
+ Modal,
8
+ ModalProps,
9
+ Platform,
10
+ Pressable,
11
+ ScrollViewProps,
12
+ StyleSheet,
13
+ useWindowDimensions,
14
+ View,
15
+ ViewStyle,
16
+ } from "react-native";
17
+ import {
18
+ GestureDetector,
19
+ GestureHandlerRootView,
20
+ ScrollView,
21
+ } from "react-native-gesture-handler";
22
+ import Animated, {
23
+ runOnJS,
24
+ SharedValue,
25
+ useAnimatedStyle,
26
+ useDerivedValue,
27
+ useReducedMotion,
28
+ useSharedValue,
29
+ withTiming,
30
+ } from "react-native-reanimated";
31
+ import { useBottomSheetGestureHandler } from "../hooks/useBottomSheetGestureHandler";
32
+ import { useKeyboard } from "../hooks/useKeyboard";
33
+
34
+ const AnimaCore = require("@react-native-ama/core");
35
+
36
+ export type BottomSheetProps = {
37
+ animationDuration?: number;
38
+ autoCloseDelay?: number;
39
+ avoidKeyboard?: boolean;
40
+ bottomSheetStyle?: ViewStyle | ViewStyle[];
41
+ closeActionAccessibilityLabel: string;
42
+ closeDistance?: number;
43
+ footerComponent?: React.ReactElement;
44
+ handleComponent?: React.ReactElement | "none";
45
+ handleStyle?: ViewStyle | ViewStyle[];
46
+ headerComponent?: React.ReactElement;
47
+ maxHeight?: number;
48
+ minVelocityToClose?: number;
49
+ onBottomSheetHidden?: () => void;
50
+ onClose: () => void;
51
+ overlayOpacity?: number;
52
+ overlayStyle?: ViewStyle | ViewStyle[];
53
+ panGestureEnabled?: boolean;
54
+ persistent?: boolean;
55
+ hasScrollableContent?: boolean;
56
+ scrollViewProps?: Omit<
57
+ ScrollViewWrapperProps,
58
+ "testID" | "maxScrollViewHeight"
59
+ >;
60
+ testID?: string;
61
+ topInset: number;
62
+ visible: boolean;
63
+ ref?: React.RefObject<BottomSheetActions>;
64
+ shouldHandleKeyboardEvents?: boolean;
65
+ supportedOrientations?: ModalProps["supportedOrientations"];
66
+ isOverlayAccessible?: boolean;
67
+ };
68
+
69
+ export type BottomSheetActions = {
70
+ close: () => Promise<void>;
71
+ isVisible: () => boolean;
72
+ };
73
+
74
+ const isIOS = Platform.OS === "ios";
75
+ const SCREEN_HEIGHT = Dimensions.get("screen").height;
76
+
77
+ export const BottomSheet = React.forwardRef<
78
+ BottomSheetActions,
79
+ React.PropsWithChildren<BottomSheetProps>
80
+ >(
81
+ (
82
+ {
83
+ children,
84
+ visible,
85
+ onClose,
86
+ handleStyle = {},
87
+ bottomSheetStyle = {},
88
+ overlayStyle,
89
+ closeActionAccessibilityLabel,
90
+ headerComponent,
91
+ animationDuration = 300,
92
+ closeDistance = 0.3,
93
+ handleComponent,
94
+ scrollViewProps,
95
+ hasScrollableContent = true,
96
+ persistent = false,
97
+ autoCloseDelay,
98
+ panGestureEnabled = true,
99
+ testID,
100
+ overlayOpacity = 1,
101
+ footerComponent,
102
+ avoidKeyboard,
103
+ maxHeight: propMaxHeight,
104
+ minVelocityToClose = 1000,
105
+ topInset,
106
+ onBottomSheetHidden,
107
+ shouldHandleKeyboardEvents = true,
108
+ supportedOrientations,
109
+ isOverlayAccessible = true,
110
+ },
111
+ ref
112
+ ) => {
113
+ // This is used to let Reanimated animate the view out, before removing it from the tree.
114
+ const [shouldRenderContent, setShouldRenderContent] =
115
+ React.useState(visible);
116
+ const [isModalVisible, setIsModalVisible] = React.useState(false);
117
+ const translateY = useSharedValue(SCREEN_HEIGHT * 1.5);
118
+ const contentHeight = useSharedValue(0);
119
+ const dragOpacity = useSharedValue(0);
120
+ const onTimeout = AnimaCore?.useTimedAction()?.onTimeout || setTimeout;
121
+ const isMounted = React.useRef(false);
122
+ const [headerHeight, setHeaderHeight] = React.useState(-1);
123
+ const [footerHeight, setFooterHeight] = React.useState(-1);
124
+ const [handleHeight, setHandleHeight] = React.useState(-1);
125
+ const promiseResolver = React.useRef<(() => void) | null>(null);
126
+ const [maxScrollViewHeight, setMaxScrollViewHeight] = React.useState(0);
127
+ const { keyboardHeight, keyboardFinalHeight } = useKeyboard(
128
+ shouldHandleKeyboardEvents
129
+ );
130
+ const shouldReduceMotion = useReducedMotion();
131
+ const { height } = useWindowDimensions();
132
+ const maxHeight = propMaxHeight ?? height * 0.9;
133
+
134
+ const duration = shouldReduceMotion ? 0 : animationDuration;
135
+
136
+ React.useImperativeHandle(ref, () => ({
137
+ close: async () => {
138
+ if (!shouldRenderContent) {
139
+ return Promise.resolve();
140
+ }
141
+
142
+ return new Promise((resolve) => {
143
+ promiseResolver.current = resolve;
144
+
145
+ onClose();
146
+ });
147
+ },
148
+ isVisible: () => {
149
+ return shouldRenderContent;
150
+ },
151
+ }));
152
+
153
+ React.useEffect(() => {
154
+ if (visible) {
155
+ dragOpacity.value = withTiming(overlayOpacity, {
156
+ duration,
157
+ });
158
+
159
+ setIsModalVisible(true);
160
+ setShouldRenderContent(true);
161
+
162
+ if (autoCloseDelay) {
163
+ onTimeout(() => {
164
+ if (isMounted.current) {
165
+ onClose();
166
+ }
167
+ }, autoCloseDelay);
168
+ }
169
+ } else if (isModalVisible) {
170
+ const finished = () => {
171
+ setShouldRenderContent(false);
172
+ setIsModalVisible(false);
173
+
174
+ onBottomSheetHidden?.();
175
+ promiseResolver.current?.();
176
+ };
177
+
178
+ dragOpacity.value = withTiming(0, {
179
+ duration,
180
+ });
181
+
182
+ translateY.value = withTiming(
183
+ contentHeight.value,
184
+ {
185
+ duration,
186
+ },
187
+ () => {
188
+ runOnJS(finished)();
189
+ }
190
+ );
191
+ }
192
+ }, [
193
+ duration,
194
+ autoCloseDelay,
195
+ isModalVisible,
196
+ onBottomSheetHidden,
197
+ onClose,
198
+ onTimeout,
199
+ translateY,
200
+ visible,
201
+ dragOpacity,
202
+ overlayOpacity,
203
+ contentHeight.value,
204
+ ]);
205
+
206
+ React.useEffect(() => {
207
+ isMounted.current = true;
208
+
209
+ return () => {
210
+ isMounted.current = false;
211
+ };
212
+ }, []);
213
+
214
+ const dragStyle = useAnimatedStyle(() => {
215
+ return {
216
+ opacity: dragOpacity.value,
217
+ };
218
+ });
219
+
220
+ const maxHeightValue = useDerivedValue(() => {
221
+ return maxHeight - keyboardHeight.value;
222
+ }, [keyboardHeight, maxHeight]);
223
+
224
+ const animatedStyle = useAnimatedStyle(() => {
225
+ const keyboard = isIOS ? keyboardHeight.value : 0;
226
+
227
+ return {
228
+ transform: [{ translateY: translateY.value - keyboard }],
229
+ maxHeight: maxHeightValue.value,
230
+ };
231
+ }, [maxHeightValue, translateY, keyboardHeight]);
232
+
233
+ useDerivedValue(() => {
234
+ const maxScrollHeight = Math.ceil(
235
+ maxHeight -
236
+ keyboardFinalHeight.value -
237
+ footerHeight -
238
+ headerHeight -
239
+ handleHeight -
240
+ topInset
241
+ );
242
+
243
+ if (
244
+ !Number.isNaN(maxScrollHeight) &&
245
+ maxScrollHeight !== maxScrollViewHeight
246
+ ) {
247
+ runOnJS(setMaxScrollViewHeight)(maxScrollHeight);
248
+ }
249
+ }, [
250
+ footerHeight,
251
+ headerHeight,
252
+ handleHeight,
253
+ keyboardFinalHeight,
254
+ topInset,
255
+ maxHeight,
256
+ ]);
257
+
258
+ const handleOnLayout = (event: LayoutChangeEvent) => {
259
+ contentHeight.value = event.nativeEvent.layout.height;
260
+
261
+ if (translateY.value === 0) {
262
+ return;
263
+ }
264
+
265
+ translateY.value = event.nativeEvent.layout.height;
266
+ translateY.value = withTiming(0, { duration });
267
+ };
268
+
269
+ const maybeCloseBottomSheet = persistent ? undefined : onClose;
270
+
271
+ const Wrapper =
272
+ avoidKeyboard && !shouldHandleKeyboardEvents
273
+ ? BottomSheetKeyboardAvoidingView
274
+ : React.Fragment;
275
+
276
+ const opacity = [footerHeight, headerHeight, handleHeight].every(
277
+ (h) => h >= 0
278
+ )
279
+ ? 1
280
+ : 0;
281
+
282
+ const ContentWrapper = hasScrollableContent
283
+ ? ScrollViewWrapper
284
+ : FragmentWrapper;
285
+
286
+ return (
287
+ <Modal
288
+ animationType="none"
289
+ transparent={true}
290
+ visible={isModalVisible}
291
+ onRequestClose={onClose}
292
+ ref={ref as any}
293
+ testID={testID}
294
+ supportedOrientations={supportedOrientations}
295
+ statusBarTranslucent={true}
296
+ >
297
+ <Wrapper>
298
+ {shouldRenderContent ? (
299
+ <GestureHandlerRootView style={{ flex: 1, opacity }}>
300
+ <Animated.View
301
+ style={[styles.overlay, overlayStyle, dragStyle]}
302
+ testID={`${testID}-overlay-wrapper`}
303
+ >
304
+ <Pressable
305
+ style={styles.closeButton}
306
+ accessibilityRole="button"
307
+ onPress={maybeCloseBottomSheet}
308
+ testID={`${testID}-overlay-button`}
309
+ accessible={isOverlayAccessible && !persistent}
310
+ importantForAccessibility={
311
+ persistent ? "no-hide-descendants" : "yes"
312
+ }
313
+ accessibilityElementsHidden={persistent}
314
+ onAccessibilityTap={maybeCloseBottomSheet}
315
+ accessibilityLabel={closeActionAccessibilityLabel}
316
+ />
317
+ </Animated.View>
318
+ <Animated.View
319
+ style={[
320
+ styles.contentWrapper,
321
+ styles.content,
322
+ bottomSheetStyle,
323
+ animatedStyle,
324
+ ]}
325
+ testID={`${testID}-wrapper`}
326
+ onLayout={handleOnLayout}
327
+ >
328
+ <GestureHandler
329
+ translateY={translateY}
330
+ closeDistance={closeDistance}
331
+ contentHeight={contentHeight}
332
+ onClose={onClose}
333
+ testID={`${testID}-gesture-handler`}
334
+ overlayOpacity={overlayOpacity}
335
+ dragOpacity={dragOpacity}
336
+ minVelocityToClose={minVelocityToClose}
337
+ panGestureEnabled={panGestureEnabled}
338
+ >
339
+ <View
340
+ onLayout={(event) => {
341
+ setHandleHeight(event.nativeEvent.layout.height);
342
+ }}
343
+ >
344
+ {handleComponent === "none"
345
+ ? null
346
+ : handleComponent ?? (
347
+ <View
348
+ style={[styles.line, handleStyle]}
349
+ testID={`${testID}-line`}
350
+ />
351
+ )}
352
+ </View>
353
+ <View
354
+ onLayout={(event) => {
355
+ setHeaderHeight(event.nativeEvent.layout.height);
356
+ }}
357
+ >
358
+ {headerComponent}
359
+ </View>
360
+ <ContentWrapper
361
+ {...scrollViewProps}
362
+ testID={testID}
363
+ maxScrollViewHeight={maxScrollViewHeight}
364
+ >
365
+ {children}
366
+ </ContentWrapper>
367
+ <View
368
+ onLayout={(event) => {
369
+ setFooterHeight(event.nativeEvent.layout.height);
370
+ }}
371
+ >
372
+ {footerComponent}
373
+ </View>
374
+ </GestureHandler>
375
+ </Animated.View>
376
+ </GestureHandlerRootView>
377
+ ) : null}
378
+ </Wrapper>
379
+ </Modal>
380
+ );
381
+ }
382
+ );
383
+
384
+ const BottomSheetKeyboardAvoidingView = ({
385
+ children,
386
+ }: React.PropsWithChildren<{}>) => {
387
+ return (
388
+ <KeyboardAvoidingView
389
+ behavior={Platform.OS === "ios" ? "padding" : undefined}
390
+ pointerEvents="box-none"
391
+ style={{ flex: 1 }}
392
+ >
393
+ {children}
394
+ </KeyboardAvoidingView>
395
+ );
396
+ };
397
+
398
+ type GestureHandlerProps = PropsWithChildren<{
399
+ translateY: SharedValue<number>;
400
+ contentHeight: SharedValue<number>;
401
+ dragOpacity: SharedValue<number>;
402
+ overlayOpacity: number;
403
+ closeDistance: number;
404
+ onClose: () => void;
405
+ panGestureEnabled: boolean;
406
+ testID?: string;
407
+ minVelocityToClose: number;
408
+ }>;
409
+
410
+ const GestureHandler = ({
411
+ translateY,
412
+ closeDistance,
413
+ contentHeight,
414
+ children,
415
+ onClose,
416
+ panGestureEnabled,
417
+ testID,
418
+ dragOpacity,
419
+ overlayOpacity,
420
+ minVelocityToClose,
421
+ }: GestureHandlerProps) => {
422
+ const { gestureHandler } = useBottomSheetGestureHandler({
423
+ translateY,
424
+ closeDistance,
425
+ contentHeight,
426
+ onClose,
427
+ dragOpacity,
428
+ overlayOpacity,
429
+ minVelocityToClose,
430
+ });
431
+
432
+ return panGestureEnabled ? (
433
+ <GestureDetector gesture={gestureHandler}>
434
+ <Animated.View testID={testID}>{children}</Animated.View>
435
+ </GestureDetector>
436
+ ) : (
437
+ <Animated.View testID={testID}>{children}</Animated.View>
438
+ );
439
+ };
440
+
441
+ type ScrollViewWrapperProps = {
442
+ testID?: string;
443
+ maxScrollViewHeight: number;
444
+ } & ScrollViewProps;
445
+
446
+ const ScrollViewWrapper: React.FC<ScrollViewWrapperProps> = ({
447
+ scrollEnabled = true,
448
+ testID,
449
+ maxScrollViewHeight,
450
+ children,
451
+ style,
452
+ ...rest
453
+ }) => {
454
+ return (
455
+ <ScrollView
456
+ {...rest}
457
+ style={[{ maxHeight: maxScrollViewHeight }, style]}
458
+ scrollEnabled={scrollEnabled}
459
+ testID={`${testID}-scrollview`}
460
+ >
461
+ {children}
462
+ </ScrollView>
463
+ );
464
+ };
465
+
466
+ const FragmentWrapper: React.FC<React.PropsWithChildren<any>> = ({
467
+ children,
468
+ }) => {
469
+ return <>{children}</>;
470
+ };
471
+
472
+ const styles = StyleSheet.create({
473
+ overlay: {
474
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
475
+ flex: 1,
476
+ },
477
+ closeButton: {
478
+ flex: 1,
479
+ },
480
+ contentWrapper: {
481
+ flexDirection: "column",
482
+ flex: 1,
483
+ position: "absolute",
484
+ bottom: 0,
485
+ alignSelf: "flex-end",
486
+ width: "100%",
487
+ },
488
+ content: {
489
+ width: "100%",
490
+ backgroundColor: "#fff",
491
+ flex: 1,
492
+ },
493
+ line: {
494
+ width: 48,
495
+ height: 4,
496
+ backgroundColor: "grey",
497
+ alignSelf: "center",
498
+ marginBottom: 24,
499
+ borderRadius: 2,
500
+ marginTop: 12,
501
+ },
502
+ });
@@ -0,0 +1,158 @@
1
+ import { renderHook } from '@testing-library/react-native';
2
+ import { SharedValue } from 'react-native-reanimated';
3
+ import { useBottomSheetGestureHandler } from './useBottomSheetGestureHandler';
4
+
5
+ var mockWithTiming: jest.Mock;
6
+ var mockRunOnJS: jest.Mock;
7
+ var mockStartY: { value: number };
8
+
9
+ jest.mock('react-native-reanimated', () => {
10
+ mockWithTiming = jest.fn(value => value);
11
+ mockRunOnJS = jest.fn(callback => callback);
12
+ mockStartY = { value: 0 };
13
+
14
+ return {
15
+ SharedValue: undefined,
16
+ runOnJS: mockRunOnJS,
17
+ useReducedMotion: jest.fn(() => false),
18
+ useAnimatedStyle: jest.fn(),
19
+ useSharedValue: jest.fn(() => mockStartY),
20
+ withTiming: mockWithTiming,
21
+ };
22
+ });
23
+
24
+ jest.mock('react-native-gesture-handler', () => {
25
+ return {
26
+ Gesture: {
27
+ Pan: jest.fn(() => {
28
+ const handler: any = {};
29
+ handler.onStart = (cb: any) => { handler._onStart = cb; return handler; };
30
+ handler.onUpdate = (cb: any) => { handler._onUpdate = cb; return handler; };
31
+ handler.onEnd = (cb: any) => { handler._onEnd = cb; return handler; };
32
+ return handler;
33
+ }),
34
+ },
35
+ GestureDetector: ({ children }: any) => children,
36
+ GestureHandlerRootView: ({ children }: any) => children,
37
+ ScrollView: 'ScrollView',
38
+ };
39
+ });
40
+
41
+ beforeEach(() => {
42
+ jest.clearAllMocks();
43
+ mockStartY = { value: 0 };
44
+ });
45
+
46
+ describe('useBottomSheetGestureHandler', () => {
47
+ it('onStart stores the current translateY position', () => {
48
+ const translateY = { value: 42 } as SharedValue<number>;
49
+ const { result } = renderHook(() =>
50
+ useBottomSheetGestureHandler({
51
+ translateY,
52
+ closeDistance: 0.5,
53
+ contentHeight: { value: 100 } as SharedValue<number>,
54
+ dragOpacity: { value: 0 } as SharedValue<number>,
55
+ minVelocityToClose: 0,
56
+ overlayOpacity: 0,
57
+ onClose: () => {},
58
+ }),
59
+ );
60
+
61
+ (result.current.gestureHandler as any)._onStart(null);
62
+
63
+ expect(mockStartY.value).toBe(42);
64
+ });
65
+
66
+ it('onUpdate updates the translateY limiting it to be >= 0', () => {
67
+ const translateY = { value: 42 } as SharedValue<number>;
68
+ const { result } = renderHook(() =>
69
+ useBottomSheetGestureHandler({
70
+ dragOpacity: { value: 0 } as SharedValue<number>,
71
+ minVelocityToClose: 0,
72
+ overlayOpacity: 0,
73
+ translateY,
74
+ closeDistance: 0.5,
75
+ contentHeight: { value: 100 } as SharedValue<number>,
76
+ onClose: () => {},
77
+ }),
78
+ );
79
+
80
+ (result.current.gestureHandler as any)._onStart(null);
81
+ (result.current.gestureHandler as any)._onUpdate({ translationY: 100 });
82
+
83
+ expect(translateY.value).toBe(142);
84
+
85
+ (result.current.gestureHandler as any)._onUpdate({ translationY: -500 });
86
+
87
+ expect(translateY.value).toBe(0);
88
+ });
89
+
90
+ describe('onEnd', () => {
91
+ it('animates back translateY to 0 when the distance is less than the specified one', () => {
92
+ const translateY = { value: 0 };
93
+ const { result } = renderHook(() =>
94
+ useBottomSheetGestureHandler({
95
+ translateY,
96
+ closeDistance: 0.5,
97
+ contentHeight: { value: 500 },
98
+ onClose: () => {},
99
+ dragOpacity: { value: 0 },
100
+ minVelocityToClose: 1000,
101
+ } as any),
102
+ );
103
+
104
+ (result.current.gestureHandler as any)._onUpdate({ translationY: 249 });
105
+ (result.current.gestureHandler as any)._onEnd({ velocityY: 100 });
106
+
107
+ expect(translateY.value).toBe(0);
108
+ expect(mockWithTiming).toHaveBeenCalledWith(0, { duration: 300 });
109
+ });
110
+
111
+ it('calls the onClose event when the distance is at least the requested one', () => {
112
+ const translateY = { value: 0 } as SharedValue<number>;
113
+ const onClose = jest.fn();
114
+
115
+ const { result } = renderHook(() =>
116
+ useBottomSheetGestureHandler({
117
+ translateY,
118
+ closeDistance: 0.5,
119
+ dragOpacity: { value: 0 } as SharedValue<number>,
120
+ minVelocityToClose: 0,
121
+ overlayOpacity: 0,
122
+ contentHeight: { value: 500 } as SharedValue<number>,
123
+ onClose,
124
+ }),
125
+ );
126
+
127
+ (result.current.gestureHandler as any)._onUpdate({ translationY: 250 });
128
+ (result.current.gestureHandler as any)._onEnd(null);
129
+
130
+ expect(mockRunOnJS).toHaveBeenCalledWith(onClose);
131
+ expect(onClose).toHaveBeenCalledWith();
132
+ expect(mockWithTiming).not.toHaveBeenCalled();
133
+ });
134
+
135
+ it('calls onClose when the velocity is bigger than minVelocityToClose', () => {
136
+ const onClose = jest.fn();
137
+
138
+ const translateY = { value: 0 };
139
+ const { result } = renderHook(() =>
140
+ useBottomSheetGestureHandler({
141
+ translateY,
142
+ closeDistance: 0.9,
143
+ contentHeight: { value: 500 },
144
+ onClose,
145
+ dragOpacity: { value: 0 },
146
+ minVelocityToClose: 1000,
147
+ } as any),
148
+ );
149
+
150
+ (result.current.gestureHandler as any)._onUpdate({ translationY: 42 });
151
+ (result.current.gestureHandler as any)._onEnd({ velocityY: 1001 });
152
+
153
+ expect(mockRunOnJS).toHaveBeenCalledWith(onClose);
154
+ expect(onClose).toHaveBeenCalledWith();
155
+ expect(mockWithTiming).not.toHaveBeenCalled();
156
+ });
157
+ });
158
+ });
@@ -0,0 +1,62 @@
1
+ import { Gesture } from 'react-native-gesture-handler';
2
+ import {
3
+ runOnJS,
4
+ SharedValue,
5
+ useReducedMotion,
6
+ useSharedValue,
7
+ withTiming,
8
+ } from 'react-native-reanimated';
9
+
10
+ type UseBottomSheetGestureHandler = {
11
+ translateY: SharedValue<number>;
12
+ contentHeight: SharedValue<number>;
13
+ dragOpacity: SharedValue<number>;
14
+ closeDistance: number;
15
+ overlayOpacity: number;
16
+ onClose: () => void;
17
+ minVelocityToClose: number;
18
+ };
19
+
20
+ export const useBottomSheetGestureHandler = ({
21
+ translateY,
22
+ closeDistance,
23
+ contentHeight,
24
+ onClose,
25
+ dragOpacity,
26
+ overlayOpacity,
27
+ minVelocityToClose,
28
+ }: UseBottomSheetGestureHandler) => {
29
+ const shouldReduceMotion = useReducedMotion();
30
+ const startY = useSharedValue(0);
31
+
32
+ const gestureHandler = Gesture.Pan()
33
+ .onStart(() => {
34
+ startY.value = translateY.value;
35
+ })
36
+ .onUpdate(event => {
37
+ translateY.value = Math.max(0, startY.value + event.translationY);
38
+
39
+ const distance = contentHeight.value - translateY.value;
40
+ const opacity = Math.min(distance / contentHeight.value, overlayOpacity);
41
+
42
+ dragOpacity.value = opacity;
43
+ })
44
+ .onEnd(event => {
45
+ const minimumDistanceToClose = contentHeight.value * closeDistance;
46
+ const shouldCloseBottomSheet =
47
+ translateY.value >= minimumDistanceToClose ||
48
+ event.velocityY >= minVelocityToClose;
49
+
50
+ if (shouldCloseBottomSheet) {
51
+ runOnJS(onClose)();
52
+ } else {
53
+ translateY.value = withTiming(0, {
54
+ duration: shouldReduceMotion ? 0 : 300,
55
+ });
56
+ }
57
+ });
58
+
59
+ return {
60
+ gestureHandler,
61
+ };
62
+ };