@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,134 @@
|
|
|
1
|
+
import type { SharedValue } from "react-native-reanimated";
|
|
2
|
+
import type {
|
|
3
|
+
AnimationSource,
|
|
4
|
+
DetachedOptions,
|
|
5
|
+
DetentSpec,
|
|
6
|
+
GestureSource,
|
|
7
|
+
KeyboardBehavior,
|
|
8
|
+
KeyboardBlurBehavior,
|
|
9
|
+
KeyboardScope,
|
|
10
|
+
ScrollableType,
|
|
11
|
+
SheetIntent,
|
|
12
|
+
SheetState,
|
|
13
|
+
} from "../core";
|
|
14
|
+
|
|
15
|
+
/** Whether an animation is running. Numeric so the UI thread never serialises a string. */
|
|
16
|
+
export const ANIM_STATUS = {
|
|
17
|
+
IDLE: 0,
|
|
18
|
+
RUNNING: 1,
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
export type AnimStatus = (typeof ANIM_STATUS)[keyof typeof ANIM_STATUS];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The root's props as the UI thread needs them, mirrored into one shared value
|
|
25
|
+
* so a worklet reads a snapshot rather than a dozen captured primitives that
|
|
26
|
+
* would each rebuild the closure when they change.
|
|
27
|
+
*/
|
|
28
|
+
export type SheetWorkletConfig = {
|
|
29
|
+
dynamicSizing: boolean;
|
|
30
|
+
maxDynamicContentSize: number | undefined;
|
|
31
|
+
hasFooter: boolean;
|
|
32
|
+
bottomInset: number;
|
|
33
|
+
detached: DetachedOptions | null;
|
|
34
|
+
enablePanDownToClose: boolean;
|
|
35
|
+
enableOverDrag: boolean;
|
|
36
|
+
overDragResistanceFactor: number;
|
|
37
|
+
initialIndex: number;
|
|
38
|
+
animateOnMount: boolean;
|
|
39
|
+
keyboardBehavior: KeyboardBehavior;
|
|
40
|
+
keyboardBlurBehavior: KeyboardBlurBehavior;
|
|
41
|
+
keyboardScope: KeyboardScope;
|
|
42
|
+
enableBlurKeyboardOnGesture: boolean;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Every raw shared value the sheet owns.
|
|
47
|
+
*
|
|
48
|
+
* Measurements start at `UNMEASURED` (`-1`); `base` is the only animated
|
|
49
|
+
* value; everything else is written by a gesture, a layout callback or the JS
|
|
50
|
+
* thread, and read by the derived geometry.
|
|
51
|
+
*/
|
|
52
|
+
export type SheetSharedState = {
|
|
53
|
+
/** The frame the sheet lives in, `top: topInset` to the host's bottom. */
|
|
54
|
+
containerHeight: SharedValue<number>;
|
|
55
|
+
containerWidth: SharedValue<number>;
|
|
56
|
+
/** Window bottom minus frame bottom — what a keyboard has to clear before it overlaps. */
|
|
57
|
+
containerBottomOffset: SharedValue<number>;
|
|
58
|
+
handleHeight: SharedValue<number>;
|
|
59
|
+
contentHeight: SharedValue<number>;
|
|
60
|
+
footerContentHeight: SharedValue<number>;
|
|
61
|
+
/** The keyboard-free height. The only value an animation writes. */
|
|
62
|
+
base: SharedValue<number>;
|
|
63
|
+
/** The settled index, `-1` closed. Written on settle, never mid-flight. */
|
|
64
|
+
currentIndex: SharedValue<number>;
|
|
65
|
+
animStatus: SharedValue<AnimStatus>;
|
|
66
|
+
animSource: SharedValue<AnimationSource>;
|
|
67
|
+
animTarget: SharedValue<number>;
|
|
68
|
+
gestureSource: SharedValue<GestureSource>;
|
|
69
|
+
/** The scrollable's live offset, written by its scroll handler; negative while bounced past the top. */
|
|
70
|
+
scrollOffsetY: SharedValue<number>;
|
|
71
|
+
/** Where the scrollable is held while the sheet is below its highest detent. */
|
|
72
|
+
scrollLockedAt: SharedValue<number>;
|
|
73
|
+
/** Which scrollable is the body, `NONE` for static content. Set by `createBottomSheetScrollable` on focus. */
|
|
74
|
+
scrollableType: SharedValue<ScrollableType>;
|
|
75
|
+
keyboardOwned: SharedValue<boolean>;
|
|
76
|
+
/** keyboard-controller's `progress`, `0` until BSHEET-3 wires the keyboard. */
|
|
77
|
+
keyboardProgress: SharedValue<number>;
|
|
78
|
+
/** The keyboard's height inside the container, positive, `0` until BSHEET-3. */
|
|
79
|
+
keyboardHeight: SharedValue<number>;
|
|
80
|
+
/** The pending request from the JS thread, `null` once resolved. */
|
|
81
|
+
intent: SharedValue<SheetIntent | null>;
|
|
82
|
+
/** Whether the next open is the mount open — reported as `"mount"` and skipped by `animateOnMount: false`. */
|
|
83
|
+
mountPending: SharedValue<boolean>;
|
|
84
|
+
/** The `snapPoints` prop, string-keyed so identity churn is harmless. */
|
|
85
|
+
detentSpec: SharedValue<readonly DetentSpec[]>;
|
|
86
|
+
config: SharedValue<SheetWorkletConfig>;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Every number derived from the raw state, each a `useDerivedValue` over the
|
|
91
|
+
* core's pure functions. Consumers read; nothing writes.
|
|
92
|
+
*/
|
|
93
|
+
export type SheetGeometry = {
|
|
94
|
+
restingBottom: SharedValue<number>;
|
|
95
|
+
closedHeight: SharedValue<number>;
|
|
96
|
+
/** The height the sheet may occupy; what a `%` detent resolves against. */
|
|
97
|
+
maxHeight: SharedValue<number>;
|
|
98
|
+
/** The safe-area band an attached sheet reserves. */
|
|
99
|
+
band: SharedValue<number>;
|
|
100
|
+
bandNow: SharedValue<number>;
|
|
101
|
+
/** Ascending, unique, the dynamic detent included when sizing to content. */
|
|
102
|
+
detents: SharedValue<readonly number[]>;
|
|
103
|
+
/** The last detent, or the closed height when there is none yet. */
|
|
104
|
+
highest: SharedValue<number>;
|
|
105
|
+
keyboardLift: SharedValue<number>;
|
|
106
|
+
/** `base + keyboardLift`, clamped. What the view shows. */
|
|
107
|
+
height: SharedValue<number>;
|
|
108
|
+
/** `translateY` for the panel. */
|
|
109
|
+
position: SharedValue<number>;
|
|
110
|
+
/** Continuous index over `base`, `-1` closed. The overlay follows this. */
|
|
111
|
+
index: SharedValue<number>;
|
|
112
|
+
sheetState: SharedValue<SheetState>;
|
|
113
|
+
layoutReady: SharedValue<boolean>;
|
|
114
|
+
/** What the body may fill above the footer or the band, sized against its detent. */
|
|
115
|
+
contentArea: SharedValue<number>;
|
|
116
|
+
footerHeight: SharedValue<number>;
|
|
117
|
+
footerTop: SharedValue<number>;
|
|
118
|
+
/** What trails the body: the footer, band included, or the band alone. The body reserves it at its end. */
|
|
119
|
+
bodyInset: SharedValue<number>;
|
|
120
|
+
/**
|
|
121
|
+
* How tall the body's clipping box is: `contentArea + bodyInset` — the body
|
|
122
|
+
* is laid out to the sheet's bottom line — held under a sticky footer to
|
|
123
|
+
* the footer's live top edge, so nothing of the body shows below it while
|
|
124
|
+
* the sheet is lower than its detent.
|
|
125
|
+
*/
|
|
126
|
+
bodyClip: SharedValue<number>;
|
|
127
|
+
/**
|
|
128
|
+
* How tall a detached card's surface is: `height` between the detents, the
|
|
129
|
+
* first detent's height below them and the last's above, so a close, a
|
|
130
|
+
* rubber-band and an over-drag move the card as a rigid body. Equal to
|
|
131
|
+
* `height` when attached.
|
|
132
|
+
*/
|
|
133
|
+
surfaceHeight: SharedValue<number>;
|
|
134
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type SharedValue, useDerivedValue } from "react-native-reanimated";
|
|
2
|
+
import { dynamicDetent, normalizeDetents } from "../core";
|
|
3
|
+
import type { SheetSharedState } from "./state.types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The ascending, unique detents, recomputed on the UI thread whenever the spec,
|
|
7
|
+
* the container or a measurement changes.
|
|
8
|
+
*
|
|
9
|
+
* The dynamic detent joins the list only once the handle and the content have
|
|
10
|
+
* both reported — before that a sheet sized to its content has no detent at
|
|
11
|
+
* all, and `layoutReady` keeps an open intent waiting rather than animating to
|
|
12
|
+
* a guess. An explicit detent equal to the dynamic one is one detent, not two.
|
|
13
|
+
*/
|
|
14
|
+
export function useDetents(
|
|
15
|
+
state: SheetSharedState,
|
|
16
|
+
maxHeight: SharedValue<number>,
|
|
17
|
+
band: SharedValue<number>
|
|
18
|
+
): SharedValue<readonly number[]> {
|
|
19
|
+
return useDerivedValue<readonly number[]>(() => {
|
|
20
|
+
const available = maxHeight.value;
|
|
21
|
+
const config = state.config.value;
|
|
22
|
+
const detents = normalizeDetents(state.detentSpec.value, available);
|
|
23
|
+
|
|
24
|
+
if (!config.dynamicSizing || available <= 0) return detents;
|
|
25
|
+
if (state.handleHeight.value < 0 || state.contentHeight.value < 0) return detents;
|
|
26
|
+
if (config.hasFooter && state.footerContentHeight.value < 0) return detents;
|
|
27
|
+
|
|
28
|
+
const dynamic = dynamicDetent({
|
|
29
|
+
handleHeight: state.handleHeight.value,
|
|
30
|
+
contentHeight: state.contentHeight.value,
|
|
31
|
+
footerContentHeight: state.footerContentHeight.value,
|
|
32
|
+
band: band.value,
|
|
33
|
+
hasFooter: config.hasFooter,
|
|
34
|
+
available,
|
|
35
|
+
maxDynamicContentSize: config.maxDynamicContentSize,
|
|
36
|
+
});
|
|
37
|
+
if (detents.indexOf(dynamic) !== -1) return detents;
|
|
38
|
+
|
|
39
|
+
const merged = [...detents, dynamic];
|
|
40
|
+
merged.sort((a, b) => a - b);
|
|
41
|
+
return merged;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { useDerivedValue } from "react-native-reanimated";
|
|
3
|
+
import {
|
|
4
|
+
availableHeight,
|
|
5
|
+
bandNow,
|
|
6
|
+
bodyClip,
|
|
7
|
+
bodyInset,
|
|
8
|
+
bottomBand,
|
|
9
|
+
clampHeight,
|
|
10
|
+
closedHeight,
|
|
11
|
+
contentArea,
|
|
12
|
+
footerHeight,
|
|
13
|
+
footerTop,
|
|
14
|
+
indexForHeight,
|
|
15
|
+
isLayoutReady,
|
|
16
|
+
keyboardLift,
|
|
17
|
+
positionFor,
|
|
18
|
+
restingBottom,
|
|
19
|
+
sheetState,
|
|
20
|
+
surfaceHeight,
|
|
21
|
+
} from "../core";
|
|
22
|
+
import type { SheetGeometry, SheetSharedState } from "./state.types";
|
|
23
|
+
import { useDetents } from "./use-detents";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Where an unmeasured panel goes: far enough down that no frame of it shows
|
|
27
|
+
* before the container has reported a height.
|
|
28
|
+
*/
|
|
29
|
+
const OFFSCREEN = 100_000;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Every derived number, as `useDerivedValue`s over the core's pure functions.
|
|
33
|
+
*
|
|
34
|
+
* Each hook is one formula from `core/AGENTS.md`, in dependency order, and none
|
|
35
|
+
* of them writes anything: a gesture, an animation or a layout callback writes
|
|
36
|
+
* the raw state and this re-derives. The keyboard values are real derivations
|
|
37
|
+
* over `keyboardHeight` and `keyboardProgress`, which BSHEET-3 writes; until
|
|
38
|
+
* then both are `0` and every keyboard term is inert.
|
|
39
|
+
*/
|
|
40
|
+
export function useSheetGeometry(state: SheetSharedState): SheetGeometry {
|
|
41
|
+
const resting = useDerivedValue(() => {
|
|
42
|
+
const detached = state.config.value.detached;
|
|
43
|
+
return restingBottom(detached !== null, detached?.bottomOffset ?? 0, state.config.value.bottomInset);
|
|
44
|
+
});
|
|
45
|
+
const closed = useDerivedValue(() => closedHeight(resting.value));
|
|
46
|
+
const maxHeight = useDerivedValue(() => availableHeight(state.containerHeight.value, resting.value));
|
|
47
|
+
const band = useDerivedValue(() => bottomBand(state.config.value.detached !== null, state.config.value.bottomInset));
|
|
48
|
+
const bandCurrent = useDerivedValue(() => bandNow(band.value, state.keyboardProgress.value));
|
|
49
|
+
const detents = useDetents(state, maxHeight, band);
|
|
50
|
+
const highest = useDerivedValue(() => {
|
|
51
|
+
const list = detents.value;
|
|
52
|
+
return list.length > 0 ? (list[list.length - 1] as number) : closed.value;
|
|
53
|
+
});
|
|
54
|
+
// The keyboard the sheet answers to: owned, and under a behaviour that does
|
|
55
|
+
// anything about it. `none` neither lifts nor shrinks the body.
|
|
56
|
+
const keyboardEffective = useDerivedValue(() =>
|
|
57
|
+
state.keyboardOwned.value && state.config.value.keyboardBehavior !== "none" ? state.keyboardHeight.value : 0
|
|
58
|
+
);
|
|
59
|
+
const lift = useDerivedValue(() =>
|
|
60
|
+
keyboardLift({
|
|
61
|
+
keyboardHeight: keyboardEffective.value,
|
|
62
|
+
progress: state.keyboardProgress.value,
|
|
63
|
+
band: band.value,
|
|
64
|
+
behavior: state.config.value.keyboardBehavior,
|
|
65
|
+
gapBelow: resting.value,
|
|
66
|
+
})
|
|
67
|
+
);
|
|
68
|
+
const height = useDerivedValue(() => clampHeight(state.base.value + lift.value, closed.value, maxHeight.value));
|
|
69
|
+
const position = useDerivedValue(() => {
|
|
70
|
+
const container = state.containerHeight.value;
|
|
71
|
+
if (!(container > 0)) return OFFSCREEN;
|
|
72
|
+
return positionFor(container, resting.value, height.value);
|
|
73
|
+
});
|
|
74
|
+
const index = useDerivedValue(() => indexForHeight(state.base.value, detents.value, closed.value));
|
|
75
|
+
const sheetStateValue = useDerivedValue(() =>
|
|
76
|
+
sheetState(state.base.value, height.value, detents.value, closed.value, maxHeight.value)
|
|
77
|
+
);
|
|
78
|
+
const layoutReady = useDerivedValue(() =>
|
|
79
|
+
isLayoutReady({
|
|
80
|
+
containerHeight: state.containerHeight.value,
|
|
81
|
+
handleHeight: state.handleHeight.value,
|
|
82
|
+
contentHeight: state.contentHeight.value,
|
|
83
|
+
footerContentHeight: state.footerContentHeight.value,
|
|
84
|
+
dynamicSizing: state.config.value.dynamicSizing,
|
|
85
|
+
hasFooter: state.config.value.hasFooter,
|
|
86
|
+
})
|
|
87
|
+
);
|
|
88
|
+
const footer = useDerivedValue(() =>
|
|
89
|
+
footerHeight(state.config.value.hasFooter, state.footerContentHeight.value, bandCurrent.value)
|
|
90
|
+
);
|
|
91
|
+
// The body is sized against the highest detent rather than `base`, so it
|
|
92
|
+
// never reflows during a snap between detents — but a `fillParent` snap
|
|
93
|
+
// takes `base` above every detent, and the body follows it up.
|
|
94
|
+
const area = useDerivedValue(() => {
|
|
95
|
+
const sheetHeight = Math.min(maxHeight.value, Math.max(highest.value, state.base.value) + lift.value);
|
|
96
|
+
return contentArea({
|
|
97
|
+
sheetHeight,
|
|
98
|
+
handleHeight: state.handleHeight.value,
|
|
99
|
+
footerHeight: footer.value,
|
|
100
|
+
keyboardHeight: keyboardEffective.value,
|
|
101
|
+
trailingBand: state.config.value.hasFooter ? 0 : bandCurrent.value,
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
const top = useDerivedValue(() =>
|
|
105
|
+
footerTop({
|
|
106
|
+
sheetHeight: height.value,
|
|
107
|
+
keyboardHeight: keyboardEffective.value,
|
|
108
|
+
footerContentHeight: state.footerContentHeight.value,
|
|
109
|
+
band: bandCurrent.value,
|
|
110
|
+
})
|
|
111
|
+
);
|
|
112
|
+
// The body is laid out to the sheet's bottom line and reserves `inset` at
|
|
113
|
+
// its end; the clip is what of it may show — under a footer, only what is
|
|
114
|
+
// above the footer's live top edge.
|
|
115
|
+
const inset = useDerivedValue(() => bodyInset(state.config.value.hasFooter, footer.value, bandCurrent.value));
|
|
116
|
+
const clip = useDerivedValue(() =>
|
|
117
|
+
bodyClip({
|
|
118
|
+
contentArea: area.value,
|
|
119
|
+
inset: inset.value,
|
|
120
|
+
hasFooter: state.config.value.hasFooter,
|
|
121
|
+
sheetHeight: height.value,
|
|
122
|
+
handleHeight: state.handleHeight.value,
|
|
123
|
+
keyboardHeight: keyboardEffective.value,
|
|
124
|
+
})
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const surface = useDerivedValue(() => {
|
|
128
|
+
if (state.config.value.detached === null) return height.value;
|
|
129
|
+
const list = detents.value;
|
|
130
|
+
const lowest = list.length > 0 ? (list[0] as number) : 0;
|
|
131
|
+
return surfaceHeight(height.value, lowest, highest.value);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
return useMemo<SheetGeometry>(
|
|
135
|
+
() => ({
|
|
136
|
+
restingBottom: resting,
|
|
137
|
+
closedHeight: closed,
|
|
138
|
+
maxHeight,
|
|
139
|
+
band,
|
|
140
|
+
bandNow: bandCurrent,
|
|
141
|
+
detents,
|
|
142
|
+
highest,
|
|
143
|
+
keyboardLift: lift,
|
|
144
|
+
height,
|
|
145
|
+
position,
|
|
146
|
+
index,
|
|
147
|
+
sheetState: sheetStateValue,
|
|
148
|
+
layoutReady,
|
|
149
|
+
contentArea: area,
|
|
150
|
+
footerHeight: footer,
|
|
151
|
+
footerTop: top,
|
|
152
|
+
bodyInset: inset,
|
|
153
|
+
bodyClip: clip,
|
|
154
|
+
surfaceHeight: surface,
|
|
155
|
+
}),
|
|
156
|
+
[
|
|
157
|
+
resting,
|
|
158
|
+
closed,
|
|
159
|
+
maxHeight,
|
|
160
|
+
band,
|
|
161
|
+
bandCurrent,
|
|
162
|
+
detents,
|
|
163
|
+
highest,
|
|
164
|
+
lift,
|
|
165
|
+
height,
|
|
166
|
+
position,
|
|
167
|
+
index,
|
|
168
|
+
sheetStateValue,
|
|
169
|
+
layoutReady,
|
|
170
|
+
area,
|
|
171
|
+
footer,
|
|
172
|
+
top,
|
|
173
|
+
inset,
|
|
174
|
+
clip,
|
|
175
|
+
surface,
|
|
176
|
+
]
|
|
177
|
+
);
|
|
178
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { useCallback, useMemo, useRef } from "react";
|
|
2
|
+
import { useAnimatedReaction } from "react-native-reanimated";
|
|
3
|
+
import type { AnimateTo, JumpTo } from "../animation/animation.types";
|
|
4
|
+
import { GESTURE_SOURCE, heightForIndex, indexForHeight, resolveIntent, type SheetIntent } from "../core";
|
|
5
|
+
import { ANIM_STATUS, type SheetGeometry, type SheetSharedState } from "./state.types";
|
|
6
|
+
|
|
7
|
+
/** `Omit` over each member of a union, rather than over the union's common keys. */
|
|
8
|
+
type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : never;
|
|
9
|
+
|
|
10
|
+
/** Every request the JS thread can make, minus the `id` the dispatcher stamps on it. */
|
|
11
|
+
export type IntentRequest = DistributiveOmit<SheetIntent, "id">;
|
|
12
|
+
|
|
13
|
+
export type SheetIntentDispatch = (request: IntentRequest) => void;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The JS thread's handle on the queue.
|
|
17
|
+
*
|
|
18
|
+
* `lastKind` is the kind of the most recent request, tracked on the JS side.
|
|
19
|
+
* A JS write to a shared value is queued to the UI thread, so reading
|
|
20
|
+
* `state.intent.value` straight after a `dispatch` still returns what was
|
|
21
|
+
* there before — the root once read `null` that way, queued an `open` on top
|
|
22
|
+
* of a ref's `expand`, and the sheet landed on the wrong detent. `clear`
|
|
23
|
+
* writes `null` in order after whatever was queued, which is what a controlled
|
|
24
|
+
* `isOpen: false` needs to cancel an open still waiting on layout.
|
|
25
|
+
*/
|
|
26
|
+
export type SheetIntents = {
|
|
27
|
+
dispatch: SheetIntentDispatch;
|
|
28
|
+
clear: () => void;
|
|
29
|
+
lastKind: () => IntentRequest["kind"] | null;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Intents replace present/dismiss.
|
|
34
|
+
*
|
|
35
|
+
* The JS thread never animates. It writes a request into `state.intent`, and a
|
|
36
|
+
* reaction keyed on the request's `id` and on `layoutReady` runs
|
|
37
|
+
* `resolveIntent` on the UI thread: a close while closed is nothing, an open
|
|
38
|
+
* before the first layout stays queued until the measurements land, a
|
|
39
|
+
* `forceClose` jumps. Two identical requests in a row both run, because each
|
|
40
|
+
* carries a fresh `id`.
|
|
41
|
+
*
|
|
42
|
+
* The first open a sheet resolves is its mount: reported as `"mount"`, and a
|
|
43
|
+
* jump rather than an animation when `animateOnMount` is off. `mountPending`
|
|
44
|
+
* is the flag, and it clears on that first resolution whatever kind it was.
|
|
45
|
+
*
|
|
46
|
+
* A second reaction handles the detents moving under a settled sheet — dynamic
|
|
47
|
+
* content growing, a `snapPoints` change — by animating to the same index in
|
|
48
|
+
* the new list, and a container resize by jumping there instead. Neither runs
|
|
49
|
+
* while a gesture or an animation owns `base`.
|
|
50
|
+
*/
|
|
51
|
+
export function useSheetIntents(
|
|
52
|
+
state: SheetSharedState,
|
|
53
|
+
geometry: SheetGeometry,
|
|
54
|
+
animateTo: AnimateTo,
|
|
55
|
+
jumpTo: JumpTo
|
|
56
|
+
): SheetIntents {
|
|
57
|
+
const nextId = useRef(0);
|
|
58
|
+
const last = useRef<IntentRequest["kind"] | null>(null);
|
|
59
|
+
|
|
60
|
+
const dispatch = useCallback<SheetIntentDispatch>(
|
|
61
|
+
(request) => {
|
|
62
|
+
nextId.current += 1;
|
|
63
|
+
last.current = request.kind;
|
|
64
|
+
state.intent.value = { ...request, id: nextId.current } as SheetIntent;
|
|
65
|
+
},
|
|
66
|
+
[state]
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const clear = useCallback(() => {
|
|
70
|
+
last.current = null;
|
|
71
|
+
state.intent.value = null;
|
|
72
|
+
}, [state]);
|
|
73
|
+
|
|
74
|
+
const lastKind = useCallback(() => last.current, []);
|
|
75
|
+
|
|
76
|
+
useAnimatedReaction(
|
|
77
|
+
() => ({ id: state.intent.value?.id ?? 0, ready: geometry.layoutReady.value }),
|
|
78
|
+
(current, previous) => {
|
|
79
|
+
if (previous !== null && current.id === previous.id && current.ready === previous.ready) return;
|
|
80
|
+
const intent = state.intent.value;
|
|
81
|
+
if (intent === null) return;
|
|
82
|
+
|
|
83
|
+
// Mid-animation the sheet is where it is going, not where it settled:
|
|
84
|
+
// a close during the open animation has to see an open sheet.
|
|
85
|
+
const effectiveIndex =
|
|
86
|
+
state.animStatus.value === ANIM_STATUS.RUNNING
|
|
87
|
+
? Math.round(indexForHeight(state.animTarget.value, geometry.detents.value, geometry.closedHeight.value))
|
|
88
|
+
: state.currentIndex.value;
|
|
89
|
+
const resolution = resolveIntent(
|
|
90
|
+
{
|
|
91
|
+
currentIndex: effectiveIndex,
|
|
92
|
+
base: state.base.value,
|
|
93
|
+
layoutReady: current.ready,
|
|
94
|
+
detents: geometry.detents.value,
|
|
95
|
+
closedHeight: geometry.closedHeight.value,
|
|
96
|
+
maxHeight: geometry.maxHeight.value,
|
|
97
|
+
initialIndex: state.config.value.initialIndex,
|
|
98
|
+
},
|
|
99
|
+
intent
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
if (resolution === null) {
|
|
103
|
+
state.intent.value = null;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (resolution.action === "wait") return;
|
|
107
|
+
state.intent.value = null;
|
|
108
|
+
|
|
109
|
+
const mounting = state.mountPending.value && intent.kind === "open";
|
|
110
|
+
state.mountPending.value = false;
|
|
111
|
+
const source = mounting ? "mount" : "user";
|
|
112
|
+
const jump = resolution.action === "jump" || (mounting && !state.config.value.animateOnMount);
|
|
113
|
+
if (jump) jumpTo(resolution.target, source);
|
|
114
|
+
else animateTo(resolution.target, source, 0);
|
|
115
|
+
},
|
|
116
|
+
[state, geometry, animateTo, jumpTo]
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
useAnimatedReaction(
|
|
120
|
+
() => ({ detents: geometry.detents.value.join(","), container: state.containerHeight.value }),
|
|
121
|
+
(current, previous) => {
|
|
122
|
+
if (previous === null || (current.detents === previous.detents && current.container === previous.container)) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const busy = state.animStatus.value !== ANIM_STATUS.IDLE || state.gestureSource.value !== GESTURE_SOURCE.NONE;
|
|
126
|
+
const index = state.currentIndex.value;
|
|
127
|
+
if (busy || index < 0) return;
|
|
128
|
+
|
|
129
|
+
const detents = geometry.detents.value;
|
|
130
|
+
if (detents.length === 0) return;
|
|
131
|
+
const target = heightForIndex(Math.min(index, detents.length - 1), detents, geometry.closedHeight.value);
|
|
132
|
+
if (Math.abs(target - state.base.value) < 0.5) return;
|
|
133
|
+
|
|
134
|
+
if (current.container !== previous.container) {
|
|
135
|
+
jumpTo(target, "container");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
animateTo(target, "snapPoints", 0);
|
|
139
|
+
},
|
|
140
|
+
[state, geometry, animateTo, jumpTo]
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
return useMemo<SheetIntents>(() => ({ dispatch, clear, lastKind }), [dispatch, clear, lastKind]);
|
|
144
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { useEffect, useMemo } from "react";
|
|
2
|
+
import { useSharedValue } from "react-native-reanimated";
|
|
3
|
+
import {
|
|
4
|
+
type AnimationSource,
|
|
5
|
+
CLOSED_INDEX,
|
|
6
|
+
type DetentSpec,
|
|
7
|
+
GESTURE_SOURCE,
|
|
8
|
+
SCROLLABLE_TYPE,
|
|
9
|
+
type ScrollableType,
|
|
10
|
+
type SheetIntent,
|
|
11
|
+
UNMEASURED,
|
|
12
|
+
} from "../core";
|
|
13
|
+
import { ANIM_STATUS, type AnimStatus, type SheetSharedState, type SheetWorkletConfig } from "./state.types";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Allocates the raw shared values, once per sheet, and keeps `detentSpec` and
|
|
17
|
+
* `config` in step with the props.
|
|
18
|
+
*
|
|
19
|
+
* `detentSpec` is written when its **serialisation** changes, not its identity,
|
|
20
|
+
* so a `snapPoints={["40%", "85%"]}` literal in a render body costs nothing —
|
|
21
|
+
* the array identity churn that made the library this replaces re-derive on
|
|
22
|
+
* every render is simply not observed.
|
|
23
|
+
*/
|
|
24
|
+
export function useSheetState(snapPoints: readonly DetentSpec[], config: SheetWorkletConfig): SheetSharedState {
|
|
25
|
+
const containerHeight = useSharedValue(UNMEASURED);
|
|
26
|
+
const containerWidth = useSharedValue(UNMEASURED);
|
|
27
|
+
const containerBottomOffset = useSharedValue(0);
|
|
28
|
+
const handleHeight = useSharedValue(UNMEASURED);
|
|
29
|
+
const contentHeight = useSharedValue(UNMEASURED);
|
|
30
|
+
const footerContentHeight = useSharedValue(UNMEASURED);
|
|
31
|
+
const base = useSharedValue(0);
|
|
32
|
+
const currentIndex = useSharedValue(CLOSED_INDEX);
|
|
33
|
+
const animStatus = useSharedValue<AnimStatus>(ANIM_STATUS.IDLE);
|
|
34
|
+
const animSource = useSharedValue<AnimationSource>("mount");
|
|
35
|
+
const animTarget = useSharedValue(0);
|
|
36
|
+
const gestureSource = useSharedValue<(typeof GESTURE_SOURCE)[keyof typeof GESTURE_SOURCE]>(GESTURE_SOURCE.NONE);
|
|
37
|
+
const scrollOffsetY = useSharedValue(0);
|
|
38
|
+
const scrollLockedAt = useSharedValue(0);
|
|
39
|
+
const scrollableType = useSharedValue<ScrollableType>(SCROLLABLE_TYPE.NONE);
|
|
40
|
+
const keyboardOwned = useSharedValue(false);
|
|
41
|
+
const keyboardProgress = useSharedValue(0);
|
|
42
|
+
const keyboardHeight = useSharedValue(0);
|
|
43
|
+
const intent = useSharedValue<SheetIntent | null>(null);
|
|
44
|
+
const mountPending = useSharedValue(true);
|
|
45
|
+
const detentSpec = useSharedValue<readonly DetentSpec[]>(snapPoints);
|
|
46
|
+
const configValue = useSharedValue<SheetWorkletConfig>(config);
|
|
47
|
+
|
|
48
|
+
const specKey = snapPoints.join("|");
|
|
49
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed on the serialisation on purpose; the array is only read when it changes
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
detentSpec.value = snapPoints;
|
|
52
|
+
}, [specKey, detentSpec]);
|
|
53
|
+
|
|
54
|
+
const {
|
|
55
|
+
dynamicSizing,
|
|
56
|
+
maxDynamicContentSize,
|
|
57
|
+
hasFooter,
|
|
58
|
+
bottomInset,
|
|
59
|
+
detached,
|
|
60
|
+
enablePanDownToClose,
|
|
61
|
+
enableOverDrag,
|
|
62
|
+
overDragResistanceFactor,
|
|
63
|
+
initialIndex,
|
|
64
|
+
animateOnMount,
|
|
65
|
+
keyboardBehavior,
|
|
66
|
+
keyboardBlurBehavior,
|
|
67
|
+
keyboardScope,
|
|
68
|
+
enableBlurKeyboardOnGesture,
|
|
69
|
+
} = config;
|
|
70
|
+
const detachedKey = detached === null ? "" : `${detached.horizontalMargin}/${detached.bottomOffset}`;
|
|
71
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `detached` is keyed by its two numbers, not its identity
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
configValue.value = {
|
|
74
|
+
dynamicSizing,
|
|
75
|
+
maxDynamicContentSize,
|
|
76
|
+
hasFooter,
|
|
77
|
+
bottomInset,
|
|
78
|
+
detached,
|
|
79
|
+
enablePanDownToClose,
|
|
80
|
+
enableOverDrag,
|
|
81
|
+
overDragResistanceFactor,
|
|
82
|
+
initialIndex,
|
|
83
|
+
animateOnMount,
|
|
84
|
+
keyboardBehavior,
|
|
85
|
+
keyboardBlurBehavior,
|
|
86
|
+
keyboardScope,
|
|
87
|
+
enableBlurKeyboardOnGesture,
|
|
88
|
+
};
|
|
89
|
+
}, [
|
|
90
|
+
configValue,
|
|
91
|
+
dynamicSizing,
|
|
92
|
+
maxDynamicContentSize,
|
|
93
|
+
hasFooter,
|
|
94
|
+
bottomInset,
|
|
95
|
+
detachedKey,
|
|
96
|
+
enablePanDownToClose,
|
|
97
|
+
enableOverDrag,
|
|
98
|
+
overDragResistanceFactor,
|
|
99
|
+
initialIndex,
|
|
100
|
+
animateOnMount,
|
|
101
|
+
keyboardBehavior,
|
|
102
|
+
keyboardBlurBehavior,
|
|
103
|
+
keyboardScope,
|
|
104
|
+
enableBlurKeyboardOnGesture,
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
return useMemo<SheetSharedState>(
|
|
108
|
+
() => ({
|
|
109
|
+
containerHeight,
|
|
110
|
+
containerWidth,
|
|
111
|
+
containerBottomOffset,
|
|
112
|
+
handleHeight,
|
|
113
|
+
contentHeight,
|
|
114
|
+
footerContentHeight,
|
|
115
|
+
base,
|
|
116
|
+
currentIndex,
|
|
117
|
+
animStatus,
|
|
118
|
+
animSource,
|
|
119
|
+
animTarget,
|
|
120
|
+
gestureSource,
|
|
121
|
+
scrollOffsetY,
|
|
122
|
+
scrollLockedAt,
|
|
123
|
+
scrollableType,
|
|
124
|
+
keyboardOwned,
|
|
125
|
+
keyboardProgress,
|
|
126
|
+
keyboardHeight,
|
|
127
|
+
intent,
|
|
128
|
+
mountPending,
|
|
129
|
+
detentSpec,
|
|
130
|
+
config: configValue,
|
|
131
|
+
}),
|
|
132
|
+
[
|
|
133
|
+
containerHeight,
|
|
134
|
+
containerWidth,
|
|
135
|
+
containerBottomOffset,
|
|
136
|
+
handleHeight,
|
|
137
|
+
contentHeight,
|
|
138
|
+
footerContentHeight,
|
|
139
|
+
base,
|
|
140
|
+
currentIndex,
|
|
141
|
+
animStatus,
|
|
142
|
+
animSource,
|
|
143
|
+
animTarget,
|
|
144
|
+
gestureSource,
|
|
145
|
+
scrollOffsetY,
|
|
146
|
+
scrollLockedAt,
|
|
147
|
+
scrollableType,
|
|
148
|
+
keyboardOwned,
|
|
149
|
+
keyboardProgress,
|
|
150
|
+
keyboardHeight,
|
|
151
|
+
intent,
|
|
152
|
+
mountPending,
|
|
153
|
+
detentSpec,
|
|
154
|
+
configValue,
|
|
155
|
+
]
|
|
156
|
+
);
|
|
157
|
+
}
|