@oxyhq/bloom 0.3.11 → 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/commonjs/bottom-sheet/index.js +339 -96
- package/lib/commonjs/bottom-sheet/index.js.map +1 -1
- package/lib/commonjs/dialog/Dialog.js +3 -10
- package/lib/commonjs/dialog/Dialog.js.map +1 -1
- package/lib/module/bottom-sheet/index.js +339 -96
- package/lib/module/bottom-sheet/index.js.map +1 -1
- package/lib/module/dialog/Dialog.js +3 -10
- package/lib/module/dialog/Dialog.js.map +1 -1
- package/lib/typescript/commonjs/bottom-sheet/index.d.ts +46 -0
- package/lib/typescript/commonjs/bottom-sheet/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/dialog/Dialog.d.ts +1 -1
- package/lib/typescript/commonjs/dialog/Dialog.d.ts.map +1 -1
- package/lib/typescript/commonjs/dialog/types.d.ts +5 -0
- package/lib/typescript/commonjs/dialog/types.d.ts.map +1 -1
- package/lib/typescript/module/bottom-sheet/index.d.ts +46 -0
- package/lib/typescript/module/bottom-sheet/index.d.ts.map +1 -1
- package/lib/typescript/module/dialog/Dialog.d.ts +1 -1
- package/lib/typescript/module/dialog/Dialog.d.ts.map +1 -1
- package/lib/typescript/module/dialog/types.d.ts +5 -0
- package/lib/typescript/module/dialog/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/BottomSheet.test.tsx +149 -2
- package/src/bottom-sheet/index.tsx +364 -80
- package/src/dialog/Dialog.tsx +9 -16
- package/src/dialog/types.ts +5 -0
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
type ViewStyle,
|
|
11
11
|
type StyleProp,
|
|
12
12
|
} from 'react-native';
|
|
13
|
-
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler';
|
|
13
|
+
import { Gesture, GestureDetector, GestureHandlerRootView, type GestureType } from 'react-native-gesture-handler';
|
|
14
14
|
import Animated, {
|
|
15
15
|
interpolate,
|
|
16
16
|
runOnJS,
|
|
@@ -93,6 +93,52 @@ export interface BottomSheetProps {
|
|
|
93
93
|
* bleed through.
|
|
94
94
|
*/
|
|
95
95
|
backdropOpacity?: number;
|
|
96
|
+
/**
|
|
97
|
+
* When `true` (default), children are wrapped in an internal scrollable
|
|
98
|
+
* container — convenient for vertical content that can overflow.
|
|
99
|
+
*
|
|
100
|
+
* Set to `false` when the screen owns its own scrolling primitive
|
|
101
|
+
* (e.g. a `FlatList`, `SectionList`, or any other VirtualizedList).
|
|
102
|
+
* Nesting a VirtualizedList inside the internal ScrollView would break
|
|
103
|
+
* windowing/keyboard handling and trigger a React Native warning. In
|
|
104
|
+
* non-scrollable mode the screen receives the full available height
|
|
105
|
+
* (minus the drag handle) and must manage its own overflow.
|
|
106
|
+
*/
|
|
107
|
+
scrollable?: boolean;
|
|
108
|
+
/**
|
|
109
|
+
* When `true`, the body pan uses RNGH's `manualActivation` and only
|
|
110
|
+
* activates when (a) the inner ScrollView is at the top AND (b) the user
|
|
111
|
+
* has moved their finger downward by > 8dp. This is the @gorhom/bottom-sheet
|
|
112
|
+
* coordination model — recommended for sheets containing scrollable content
|
|
113
|
+
* on Android (avoids stealing vertical events from the inner scroller).
|
|
114
|
+
*
|
|
115
|
+
* When `false` (default), the body pan is always active and gates on the
|
|
116
|
+
* scroll offset at `onStart` time. This is the legacy behavior, preserved
|
|
117
|
+
* for backwards compatibility with current bloom consumers.
|
|
118
|
+
*
|
|
119
|
+
* Enabling this also splits the drag handle into its own dedicated,
|
|
120
|
+
* unconditional pan so users can always grab the handle to drag — even
|
|
121
|
+
* when the inner ScrollView is mid-scroll.
|
|
122
|
+
*/
|
|
123
|
+
manualActivation?: boolean;
|
|
124
|
+
/**
|
|
125
|
+
* When `true`, the backdrop dims proportionally with drag distance — the
|
|
126
|
+
* overlay fades from full opacity (sheet at rest) to 30% as the sheet is
|
|
127
|
+
* pulled down 40% of the screen height. iOS Photos style. The base
|
|
128
|
+
* `backdropOpacity` still controls the resting dim level.
|
|
129
|
+
*
|
|
130
|
+
* Defaults to `false` (constant opacity during drag).
|
|
131
|
+
*/
|
|
132
|
+
dynamicBackdrop?: boolean;
|
|
133
|
+
/**
|
|
134
|
+
* Custom handle slot. When provided, replaces the default drag handle
|
|
135
|
+
* (the 36×5 pill). The rendered handle is wrapped in the dedicated handle
|
|
136
|
+
* gesture detector (when `manualActivation` is `true`) so it remains
|
|
137
|
+
* unconditionally draggable. `showHandle={false}` still suppresses any
|
|
138
|
+
* handle rendering — `handleComponent` is only consulted when
|
|
139
|
+
* `showHandle` is `true`.
|
|
140
|
+
*/
|
|
141
|
+
handleComponent?: () => React.ReactNode;
|
|
96
142
|
}
|
|
97
143
|
|
|
98
144
|
const BottomSheet = forwardRef((props: BottomSheetProps, ref: React.ForwardedRef<BottomSheetRef>) => {
|
|
@@ -108,6 +154,10 @@ const BottomSheet = forwardRef((props: BottomSheetProps, ref: React.ForwardedRef
|
|
|
108
154
|
detached = false,
|
|
109
155
|
showHandle = true,
|
|
110
156
|
backdropOpacity = 0.5,
|
|
157
|
+
scrollable = true,
|
|
158
|
+
manualActivation = false,
|
|
159
|
+
dynamicBackdrop = false,
|
|
160
|
+
handleComponent,
|
|
111
161
|
} = props;
|
|
112
162
|
|
|
113
163
|
const insets = useSafeAreaInsets();
|
|
@@ -119,6 +169,16 @@ const BottomSheet = forwardRef((props: BottomSheetProps, ref: React.ForwardedRef
|
|
|
119
169
|
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
120
170
|
const hasClosedRef = useRef(false);
|
|
121
171
|
const scrollViewRef = useRef<Animated.ScrollView>(null);
|
|
172
|
+
/**
|
|
173
|
+
* Monotonically increasing counter that identifies "the current close
|
|
174
|
+
* attempt". Bumped every time the sheet re-opens (`present()`), so any
|
|
175
|
+
* in-flight `withTiming` completion callback or fallback timer from a
|
|
176
|
+
* PREVIOUS close cycle becomes a no-op. Without this guard, a stale
|
|
177
|
+
* `runOnJS(finishClose)` from an aborted close would fire `onDismiss`
|
|
178
|
+
* and unmount the sheet immediately after the user opens it again,
|
|
179
|
+
* causing "tap to open does nothing" reports in production.
|
|
180
|
+
*/
|
|
181
|
+
const closeGenerationRef = useRef(0);
|
|
122
182
|
|
|
123
183
|
const screenHeightSV = useSharedValue(screenHeight);
|
|
124
184
|
// Keep shared value in sync when screen dimensions change (rotation/resize)
|
|
@@ -133,6 +193,21 @@ const BottomSheet = forwardRef((props: BottomSheetProps, ref: React.ForwardedRef
|
|
|
133
193
|
const allowPanClose = useSharedValue(true);
|
|
134
194
|
const keyboardHeight = useSharedValue(0);
|
|
135
195
|
const context = useSharedValue({ y: 0 });
|
|
196
|
+
// Mirror of `closeGenerationRef` for worklet access. Bumped from the JS
|
|
197
|
+
// thread in lockstep with the ref so gesture worklets always see the
|
|
198
|
+
// current generation when they snapshot it on `onEnd`.
|
|
199
|
+
const closeGeneration = useSharedValue(0);
|
|
200
|
+
// Used by `manualActivation` body pan to track the touch's initial Y so
|
|
201
|
+
// it can compute downward distance for the activation decision.
|
|
202
|
+
const touchStartY = useSharedValue(0);
|
|
203
|
+
|
|
204
|
+
// Refs used to mark the handle pan and the body pan as mutually
|
|
205
|
+
// simultaneous (manualActivation mode only). Without this RNGH treats them
|
|
206
|
+
// as racing gestures and a touch that begins in the handle area could be
|
|
207
|
+
// claimed by whichever recognizer activates first — leading to
|
|
208
|
+
// inconsistent drag start.
|
|
209
|
+
const bodyPanRef = useRef<GestureType | undefined>(undefined);
|
|
210
|
+
const handlePanRef = useRef<GestureType | undefined>(undefined);
|
|
136
211
|
|
|
137
212
|
useKeyboardHandler({
|
|
138
213
|
onMove: (e) => {
|
|
@@ -166,7 +241,19 @@ const BottomSheet = forwardRef((props: BottomSheetProps, ref: React.ForwardedRef
|
|
|
166
241
|
renderedRef.current = rendered;
|
|
167
242
|
}, [rendered]);
|
|
168
243
|
|
|
169
|
-
|
|
244
|
+
/**
|
|
245
|
+
* Commit a close. Two guards prevent stale callbacks from firing:
|
|
246
|
+
* 1. `hasClosedRef` — protects against the fallback timer AND the
|
|
247
|
+
* animation callback both racing to call us within a single close
|
|
248
|
+
* cycle.
|
|
249
|
+
* 2. `generation` — protects against a callback from a PREVIOUS close
|
|
250
|
+
* cycle firing AFTER the user reopened. If the live generation has
|
|
251
|
+
* advanced past the one captured when the close started, this
|
|
252
|
+
* callback is from a cycle that the user has implicitly cancelled
|
|
253
|
+
* by reopening — silently drop it.
|
|
254
|
+
*/
|
|
255
|
+
const finishClose = useCallback((generation: number) => {
|
|
256
|
+
if (closeGenerationRef.current !== generation) return;
|
|
170
257
|
if (hasClosedRef.current) return;
|
|
171
258
|
hasClosedRef.current = true;
|
|
172
259
|
safeClose();
|
|
@@ -180,26 +267,36 @@ const BottomSheet = forwardRef((props: BottomSheetProps, ref: React.ForwardedRef
|
|
|
180
267
|
closeTimeoutRef.current = null;
|
|
181
268
|
}
|
|
182
269
|
hasClosedRef.current = false;
|
|
270
|
+
// Bump generation: any pending close-completion callback from a
|
|
271
|
+
// prior cycle (animation or fallback timer) will now no-op when
|
|
272
|
+
// it eventually fires, because its captured generation is stale.
|
|
273
|
+
closeGenerationRef.current += 1;
|
|
274
|
+
closeGeneration.value = closeGenerationRef.current;
|
|
183
275
|
opacity.value = withTiming(1, { duration: 250 });
|
|
184
276
|
translateY.value = withSpring(0, SPRING_CONFIG);
|
|
185
277
|
} else if (rendered) {
|
|
278
|
+
// Capture the generation for THIS close cycle so the animation
|
|
279
|
+
// callback (running on the UI thread, scheduled back to JS) and
|
|
280
|
+
// the fallback timer agree on which cycle they belong to.
|
|
281
|
+
const generation = closeGenerationRef.current;
|
|
186
282
|
opacity.value = withTiming(0, { duration: 250 }, (finished) => {
|
|
187
283
|
if (finished) {
|
|
188
|
-
runOnJS(finishClose)();
|
|
284
|
+
runOnJS(finishClose)(generation);
|
|
189
285
|
}
|
|
190
286
|
});
|
|
191
287
|
translateY.value = withSpring(screenHeight, { ...SPRING_CONFIG, stiffness: 250 });
|
|
192
288
|
|
|
193
|
-
// Fallback timer to ensure close completes (especially on web
|
|
289
|
+
// Fallback timer to ensure close completes (especially on web
|
|
290
|
+
// where reanimated callbacks occasionally drop on tab blur).
|
|
194
291
|
if (closeTimeoutRef.current) {
|
|
195
292
|
clearTimeout(closeTimeoutRef.current);
|
|
196
293
|
}
|
|
197
294
|
closeTimeoutRef.current = setTimeout(() => {
|
|
198
|
-
finishClose();
|
|
295
|
+
finishClose(generation);
|
|
199
296
|
closeTimeoutRef.current = null;
|
|
200
297
|
}, 300);
|
|
201
298
|
}
|
|
202
|
-
}, [visible, rendered, finishClose]);
|
|
299
|
+
}, [visible, rendered, finishClose, screenHeight, closeGeneration, opacity, translateY]);
|
|
203
300
|
|
|
204
301
|
// On unmount: ensure pending close callbacks (e.g. consumer's `onDismiss`)
|
|
205
302
|
// still fire if the BS is yanked mid-animation by a parent re-render.
|
|
@@ -252,47 +349,61 @@ const BottomSheet = forwardRef((props: BottomSheetProps, ref: React.ForwardedRef
|
|
|
252
349
|
|
|
253
350
|
const nativeGesture = useMemo(() => Gesture.Native(), []);
|
|
254
351
|
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
352
|
+
// Body pan — two strategies, switched by `manualActivation`.
|
|
353
|
+
//
|
|
354
|
+
// (1) Legacy mode (`manualActivation: false`, the bloom 0.3.x default):
|
|
355
|
+
// Pan is always active. We snapshot the scroll offset at `onStart`
|
|
356
|
+
// and gate movement on `scrollOffsetY <= 8` during the gesture.
|
|
357
|
+
// This is what current bloom consumers expect.
|
|
358
|
+
//
|
|
359
|
+
// (2) gorhom-style mode (`manualActivation: true`):
|
|
360
|
+
// Pan uses `manualActivation(true)` and only flips to active when
|
|
361
|
+
// the inner ScrollView is at the top AND the user has moved their
|
|
362
|
+
// finger downward > 8dp. In every other case it fails, so the
|
|
363
|
+
// ScrollView keeps full ownership of the touch. This is the only
|
|
364
|
+
// RNGH 2.x pattern that does not steal vertical events from the
|
|
365
|
+
// inner scroller on Android. Required for FileManagement /
|
|
366
|
+
// PhotoPicker style sheets.
|
|
367
|
+
const panGesture = useMemo(() => {
|
|
368
|
+
if (manualActivation) {
|
|
369
|
+
return Gesture.Pan()
|
|
260
370
|
.enabled(enablePanDownToClose)
|
|
261
|
-
.
|
|
262
|
-
.
|
|
371
|
+
.withRef(bodyPanRef)
|
|
372
|
+
.manualActivation(true)
|
|
373
|
+
.simultaneousWithExternalGesture(scrollViewRef, handlePanRef)
|
|
374
|
+
.onTouchesDown((e) => {
|
|
263
375
|
'worklet';
|
|
376
|
+
const t = e.changedTouches[0];
|
|
377
|
+
if (t) touchStartY.value = t.absoluteY;
|
|
264
378
|
context.value = { y: translateY.value };
|
|
265
|
-
allowPanClose.value = scrollOffsetY.value <= 8;
|
|
266
379
|
})
|
|
267
|
-
.
|
|
380
|
+
.onTouchesMove((e, state) => {
|
|
268
381
|
'worklet';
|
|
269
|
-
|
|
270
|
-
|
|
382
|
+
const t = e.changedTouches[0];
|
|
383
|
+
if (!t) return;
|
|
384
|
+
const dy = t.absoluteY - touchStartY.value;
|
|
385
|
+
const atTop = scrollOffsetY.value <= 4;
|
|
386
|
+
// Activate only when (at scroll top) AND (finger has moved
|
|
387
|
+
// downward by > 8dp). Any other motion: fail so the
|
|
388
|
+
// ScrollView claims the gesture.
|
|
389
|
+
if (atTop && dy > 8) {
|
|
390
|
+
state.activate();
|
|
391
|
+
} else if (dy < -4 || !atTop) {
|
|
392
|
+
state.fail();
|
|
271
393
|
}
|
|
394
|
+
})
|
|
395
|
+
.onUpdate((event) => {
|
|
396
|
+
'worklet';
|
|
397
|
+
if (event.translationY < 0) return;
|
|
272
398
|
const newTranslateY = context.value.y + event.translationY;
|
|
273
|
-
// If user is scrolling down while content isn't at (or near) the top, let ScrollView handle it
|
|
274
|
-
const atTopOrNearTop = scrollOffsetY.value <= 8; // slightly larger tolerance for smoother handoff
|
|
275
|
-
if (event.translationY > 0 && !atTopOrNearTop) {
|
|
276
|
-
return;
|
|
277
|
-
}
|
|
278
399
|
if (newTranslateY >= 0) {
|
|
279
400
|
translateY.value = newTranslateY;
|
|
280
|
-
} else if (detached) {
|
|
281
|
-
// Only allow overdrag (pulling up beyond top) when detached
|
|
282
|
-
translateY.value = newTranslateY * 0.3;
|
|
283
|
-
} else {
|
|
284
|
-
// In normal mode, prevent overdrag - clamp to 0
|
|
285
|
-
translateY.value = 0;
|
|
286
401
|
}
|
|
287
402
|
})
|
|
288
403
|
.onEnd((event) => {
|
|
289
404
|
'worklet';
|
|
290
|
-
if (!allowPanClose.value) {
|
|
291
|
-
return;
|
|
292
|
-
}
|
|
293
405
|
const velocity = event.velocityY;
|
|
294
406
|
const distance = translateY.value;
|
|
295
|
-
// Require a deeper pull to close (more like native bottom sheets)
|
|
296
407
|
const closeThreshold = Math.max(140, screenHeightSV.value * 0.25);
|
|
297
408
|
const fastSwipeThreshold = 900;
|
|
298
409
|
const shouldClose =
|
|
@@ -300,35 +411,156 @@ const BottomSheet = forwardRef((props: BottomSheetProps, ref: React.ForwardedRef
|
|
|
300
411
|
(distance > closeThreshold && velocity > -300);
|
|
301
412
|
|
|
302
413
|
if (shouldClose) {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
414
|
+
// Snapshot the generation on the UI thread at the
|
|
415
|
+
// moment the close gesture commits. The completion
|
|
416
|
+
// callback only fires `finishClose` if no reopen
|
|
417
|
+
// bumped the generation in between.
|
|
418
|
+
const generation = closeGeneration.value;
|
|
419
|
+
translateY.value = withSpring(screenHeightSV.value, { ...SPRING_CONFIG, velocity });
|
|
307
420
|
opacity.value = withTiming(0, { duration: 250 }, (finished) => {
|
|
308
|
-
if (finished)
|
|
309
|
-
runOnJS(finishClose)();
|
|
310
|
-
}
|
|
421
|
+
if (finished) runOnJS(finishClose)(generation);
|
|
311
422
|
});
|
|
312
423
|
} else {
|
|
313
|
-
translateY.value = withSpring(0, {
|
|
314
|
-
...SPRING_CONFIG,
|
|
315
|
-
velocity: velocity,
|
|
316
|
-
});
|
|
424
|
+
translateY.value = withSpring(0, { ...SPRING_CONFIG, velocity });
|
|
317
425
|
}
|
|
318
|
-
})
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
//
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Legacy always-active pan (bloom 0.3.x behaviour).
|
|
430
|
+
return Gesture.Pan()
|
|
431
|
+
.enabled(enablePanDownToClose)
|
|
432
|
+
.simultaneousWithExternalGesture(nativeGesture)
|
|
433
|
+
.onStart(() => {
|
|
434
|
+
'worklet';
|
|
435
|
+
context.value = { y: translateY.value };
|
|
436
|
+
allowPanClose.value = scrollOffsetY.value <= 8;
|
|
437
|
+
})
|
|
438
|
+
.onUpdate((event) => {
|
|
439
|
+
'worklet';
|
|
440
|
+
if (!allowPanClose.value) {
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const newTranslateY = context.value.y + event.translationY;
|
|
444
|
+
// If user is scrolling down while content isn't at (or near) the top, let ScrollView handle it
|
|
445
|
+
const atTopOrNearTop = scrollOffsetY.value <= 8; // slightly larger tolerance for smoother handoff
|
|
446
|
+
if (event.translationY > 0 && !atTopOrNearTop) {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (newTranslateY >= 0) {
|
|
450
|
+
translateY.value = newTranslateY;
|
|
451
|
+
} else if (detached) {
|
|
452
|
+
// Only allow overdrag (pulling up beyond top) when detached
|
|
453
|
+
translateY.value = newTranslateY * 0.3;
|
|
454
|
+
} else {
|
|
455
|
+
// In normal mode, prevent overdrag - clamp to 0
|
|
456
|
+
translateY.value = 0;
|
|
457
|
+
}
|
|
458
|
+
})
|
|
459
|
+
.onEnd((event) => {
|
|
460
|
+
'worklet';
|
|
461
|
+
if (!allowPanClose.value) {
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
const velocity = event.velocityY;
|
|
465
|
+
const distance = translateY.value;
|
|
466
|
+
// Require a deeper pull to close (more like native bottom sheets)
|
|
467
|
+
const closeThreshold = Math.max(140, screenHeightSV.value * 0.25);
|
|
468
|
+
const fastSwipeThreshold = 900;
|
|
469
|
+
const shouldClose =
|
|
470
|
+
velocity > fastSwipeThreshold ||
|
|
471
|
+
(distance > closeThreshold && velocity > -300);
|
|
472
|
+
|
|
473
|
+
if (shouldClose) {
|
|
474
|
+
const generation = closeGeneration.value;
|
|
475
|
+
translateY.value = withSpring(screenHeightSV.value, {
|
|
476
|
+
...SPRING_CONFIG,
|
|
477
|
+
velocity: velocity,
|
|
478
|
+
});
|
|
479
|
+
opacity.value = withTiming(0, { duration: 250 }, (finished) => {
|
|
480
|
+
if (finished) {
|
|
481
|
+
runOnJS(finishClose)(generation);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
} else {
|
|
485
|
+
translateY.value = withSpring(0, {
|
|
486
|
+
...SPRING_CONFIG,
|
|
487
|
+
velocity: velocity,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
// Shared values are stable refs; the listed deps are the only JS-side
|
|
492
|
+
// values that change the gesture's behavior. `finishClose` is stable
|
|
493
|
+
// (useCallback with stable deps).
|
|
322
494
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
323
|
-
|
|
324
|
-
|
|
495
|
+
}, [enablePanDownToClose, detached, manualActivation, nativeGesture, finishClose]);
|
|
496
|
+
|
|
497
|
+
// Dedicated handle pan — only built in `manualActivation` mode. Always
|
|
498
|
+
// active so users can drag the handle even while content is mid-scroll.
|
|
499
|
+
// In legacy mode the body pan already wraps the whole sheet (handle
|
|
500
|
+
// included), so no separate gesture is needed.
|
|
501
|
+
const handlePanGesture = useMemo(() => {
|
|
502
|
+
if (!manualActivation) return undefined;
|
|
503
|
+
return Gesture.Pan()
|
|
504
|
+
.enabled(enablePanDownToClose && enableHandlePanningGesture)
|
|
505
|
+
.withRef(handlePanRef)
|
|
506
|
+
.simultaneousWithExternalGesture(bodyPanRef)
|
|
507
|
+
.activeOffsetY([-8, 8])
|
|
508
|
+
.onStart(() => {
|
|
509
|
+
'worklet';
|
|
510
|
+
context.value = { y: translateY.value };
|
|
511
|
+
})
|
|
512
|
+
.onUpdate((event) => {
|
|
513
|
+
'worklet';
|
|
514
|
+
const newTranslateY = context.value.y + event.translationY;
|
|
515
|
+
if (newTranslateY >= 0) {
|
|
516
|
+
translateY.value = newTranslateY;
|
|
517
|
+
} else if (detached) {
|
|
518
|
+
translateY.value = newTranslateY * 0.3;
|
|
519
|
+
} else {
|
|
520
|
+
translateY.value = 0;
|
|
521
|
+
}
|
|
522
|
+
})
|
|
523
|
+
.onEnd((event) => {
|
|
524
|
+
'worklet';
|
|
525
|
+
const velocity = event.velocityY;
|
|
526
|
+
const distance = translateY.value;
|
|
527
|
+
const closeThreshold = Math.max(140, screenHeightSV.value * 0.25);
|
|
528
|
+
const fastSwipeThreshold = 900;
|
|
529
|
+
const shouldClose =
|
|
530
|
+
velocity > fastSwipeThreshold ||
|
|
531
|
+
(distance > closeThreshold && velocity > -300);
|
|
532
|
+
|
|
533
|
+
if (shouldClose) {
|
|
534
|
+
const generation = closeGeneration.value;
|
|
535
|
+
translateY.value = withSpring(screenHeightSV.value, { ...SPRING_CONFIG, velocity });
|
|
536
|
+
opacity.value = withTiming(0, { duration: 250 }, (finished) => {
|
|
537
|
+
if (finished) runOnJS(finishClose)(generation);
|
|
538
|
+
});
|
|
539
|
+
} else {
|
|
540
|
+
translateY.value = withSpring(0, { ...SPRING_CONFIG, velocity });
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
544
|
+
}, [manualActivation, enablePanDownToClose, enableHandlePanningGesture, detached, finishClose]);
|
|
325
545
|
|
|
326
546
|
// `opacity.value` drives the fade in/out (0 -> 1). `backdropOpacity` is the
|
|
327
547
|
// final dim level once fully visible. We multiply so the consumer-provided
|
|
328
|
-
// dim opacity applies smoothly across the animation.
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
548
|
+
// dim opacity applies smoothly across the animation. When
|
|
549
|
+
// `dynamicBackdrop` is enabled, the dim also fades proportionally with
|
|
550
|
+
// drag distance (iOS Photos style).
|
|
551
|
+
const backdropStyle = useAnimatedStyle(() => {
|
|
552
|
+
const dragFactor = dynamicBackdrop
|
|
553
|
+
? interpolate(
|
|
554
|
+
translateY.value,
|
|
555
|
+
[0, screenHeightSV.value * 0.4],
|
|
556
|
+
[1, 0.3],
|
|
557
|
+
'clamp',
|
|
558
|
+
)
|
|
559
|
+
: 1;
|
|
560
|
+
return {
|
|
561
|
+
opacity: opacity.value * backdropOpacity * dragFactor,
|
|
562
|
+
};
|
|
563
|
+
}, [backdropOpacity, dynamicBackdrop]);
|
|
332
564
|
|
|
333
565
|
const sheetStyle = useAnimatedStyle(() => {
|
|
334
566
|
const scale = interpolate(translateY.value, [0, screenHeightSV.value], [1, 0.95]);
|
|
@@ -392,6 +624,61 @@ const BottomSheet = forwardRef((props: BottomSheetProps, ref: React.ForwardedRef
|
|
|
392
624
|
|
|
393
625
|
if (!rendered) return null;
|
|
394
626
|
|
|
627
|
+
// Default handle render — used when `handleComponent` is not provided.
|
|
628
|
+
const renderDefaultHandle = () => <View style={dynamicStyles.handle} />;
|
|
629
|
+
const handleNode = showHandle ? (handleComponent ? handleComponent() : renderDefaultHandle()) : null;
|
|
630
|
+
|
|
631
|
+
// In `manualActivation` mode the handle gets its own gesture detector
|
|
632
|
+
// sitting in a dedicated absolutely-positioned hit area at the top of the
|
|
633
|
+
// sheet. In legacy mode the handle is rendered inline as a decorative
|
|
634
|
+
// overlay (the body pan covers the entire sheet, handle included).
|
|
635
|
+
const handleSlot = handleNode && manualActivation && handlePanGesture ? (
|
|
636
|
+
<GestureDetector gesture={handlePanGesture}>
|
|
637
|
+
<View style={styles.handleHitArea} accessible accessibilityRole="adjustable">
|
|
638
|
+
{handleNode}
|
|
639
|
+
</View>
|
|
640
|
+
</GestureDetector>
|
|
641
|
+
) : handleNode;
|
|
642
|
+
|
|
643
|
+
// Inner content: scrollable wraps in Animated.ScrollView, non-scrollable
|
|
644
|
+
// renders children directly. In legacy mode the scrollview is also
|
|
645
|
+
// wrapped in the `nativeGesture` detector for scroll/pan coordination.
|
|
646
|
+
const scrollViewNode = (
|
|
647
|
+
<Animated.ScrollView
|
|
648
|
+
ref={scrollViewRef}
|
|
649
|
+
style={[
|
|
650
|
+
styles.scrollView,
|
|
651
|
+
Platform.OS === 'web' && ({
|
|
652
|
+
scrollbarWidth: 'thin',
|
|
653
|
+
scrollbarColor: `${colors.border} transparent`,
|
|
654
|
+
} as ViewStyle),
|
|
655
|
+
]}
|
|
656
|
+
contentContainerStyle={dynamicStyles.scrollContent}
|
|
657
|
+
showsVerticalScrollIndicator={false}
|
|
658
|
+
keyboardShouldPersistTaps="handled"
|
|
659
|
+
onScroll={scrollHandler}
|
|
660
|
+
scrollEventThrottle={16}
|
|
661
|
+
{...(Platform.OS === 'web' ? { className: 'bottom-sheet-scrollview' } : undefined)}
|
|
662
|
+
onLayout={() => {
|
|
663
|
+
if (Platform.OS === 'web') {
|
|
664
|
+
createWebScrollbarStyle(colors.border);
|
|
665
|
+
}
|
|
666
|
+
}}
|
|
667
|
+
>
|
|
668
|
+
{children}
|
|
669
|
+
</Animated.ScrollView>
|
|
670
|
+
);
|
|
671
|
+
|
|
672
|
+
const bodyContent = scrollable
|
|
673
|
+
? (manualActivation
|
|
674
|
+
// In manualActivation mode the scroll view is referenced by the
|
|
675
|
+
// pan's simultaneous list directly; no wrapping native gesture.
|
|
676
|
+
? scrollViewNode
|
|
677
|
+
// Legacy mode: native gesture wraps the scroll view to coordinate
|
|
678
|
+
// with the always-active body pan.
|
|
679
|
+
: <GestureDetector gesture={nativeGesture}>{scrollViewNode}</GestureDetector>)
|
|
680
|
+
: <View style={styles.nonScrollableContent}>{children}</View>;
|
|
681
|
+
|
|
395
682
|
return (
|
|
396
683
|
<Modal visible={rendered} transparent animationType="none" statusBarTranslucent onRequestClose={dismiss}>
|
|
397
684
|
{/* RN's Modal renders into its own native window. The app-root
|
|
@@ -413,33 +700,9 @@ const BottomSheet = forwardRef((props: BottomSheetProps, ref: React.ForwardedRef
|
|
|
413
700
|
<Animated.View style={[dynamicStyles.sheet, sheetMarginStyle, sheetStyle, sheetHeightStyle, style]}>
|
|
414
701
|
{backgroundComponent?.({ style: styles.background })}
|
|
415
702
|
|
|
416
|
-
{
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
<Animated.ScrollView
|
|
420
|
-
ref={scrollViewRef}
|
|
421
|
-
style={[
|
|
422
|
-
styles.scrollView,
|
|
423
|
-
Platform.OS === 'web' && ({
|
|
424
|
-
scrollbarWidth: 'thin',
|
|
425
|
-
scrollbarColor: `${colors.border} transparent`,
|
|
426
|
-
} as ViewStyle),
|
|
427
|
-
]}
|
|
428
|
-
contentContainerStyle={dynamicStyles.scrollContent}
|
|
429
|
-
showsVerticalScrollIndicator={false}
|
|
430
|
-
keyboardShouldPersistTaps="handled"
|
|
431
|
-
onScroll={scrollHandler}
|
|
432
|
-
scrollEventThrottle={16}
|
|
433
|
-
{...(Platform.OS === 'web' ? { className: 'bottom-sheet-scrollview' } : undefined)}
|
|
434
|
-
onLayout={() => {
|
|
435
|
-
if (Platform.OS === 'web') {
|
|
436
|
-
createWebScrollbarStyle(colors.border);
|
|
437
|
-
}
|
|
438
|
-
}}
|
|
439
|
-
>
|
|
440
|
-
{children}
|
|
441
|
-
</Animated.ScrollView>
|
|
442
|
-
</GestureDetector>
|
|
703
|
+
{handleSlot}
|
|
704
|
+
|
|
705
|
+
{bodyContent}
|
|
443
706
|
</Animated.View>
|
|
444
707
|
</GestureDetector>
|
|
445
708
|
</View>
|
|
@@ -482,6 +745,7 @@ const styles = StyleSheet.create({
|
|
|
482
745
|
borderTopLeftRadius: 24,
|
|
483
746
|
borderTopRightRadius: 24,
|
|
484
747
|
},
|
|
748
|
+
/** Legacy (non-manualActivation) handle: decorative overlay only. */
|
|
485
749
|
handle: {
|
|
486
750
|
position: 'absolute',
|
|
487
751
|
top: 10,
|
|
@@ -492,6 +756,23 @@ const styles = StyleSheet.create({
|
|
|
492
756
|
borderRadius: 3,
|
|
493
757
|
zIndex: 100,
|
|
494
758
|
},
|
|
759
|
+
/**
|
|
760
|
+
* Hit area for the drag handle in `manualActivation` mode. Absolutely
|
|
761
|
+
* positioned at the top of the sheet so the area visually "floats" above
|
|
762
|
+
* the content — content scrolls up underneath it (no layout offset)
|
|
763
|
+
* while the thumb can still grab the full-width 28dp strip to drag.
|
|
764
|
+
*/
|
|
765
|
+
handleHitArea: {
|
|
766
|
+
position: 'absolute',
|
|
767
|
+
top: 0,
|
|
768
|
+
left: 0,
|
|
769
|
+
right: 0,
|
|
770
|
+
height: 28,
|
|
771
|
+
alignItems: 'center',
|
|
772
|
+
justifyContent: 'flex-start',
|
|
773
|
+
paddingTop: 6,
|
|
774
|
+
zIndex: 100,
|
|
775
|
+
},
|
|
495
776
|
background: {
|
|
496
777
|
...StyleSheet.absoluteFillObject,
|
|
497
778
|
},
|
|
@@ -501,6 +782,9 @@ const styles = StyleSheet.create({
|
|
|
501
782
|
scrollContent: {
|
|
502
783
|
flexGrow: 1,
|
|
503
784
|
},
|
|
785
|
+
nonScrollableContent: {
|
|
786
|
+
flex: 1,
|
|
787
|
+
},
|
|
504
788
|
});
|
|
505
789
|
|
|
506
790
|
// Create web scrollbar styles dynamically based on theme
|
package/src/dialog/Dialog.tsx
CHANGED
|
@@ -14,7 +14,6 @@ export function Outer({
|
|
|
14
14
|
control,
|
|
15
15
|
onClose,
|
|
16
16
|
testID,
|
|
17
|
-
preventExpansion,
|
|
18
17
|
}: React.PropsWithChildren<DialogOuterProps>) {
|
|
19
18
|
const theme = useTheme();
|
|
20
19
|
const ref = useRef<BottomSheetRef>(null);
|
|
@@ -63,21 +62,15 @@ export function Outer({
|
|
|
63
62
|
);
|
|
64
63
|
|
|
65
64
|
const sheetStyle = useMemo(
|
|
66
|
-
() =>
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
// When the dialog should not be expandable to fill the screen, clamp the
|
|
76
|
-
// sheet to a comfortable fixed height. Mirrors the historical gorhom
|
|
77
|
-
// behaviour where `preventExpansion` locked the sheet to a 40% snap point.
|
|
78
|
-
preventExpansion ? { height: '40%' as const } : null,
|
|
79
|
-
],
|
|
80
|
-
[theme.colors.background, preventExpansion],
|
|
65
|
+
() => ({
|
|
66
|
+
maxWidth: 500,
|
|
67
|
+
backgroundColor: theme.colors.background,
|
|
68
|
+
// All four corners rounded — Dialog uses BottomSheet in `detached` mode
|
|
69
|
+
// (floating card with safe-area margins), so we round the bottom too
|
|
70
|
+
// instead of leaving the default top-only radius.
|
|
71
|
+
borderRadius: 20,
|
|
72
|
+
}),
|
|
73
|
+
[theme.colors.background],
|
|
81
74
|
);
|
|
82
75
|
|
|
83
76
|
return (
|
package/src/dialog/types.ts
CHANGED
|
@@ -22,6 +22,11 @@ export type DialogOuterProps = {
|
|
|
22
22
|
webOptions?: {
|
|
23
23
|
alignCenter?: boolean;
|
|
24
24
|
};
|
|
25
|
+
/**
|
|
26
|
+
* @deprecated No-op since 0.3.12. Bloom's BottomSheet sizes to its content by
|
|
27
|
+
* default (capped by safe-area `maxHeight`), so dialogs no longer expand to
|
|
28
|
+
* fill the screen and a manual lock is unnecessary.
|
|
29
|
+
*/
|
|
25
30
|
preventExpansion?: boolean;
|
|
26
31
|
};
|
|
27
32
|
|