@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,100 @@
|
|
|
1
|
+
# steps
|
|
2
|
+
|
|
3
|
+
`BottomSheet.Steps` and `BottomSheet.Step` — a body whose contents change
|
|
4
|
+
with a machine, and whose height glides between them — plus the hooks that
|
|
5
|
+
run the machine and read it.
|
|
6
|
+
|
|
7
|
+
## Files
|
|
8
|
+
|
|
9
|
+
| Path | What |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
| `steps.types.ts` | `SheetStepController` (a snapshot plus `send`, `can`, `matches`, `reset`, `direction`), `UseSheetMachineOptions`, the two parts' props |
|
|
12
|
+
| `use-sheet-machine.ts` | `useSheetMachine(machine, { onTransition?, onRejected? })` — the machine as React state |
|
|
13
|
+
| `steps.context.tsx` | `SheetStepContext` (the controller, untyped at the boundary) and `StepsLayoutContext` (what `Steps` tells each `Step`) |
|
|
14
|
+
| `use-sheet-step.ts` | `useSheetStep<S, C, E>()` / `useOptionalSheetStep()` — the controller with its generics asserted back on |
|
|
15
|
+
| `bottom-sheet-steps.tsx` | The body: `Content` with an animated-height stack inside, the `contentHeight` write, the per-step override, the reset on close |
|
|
16
|
+
| `bottom-sheet-step.tsx` | One step: absolutely positioned, measured while current, framed by `stepFrame` |
|
|
17
|
+
|
|
18
|
+
The machine itself — `defineSheetMachine`, `transition`, `stepFrame`,
|
|
19
|
+
`stepOverride` — is pure and lives in `core/machine`.
|
|
20
|
+
|
|
21
|
+
## The model
|
|
22
|
+
|
|
23
|
+
```tsx
|
|
24
|
+
const machine = defineSheetMachine<Step, Context, Event>({ initial: "details", context, states: { … } });
|
|
25
|
+
|
|
26
|
+
function Form() {
|
|
27
|
+
const controller = useSheetMachine(machine);
|
|
28
|
+
return (
|
|
29
|
+
<BottomSheet>
|
|
30
|
+
<BottomSheet.Portal>
|
|
31
|
+
<BottomSheet.Overlay />
|
|
32
|
+
<BottomSheet.Container>
|
|
33
|
+
<BottomSheet.Handle />
|
|
34
|
+
<BottomSheet.Steps controller transition="crossfade">
|
|
35
|
+
<BottomSheet.Step name="details">…</BottomSheet.Step>
|
|
36
|
+
<BottomSheet.Step name="confirm">…</BottomSheet.Step>
|
|
37
|
+
</BottomSheet.Steps>
|
|
38
|
+
<BottomSheet.Footer><Actions /></BottomSheet.Footer>
|
|
39
|
+
</BottomSheet.Container>
|
|
40
|
+
</BottomSheet.Portal>
|
|
41
|
+
</BottomSheet>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function Actions() {
|
|
46
|
+
const { send, can, matches } = useSheetStep<Step, Context, Event>();
|
|
47
|
+
…
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- **`useSheetMachine` is `useState` over `machine.transition`, not
|
|
52
|
+
`useReducer`.** A reducer has to be pure and React may run it twice;
|
|
53
|
+
`onRejected` is a side effect a form wants exactly once. The latest snapshot
|
|
54
|
+
is also kept in a ref so two `send`s in one tick chain. `direction` is
|
|
55
|
+
`machine.directionOf(from, to)` of the last change of step, and a `reset`
|
|
56
|
+
reads as `back`.
|
|
57
|
+
- **`Steps` is `Content` with a stack inside.** It renders `Content` itself,
|
|
58
|
+
so the content pan, the `contentArea` clamp, the detached clamp and the
|
|
59
|
+
footer spacer are all the same code; the stack is an `Animated.View` whose
|
|
60
|
+
height is a shared value. Only the current step and, during a change, the
|
|
61
|
+
one leaving are rendered; each is absolutely positioned across the top of
|
|
62
|
+
the stack, measured by its `onLayout` while current.
|
|
63
|
+
- **The height glides because the detent moves once.** When the current step
|
|
64
|
+
measures, `Steps` writes the number to the sheet's `contentHeight` straight
|
|
65
|
+
away and springs the stack's height to it. The dynamic detent re-derives on
|
|
66
|
+
the UI thread, the root's detent-change reaction (`state/use-sheet-intents.ts`)
|
|
67
|
+
animates `base` to the same index in the new list with the sheet's own
|
|
68
|
+
animation, and — because the stack's spring is resolved from the same
|
|
69
|
+
config — the panel's top edge and the body's bottom edge move together. A
|
|
70
|
+
sheet resting on an explicit detent does not move at all. If the sheet was
|
|
71
|
+
busy when the detent moved (a step change mid-open), the stack animation's
|
|
72
|
+
completion nudges `base` onto its detent.
|
|
73
|
+
- **`Content` stops measuring.** The internal context's
|
|
74
|
+
`contentHeightSource` reads `steps` while a `Steps` body is mounted, and
|
|
75
|
+
`Content`'s `onLayout` returns early; its layout events would otherwise be
|
|
76
|
+
the stack mid-animation, a frame stale. The unmount reset to `UNMEASURED`
|
|
77
|
+
still runs, so the next open waits for the first step to measure again.
|
|
78
|
+
- **A step's `snapPoints` replace the root's, and turn dynamic sizing off,
|
|
79
|
+
while it is current.** A step that names its detents is sized by them.
|
|
80
|
+
`dismissible: false` switches off pan-down-to-close and the overlay's press
|
|
81
|
+
for that step — `Close` and the ref still work. Both go through
|
|
82
|
+
`setStepOverride` on the root (`components/bottom-sheet.tsx`), because the
|
|
83
|
+
overlay is not a descendant of the body.
|
|
84
|
+
- **Transitions are `stepFrame` over one `progress`.** `crossfade` fades
|
|
85
|
+
the two across each other, `slide` moves them a container-width apart along
|
|
86
|
+
`direction`, `none` swaps them. `progress` runs `0 → 1` on the incoming
|
|
87
|
+
step's first layout, with the same animation as the height, and the outgoing
|
|
88
|
+
step is dropped when it completes.
|
|
89
|
+
- **`resetOnClose` resets from two places.** The `isOpen` flip, for a body
|
|
90
|
+
that stays mounted (inline, `keepMounted`), and the unmount cleanup, for one
|
|
91
|
+
the portal takes down — a close settles, the root sets `isOpen` false and
|
|
92
|
+
`presented` false in one render, and only the cleanup runs.
|
|
93
|
+
- **`useSheetStep` reaches a `Footer`.** The footer is written beside the
|
|
94
|
+
body, not inside it, so `Steps` also registers its controller with the root
|
|
95
|
+
through `setStepController`; the hook reads the context first and the
|
|
96
|
+
registration second. Anywhere inside the `BottomSheet` works once the body
|
|
97
|
+
has mounted.
|
|
98
|
+
- **Vertical padding goes on the `Step`, not on `Steps`.** `Steps`'s `style`
|
|
99
|
+
lands on `Content`'s inner view around the stack, and the sheet's height is
|
|
100
|
+
what the step measures — padding around the stack is not counted.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { StyleSheet } from "react-native";
|
|
3
|
+
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
|
4
|
+
import { stepFrame } from "../core";
|
|
5
|
+
import { useStepsLayout } from "./steps.context";
|
|
6
|
+
import type { BottomSheetStepProps } from "./steps.types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One step's body. Rendered by `Steps` only while it is the current step or
|
|
10
|
+
* the one on its way out, so a step that is neither costs nothing.
|
|
11
|
+
*
|
|
12
|
+
* Absolutely positioned across the top of the stack, so two steps can overlap
|
|
13
|
+
* during a change and neither pushes the other down; the stack's own height
|
|
14
|
+
* is animated by `Steps` from what the current step measures here. Its frame
|
|
15
|
+
* — opacity and horizontal offset — is `stepFrame` over the shared `progress`,
|
|
16
|
+
* on the UI thread.
|
|
17
|
+
*/
|
|
18
|
+
export function BottomSheetStep<S extends string>({
|
|
19
|
+
name,
|
|
20
|
+
children,
|
|
21
|
+
style,
|
|
22
|
+
ref,
|
|
23
|
+
onLayout,
|
|
24
|
+
...props
|
|
25
|
+
}: BottomSheetStepProps<S>): ReactElement | null {
|
|
26
|
+
const { active, outgoing, direction, transition, progress, width, onActiveLayout } = useStepsLayout();
|
|
27
|
+
const role = name === active ? "incoming" : name === outgoing ? "outgoing" : null;
|
|
28
|
+
|
|
29
|
+
const frame = useAnimatedStyle(() => {
|
|
30
|
+
if (role === null) return { opacity: 0, transform: [{ translateX: 0 }] };
|
|
31
|
+
const { opacity, translateX } = stepFrame(transition, role, direction, progress.value, width.value);
|
|
32
|
+
return { opacity, transform: [{ translateX }] };
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (role === null) return null;
|
|
36
|
+
|
|
37
|
+
const measure =
|
|
38
|
+
role === "incoming"
|
|
39
|
+
? (event: Parameters<typeof onActiveLayout>[0]): void => {
|
|
40
|
+
onActiveLayout(event);
|
|
41
|
+
onLayout?.(event);
|
|
42
|
+
}
|
|
43
|
+
: onLayout;
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<Animated.View
|
|
47
|
+
onLayout={measure}
|
|
48
|
+
pointerEvents={role === "incoming" ? "auto" : "none"}
|
|
49
|
+
ref={ref}
|
|
50
|
+
style={[styles.step, style, frame]}
|
|
51
|
+
{...props}
|
|
52
|
+
>
|
|
53
|
+
{children}
|
|
54
|
+
</Animated.View>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
BottomSheetStep.displayName = "DelacourBottomSheet.BottomSheet.Step";
|
|
58
|
+
|
|
59
|
+
const styles = StyleSheet.create({
|
|
60
|
+
step: { left: 0, position: "absolute", right: 0, top: 0 },
|
|
61
|
+
});
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { type ReactElement, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { type LayoutChangeEvent, Platform, StyleSheet } from "react-native";
|
|
3
|
+
import Animated, { useAnimatedStyle, useSharedValue, withSpring, withTiming } from "react-native-reanimated";
|
|
4
|
+
import { scheduleOnRN } from "react-native-worklets";
|
|
5
|
+
import { type ReanimatedAnimation, toReanimated } from "../animation/resolve-animation";
|
|
6
|
+
import { useBottomSheet, useBottomSheetInternal } from "../components/bottom-sheet.context";
|
|
7
|
+
import { BottomSheetContent } from "../components/bottom-sheet-content";
|
|
8
|
+
import {
|
|
9
|
+
GESTURE_SOURCE,
|
|
10
|
+
heightForIndex,
|
|
11
|
+
type SheetEvent,
|
|
12
|
+
type SheetStepDirection,
|
|
13
|
+
selectAnimation,
|
|
14
|
+
stepOverride,
|
|
15
|
+
UNMEASURED,
|
|
16
|
+
} from "../core";
|
|
17
|
+
import { ANIM_STATUS } from "../state/state.types";
|
|
18
|
+
import { SheetStepContext, StepsLayoutContext, type StepsLayoutValue } from "./steps.context";
|
|
19
|
+
import type { BottomSheetStepsProps, SheetStepController } from "./steps.types";
|
|
20
|
+
|
|
21
|
+
type Stage<S extends string> = { active: S; outgoing: S | null; direction: SheetStepDirection };
|
|
22
|
+
|
|
23
|
+
/** Hands `value` to a spring or a timing, with `complete` on the UI thread once it lands. */
|
|
24
|
+
function animateWith(animation: ReanimatedAnimation, value: number, complete: (finished?: boolean) => void): number {
|
|
25
|
+
if (animation.type === "spring") return withSpring(value, animation.config, complete);
|
|
26
|
+
return withTiming(value, animation.config, complete);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A multi-step body: the `Content` of a sheet whose contents change with a
|
|
31
|
+
* machine.
|
|
32
|
+
*
|
|
33
|
+
* It is `Content` with a stack inside — each `Step` is absolutely positioned
|
|
34
|
+
* across the top of the stack and only the current step and, during a change,
|
|
35
|
+
* the one leaving are rendered. The stack's height is a shared value animated
|
|
36
|
+
* to whatever the current step measures, and that same measurement is written
|
|
37
|
+
* to the sheet's `contentHeight` the moment it lands, so the dynamic detent
|
|
38
|
+
* moves once and the root's detent-change reaction animates `base` there with
|
|
39
|
+
* the same spring the body is using: the panel's top edge and the body's
|
|
40
|
+
* bottom edge glide together, and a sheet resting on an explicit detent does
|
|
41
|
+
* not move at all. Should the sheet have been busy when the detent moved — a
|
|
42
|
+
* step change mid-open — the height animation's completion nudges it onto its
|
|
43
|
+
* detent.
|
|
44
|
+
*
|
|
45
|
+
* `Content`'s own measurement is switched off while this is the body, or its
|
|
46
|
+
* layout events would race the animated height with stale numbers.
|
|
47
|
+
*
|
|
48
|
+
* The step the machine is on decides two things about the sheet: its
|
|
49
|
+
* `snapPoints` replace the root's while it is current (and dynamic sizing is
|
|
50
|
+
* off for it — a step that names its detents is sized by them), and
|
|
51
|
+
* `dismissible: false` disables pan-down-to-close and the overlay's press.
|
|
52
|
+
* Both go through the root, since the overlay is not a descendant of the body.
|
|
53
|
+
*
|
|
54
|
+
* The controller is provided to every descendant through `SheetStepContext`,
|
|
55
|
+
* and registered with the root so a `Footer` written beside this body reaches
|
|
56
|
+
* it through `useSheetStep` too.
|
|
57
|
+
*/
|
|
58
|
+
export function BottomSheetSteps<S extends string, C, E extends SheetEvent>({
|
|
59
|
+
controller,
|
|
60
|
+
transition = "crossfade",
|
|
61
|
+
animation,
|
|
62
|
+
resetOnClose = true,
|
|
63
|
+
footerGap,
|
|
64
|
+
children,
|
|
65
|
+
style,
|
|
66
|
+
ref,
|
|
67
|
+
...props
|
|
68
|
+
}: BottomSheetStepsProps<S, C, E>): ReactElement {
|
|
69
|
+
const internal = useBottomSheetInternal();
|
|
70
|
+
const { state, geometry, animateTo, setContentHeightSource, setStepOverride, setStepController } = internal;
|
|
71
|
+
const { isOpen } = useBottomSheet();
|
|
72
|
+
const stepHeight = useSharedValue(UNMEASURED);
|
|
73
|
+
const progress = useSharedValue(1);
|
|
74
|
+
|
|
75
|
+
const [stage, setStage] = useState<Stage<S>>({ active: controller.value, outgoing: null, direction: "forward" });
|
|
76
|
+
const changePending = useRef(false);
|
|
77
|
+
const lastHeight = useRef(UNMEASURED);
|
|
78
|
+
|
|
79
|
+
// Derived during render, so the incoming step mounts in the same commit the
|
|
80
|
+
// machine moved in and its first layout is the one that starts the change.
|
|
81
|
+
if (controller.value !== stage.active) {
|
|
82
|
+
changePending.current = true;
|
|
83
|
+
setStage({
|
|
84
|
+
active: controller.value,
|
|
85
|
+
outgoing: transition === "none" ? null : stage.active,
|
|
86
|
+
direction: controller.direction,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const animationKey = JSON.stringify(animation ?? internal.animation ?? null);
|
|
91
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: the animation is keyed by its serialisation
|
|
92
|
+
const resolved = useMemo(
|
|
93
|
+
() => toReanimated(selectAnimation(animation ?? internal.animation, Platform.OS, internal.overrideReduceMotion)),
|
|
94
|
+
[animationKey, internal.overrideReduceMotion]
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
useLayoutEffect(() => {
|
|
98
|
+
setContentHeightSource("steps");
|
|
99
|
+
return () => setContentHeightSource("content");
|
|
100
|
+
}, [setContentHeightSource]);
|
|
101
|
+
|
|
102
|
+
const { machine, value } = controller;
|
|
103
|
+
useLayoutEffect(() => {
|
|
104
|
+
setStepOverride(stepOverride(machine.nodeOf(value)));
|
|
105
|
+
}, [machine, value, setStepOverride]);
|
|
106
|
+
useEffect(() => () => setStepOverride(null), [setStepOverride]);
|
|
107
|
+
|
|
108
|
+
// The ref during render, so a `Footer` rendered after this body in the
|
|
109
|
+
// same pass reads the controller; the state from an effect, to re-render
|
|
110
|
+
// anything that did not.
|
|
111
|
+
const untyped = controller as unknown as SheetStepController<string, unknown, SheetEvent>;
|
|
112
|
+
internal.stepControllerRef.current = untyped;
|
|
113
|
+
useLayoutEffect(() => {
|
|
114
|
+
setStepController(untyped);
|
|
115
|
+
}, [untyped, setStepController]);
|
|
116
|
+
useEffect(
|
|
117
|
+
() => () => {
|
|
118
|
+
internal.stepControllerRef.current = null;
|
|
119
|
+
setStepController(null);
|
|
120
|
+
},
|
|
121
|
+
[internal.stepControllerRef, setStepController]
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// A close resets the machine: through the `isOpen` flip for a body that
|
|
125
|
+
// stays mounted, and through unmount for one the portal takes down.
|
|
126
|
+
const latest = useRef({ controller, resetOnClose });
|
|
127
|
+
latest.current = { controller, resetOnClose };
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (isOpen || !latest.current.resetOnClose) return;
|
|
130
|
+
latest.current.controller.reset();
|
|
131
|
+
}, [isOpen]);
|
|
132
|
+
useEffect(
|
|
133
|
+
() => () => {
|
|
134
|
+
if (latest.current.resetOnClose) latest.current.controller.reset();
|
|
135
|
+
},
|
|
136
|
+
[]
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const finishChange = useCallback(() => {
|
|
140
|
+
setStage((current) => (current.outgoing === null ? current : { ...current, outgoing: null }));
|
|
141
|
+
}, []);
|
|
142
|
+
|
|
143
|
+
const onActiveLayout = useCallback(
|
|
144
|
+
(event: LayoutChangeEvent) => {
|
|
145
|
+
const height = event.nativeEvent.layout.height;
|
|
146
|
+
const changing = changePending.current;
|
|
147
|
+
changePending.current = false;
|
|
148
|
+
if (lastHeight.current === height && !changing) return;
|
|
149
|
+
const first = lastHeight.current < 0;
|
|
150
|
+
lastHeight.current = height;
|
|
151
|
+
state.contentHeight.value = height;
|
|
152
|
+
if (first) {
|
|
153
|
+
stepHeight.value = height;
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const settle = (finished?: boolean): void => {
|
|
158
|
+
"worklet";
|
|
159
|
+
if (finished !== true) return;
|
|
160
|
+
const busy = state.animStatus.value !== ANIM_STATUS.IDLE || state.gestureSource.value !== GESTURE_SOURCE.NONE;
|
|
161
|
+
const index = state.currentIndex.value;
|
|
162
|
+
const detents = geometry.detents.value;
|
|
163
|
+
if (busy || index < 0 || detents.length === 0) return;
|
|
164
|
+
const target = heightForIndex(Math.min(index, detents.length - 1), detents, geometry.closedHeight.value);
|
|
165
|
+
if (Math.abs(target - state.base.value) > 0.5) animateTo(target, "snapPoints", 0);
|
|
166
|
+
};
|
|
167
|
+
stepHeight.value = animateWith(resolved, height, settle);
|
|
168
|
+
|
|
169
|
+
if (!changing) return;
|
|
170
|
+
if (transition === "none") {
|
|
171
|
+
progress.value = 1;
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const complete = (finished?: boolean): void => {
|
|
175
|
+
"worklet";
|
|
176
|
+
if (finished === true) scheduleOnRN(finishChange);
|
|
177
|
+
};
|
|
178
|
+
progress.value = 0;
|
|
179
|
+
progress.value = animateWith(resolved, 1, complete);
|
|
180
|
+
},
|
|
181
|
+
[state, geometry, animateTo, resolved, transition, stepHeight, progress, finishChange]
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const stack = useAnimatedStyle(() => ({
|
|
185
|
+
height: stepHeight.value >= 0 ? stepHeight.value : undefined,
|
|
186
|
+
}));
|
|
187
|
+
|
|
188
|
+
const layoutValue = useMemo<StepsLayoutValue>(
|
|
189
|
+
() => ({
|
|
190
|
+
active: stage.active,
|
|
191
|
+
outgoing: stage.outgoing,
|
|
192
|
+
direction: stage.direction,
|
|
193
|
+
transition,
|
|
194
|
+
progress,
|
|
195
|
+
width: state.containerWidth,
|
|
196
|
+
onActiveLayout,
|
|
197
|
+
}),
|
|
198
|
+
[stage, transition, progress, state.containerWidth, onActiveLayout]
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<SheetStepContext.Provider value={untyped}>
|
|
203
|
+
<StepsLayoutContext.Provider value={layoutValue}>
|
|
204
|
+
<BottomSheetContent footerGap={footerGap} ref={ref} style={style} {...props}>
|
|
205
|
+
<Animated.View style={[styles.stack, stack]}>{children}</Animated.View>
|
|
206
|
+
</BottomSheetContent>
|
|
207
|
+
</StepsLayoutContext.Provider>
|
|
208
|
+
</SheetStepContext.Provider>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
BottomSheetSteps.displayName = "DelacourBottomSheet.BottomSheet.Steps";
|
|
212
|
+
|
|
213
|
+
const styles = StyleSheet.create({
|
|
214
|
+
stack: { overflow: "hidden", width: "100%" },
|
|
215
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
import type { LayoutChangeEvent } from "react-native";
|
|
3
|
+
import type { SharedValue } from "react-native-reanimated";
|
|
4
|
+
import { useOptionalBottomSheetInternal } from "../components/bottom-sheet.context";
|
|
5
|
+
import type { SheetEvent, SheetStepDirection, SheetStepTransition } from "../core";
|
|
6
|
+
import type { SheetStepController } from "./steps.types";
|
|
7
|
+
|
|
8
|
+
const OUTSIDE = (hook: string): Error =>
|
|
9
|
+
new Error(
|
|
10
|
+
`[@delacour/react-native-bottom-sheet] ${hook} outside <BottomSheet.Steps>. A step reads its controller from context.`
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The controller `BottomSheet.Steps` was given, for every descendant — a
|
|
15
|
+
* step's own buttons, a title that names the step. A `Footer` written beside
|
|
16
|
+
* the body rather than inside it is not a descendant, so `Steps` also
|
|
17
|
+
* registers the controller with the root and `useSheetStep` falls back to
|
|
18
|
+
* that: anywhere inside the `BottomSheet` works once the body has mounted.
|
|
19
|
+
*
|
|
20
|
+
* Typed loosely at the context boundary: a provider cannot carry the machine's
|
|
21
|
+
* generics through `createContext`, so `useSheetStep<S, C, E>()` asserts them
|
|
22
|
+
* back at the call site, the way a typed selector would.
|
|
23
|
+
*/
|
|
24
|
+
export const SheetStepContext = createContext<SheetStepController<string, unknown, SheetEvent> | null>(null);
|
|
25
|
+
SheetStepContext.displayName = "DelacourBottomSheet.StepContext";
|
|
26
|
+
|
|
27
|
+
/** Untyped at the boundary; `use-sheet-step.ts` puts the machine's generics back. */
|
|
28
|
+
export function useSheetStep(): SheetStepController<string, unknown, SheetEvent> {
|
|
29
|
+
const value = useContext(SheetStepContext);
|
|
30
|
+
const registered = useOptionalBottomSheetInternal()?.stepControllerRef.current ?? null;
|
|
31
|
+
const controller = value ?? registered;
|
|
32
|
+
if (controller === null) throw OUTSIDE("useSheetStep");
|
|
33
|
+
return controller;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function useOptionalSheetStep(): SheetStepController<string, unknown, SheetEvent> | null {
|
|
37
|
+
const value = useContext(SheetStepContext);
|
|
38
|
+
const registered = useOptionalBottomSheetInternal()?.stepControllerRef.current ?? null;
|
|
39
|
+
return value ?? registered;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What `Steps` tells each `Step`: whether it is the one arriving or the one
|
|
44
|
+
* leaving, how far the change has run, and where to report its height.
|
|
45
|
+
* Internal to the folder.
|
|
46
|
+
*/
|
|
47
|
+
export type StepsLayoutValue = {
|
|
48
|
+
/** The step the machine is on. */
|
|
49
|
+
active: string;
|
|
50
|
+
/** The step on its way out during a transition, `null` otherwise. */
|
|
51
|
+
outgoing: string | null;
|
|
52
|
+
direction: SheetStepDirection;
|
|
53
|
+
transition: SheetStepTransition;
|
|
54
|
+
/** `0` at the start of a change, `1` once the incoming step is fully in. */
|
|
55
|
+
progress: SharedValue<number>;
|
|
56
|
+
/** The frame's width, for `slide`. */
|
|
57
|
+
width: SharedValue<number>;
|
|
58
|
+
/** The active step's measured height, from its `onLayout`. */
|
|
59
|
+
onActiveLayout: (event: LayoutChangeEvent) => void;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const StepsLayoutContext = createContext<StepsLayoutValue | null>(null);
|
|
63
|
+
StepsLayoutContext.displayName = "DelacourBottomSheet.StepsLayoutContext";
|
|
64
|
+
|
|
65
|
+
export function useStepsLayout(): StepsLayoutValue {
|
|
66
|
+
const value = useContext(StepsLayoutContext);
|
|
67
|
+
if (value === null) throw OUTSIDE("BottomSheet.Step");
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function useOptionalStepsLayout(): StepsLayoutValue | null {
|
|
72
|
+
return useContext(StepsLayoutContext);
|
|
73
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ReactNode, Ref } from "react";
|
|
2
|
+
import type { View, ViewProps } from "react-native";
|
|
3
|
+
import type { SheetAnimation } from "../animation/animation.types";
|
|
4
|
+
import type {
|
|
5
|
+
SheetEvent,
|
|
6
|
+
SheetMachine,
|
|
7
|
+
SheetMachineSnapshot,
|
|
8
|
+
SheetStepDirection,
|
|
9
|
+
SheetStepTransition,
|
|
10
|
+
SheetTransitionError,
|
|
11
|
+
} from "../core";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What `useSheetMachine` returns and `BottomSheet.Steps` takes.
|
|
15
|
+
*
|
|
16
|
+
* A snapshot plus the verbs: `send` an event, ask whether one `can` be sent,
|
|
17
|
+
* test the current step with `matches`. `direction` is which way the last
|
|
18
|
+
* change of step read, for the transition the body animates. An XState
|
|
19
|
+
* adapter is any object with this shape — nothing checks its identity.
|
|
20
|
+
*/
|
|
21
|
+
export type SheetStepController<S extends string, C, E extends SheetEvent> = SheetMachineSnapshot<S, C> & {
|
|
22
|
+
/** The machine the controller runs, for `nodeOf` and `steps`. */
|
|
23
|
+
machine: SheetMachine<S, C, E>;
|
|
24
|
+
/** The steps in declaration order. */
|
|
25
|
+
steps: readonly S[];
|
|
26
|
+
/** Which way the last change of step went. `forward` until one happens. */
|
|
27
|
+
direction: SheetStepDirection;
|
|
28
|
+
send: (event: E) => void;
|
|
29
|
+
/** Whether `send` would move — for disabling a Next button. */
|
|
30
|
+
can: (event: E) => boolean;
|
|
31
|
+
matches: (step: S) => boolean;
|
|
32
|
+
/** Back to `machine.initial`. `Steps` calls it after a close when `resetOnClose`. */
|
|
33
|
+
reset: () => void;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type UseSheetMachineOptions<S extends string, C, E extends SheetEvent> = {
|
|
37
|
+
/** A transition succeeded: the snapshot it left and the one it reached. */
|
|
38
|
+
onTransition?: (from: SheetMachineSnapshot<S, C>, to: SheetMachineSnapshot<S, C>, event: E) => void;
|
|
39
|
+
/** A transition was refused. The snapshot is unchanged. */
|
|
40
|
+
onRejected?: (error: SheetTransitionError<S>, event: E) => void;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type BottomSheetStepsProps<S extends string, C, E extends SheetEvent> = Omit<ViewProps, "children"> & {
|
|
44
|
+
children?: ReactNode;
|
|
45
|
+
ref?: Ref<View>;
|
|
46
|
+
controller: SheetStepController<S, C, E>;
|
|
47
|
+
/** How one step gives way to the next. @default "crossfade" */
|
|
48
|
+
transition?: SheetStepTransition;
|
|
49
|
+
/** The spring or timing for the height and the transition. The root's `animation` when omitted. */
|
|
50
|
+
animation?: SheetAnimation;
|
|
51
|
+
/** Send the controller back to its initial step once the sheet has closed. @default true */
|
|
52
|
+
resetOnClose?: boolean;
|
|
53
|
+
/** Extra space between the steps and a sticky footer. @default 0 */
|
|
54
|
+
footerGap?: number;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type BottomSheetStepProps<S extends string = string> = ViewProps & {
|
|
58
|
+
ref?: Ref<View>;
|
|
59
|
+
/** The step this body belongs to — a key of the machine's `states`. */
|
|
60
|
+
name: S;
|
|
61
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useCallback, useMemo, useRef, useState } from "react";
|
|
2
|
+
import type { SheetEvent, SheetMachine, SheetMachineSnapshot, SheetStepDirection } from "../core";
|
|
3
|
+
import type { SheetStepController, UseSheetMachineOptions } from "./steps.types";
|
|
4
|
+
|
|
5
|
+
type MachineState<S extends string, C> = { snapshot: SheetMachineSnapshot<S, C>; direction: SheetStepDirection };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Runs a sheet machine as React state.
|
|
9
|
+
*
|
|
10
|
+
* `send` is `machine.transition` over the latest snapshot: a success becomes
|
|
11
|
+
* the new state and `onTransition` hears about it, a refusal leaves the state
|
|
12
|
+
* alone and `onRejected` hears why. The latest snapshot lives in a ref as
|
|
13
|
+
* well as in state, so two `send`s in one tick chain rather than both reading
|
|
14
|
+
* the render they were made in.
|
|
15
|
+
*
|
|
16
|
+
* Not a `useReducer`, on purpose: a reducer has to be pure and React may run
|
|
17
|
+
* it twice, and `onRejected` is a side effect a caller wants exactly once.
|
|
18
|
+
*
|
|
19
|
+
* `can` and `matches` close over the rendered snapshot, not the ref, and are
|
|
20
|
+
* rebuilt with it: the React Compiler memoises `can({ type: "NEXT" })` on the
|
|
21
|
+
* identity of `can`, and a stable `can` reading a ref returned the answer
|
|
22
|
+
* from the render it was first called in — the Next button never unlocked.
|
|
23
|
+
* `send` and `reset` stay stable; they are actions, not questions.
|
|
24
|
+
*/
|
|
25
|
+
export function useSheetMachine<S extends string, C, E extends SheetEvent>(
|
|
26
|
+
machine: SheetMachine<S, C, E>,
|
|
27
|
+
options: UseSheetMachineOptions<S, C, E> = {}
|
|
28
|
+
): SheetStepController<S, C, E> {
|
|
29
|
+
const [state, setState] = useState<MachineState<S, C>>({ snapshot: machine.initial, direction: "forward" });
|
|
30
|
+
const latest = useRef(state);
|
|
31
|
+
const callbacks = useRef(options);
|
|
32
|
+
callbacks.current = options;
|
|
33
|
+
|
|
34
|
+
const send = useCallback(
|
|
35
|
+
(event: E) => {
|
|
36
|
+
const from = latest.current.snapshot;
|
|
37
|
+
const result = machine.transition(from, event);
|
|
38
|
+
if (!result.success) {
|
|
39
|
+
callbacks.current.onRejected?.(result.error, event);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const to = result.data;
|
|
43
|
+
const direction = to.value === from.value ? latest.current.direction : machine.directionOf(from.value, to.value);
|
|
44
|
+
latest.current = { snapshot: to, direction };
|
|
45
|
+
setState(latest.current);
|
|
46
|
+
callbacks.current.onTransition?.(from, to, event);
|
|
47
|
+
},
|
|
48
|
+
[machine]
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const reset = useCallback(() => {
|
|
52
|
+
latest.current = { snapshot: machine.initial, direction: "back" };
|
|
53
|
+
setState(latest.current);
|
|
54
|
+
}, [machine]);
|
|
55
|
+
|
|
56
|
+
const { snapshot, direction } = state;
|
|
57
|
+
return useMemo<SheetStepController<S, C, E>>(
|
|
58
|
+
() => ({
|
|
59
|
+
...snapshot,
|
|
60
|
+
machine,
|
|
61
|
+
steps: machine.steps,
|
|
62
|
+
direction,
|
|
63
|
+
send,
|
|
64
|
+
can: (event) => machine.can(snapshot, event),
|
|
65
|
+
matches: (step) => snapshot.value === step,
|
|
66
|
+
reset,
|
|
67
|
+
}),
|
|
68
|
+
[snapshot, direction, machine, send, reset]
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { SheetEvent } from "../core";
|
|
2
|
+
import { useOptionalSheetStep as useOptionalUntyped, useSheetStep as useUntyped } from "./steps.context";
|
|
3
|
+
import type { SheetStepController } from "./steps.types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The controller of the nearest `BottomSheet.Steps` — or the one registered
|
|
7
|
+
* with the enclosing `BottomSheet`, for a part written beside the body such
|
|
8
|
+
* as a `Footer` — with the machine's generics asserted back on.
|
|
9
|
+
*
|
|
10
|
+
* A context cannot carry `<S, C, E>` through `createContext`, so the hook in
|
|
11
|
+
* `steps.context.tsx` returns the untyped controller and this one narrows it
|
|
12
|
+
* the way a typed selector would: `useSheetStep<Step, Context, Event>()` is
|
|
13
|
+
* the caller's promise that the enclosing `Steps` runs that machine.
|
|
14
|
+
*/
|
|
15
|
+
export function useSheetStep<
|
|
16
|
+
S extends string = string,
|
|
17
|
+
C = unknown,
|
|
18
|
+
E extends SheetEvent = SheetEvent,
|
|
19
|
+
>(): SheetStepController<S, C, E> {
|
|
20
|
+
return useUntyped() as unknown as SheetStepController<S, C, E>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function useOptionalSheetStep<
|
|
24
|
+
S extends string = string,
|
|
25
|
+
C = unknown,
|
|
26
|
+
E extends SheetEvent = SheetEvent,
|
|
27
|
+
>(): SheetStepController<S, C, E> | null {
|
|
28
|
+
return useOptionalUntyped() as unknown as SheetStepController<S, C, E> | null;
|
|
29
|
+
}
|