@delacour/react-native-bottom-sheet 0.1.0-alpha.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 +132 -0
- package/package.json +57 -0
- package/src/animation/AGENTS.md +58 -0
- package/src/animation/animation.types.ts +35 -0
- package/src/animation/resolve-animation.ts +75 -0
- package/src/animation/use-animate-to.ts +107 -0
- package/src/animation/use-settle-callbacks.ts +60 -0
- package/src/components/AGENTS.md +80 -0
- package/src/components/bottom-sheet-background.tsx +34 -0
- package/src/components/bottom-sheet-close.tsx +45 -0
- package/src/components/bottom-sheet-container.tsx +79 -0
- package/src/components/bottom-sheet-content.tsx +102 -0
- package/src/components/bottom-sheet-description.tsx +33 -0
- package/src/components/bottom-sheet-footer.tsx +97 -0
- package/src/components/bottom-sheet-handle.tsx +72 -0
- package/src/components/bottom-sheet-overlay.tsx +91 -0
- package/src/components/bottom-sheet-portal.tsx +96 -0
- package/src/components/bottom-sheet-text-input.tsx +36 -0
- package/src/components/bottom-sheet-title.tsx +30 -0
- package/src/components/bottom-sheet-trigger.tsx +51 -0
- package/src/components/bottom-sheet.context.tsx +134 -0
- package/src/components/bottom-sheet.tsx +475 -0
- package/src/components/bottom-sheet.types.ts +235 -0
- package/src/core/AGENTS.md +160 -0
- package/src/core/animation/select-animation.test.ts +57 -0
- package/src/core/animation/select-animation.ts +104 -0
- package/src/core/backdrop/backdrop-opacity.test.ts +49 -0
- package/src/core/backdrop/backdrop-opacity.ts +21 -0
- package/src/core/detents/dynamic-detent.test.ts +40 -0
- package/src/core/detents/dynamic-detent.ts +41 -0
- package/src/core/detents/index-for-height.test.ts +65 -0
- package/src/core/detents/index-for-height.ts +44 -0
- package/src/core/detents/normalize-detents.test.ts +72 -0
- package/src/core/detents/normalize-detents.ts +63 -0
- package/src/core/detents/over-drag.test.ts +46 -0
- package/src/core/detents/over-drag.ts +23 -0
- package/src/core/detents/select-snap-height.test.ts +52 -0
- package/src/core/detents/select-snap-height.ts +44 -0
- package/src/core/detents/sheet-state.test.ts +42 -0
- package/src/core/detents/sheet-state.ts +32 -0
- package/src/core/footer/body-inset.test.ts +95 -0
- package/src/core/footer/body-inset.ts +65 -0
- package/src/core/footer/bottom-band.test.ts +46 -0
- package/src/core/footer/bottom-band.ts +30 -0
- package/src/core/footer/footer-top.test.ts +61 -0
- package/src/core/footer/footer-top.ts +27 -0
- package/src/core/footer/sheet-insets.test.ts +27 -0
- package/src/core/footer/sheet-insets.ts +30 -0
- package/src/core/geometry/clamp-height.test.ts +25 -0
- package/src/core/geometry/clamp-height.ts +12 -0
- package/src/core/geometry/closed-height.test.ts +37 -0
- package/src/core/geometry/closed-height.ts +29 -0
- package/src/core/geometry/detached-frame.test.ts +50 -0
- package/src/core/geometry/detached-frame.ts +49 -0
- package/src/core/geometry/position.test.ts +21 -0
- package/src/core/geometry/position.ts +13 -0
- package/src/core/geometry/surface-height.test.ts +30 -0
- package/src/core/geometry/surface-height.ts +22 -0
- package/src/core/haptic/crossed-detent.test.ts +43 -0
- package/src/core/haptic/crossed-detent.ts +33 -0
- package/src/core/index.ts +92 -0
- package/src/core/intent/layout-ready.test.ts +37 -0
- package/src/core/intent/layout-ready.ts +24 -0
- package/src/core/intent/resolve-intent.test.ts +163 -0
- package/src/core/intent/resolve-intent.ts +98 -0
- package/src/core/keyboard/container-layout-guard.test.ts +32 -0
- package/src/core/keyboard/container-layout-guard.ts +31 -0
- package/src/core/keyboard/content-area.test.ts +34 -0
- package/src/core/keyboard/content-area.ts +26 -0
- package/src/core/keyboard/keyboard-lift.test.ts +54 -0
- package/src/core/keyboard/keyboard-lift.ts +47 -0
- package/src/core/keyboard/keyboard-owner.test.ts +35 -0
- package/src/core/keyboard/keyboard-owner.ts +25 -0
- package/src/core/keyboard/keyboard-ownership.test.ts +69 -0
- package/src/core/keyboard/keyboard-ownership.ts +39 -0
- package/src/core/keyboard/keyboard-reset-guard.test.ts +33 -0
- package/src/core/keyboard/keyboard-reset-guard.ts +33 -0
- package/src/core/keyboard/keyboard-step.test.ts +66 -0
- package/src/core/keyboard/keyboard-step.ts +53 -0
- package/src/core/machine/define-sheet-machine.test.ts +187 -0
- package/src/core/machine/define-sheet-machine.ts +35 -0
- package/src/core/machine/index.ts +23 -0
- package/src/core/machine/machine.types.ts +80 -0
- package/src/core/machine/step-transition.test.ts +46 -0
- package/src/core/machine/step-transition.ts +59 -0
- package/src/core/machine/transition.test.ts +51 -0
- package/src/core/machine/transition.ts +58 -0
- package/src/core/result.test.ts +15 -0
- package/src/core/result.ts +20 -0
- package/src/core/scroll/scroll-lock.test.ts +40 -0
- package/src/core/scroll/scroll-lock.ts +35 -0
- package/src/core/scroll/scroll-pan.test.ts +99 -0
- package/src/core/scroll/scroll-pan.ts +96 -0
- package/src/core/sheet.types.ts +93 -0
- package/src/display-name.test.ts +181 -0
- package/src/docs.test.ts +45 -0
- package/src/flat-worklet.test.ts +130 -0
- package/src/gesture/AGENTS.md +71 -0
- package/src/gesture/gesture.types.ts +37 -0
- package/src/gesture/use-scroll-lock.ts +162 -0
- package/src/gesture/use-sheet-pan.ts +250 -0
- package/src/index.ts +127 -0
- package/src/keyboard/AGENTS.md +84 -0
- package/src/keyboard/use-bottom-sheet-text-input.ts +79 -0
- package/src/keyboard/use-keyboard-animation-guard.ts +59 -0
- package/src/keyboard/use-sheet-keyboard.ts +163 -0
- package/src/layout/AGENTS.md +52 -0
- package/src/layout/use-container-layout.ts +63 -0
- package/src/layout/use-measure-height.ts +40 -0
- package/src/lib/AGENTS.md +35 -0
- package/src/lib/compose-refs.ts +37 -0
- package/src/lib/merge-props.ts +42 -0
- package/src/lib/slot.tsx +44 -0
- package/src/lib/use-controllable-state.ts +45 -0
- package/src/no-classname.test.ts +27 -0
- package/src/portal/AGENTS.md +101 -0
- package/src/portal/bottom-sheet-host.tsx +67 -0
- package/src/portal/bottom-sheet-provider.tsx +33 -0
- package/src/portal/host.context.tsx +25 -0
- package/src/portal/sheet-registry.context.tsx +124 -0
- package/src/portal/sheet-registry.test.ts +87 -0
- package/src/portal/sheet-registry.ts +78 -0
- package/src/purity.test.ts +90 -0
- package/src/scrollable/AGENTS.md +123 -0
- package/src/scrollable/bottom-sheet-flat-list.tsx +15 -0
- package/src/scrollable/bottom-sheet-scroll-view.tsx +15 -0
- package/src/scrollable/bottom-sheet-section-list.tsx +22 -0
- package/src/scrollable/create-bottom-sheet-scrollable.tsx +248 -0
- package/src/scrollable/scrollable.types.ts +76 -0
- package/src/source-tree.test.ts +33 -0
- package/src/state/AGENTS.md +63 -0
- package/src/state/state.types.ts +134 -0
- package/src/state/use-detents.ts +43 -0
- package/src/state/use-sheet-geometry.ts +178 -0
- package/src/state/use-sheet-intents.ts +144 -0
- package/src/state/use-sheet-state.ts +157 -0
- package/src/steps/AGENTS.md +100 -0
- package/src/steps/bottom-sheet-step.tsx +61 -0
- package/src/steps/bottom-sheet-steps.tsx +215 -0
- package/src/steps/steps.context.tsx +73 -0
- package/src/steps/steps.types.ts +61 -0
- package/src/steps/use-sheet-machine.ts +70 -0
- package/src/steps/use-sheet-step.ts +29 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# scrollable
|
|
2
|
+
|
|
3
|
+
A `ScrollView`, `FlatList` or `SectionList` as the sheet's body, and the
|
|
4
|
+
factory that makes one out of any animated scrollable.
|
|
5
|
+
|
|
6
|
+
## Files
|
|
7
|
+
|
|
8
|
+
| Path | What |
|
|
9
|
+
| --- | --- |
|
|
10
|
+
| `scrollable.types.ts` | `BottomSheetScrollableProps` (`focusHook`), `FocusHook`, `ScrollableHandle`, the inner shape the wrapper is written against, and the restated generic component types |
|
|
11
|
+
| `create-bottom-sheet-scrollable.tsx` | `createBottomSheetScrollable(AnimatedComponent, type)` — the two detectors, the lock, the clip and the clamp, the trailing spacer, content size, registration |
|
|
12
|
+
| `bottom-sheet-scroll-view.tsx` | `BottomSheet.ScrollView` over `Animated.ScrollView` |
|
|
13
|
+
| `bottom-sheet-flat-list.tsx` | `BottomSheet.FlatList` over `Animated.FlatList`, generic in `ItemT` |
|
|
14
|
+
| `bottom-sheet-section-list.tsx` | `BottomSheet.SectionList` over a module-scope `createAnimatedComponent(SectionList)`, generic in `ItemT, SectionT` |
|
|
15
|
+
|
|
16
|
+
The UI-thread half — the scroll handler, the lock reaction and the animated
|
|
17
|
+
props — is `gesture/use-scroll-lock.ts`, beside the pan it cooperates with.
|
|
18
|
+
The numbers are `core/scroll/`: `shouldLockScroll`, `listDragHeight`,
|
|
19
|
+
`listOwnsRelease`, `scrollLockTarget`.
|
|
20
|
+
|
|
21
|
+
## How a list and the sheet share a finger
|
|
22
|
+
|
|
23
|
+
The content pan and the list's native scroll are **simultaneous**: the list's
|
|
24
|
+
`Native` gesture is declared `simultaneousWithExternalGesture(pans.content)`,
|
|
25
|
+
so both see every touch. The rule for who moves is the sheet's state:
|
|
26
|
+
|
|
27
|
+
- **Below the highest detent — locked.** Every scroll event is answered with
|
|
28
|
+
`scrollTo(ref, 0, scrollLockedAt, false)` and the offset the pan reads is
|
|
29
|
+
pinned there, so the list consumes nothing and the pan moves the sheet. The
|
|
30
|
+
indicator is hidden, `bounces` is off and `decelerationRate` is `0`, so a
|
|
31
|
+
fling has no momentum for the lock to fight.
|
|
32
|
+
- **At the highest detent — unlocked.** The list scrolls. The pan treats the
|
|
33
|
+
offset the list *began* the gesture with as a budget the finger spends
|
|
34
|
+
downward before the sheet moves (`listDragHeight`): the list scrolls
|
|
35
|
+
one-for-one under the same finger, so it reaches its top exactly as the
|
|
36
|
+
budget runs out and the sheet takes over from there. Upward, the clamp at
|
|
37
|
+
`highest` means the list scrolls and bounces rather than the sheet
|
|
38
|
+
over-dragging.
|
|
39
|
+
- **A release while scrolled at the top** belongs to the list
|
|
40
|
+
(`listOwnsRelease`): the velocity is the list's momentum, not a snap. The
|
|
41
|
+
library this replaces snapped here and hopped a detent under interrupted
|
|
42
|
+
scrolls.
|
|
43
|
+
|
|
44
|
+
The budget is the offset at the *start* of the gesture, never the live one.
|
|
45
|
+
The live offset was tried first: the pan and the scroll event for one touch
|
|
46
|
+
move land in the same frame in no fixed order, and when the pan ran first the
|
|
47
|
+
sheet dipped a pixel, the lock engaged and the list froze where it was. A
|
|
48
|
+
fixed budget cannot race anything. A list held by the lock — scrolled, and
|
|
49
|
+
below the top — has no budget until the sheet reaches the top this gesture
|
|
50
|
+
(`listHeld` in `use-sheet-pan.ts`), the previous engine's
|
|
51
|
+
`isScrollablePositionLocked` under another name.
|
|
52
|
+
|
|
53
|
+
The lock's target is taken when the lock engages, by any path — a drag, a
|
|
54
|
+
`snapToIndex`, a keyboard — as `max(0, offset)`. A pull-down from the top
|
|
55
|
+
locks at `0`; a handle drag on a scrolled list holds the rows where they are.
|
|
56
|
+
|
|
57
|
+
## Sizing
|
|
58
|
+
|
|
59
|
+
The list fills the body to the sheet's bottom line: its `maxHeight` follows
|
|
60
|
+
`contentArea + bodyInset` on the UI thread, exactly as `Content`'s layout box
|
|
61
|
+
does. What the footer or the safe-area band would cover is reserved *inside*
|
|
62
|
+
the content, as a trailing spacer — appended to a `ScrollView`'s children,
|
|
63
|
+
composed after the consumer's `ListFooterComponent` on a `FlatList` or
|
|
64
|
+
`SectionList` — the height of the geometry's `bodyInset`, so the rows scroll
|
|
65
|
+
under the footer or the band and the last row can still be brought fully clear
|
|
66
|
+
of them. The spacer is animated because the band collapses as the keyboard
|
|
67
|
+
rises, and `contentContainerStyle` cannot be; `scrollIndicatorInsets.bottom`
|
|
68
|
+
follows the same value through the lock's `animatedProps`, so the indicator
|
|
69
|
+
ends where the rows stop being visible. The outer view clips to `bodyClip`,
|
|
70
|
+
which under a footer is the footer's live top edge.
|
|
71
|
+
|
|
72
|
+
The list must not shrink to that clip. A React Native scrollable ships
|
|
73
|
+
`flexShrink: 1`, and Yoga shrank it to the wrapper — the footer's top — so
|
|
74
|
+
the spacer's clearance landed on top of the wrapper's and the last row stopped
|
|
75
|
+
a whole footer above the footer. `flexShrink: 0` on the list leaves
|
|
76
|
+
`maxHeight` as its only bound. A `marginBottom` of the footer's height was the
|
|
77
|
+
earlier answer and is gone: it ended the list above the footer with the rows
|
|
78
|
+
cut at that edge and an empty band under them.
|
|
79
|
+
|
|
80
|
+
`onContentSizeChange`, less the spacer's own measured height
|
|
81
|
+
(`scrollContentHeight`), writes `contentHeight`, which is the dynamic detent's
|
|
82
|
+
measurement. A `BottomSheet.ScrollView` of forty rows therefore needs neither
|
|
83
|
+
`snapPoints` nor `dynamicSizing={false}`: it sizes to its rows, capped by
|
|
84
|
+
`maxDynamicContentSize`, and scrolls inside that. The content size and the
|
|
85
|
+
spacer's layout arrive as two events from one commit; both are held in refs
|
|
86
|
+
and folded into a single write after the batch, because the first open
|
|
87
|
+
resolves on whatever `contentHeight` says first and the mount animation, once
|
|
88
|
+
running, ignores a corrected detent list — a frame of rows-plus-spacer would
|
|
89
|
+
open the sheet a footer too tall. `contentHeight` goes back to `UNMEASURED` on
|
|
90
|
+
unmount for the same reason `useMeasureHeight` resets it.
|
|
91
|
+
|
|
92
|
+
## The factory's contract
|
|
93
|
+
|
|
94
|
+
The component handed to `createBottomSheetScrollable` must already be
|
|
95
|
+
animated, and must be made **once at module scope** — `Animated.ScrollView`,
|
|
96
|
+
`Animated.FlatList`, or `Animated.createAnimatedComponent(X)` in a module
|
|
97
|
+
body. The wrapper drives it with `animatedProps` and an animated `onScroll`;
|
|
98
|
+
a component created inside a render is a new type every frame and remounts
|
|
99
|
+
its subtree.
|
|
100
|
+
|
|
101
|
+
The wrapper is written once against the props every React Native scrollable
|
|
102
|
+
shares (`ScrollableInnerProps`) and passes the rest through. The built-ins
|
|
103
|
+
cast back to a generic signature so `data` and `renderItem` keep checking
|
|
104
|
+
against each other — the same move `Screen.FlatList` makes in
|
|
105
|
+
`@delacour/react-native-ui`.
|
|
106
|
+
|
|
107
|
+
`contentContainerStyle` reaches the list flattened to one object. A
|
|
108
|
+
virtualised list measures its content container, and an array style makes
|
|
109
|
+
that measurement lag the layout by a frame.
|
|
110
|
+
|
|
111
|
+
The consumer's `onScroll`, `onScrollBeginDrag`, `onScrollEndDrag` and
|
|
112
|
+
`onMomentumScrollEnd` are called on the JS thread through `scheduleOnRN` with
|
|
113
|
+
the event the worklet saw; they cannot be worklets of their own.
|
|
114
|
+
|
|
115
|
+
`focusHook` defaults to `useEffect`. Under React Navigation pass
|
|
116
|
+
`useFocusEffect`, and a list on a screen that is not showing withdraws from
|
|
117
|
+
the sheet.
|
|
118
|
+
|
|
119
|
+
## Out of scope
|
|
120
|
+
|
|
121
|
+
Refresh control. `refreshControl` and `onRefresh` pass through untouched, but
|
|
122
|
+
a pull-to-refresh at the top of a locked list fights the sheet's own
|
|
123
|
+
pull-down, and no rule for that is written yet.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import Animated from "react-native-reanimated";
|
|
2
|
+
import { SCROLLABLE_TYPE } from "../core";
|
|
3
|
+
import { createBottomSheetScrollable } from "./create-bottom-sheet-scrollable";
|
|
4
|
+
import type { BottomSheetFlatListComponent } from "./scrollable.types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A `FlatList` as the sheet's body. `Animated.FlatList` is Reanimated's own,
|
|
8
|
+
* made once at module scope; the cast restores `<ItemT>` so `data` and
|
|
9
|
+
* `renderItem` check against each other.
|
|
10
|
+
*/
|
|
11
|
+
export const BottomSheetFlatList = createBottomSheetScrollable(
|
|
12
|
+
Animated.FlatList,
|
|
13
|
+
SCROLLABLE_TYPE.FLAT_LIST
|
|
14
|
+
) as unknown as BottomSheetFlatListComponent;
|
|
15
|
+
BottomSheetFlatList.displayName = "DelacourBottomSheet.BottomSheet.FlatList";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import Animated from "react-native-reanimated";
|
|
2
|
+
import { SCROLLABLE_TYPE } from "../core";
|
|
3
|
+
import { createBottomSheetScrollable } from "./create-bottom-sheet-scrollable";
|
|
4
|
+
import type { BottomSheetScrollViewComponent } from "./scrollable.types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A `ScrollView` as the sheet's body: locked below the highest detent, sized
|
|
8
|
+
* to its content when the sheet is dynamic. `Animated.ScrollView` is
|
|
9
|
+
* Reanimated's own, made once at module scope.
|
|
10
|
+
*/
|
|
11
|
+
export const BottomSheetScrollView = createBottomSheetScrollable(
|
|
12
|
+
Animated.ScrollView,
|
|
13
|
+
SCROLLABLE_TYPE.SCROLL_VIEW
|
|
14
|
+
) as unknown as BottomSheetScrollViewComponent;
|
|
15
|
+
BottomSheetScrollView.displayName = "DelacourBottomSheet.BottomSheet.ScrollView";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { SectionList } from "react-native";
|
|
2
|
+
import Animated from "react-native-reanimated";
|
|
3
|
+
import { SCROLLABLE_TYPE } from "../core";
|
|
4
|
+
import { createBottomSheetScrollable } from "./create-bottom-sheet-scrollable";
|
|
5
|
+
import type { BottomSheetSectionListComponent } from "./scrollable.types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Reanimated ships no `Animated.SectionList`, so this is the one built-in
|
|
9
|
+
* that calls `createAnimatedComponent` — once, here, at module scope, never
|
|
10
|
+
* inside a render.
|
|
11
|
+
*/
|
|
12
|
+
const AnimatedSectionList = Animated.createAnimatedComponent(SectionList);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A `SectionList` as the sheet's body; the cast restores `<ItemT, SectionT>`
|
|
16
|
+
* so `sections` and `renderItem` check against each other.
|
|
17
|
+
*/
|
|
18
|
+
export const BottomSheetSectionList = createBottomSheetScrollable(
|
|
19
|
+
AnimatedSectionList,
|
|
20
|
+
SCROLLABLE_TYPE.SECTION_LIST
|
|
21
|
+
) as unknown as BottomSheetSectionListComponent;
|
|
22
|
+
BottomSheetSectionList.displayName = "DelacourBottomSheet.BottomSheet.SectionList";
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ComponentType,
|
|
3
|
+
createElement,
|
|
4
|
+
isValidElement,
|
|
5
|
+
type ReactElement,
|
|
6
|
+
type Ref,
|
|
7
|
+
useCallback,
|
|
8
|
+
useEffect,
|
|
9
|
+
useMemo,
|
|
10
|
+
useRef,
|
|
11
|
+
} from "react";
|
|
12
|
+
import { type LayoutChangeEvent, StyleSheet } from "react-native";
|
|
13
|
+
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
14
|
+
import Animated, { useAnimatedRef, useAnimatedStyle } from "react-native-reanimated";
|
|
15
|
+
import { useBottomSheetInternal } from "../components/bottom-sheet.context";
|
|
16
|
+
import { SCROLLABLE_TYPE, type ScrollableType, scrollContentHeight, UNMEASURED } from "../core";
|
|
17
|
+
import { useScrollLock } from "../gesture/use-scroll-lock";
|
|
18
|
+
import { composeRefs } from "../lib/compose-refs";
|
|
19
|
+
import type {
|
|
20
|
+
BottomSheetScrollableProps,
|
|
21
|
+
ScrollableHandle,
|
|
22
|
+
ScrollableInnerComponent,
|
|
23
|
+
ScrollableInnerProps,
|
|
24
|
+
ScrollableListFooter,
|
|
25
|
+
} from "./scrollable.types";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Before the first layout there is no content area to clamp to, and a
|
|
29
|
+
* `maxHeight` of zero would clip the list while it measures itself. Tall
|
|
30
|
+
* enough that no sheet reaches it.
|
|
31
|
+
*/
|
|
32
|
+
const UNCLAMPED = 100_000;
|
|
33
|
+
|
|
34
|
+
/** How often the list reports its offset, in milliseconds — every frame. */
|
|
35
|
+
const SCROLL_EVENT_THROTTLE = 16;
|
|
36
|
+
|
|
37
|
+
type ScrollableProps = ScrollableInnerProps & BottomSheetScrollableProps;
|
|
38
|
+
|
|
39
|
+
/** A consumer's `ListFooterComponent` — an element, a component type, or nothing — as an element. */
|
|
40
|
+
function footerElement(footer: ScrollableListFooter | undefined): ReactElement | null {
|
|
41
|
+
if (footer === undefined || footer === null) return null;
|
|
42
|
+
if (isValidElement(footer)) return footer;
|
|
43
|
+
return createElement(footer);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Wraps an animated scrollable as a sheet body.
|
|
48
|
+
*
|
|
49
|
+
* The component passed in must already be animated — `Animated.ScrollView`,
|
|
50
|
+
* `Animated.FlatList`, or the result of `Animated.createAnimatedComponent`
|
|
51
|
+
* made **once, at module scope** — because the wrapper drives it with
|
|
52
|
+
* `animatedProps` and an animated `onScroll`, and because a component created
|
|
53
|
+
* inside a render remounts its subtree every frame.
|
|
54
|
+
*
|
|
55
|
+
* What the wrapper does, top to bottom:
|
|
56
|
+
*
|
|
57
|
+
* - Two gesture detectors. The outer is the sheet's content pan, the same
|
|
58
|
+
* object `Content` uses; the inner is a `Native` gesture on the list itself,
|
|
59
|
+
* declared simultaneous with that pan and kept alive when the finger leaves
|
|
60
|
+
* the view. Both run at once, and the pan subtracts what the list consumed.
|
|
61
|
+
* - The scroll lock (`useScrollLock`). Below the highest detent the list is
|
|
62
|
+
* held where it was, so the pan moves the sheet; at the highest it scrolls,
|
|
63
|
+
* and a drag down at the top hands back to the sheet. The indicator hides,
|
|
64
|
+
* bounce switches off and momentum is cut while locked.
|
|
65
|
+
* - The sizing. The list fills the body to the sheet's bottom line —
|
|
66
|
+
* `contentArea + bodyInset` — and reserves the footer or the safe-area band
|
|
67
|
+
* as a trailing spacer *inside* its content, so the rows scroll under either
|
|
68
|
+
* and the last row can be brought fully clear of them. The spacer is
|
|
69
|
+
* animated: it collapses with the band as the keyboard rises. The indicator
|
|
70
|
+
* is inset by the same amount so it ends above the footer. The outer view
|
|
71
|
+
* clips to the geometry's `bodyClip`, so under a footer nothing of the list
|
|
72
|
+
* shows below the footer's top edge, at rest or while the sheet is dragged
|
|
73
|
+
* down; the list itself does not shrink to that clip (see `styles.list`).
|
|
74
|
+
* - Content size. `onContentSizeChange` less the spacer's own measured height
|
|
75
|
+
* feeds `contentHeight`, which is the dynamic detent's measurement: a
|
|
76
|
+
* `ScrollView` of forty rows needs no `snapPoints` and no
|
|
77
|
+
* `dynamicSizing={false}` — it sizes to its rows up to
|
|
78
|
+
* `maxDynamicContentSize`, then scrolls. The two layout events land in one
|
|
79
|
+
* batch and are folded into one write, so the detent never sees the rows
|
|
80
|
+
* plus the spacer for a frame — a first open resolved on that sum would
|
|
81
|
+
* animate to it and ignore the correction.
|
|
82
|
+
* - Registration. On focus the list tells the sheet what kind it is, and on
|
|
83
|
+
* blur it withdraws and the offset resets; `focusHook` is where a navigator's
|
|
84
|
+
* `useFocusEffect` goes.
|
|
85
|
+
*
|
|
86
|
+
* `contentContainerStyle` is flattened to one object before it reaches the
|
|
87
|
+
* list: a virtualised list measures its content container, and an array style
|
|
88
|
+
* makes that measurement lag a frame behind the layout.
|
|
89
|
+
*
|
|
90
|
+
* Refresh control is out of scope here — a `refreshControl` prop passes
|
|
91
|
+
* through untouched, but a pull-to-refresh at the top of a locked list fights
|
|
92
|
+
* the sheet's own pull-down, and no rule for that is written yet.
|
|
93
|
+
*/
|
|
94
|
+
export function createBottomSheetScrollable<P extends object>(
|
|
95
|
+
Scrollable: ComponentType<P>,
|
|
96
|
+
type: ScrollableType
|
|
97
|
+
): ComponentType<P & BottomSheetScrollableProps> {
|
|
98
|
+
// The wrapper reads and writes the props every React Native scrollable
|
|
99
|
+
// shares and passes the rest through, so it is written once against that
|
|
100
|
+
// common shape; the built-ins restate their own generic signatures.
|
|
101
|
+
const Inner = Scrollable as unknown as ScrollableInnerComponent;
|
|
102
|
+
const isScrollView = type === SCROLLABLE_TYPE.SCROLL_VIEW;
|
|
103
|
+
|
|
104
|
+
function BottomSheetScrollable({
|
|
105
|
+
focusHook = useEffect,
|
|
106
|
+
ref,
|
|
107
|
+
style,
|
|
108
|
+
contentContainerStyle,
|
|
109
|
+
onContentSizeChange,
|
|
110
|
+
onScroll,
|
|
111
|
+
onScrollBeginDrag,
|
|
112
|
+
onScrollEndDrag,
|
|
113
|
+
onMomentumScrollEnd,
|
|
114
|
+
showsVerticalScrollIndicator,
|
|
115
|
+
scrollIndicatorInsets,
|
|
116
|
+
bounces,
|
|
117
|
+
decelerationRate,
|
|
118
|
+
children,
|
|
119
|
+
ListFooterComponent,
|
|
120
|
+
...rest
|
|
121
|
+
}: ScrollableProps): ReactElement {
|
|
122
|
+
const { state, geometry, pans, enableContentPanningGesture, setScrollableRef, removeScrollableRef } =
|
|
123
|
+
useBottomSheetInternal();
|
|
124
|
+
const { contentArea, bodyInset, bodyClip, layoutReady } = geometry;
|
|
125
|
+
const animatedRef = useAnimatedRef<ScrollableHandle>();
|
|
126
|
+
|
|
127
|
+
const { scrollHandler, animatedProps } = useScrollLock(state, geometry, animatedRef, {
|
|
128
|
+
enableContentPan: enableContentPanningGesture,
|
|
129
|
+
showsVerticalScrollIndicator,
|
|
130
|
+
scrollIndicatorInsets,
|
|
131
|
+
indicatorInset: bodyInset,
|
|
132
|
+
bounces,
|
|
133
|
+
decelerationRate,
|
|
134
|
+
listeners: { onScroll, onScrollBeginDrag, onScrollEndDrag, onMomentumScrollEnd },
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const native = useMemo(
|
|
138
|
+
() => Gesture.Native().simultaneousWithExternalGesture(pans.content).shouldCancelWhenOutside(false),
|
|
139
|
+
[pans.content]
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const composedRef = useMemo(
|
|
143
|
+
() => composeRefs<ScrollableHandle>(animatedRef as unknown as Ref<ScrollableHandle>, ref),
|
|
144
|
+
[animatedRef, ref]
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const clip = useAnimatedStyle(() => ({
|
|
148
|
+
maxHeight: layoutReady.value ? bodyClip.value : UNCLAMPED,
|
|
149
|
+
}));
|
|
150
|
+
const clamp = useAnimatedStyle(() => ({
|
|
151
|
+
maxHeight: layoutReady.value ? contentArea.value + bodyInset.value : UNCLAMPED,
|
|
152
|
+
}));
|
|
153
|
+
const spacer = useAnimatedStyle(() => ({ height: bodyInset.value }));
|
|
154
|
+
|
|
155
|
+
// The content size and the spacer's height arrive as two layout events
|
|
156
|
+
// from the same commit; both are kept and one write follows the batch.
|
|
157
|
+
const contentSize = useRef<number | null>(null);
|
|
158
|
+
const spacerHeight = useRef<number | null>(null);
|
|
159
|
+
const flushQueued = useRef(false);
|
|
160
|
+
const flush = useCallback(() => {
|
|
161
|
+
flushQueued.current = false;
|
|
162
|
+
if (contentSize.current === null || spacerHeight.current === null) return;
|
|
163
|
+
const rows = scrollContentHeight(contentSize.current, spacerHeight.current);
|
|
164
|
+
if (state.contentHeight.value !== rows) state.contentHeight.value = rows;
|
|
165
|
+
}, [state]);
|
|
166
|
+
const queueFlush = useCallback(() => {
|
|
167
|
+
if (flushQueued.current) return;
|
|
168
|
+
flushQueued.current = true;
|
|
169
|
+
queueMicrotask(flush);
|
|
170
|
+
}, [flush]);
|
|
171
|
+
|
|
172
|
+
const handleContentSizeChange = useCallback(
|
|
173
|
+
(width: number, height: number) => {
|
|
174
|
+
contentSize.current = height;
|
|
175
|
+
queueFlush();
|
|
176
|
+
if (onContentSizeChange) onContentSizeChange(width, height);
|
|
177
|
+
},
|
|
178
|
+
[queueFlush, onContentSizeChange]
|
|
179
|
+
);
|
|
180
|
+
const handleSpacerLayout = useCallback(
|
|
181
|
+
(event: LayoutChangeEvent) => {
|
|
182
|
+
spacerHeight.current = event.nativeEvent.layout.height;
|
|
183
|
+
queueFlush();
|
|
184
|
+
},
|
|
185
|
+
[queueFlush]
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
useEffect(
|
|
189
|
+
() => () => {
|
|
190
|
+
state.contentHeight.value = UNMEASURED;
|
|
191
|
+
},
|
|
192
|
+
[state]
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const register = useCallback(() => {
|
|
196
|
+
setScrollableRef(animatedRef, type);
|
|
197
|
+
return () => removeScrollableRef(animatedRef);
|
|
198
|
+
}, [setScrollableRef, removeScrollableRef, animatedRef]);
|
|
199
|
+
focusHook(register, [register]);
|
|
200
|
+
|
|
201
|
+
const trailing = <Animated.View onLayout={handleSpacerLayout} pointerEvents="none" style={spacer} />;
|
|
202
|
+
const consumerFooter = footerElement(ListFooterComponent);
|
|
203
|
+
const listFooter = isScrollView ? undefined : (
|
|
204
|
+
<>
|
|
205
|
+
{consumerFooter}
|
|
206
|
+
{trailing}
|
|
207
|
+
</>
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<GestureDetector gesture={pans.content}>
|
|
212
|
+
<Animated.View style={[styles.outer, clip]}>
|
|
213
|
+
<GestureDetector gesture={native}>
|
|
214
|
+
<Inner
|
|
215
|
+
{...rest}
|
|
216
|
+
animatedProps={animatedProps}
|
|
217
|
+
contentContainerStyle={StyleSheet.flatten(contentContainerStyle)}
|
|
218
|
+
keyboardDismissMode="interactive"
|
|
219
|
+
ListFooterComponent={listFooter}
|
|
220
|
+
onContentSizeChange={handleContentSizeChange}
|
|
221
|
+
onScroll={scrollHandler}
|
|
222
|
+
overScrollMode="never"
|
|
223
|
+
ref={composedRef}
|
|
224
|
+
scrollEventThrottle={SCROLL_EVENT_THROTTLE}
|
|
225
|
+
style={[styles.list, style, clamp]}
|
|
226
|
+
>
|
|
227
|
+
{isScrollView ? children : undefined}
|
|
228
|
+
{isScrollView ? trailing : undefined}
|
|
229
|
+
</Inner>
|
|
230
|
+
</GestureDetector>
|
|
231
|
+
</Animated.View>
|
|
232
|
+
</GestureDetector>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
BottomSheetScrollable.displayName = "DelacourBottomSheet.BottomSheet.Scrollable";
|
|
236
|
+
|
|
237
|
+
return BottomSheetScrollable as unknown as ComponentType<P & BottomSheetScrollableProps>;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// A React Native scrollable ships `flexShrink: 1`, and Yoga would shrink it to
|
|
241
|
+
// the clipping wrapper — the footer's top edge — instead of letting it reach
|
|
242
|
+
// the sheet's bottom line, which puts the trailing spacer's clearance on top
|
|
243
|
+
// of the wrapper's and doubles it. `flexShrink: 0` leaves only `maxHeight` to
|
|
244
|
+
// bound it, and the wrapper alone does the clipping.
|
|
245
|
+
const styles = StyleSheet.create({
|
|
246
|
+
outer: { flexShrink: 1, overflow: "hidden" },
|
|
247
|
+
list: { flexShrink: 0 },
|
|
248
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Component, ComponentType, DependencyList, EffectCallback, ReactElement, Ref, RefAttributes } from "react";
|
|
2
|
+
import type { FlatList, FlatListProps, ScrollView, ScrollViewProps, SectionList, SectionListProps } from "react-native";
|
|
3
|
+
import type { AnimatedProps } from "react-native-reanimated";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The list a scrollable registers with its sheet. Kept for identity — the
|
|
7
|
+
* lock scrolls it through Reanimated's own animated ref — so the constraint is
|
|
8
|
+
* only that it is a component instance.
|
|
9
|
+
*/
|
|
10
|
+
export type ScrollableHandle = Component;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The hook that runs the registration effect.
|
|
14
|
+
*
|
|
15
|
+
* `useEffect` registers on mount. Under React Navigation `useFocusEffect` has
|
|
16
|
+
* the same shape and registers when the screen gains focus, so two screens in
|
|
17
|
+
* a stack, each with a list in the same sheet, hand the sheet the one that is
|
|
18
|
+
* showing.
|
|
19
|
+
*/
|
|
20
|
+
export type FocusHook = (effect: EffectCallback, deps?: DependencyList) => void;
|
|
21
|
+
|
|
22
|
+
/** What every `createBottomSheetScrollable` component adds on top of the list's own props. */
|
|
23
|
+
export type BottomSheetScrollableProps = {
|
|
24
|
+
/** @default useEffect */
|
|
25
|
+
focusHook?: FocusHook;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** A list's `ListFooterComponent`, as the virtualised lists take it. */
|
|
29
|
+
export type ScrollableListFooter = ComponentType<unknown> | ReactElement | null;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The props the wrapper itself reads or writes, and that every React Native
|
|
33
|
+
* scrollable has. The generic list's remaining props pass through untouched.
|
|
34
|
+
* `ListFooterComponent` is the one list-only prop here: the wrapper appends
|
|
35
|
+
* its trailing spacer to it, where a `ScrollView` takes the spacer as a child.
|
|
36
|
+
*/
|
|
37
|
+
export type ScrollableInnerProps = Omit<ScrollViewProps, "style"> & {
|
|
38
|
+
ref?: Ref<ScrollableHandle>;
|
|
39
|
+
style?: AnimatedProps<ScrollViewProps>["style"];
|
|
40
|
+
animatedProps?: Partial<ScrollViewProps> | readonly Partial<ScrollViewProps>[];
|
|
41
|
+
ListFooterComponent?: ScrollableListFooter;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** The animated list a factory wraps — `Animated.ScrollView`, `Animated.FlatList`, or one made with `createAnimatedComponent`. */
|
|
45
|
+
export type ScrollableInnerComponent = ComponentType<ScrollableInnerProps>;
|
|
46
|
+
|
|
47
|
+
export type BottomSheetScrollViewProps = ScrollViewProps &
|
|
48
|
+
BottomSheetScrollableProps & {
|
|
49
|
+
ref?: Ref<ScrollView>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type BottomSheetFlatListProps<ItemT> = Omit<FlatListProps<ItemT>, "children"> & BottomSheetScrollableProps;
|
|
53
|
+
|
|
54
|
+
export type BottomSheetSectionListProps<ItemT, SectionT> = Omit<SectionListProps<ItemT, SectionT>, "children"> &
|
|
55
|
+
BottomSheetScrollableProps;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The generic component types, restated so `<ItemT>` survives the factory.
|
|
59
|
+
*
|
|
60
|
+
* `createBottomSheetScrollable` returns one concrete component, so `data` and
|
|
61
|
+
* `renderItem` would check against `unknown` and stop constraining each
|
|
62
|
+
* other. Writing the signature out is the same move
|
|
63
|
+
* `@delacour/react-native-ui`'s `Screen.FlatList` makes, and the `displayName`
|
|
64
|
+
* member is what keeps the trailing assignment legal after the cast.
|
|
65
|
+
*/
|
|
66
|
+
export type BottomSheetFlatListComponent = (<ItemT>(
|
|
67
|
+
props: BottomSheetFlatListProps<ItemT> & RefAttributes<FlatList<ItemT>>
|
|
68
|
+
) => ReactElement) & { displayName?: string };
|
|
69
|
+
|
|
70
|
+
export type BottomSheetSectionListComponent = (<ItemT, SectionT>(
|
|
71
|
+
props: BottomSheetSectionListProps<ItemT, SectionT> & RefAttributes<SectionList<ItemT, SectionT>>
|
|
72
|
+
) => ReactElement) & { displayName?: string };
|
|
73
|
+
|
|
74
|
+
export type BottomSheetScrollViewComponent = ((props: BottomSheetScrollViewProps) => ReactElement) & {
|
|
75
|
+
displayName?: string;
|
|
76
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Every `.ts`/`.tsx` under `dir`, tests included unless `skipTests`. */
|
|
5
|
+
export function sourceFiles(dir: string, options: { skipTests?: boolean } = {}): string[] {
|
|
6
|
+
const found: string[] = [];
|
|
7
|
+
const walk = (current: string): void => {
|
|
8
|
+
for (const entry of readdirSync(current)) {
|
|
9
|
+
const path = join(current, entry);
|
|
10
|
+
if (statSync(path).isDirectory()) {
|
|
11
|
+
walk(path);
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
if (!/\.tsx?$/.test(entry)) continue;
|
|
15
|
+
if (options.skipTests && /\.test\.tsx?$/.test(entry)) continue;
|
|
16
|
+
found.push(path);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
walk(dir);
|
|
20
|
+
return found;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const SRC = import.meta.dir;
|
|
24
|
+
export const PACKAGE_ROOT = join(SRC, "..");
|
|
25
|
+
|
|
26
|
+
export function read(path: string): string {
|
|
27
|
+
return readFileSync(path, "utf8");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `path` relative to the package, for a failure message that can be acted on. */
|
|
31
|
+
export function relative(path: string): string {
|
|
32
|
+
return path.slice(PACKAGE_ROOT.length + 1);
|
|
33
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# state
|
|
2
|
+
|
|
3
|
+
The shared values a sheet owns, the numbers derived from them, and the intent
|
|
4
|
+
queue that moves them.
|
|
5
|
+
|
|
6
|
+
## Files
|
|
7
|
+
|
|
8
|
+
| Path | What |
|
|
9
|
+
| --- | --- |
|
|
10
|
+
| `state.types.ts` | `SheetSharedState` (the raw shared values), `SheetGeometry` (the derived ones), `SheetWorkletConfig` (the props a worklet reads), `ANIM_STATUS` |
|
|
11
|
+
| `use-sheet-state.ts` | Allocates the raw values once per sheet; mirrors `snapPoints` and the config into them, keyed on serialisation rather than identity |
|
|
12
|
+
| `use-detents.ts` | `detents` — `normalizeDetents` plus the dynamic detent once handle and content have measured |
|
|
13
|
+
| `use-sheet-geometry.ts` | Every formula in `core/AGENTS.md` as a `useDerivedValue`, in dependency order |
|
|
14
|
+
| `use-sheet-intents.ts` | The JS-thread `dispatch`, the reaction that resolves an intent on the UI thread, and the reaction that follows a detent change or a container resize |
|
|
15
|
+
|
|
16
|
+
## The model
|
|
17
|
+
|
|
18
|
+
`base` is the only value an animation or a gesture writes. Everything a view
|
|
19
|
+
reads — `height`, `position`, `index`, `contentArea` — is derived from it and
|
|
20
|
+
from the measurements, on the UI thread, by the core's pure functions. Nothing
|
|
21
|
+
in this folder contains a formula; it contains `useDerivedValue(() =>
|
|
22
|
+
coreFunction(...))` and nothing else, which is what keeps the numbers
|
|
23
|
+
testable.
|
|
24
|
+
|
|
25
|
+
Measurements start at `UNMEASURED` (`-1`) and go back to it when the part that
|
|
26
|
+
reported them unmounts (`layout/`). `layoutReady` is derived from them, and an
|
|
27
|
+
`open` intent waits on it.
|
|
28
|
+
|
|
29
|
+
The keyboard values — `keyboardHeight`, `keyboardProgress`, `keyboardOwned` —
|
|
30
|
+
are allocated here and every keyboard term in the geometry is a real
|
|
31
|
+
derivation over them. BSHEET-3 writes them; until then they are `0`, `0` and
|
|
32
|
+
`false`, and every keyboard term is inert. `footerContentHeight` is the same:
|
|
33
|
+
allocated, derived over, and unmeasured until BSHEET-3 mounts a footer and the
|
|
34
|
+
root sets `hasFooter`.
|
|
35
|
+
|
|
36
|
+
## Intents
|
|
37
|
+
|
|
38
|
+
`dispatch({ kind: "open" })` writes `state.intent`; a reaction keyed on the
|
|
39
|
+
intent's `id` and on `layoutReady` runs `resolveIntent` from the core and calls
|
|
40
|
+
`animateTo` or `jumpTo`. The JS thread never animates and never reads a
|
|
41
|
+
measurement to decide whether it may.
|
|
42
|
+
|
|
43
|
+
The resolver is told the sheet's **effective** index: the settled one, or the
|
|
44
|
+
running animation's target. Without that a `close()` during the open animation
|
|
45
|
+
would see a closed sheet, resolve to nothing, and let the sheet finish opening.
|
|
46
|
+
|
|
47
|
+
The first open a sheet resolves is reported as `"mount"` and, with
|
|
48
|
+
`animateOnMount: false`, jumps. `mountPending` is that flag.
|
|
49
|
+
|
|
50
|
+
## Detents moving under a settled sheet
|
|
51
|
+
|
|
52
|
+
The second reaction watches the detent list and the container height. When
|
|
53
|
+
the list changes with the sheet idle and open, the sheet animates to the same
|
|
54
|
+
index in the new list — dynamic content growing, a `snapPoints` change. When
|
|
55
|
+
the container changed, it jumps there instead: a rotation is not something to
|
|
56
|
+
animate through. Neither runs while a gesture or an animation owns `base`.
|
|
57
|
+
|
|
58
|
+
## Why the config is one shared value
|
|
59
|
+
|
|
60
|
+
A worklet that captured a dozen boolean props would rebuild every time one of
|
|
61
|
+
them changed, and a gesture captures the pan handlers. `config` is one object
|
|
62
|
+
written by one effect, and a worklet reads `state.config.value.x` at the
|
|
63
|
+
moment it needs it.
|