diff --git a/CLAUDE.md b/CLAUDE.md index fa3f87a..0748bb6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,8 +76,8 @@ to the `Pick` semver-locks it. ```ts type OpenPayload = - | { kind: 'inline'; id; groupId; content: ReactNode; scaleBackground?; backdrop?; params? } - | { kind: 'portal'; id; groupId; scaleBackground?; backdrop?; params? }; + | { kind: 'inline'; id; groupId; content: ReactNode; scaleBackground?; params? } + | { kind: 'portal'; id; groupId; scaleBackground?; params? }; ``` `kind` is what callers reason about, `usePortal` is what the renderer checks; @@ -94,6 +94,13 @@ Key actions: group's top and the one below is `hidden` — undoing a `switch`. - `finishClosing(id)` hides if `keepMounted`, removes otherwise. - `clearGroup` / `clearAll` are teardown: no animation, interceptors skipped. +- `setBackdrop(id, false | true | BackdropConfig)` is the **only** writer of the + record's `backdrop` field — `open()` never touches it, which is what lets a + persistent sheet's config survive re-open cycles. `true` means *clear the + override*, not a stored flag. The action bails on value-equal writes + (`backdropValuesEqual`): adapters re-apply their `backdrop` prop with a fresh + object literal on every consumer render, and without the bail each render + would wake every store subscriber. **Modes:** `push` keeps the previous sheet visible, `switch` hides it (restored on close), `replace` closes it. @@ -195,6 +202,31 @@ whole stack above arbitrary app chrome — without it any host view with a modes `animatedIndex`. Do not add a timer or delay gate: deferring the mount drops the opening frames the adapter already drove, and the backdrop pops in mid-fade. +The backdrop's *look* is configurable (`BackdropConfig`, a `kind: 'styled' | +'custom'` union): group default via `backdrop` on the provider, per sheet +via the `backdrop` prop on the adapter (routed through `useAdapterBackdrop` → +`setBackdrop`). Resolution is **atomic for the visual choice** — a sheet-level +config replaces the group's rendering entirely; only `pressToDismiss` resolves +per field, and styles compose (`[default, group, sheet]`) when both levels are +`styled`. The adapter prop lands via effect a beat after the backdrop first +mounts, so it is applied in a *layout* effect: `animatedIndex` starts at `-1`, +which holds a `styled` backdrop at zero opacity for that frame, but a `custom` +one owns its own fade and would otherwise paint at full strength before the +sheet's config replaced it. The guarantee is structural for `styled` and +contractual for `custom`. A `kind: 'custom'` component owns its own fade +off `animatedIndex` — the built-in opacity is deliberately not applied on top. + +Two selectors read the field, and the split is deliberate: `QueueItem` takes +`useSheetBackdropEnabled` (a boolean — "render one at all") so restyling does +not re-render the memoized sheet layer, and only `BottomSheetBackdrop` takes the +config through `useSheetBackdrop`. + +Every shipped adapter exposes `backdrop?: BackdropConfig | false` (via the +shared `AdapterBackdropProps`) and, where its library draws an overlay of its +own, forces that overlay off. Re-exposing the underlying prop (gorhom's +`backdropComponent`, actions-sheet's overlay) would let a second, non-stack-aware +overlay paint over the manager's. + `useSheetRenderData` orders hidden persistent sheets before active ones so React does not unmount and remount across transitions. @@ -237,7 +269,8 @@ Public so a third-party adapter reaches parity with the shipped ones: | `useAdapterRef(forwardedRef)` | resolves the ref context (portal/persistent) or the forwarded one (inline) | | `useAnimatedIndex()` | the sheet's shared value, `-1` hidden → `0` visible | | `useBackHandler(id, onBackPress)` | registered only while the sheet is open **and** topmost in its own group | -| `useSetBackdrop()` | suppress the manager's shared backdrop when the adapter draws its own | +| `useAdapterBackdrop(id, backdrop)` | applies the adapter's `backdrop?: BackdropConfig \| false` prop; two effects on purpose — value-sync (store bails on equal) and unmount-clear — so fresh JSX literals don't clear-and-rewrite every render | +| `useSetBackdrop()` | imperative form: `false` suppresses the shared backdrop (adapter draws its own), config restyles it, `true` clears | | `useSheetPreventDismiss(id)` | whether an interceptor is blocking, so native gestures can be disabled | **Drive `animatedIndex` continuously.** Setting it discretely in expand/close @@ -250,7 +283,7 @@ when the show animation ends and `handleClosed` when the hide animation ends. - Native `scrimColor` / `scrimOpacities` are gated on `modal` sheets on both platforms. The manager always renders inline, so they can never paint — the - adapter does not accept them. Use `backdrop: false`. + adapter does not accept them. Use the `backdrop` prop (`false` to disable). - `fullHeight` passes a detent taller than any screen and lets native clamp it. Do **not** recompute `windowHeight - insets.top` in JS: since 0.16 there is no JS-provided cap, and a JS estimate ignores that the sheet lives inside the @@ -333,36 +366,6 @@ Consumer apps need nothing — Metro reads `exports` from package.json. --- -## File map - -``` -src/ -├── index.tsx # public exports (no 3rd-party adapter deps) -├── testing.ts # → '…/testing' -├── store/ # store · hooks · helpers · types -├── bottomSheetCoordinator.ts # store ↔ adapter -├── refsMap · animatedRegistry · onBeforeCloseRegistry · portalSessionRegistry -├── adapter.types.ts · portal.types.ts -│ -├── BottomSheetManager.provider.tsx / .context.tsx -├── BottomSheet.context.ts · BottomSheetRef.context.ts -├── BottomSheetDefaultIndex.context.ts # 0 for portal, -1 for persistent -│ -├── BottomSheetHost · QueueItem · BottomSheetBackdrop · BottomSheetScaleView -├── BottomSheetPortal ('use no memo') · BottomSheetPersistent -│ -├── useBottomSheetManager · useBottomSheetControl · useBottomSheetContext -├── useBottomSheetStatus · useOnBeforeClose · useSheetRenderData -├── useScaleAnimation · useStableCallback -├── useAdapterRef · useAnimatedIndex · useBackHandler -│ -└── adapters/ # one directory per subpath export, no barrel - ├── gorhom-sheet · custom-modal · react-native-modal · actions-sheet - └── swmansion/ # + SwmansionKeyboardInset (optional peer) -``` - ---- - ## Pitfalls 1. Do not memoize by hand — three sanctioned exceptions, listed above. @@ -377,3 +380,8 @@ src/ 8. Do not set `animatedIndex` discretely in an adapter. 9. Do not branch on `isOpen` for "is it on screen" — use `isVisible`. 10. Do not read `params` without `?.`. +11. Do not drop `setBackdrop`'s value-equality bail, and do not subscribe + `QueueItem` to the backdrop *config* — both turn one consumer render into a + store write that re-renders the whole sheet layer. +12. An adapter must never expose its library's own backdrop prop. The manager + renders the one backdrop; a second overlay stacks and is not stack-aware. diff --git a/README.md b/README.md index 7fd8237..784d535 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ A stack manager for bottom sheets and modals in React Native. Supports `push`, ` - [Imperative vs Portal API](https://arekkubaczkowski.github.io/react-native-bottom-sheet-stack/api-comparison) - [Navigation Modes](https://arekkubaczkowski.github.io/react-native-bottom-sheet-stack/navigation-modes) - [Scale Animation](https://arekkubaczkowski.github.io/react-native-bottom-sheet-stack/scale-animation) +- [Backdrop](https://arekkubaczkowski.github.io/react-native-bottom-sheet-stack/backdrop) - [Portal API (Context Preservation)](https://arekkubaczkowski.github.io/react-native-bottom-sheet-stack/context-preservation) - [Persistent Sheets](https://arekkubaczkowski.github.io/react-native-bottom-sheet-stack/persistent-sheets) - [Type-Safe IDs & Params](https://arekkubaczkowski.github.io/react-native-bottom-sheet-stack/type-safe-ids) @@ -21,6 +22,7 @@ A stack manager for bottom sheets and modals in React Native. Supports `push`, ` - **Adapter Architecture** - Pluggable adapters for different bottom sheet/modal libraries. Ships with adapters for `@gorhom/bottom-sheet`, `react-native-modal`, `react-native-actions-sheet`, `@swmansion/react-native-bottom-sheet`, and a custom modal. You can also build your own. - **Stack Navigation** - `push`, `switch`, and `replace` modes for managing multiple sheets - **Scale Animation** - iOS-style background scaling effect when sheets are stacked +- **Configurable Backdrop** - Theme the shared stack-aware backdrop per group or per sheet, or replace it with a custom component (e.g. blur) - **Context Preservation** - Portal-based API that preserves React context in bottom sheets - **Mixed Stacking** - Bottom sheets and modals coexist in the same stack - **Persistent Sheets** - Pre-mounted sheets that open instantly and preserve state diff --git a/docs/docs/adapters.md b/docs/docs/adapters.md index 6cbeade..3eef5c2 100644 --- a/docs/docs/adapters.md +++ b/docs/docs/adapters.md @@ -1,5 +1,5 @@ --- -sidebar_position: 10 +sidebar_position: 11 --- # Library-Agnostic Architecture diff --git a/docs/docs/api/components.md b/docs/docs/api/components.md index e2485d0..c98a8ae 100644 --- a/docs/docs/api/components.md +++ b/docs/docs/api/components.md @@ -23,6 +23,7 @@ Root provider that manages the bottom sheet stack. |------|------|----------|-------------| | `id` | `string` | Yes | Unique identifier for this stack group | | `scaleConfig` | `ScaleConfig` | No | Scale animation configuration | +| `backdrop` | `BackdropConfig \| false` | No | The group's default backdrop; `false` disables it for the whole group. A sheet overrides it with the `backdrop` prop on its adapter. See [Backdrop](/backdrop) | | `children` | `ReactNode` | Yes | App content | --- diff --git a/docs/docs/api/hooks.md b/docs/docs/api/hooks.md index a7229c1..79dcc31 100644 --- a/docs/docs/api/hooks.md +++ b/docs/docs/api/hooks.md @@ -27,7 +27,8 @@ is built from these. You do not need them to use the library. | `useAdapterRef` | **Inside adapter only** | Resolve the right ref for inline/portal/persistent mode | | `useAnimatedIndex` | **Inside adapter only** | The sheet's `animatedIndex` shared value, driving backdrop and scale | | `useBackHandler` | **Inside adapter only** | Android back button, scoped to the topmost open sheet **of its own group** | -| `useSetBackdrop` | Anywhere | Returns `setBackdrop(id, boolean)` — suppress the manager's shared backdrop for a sheet that renders its own | +| `useAdapterBackdrop` | **Inside adapter only** | Applies the adapter's `backdrop` prop (`BackdropConfig \| false`) to the sheet — see [Backdrop](/backdrop) | +| `useSetBackdrop` | Anywhere | Returns `setBackdrop(id, value)` — `false` suppresses the manager's shared backdrop, a `BackdropConfig` restyles/replaces it, `true` clears the override | | `useSheetPreventDismiss` | Anywhere | `useSheetPreventDismiss(id)` — whether an interceptor is currently blocking dismissal, so the adapter can disable native gestures | --- @@ -109,9 +110,10 @@ open(, { | `groupId` | `string` | context or `'default'` | Group ID for the sheet | | `mode` | `OpenMode` | `'push'` | Navigation mode | | `scaleBackground` | `boolean` | `false` | Enable background scaling | -| `backdrop` | `boolean` | `true` | When `false`, the manager's shared backdrop is not rendered for this sheet. `GorhomSheetAdapter` sets this itself when you pass it a custom `backdropComponent`; the other shipped adapters use the manager's backdrop and never touch it. | | `params` | `Record` | - | Params for the sheet, readable inside it via `useBottomSheetContext()`. Untyped here — the typed variant lives on `useBottomSheetControl` | +`backdrop?: boolean` was **removed in v3** — configure it on the adapter (the `backdrop` prop) or on the provider, not per `open()` call. See [Backdrop → Migration](/backdrop#migration-from-v2). + `open()` returns the sheet's ID, or **`null`** when the store declined to open it — because the sheet is already on the stack, or another sheet in the group is still animating open. A dev-mode warning explains which. ```tsx @@ -258,9 +260,10 @@ open({ |--------|------|---------|-------------| | `mode` | `OpenMode` | `'push'` | Navigation mode | | `scaleBackground` | `boolean` | `false` | Enable background scaling | -| `backdrop` | `boolean` | `true` | When `false`, the manager's shared backdrop is not rendered for this sheet. `GorhomSheetAdapter` sets this itself when you pass it a custom `backdropComponent`; the other shipped adapters use the manager's backdrop and never touch it. | | `params` | `BottomSheetPortalParams` | - | Type-safe params | +`backdrop?: boolean` was **removed in v3** — configure it on the adapter (the `backdrop` prop) or on the provider, not per `open()` call. See [Backdrop → Migration](/backdrop#migration-from-v2). + `useBottomSheetManager().open()` also accepts `params` now, so inline sheets can read them from `useBottomSheetContext()` just like portal sheets. --- diff --git a/docs/docs/api/types.md b/docs/docs/api/types.md index 0f7225e..8d35c8b 100644 --- a/docs/docs/api/types.md +++ b/docs/docs/api/types.md @@ -240,6 +240,42 @@ const springConfig: ScaleAnimationConfig = { --- +### BackdropConfig + +What the manager's shared backdrop renders — a discriminated union, `kind` first. Set as the group default via `backdrop` on the provider, or per sheet via the `backdrop` prop on an adapter (`false` there disables the backdrop). See [Backdrop](/backdrop). + +```tsx +type BackdropConfig = + | { + kind: 'styled'; + style?: StyleProp; // merged over the group's style and the default rgba(0,0,0,0.5) + pressToDismiss?: boolean; // default: true + } + | { + kind: 'custom'; + component: ComponentType; + pressToDismiss?: boolean; // default: true + }; +``` + +--- + +### BackdropComponentProps + +Props a `kind: 'custom'` backdrop component receives. + +```tsx +type BackdropComponentProps = { + sheetId: string; + /** -1 hidden → 0 fully visible (see HIDDEN_ANIMATED_INDEX). */ + animatedIndex: SharedValue; + /** Calls requestClose(sheetId) — onBeforeClose interceptors still run. */ + close: () => void; +}; +``` + +--- + ## Portal Types ### BottomSheetPortalRegistry diff --git a/docs/docs/backdrop.md b/docs/docs/backdrop.md new file mode 100644 index 0000000..0406d68 --- /dev/null +++ b/docs/docs/backdrop.md @@ -0,0 +1,186 @@ +--- +sidebar_position: 6 +--- + +# Backdrop + +Every sheet gets a shared, stack-aware backdrop rendered by the manager: it sits outside the scale transform, layers correctly under its own sheet and above the one beneath, fades with the sheet's live `animatedIndex`, and closes the sheet on tap through the `onBeforeClose` interceptor path. + +By default it is a `rgba(0, 0, 0, 0.5)` scrim. Two levels make it configurable — the sheet's choice wins over the group's: + +1. **Group default** — `backdrop` on `BottomSheetManagerProvider` +2. **Per sheet** — the `backdrop` prop on any shipped adapter (`false` disables it) + +## BackdropConfig + +A discriminated union — `kind` says what the backdrop renders: + +```tsx +type BackdropConfig = + | { + kind: 'styled'; + /** Merged over the group's style and the default rgba(0,0,0,0.5). */ + style?: StyleProp; + /** Tap closes the sheet. Default: true. */ + pressToDismiss?: boolean; + } + | { + kind: 'custom'; + /** Replaces the built-in backdrop view entirely. */ + component: ComponentType; + pressToDismiss?: boolean; + }; +``` + +Only the *look* is configurable. Mount timing, z-index/stack handling, and tap routing through `requestClose` (so `onBeforeClose` interceptors still run) stay with the manager in every variant. + +## Theming a group + +```tsx + + ... + +``` + +`backdrop={false}` on the provider gives the whole group no backdrop. It is the +same prop name and type as on the adapters — one is the default, the other the +override. + +## Per-sheet configuration + +Pass `backdrop` to the adapter, right where the sheet's other visual props live — it works the same in inline, portal, and persistent mode: + +```tsx +// A light scrim for a small action sheet + + +// No backdrop at all + + +// Keep the backdrop, but don't close on tap + +``` + +`backdrop={false}` also removes the layer that blocks touches: taps outside the +sheet then reach whatever is behind it. `pressToDismiss: false` keeps that shield +and only stops the tap from closing the sheet. + +Resolution is per field for `pressToDismiss`, and **atomic for the visual choice**: a sheet-level config replaces the group's rendering entirely (a group `custom` component never bleeds under a sheet that asked for `styled`). When both levels are `styled`, their styles compose — group over default, sheet over group. + +## Custom component (blur, gradients) + +`kind: 'custom'` replaces the rendered backdrop with your own component — the common case is a blur: + +```tsx +import { StyleSheet } from 'react-native'; +import { BlurView } from 'expo-blur'; +import Animated, { + interpolate, + useAnimatedProps, + Extrapolation, +} from 'react-native-reanimated'; +import { + HIDDEN_ANIMATED_INDEX, + type BackdropComponentProps, +} from 'react-native-bottom-sheet-stack'; + +const AnimatedBlur = Animated.createAnimatedComponent(BlurView); + +function BlurBackdrop({ animatedIndex }: BackdropComponentProps) { + const animatedProps = useAnimatedProps(() => ({ + intensity: interpolate( + animatedIndex.value, + [HIDDEN_ANIMATED_INDEX, 0], + [0, 40], + Extrapolation.CLAMP + ), + })); + + return ( + + ); +} + +``` + +```tsx + +``` + +:::warning Define the component at module scope +The component is compared by identity. An inline arrow (`component: (p) => `) +is a new type on every render, which remounts the backdrop and restarts whatever it animates. +::: + +The component receives the sheet's raw `animatedIndex` (`-1` hidden → `0` fully visible, exported as `HIDDEN_ANIMATED_INDEX` → `0`) rather than a pre-computed opacity, so blur intensity, gradients, or anything else can be driven from the sheet's real position on the UI thread — exactly how the built-in backdrop drives its own fade. That also means a custom component owns its fade entirely: render it visible and it will pop in instead of fading. + +```tsx +type BackdropComponentProps = { + sheetId: string; + animatedIndex: SharedValue; + /** Calls requestClose(sheetId) — onBeforeClose interceptors still run. */ + close: () => void; +}; +``` + +Tap-to-dismiss keeps working around a custom component (the manager's own pressable wraps it); use `pressToDismiss: false` to turn it off, or the `close` prop to wire your own gesture. + +## Adapter authors + +Third-party adapters reach parity with one hook: + +```tsx +import { + useAdapterBackdrop, + useBottomSheetContext, + type BackdropConfig, +} from 'react-native-bottom-sheet-stack'; + +function MyAdapter({ backdrop, ...props }: { backdrop?: BackdropConfig | false }) { + const { id } = useBottomSheetContext(); + useAdapterBackdrop(id, backdrop); + // ... +} +``` + +`useSetBackdrop` is the imperative escape hatch for what the prop cannot express: `setBackdrop(id, false)` suppresses the shared backdrop (for an adapter that draws its own overlay), `setBackdrop(id, config)` restyles or replaces it, and `setBackdrop(id, true)` **clears** the override so the sheet falls back to the group default. + +## Migration from v2 + +**`backdrop: false` moved from `open()` options to the adapter:** + +```tsx +// v2 +open(, { backdrop: false }); + +// v3 — in MySheet's JSX + +``` + +The `open()` option is gone because it duplicated per call site what is really a property of the sheet — the adapter prop declares it once and works identically in inline, portal, and persistent mode. It is also what lets a persistent sheet keep its backdrop across close/re-open cycles, since `open()` no longer writes the field at all. + +The capability that moves rather than disappears is **per-open variation** — the same sheet opening with a scrim from one flow and without one from another. Drive it from `params`: + +```tsx +function MySheet() { + const { params } = useBottomSheetContext<'filters'>(); + return {/* … */}; +} + +open({ params: { bare: true } }); +``` + +**`GorhomSheetAdapter` no longer accepts gorhom's `backdropComponent`.** The manager always renders the backdrop, so the two can never stack: + +```tsx +// v2 + + +// v3 — the same rendering, but stack-aware + +``` + +The replacement is not a like-for-like swap: a `kind: 'custom'` component receives `{ sheetId, animatedIndex, close }` instead of gorhom's `BottomSheetBackdropProps`, and it renders in the manager's backdrop layer — outside the scale transform, correctly z-indexed within the stack. diff --git a/docs/docs/built-in-adapters/actions-sheet.md b/docs/docs/built-in-adapters/actions-sheet.md index 342c1c7..452a3a7 100644 --- a/docs/docs/built-in-adapters/actions-sheet.md +++ b/docs/docs/built-in-adapters/actions-sheet.md @@ -64,3 +64,7 @@ and backdrop tap stay enabled, because they route through `onBeforeClose` into the manager's interceptor — which is what produces the confirmation prompt. Disabling them natively would make the sheet silently undismissable. ::: + +### Backdrop + +`backdrop` (`BackdropConfig | false`) configures the manager-rendered backdrop for this sheet — a config overrides the group's `backdrop` default, `false` disables it. The library's own overlay stays forced off either way. See [Backdrop](/backdrop). diff --git a/docs/docs/built-in-adapters/custom-modal.md b/docs/docs/built-in-adapters/custom-modal.md index b429cb4..9f62e87 100644 --- a/docs/docs/built-in-adapters/custom-modal.md +++ b/docs/docs/built-in-adapters/custom-modal.md @@ -40,3 +40,7 @@ open(, { mode: 'push' }); modalControl.open({ mode: 'push' }); // Both are in the stack — closing the modal returns to the bottom sheet ``` + +### Backdrop + +`backdrop` (`BackdropConfig | false`) configures the manager-rendered backdrop for this sheet — a config overrides the group's `backdrop` default, `false` disables it. The library's own overlay stays forced off either way. See [Backdrop](/backdrop). diff --git a/docs/docs/built-in-adapters/gorhom.md b/docs/docs/built-in-adapters/gorhom.md index 2ee84e0..0f1c195 100644 --- a/docs/docs/built-in-adapters/gorhom.md +++ b/docs/docs/built-in-adapters/gorhom.md @@ -36,7 +36,9 @@ const MySheet = forwardRef((props, ref) => { ## Props -`GorhomSheetAdapterProps` extends [`BottomSheetProps`](https://gorhom.dev/react-native-bottom-sheet/props) — the full gorhom prop surface is accepted, nothing is omitted from the type. But the manager owns some of it at runtime. +`GorhomSheetAdapterProps` extends [`BottomSheetProps`](https://gorhom.dev/react-native-bottom-sheet/props) — the full gorhom prop surface is accepted except `backdropComponent`, which the manager owns. Some other props are owned at runtime. + +It adds one prop of its own: `backdrop` (`BackdropConfig | false`) — see [Backdrop](#backdrop). **Managed by the adapter (your value is ignored or wrapped):** @@ -47,29 +49,34 @@ const MySheet = forwardRef((props, ref) => { | `onChange` | Wrapped — reports `handleOpened()` at index `>= 0`, then calls yours | | `onClose` | Wrapped — calls yours, then reports `handleClosed()` | | `onAnimate` | Wrapped — reports `handleDismiss()` when animating toward `-1`, then calls yours | +| `backdropComponent` | Forced to render nothing — the manager draws the backdrop. Not accepted by the type; use `backdrop` instead. See [Backdrop](#backdrop) and [Migration](/backdrop#migration-from-v2) | **Adapter defaults (yours wins):** | Prop | Default | Note | |------|---------|------| | `animationConfigs` | spring — `stiffness: 400`, `damping: 80`, `mass: 0.7` | | -| `backdropComponent` | a component returning `null` | See [Backdrop](#backdrop) | | `enablePanDownToClose` | `true` | Forced to `false` while a [`useOnBeforeClose`](/close-interception) interceptor is blocking dismissal, so the interceptor always gets to run | ## Backdrop -By default this adapter renders gorhom's `backdropComponent` as `null` so the **stack manager's shared backdrop** (`BottomSheetBackdrop`) is used instead. This is recommended — the manager's backdrop is **stack-aware** (correct opacity across stacked sheets, z-index, scale coordination, cascading tap-to-dismiss), which a per-sheet gorhom backdrop is not. +The **stack manager's shared backdrop** (`BottomSheetBackdrop`) is always the one rendered: gorhom's own `backdropComponent` is forced to render nothing, and is not part of `GorhomSheetAdapterProps`. Two overlays would otherwise stack into a double-dark layer, and only the manager's is **stack-aware** (correct opacity across stacked sheets, z-index, scale coordination, cascading tap-to-dismiss). -You **can** override it by passing your own `backdropComponent`, but it's **not recommended** unless you specifically need gorhom's backdrop behavior. When you do, the adapter **automatically disables the manager backdrop** for that sheet so the two never stack: +Configure it with the `backdrop` prop — restyle it, replace it with your own component (blur, gradients), or turn it off entirely, all without losing that stack-aware behavior: ```tsx -import { BottomSheetBackdrop as GorhomBackdrop } from '@gorhom/bottom-sheet'; +// Restyle + + +// Replace (receives the sheet's live animatedIndex) + - - {/* ... */} -; +// None + ``` +See [Backdrop](/backdrop) for the full API. + ## When to Use - You need snap points, scrollable content, keyboard handling diff --git a/docs/docs/built-in-adapters/react-native-modal.md b/docs/docs/built-in-adapters/react-native-modal.md index 2b4c0a1..cf5f19f 100644 --- a/docs/docs/built-in-adapters/react-native-modal.md +++ b/docs/docs/built-in-adapters/react-native-modal.md @@ -69,3 +69,7 @@ style. The manager's backdrop is faded with `withTiming` over `animationInTiming` / `animationOutTiming`, so overriding those keeps the backdrop in step with the modal instead of letting it run ahead. ::: + +### Backdrop + +`backdrop` (`BackdropConfig | false`) configures the manager-rendered backdrop for this sheet — a config overrides the group's `backdrop` default, `false` disables it. The library's own overlay stays forced off either way. See [Backdrop](/backdrop). diff --git a/docs/docs/built-in-adapters/swmansion.md b/docs/docs/built-in-adapters/swmansion.md index 4b33024..a9f7965 100644 --- a/docs/docs/built-in-adapters/swmansion.md +++ b/docs/docs/built-in-adapters/swmansion.md @@ -46,7 +46,7 @@ Software Mansion's sheet is **fully controlled**: it exposes no imperative ref, | Manager action / event | What the adapter does | | --- | --- | -| `expand()` | Sets `index` to `expandedIndex` (defaults to the last detent) | +| `expand()` | Sets `index` to `backdrop`, `expandedIndex` (defaults to the last detent) | | `close()` | Sets `index` back to the collapsed detent | | `onSettle(i)` | Settled on a zero-height detent → reports **closed**; anything else → reports **opened** | | `onIndexChange(i)` | User swiped down to a zero-height detent → reports **dismiss** (re-snaps up when the sheet is non-dismissable) | @@ -207,7 +207,7 @@ Defaults are chosen so a bare `detached` looks right: `16` horizontally, and the The sheet uses the **stack manager's shared backdrop** (`BottomSheetBackdrop`), faded from the sheet's live native position via `onPositionChange`. The manager's backdrop is **stack-aware**: it interpolates opacity correctly across stacked sheets, sits at the right z-index, coordinates with the background scale animation, and participates in cascading tap-to-dismiss. :::info There is no native-scrim option here -swmansion's `scrimColor` / `scrimOpacities` only apply to **modal** sheets. The manager always renders inline inside its `QueueItem` layer so the sheet's z-index participates in the stack, and the native scrim is gated on `modal` on both platforms — so it would never paint. The adapter therefore does not accept those props. To render no backdrop at all, pass `backdrop: false` when opening the sheet. +swmansion's `scrimColor` / `scrimOpacities` only apply to **modal** sheets. The manager always renders inline inside its `QueueItem` layer so the sheet's z-index participates in the stack, and the native scrim is gated on `modal` on both platforms — so it would never paint. The adapter therefore does not accept those props. To render no backdrop at all, pass `backdrop={false}` to the adapter; to restyle or replace it, pass a `BackdropConfig` — see [Backdrop](/backdrop). ::: ## Android back button diff --git a/docs/docs/close-interception.md b/docs/docs/close-interception.md index 1ea9953..dec679d 100644 --- a/docs/docs/close-interception.md +++ b/docs/docs/close-interception.md @@ -1,5 +1,5 @@ --- -sidebar_position: 8 +sidebar_position: 9 --- # Close Interception diff --git a/docs/docs/context-preservation.md b/docs/docs/context-preservation.md index 6a74b01..eef0b15 100644 --- a/docs/docs/context-preservation.md +++ b/docs/docs/context-preservation.md @@ -1,5 +1,5 @@ --- -sidebar_position: 6 +sidebar_position: 7 --- # Portal-based API diff --git a/docs/docs/custom-adapters.md b/docs/docs/custom-adapters.md index 9a897d1..ada0809 100644 --- a/docs/docs/custom-adapters.md +++ b/docs/docs/custom-adapters.md @@ -1,5 +1,5 @@ --- -sidebar_position: 12 +sidebar_position: 13 --- # Building Custom Adapters @@ -346,21 +346,33 @@ bridge Software Mansion's native sheet. Note that `animatedIndex` is driven reports the end of an animation and would therefore snap the backdrop to its final value one animation late. -### Suppressing the manager backdrop +### Backdrop -If your adapter renders a backdrop of its own, suppress the manager's shared one so the two don't stack into a double-dark overlay: +The manager draws the one shared, stack-aware backdrop for every sheet. Your adapter should **force its library's own overlay off** — two would stack, and only the manager's is stack-aware — and expose a `backdrop` prop so consumers can configure the manager's: ```tsx -import { useSetBackdrop, useSheetPreventDismiss } from 'react-native-bottom-sheet-stack'; - -const setBackdrop = useSetBackdrop(); -useEffect(() => { - if (!hasOwnBackdrop) return; - setBackdrop(id, false); - return () => setBackdrop(id, true); -}, [id, hasOwnBackdrop, setBackdrop]); +import { + useAdapterBackdrop, + type AdapterBackdropProps, +} from 'react-native-bottom-sheet-stack'; + +interface MyAdapterProps extends AdapterBackdropProps { + children: React.ReactNode; +} + +function MyAdapter({ backdrop, children }: MyAdapterProps) { + const { id } = useBottomSheetContext(); + useAdapterBackdrop(id, backdrop); + // ... +} ``` +That is the whole contract, and it is what all five shipped adapters do. `AdapterBackdropProps` supplies the `backdrop?: BackdropConfig | false` prop and its documentation; see [Backdrop](/backdrop) for what consumers can pass. + +Do **not** hand-roll the effect. A single effect keyed on the prop clears and rewrites the store on every consumer render, because a JSX object literal is a fresh object each time — `useAdapterBackdrop` splits the value sync from the unmount cleanup precisely to avoid that. + +`useSetBackdrop` remains the imperative escape hatch: `setBackdrop(id, false)` suppresses the shared backdrop, `setBackdrop(id, true)` clears the override. + `useSheetPreventDismiss(id)` reports whether a `useOnBeforeClose` interceptor is currently blocking dismissal, so you can disable your library's native swipe/tap gestures while it is. ### Libraries Without Separate Dismiss/Close Phases diff --git a/docs/docs/intro.md b/docs/docs/intro.md index a87853c..3908341 100644 --- a/docs/docs/intro.md +++ b/docs/docs/intro.md @@ -18,6 +18,7 @@ A **library-agnostic** stack manager for bottom sheets and modals in React Nativ - **Library-Agnostic** — Pluggable [adapter architecture](/adapters) works with any bottom sheet or modal library - **Stack Navigation** — `push`, `switch`, and `replace` modes for managing multiple sheets - **Scale Animation** — iOS-style background scaling effect when sheets are stacked +- **Configurable Backdrop** - Theme the shared stack-aware backdrop per group or per sheet, or replace it with a custom component (e.g. blur) - **Context Preservation** — Portal-based API that preserves React context in bottom sheets - **Close Interception** — [`useOnBeforeClose`](/close-interception) to confirm or prevent sheet dismissal - **Cascading Close** — `closeAll()` with staggered animation, respecting interceptors diff --git a/docs/docs/persistent-sheets.md b/docs/docs/persistent-sheets.md index 1bdc2e7..60cab98 100644 --- a/docs/docs/persistent-sheets.md +++ b/docs/docs/persistent-sheets.md @@ -1,5 +1,5 @@ --- -sidebar_position: 7 +sidebar_position: 8 --- # Persistent Sheets diff --git a/docs/docs/type-safe-ids.md b/docs/docs/type-safe-ids.md index 03ebbe8..8e8df72 100644 --- a/docs/docs/type-safe-ids.md +++ b/docs/docs/type-safe-ids.md @@ -1,5 +1,5 @@ --- -sidebar_position: 9 +sidebar_position: 10 --- # Type-Safe Portal IDs & Params diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 8b508f1..92187df 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -7,6 +7,7 @@ const sidebars: SidebarsConfig = { 'api-comparison', 'navigation-modes', 'scale-animation', + 'backdrop', 'context-preservation', 'persistent-sheets', 'close-interception', diff --git a/example/package.json b/example/package.json index 9979669..84f81d6 100644 --- a/example/package.json +++ b/example/package.json @@ -15,6 +15,7 @@ "@react-native-clipboard/clipboard": "^1.16.3", "@swmansion/react-native-bottom-sheet": "0.16.2", "expo": "^54.0.31", + "expo-blur": "~15.0.8", "expo-dev-client": "~6.0.13", "expo-linking": "~8.0.11", "expo-status-bar": "~2.0.1", diff --git a/example/src/components/Sheet.tsx b/example/src/components/Sheet.tsx index 184df5d..3fc7fbb 100644 --- a/example/src/components/Sheet.tsx +++ b/example/src/components/Sheet.tsx @@ -7,6 +7,7 @@ import type { BottomSheetMethods } from '@gorhom/bottom-sheet/lib/typescript/typ import { forwardRef, useCallback, useMemo, type ReactNode } from 'react'; import { View, type StyleProp, type ViewStyle } from 'react-native'; import { GorhomSheetAdapter } from '../../../src/adapters/gorhom-sheet'; +import type { BackdropConfig } from 'react-native-bottom-sheet-stack'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { colors, sharedStyles } from '../styles/theme'; @@ -18,6 +19,7 @@ interface SheetProps { enableDynamicSizing?: boolean; scrollable?: boolean; style?: StyleProp; + backdrop?: BackdropConfig | false; } export const Sheet = forwardRef( @@ -29,6 +31,7 @@ export const Sheet = forwardRef( enableDynamicSizing = true, scrollable = false, style, + backdrop, }, ref ) => { @@ -89,6 +92,7 @@ export const Sheet = forwardRef( ref={ref} handleComponent={renderHandle} backgroundStyle={backgroundStyle} + backdrop={backdrop} > {scrollable ? ( @@ -107,6 +111,7 @@ export const Sheet = forwardRef( ref={ref} handleComponent={renderHandle} backgroundStyle={backgroundStyle} + backdrop={backdrop} > {scrollable ? {children} : children} diff --git a/example/src/screens/HomeScreen.tsx b/example/src/screens/HomeScreen.tsx index b63eec7..5c7db40 100644 --- a/example/src/screens/HomeScreen.tsx +++ b/example/src/screens/HomeScreen.tsx @@ -10,6 +10,7 @@ import { DemoCard, FeatureItem } from '../components'; import { ActionsSheetDemoContent, AdapterComparisonContent, + BackdropDemo, ContextComparisonSheet, ContextSheetPortal, ForceCloseDemo, @@ -201,9 +202,16 @@ export function HomeScreen() { Lifecycle & Groups + open(, { scaleBackground: true })} + /> + open(, { scaleBackground: true })} /> diff --git a/example/src/sheets/BackdropSheets.tsx b/example/src/sheets/BackdropSheets.tsx new file mode 100644 index 0000000..70f0b92 --- /dev/null +++ b/example/src/sheets/BackdropSheets.tsx @@ -0,0 +1,157 @@ +import type { BottomSheetMethods } from '@gorhom/bottom-sheet/lib/typescript/types'; +import { BlurView } from 'expo-blur'; +import { forwardRef } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { + HIDDEN_ANIMATED_INDEX, + useBottomSheetContext, + useBottomSheetManager, + type BackdropComponentProps, +} from 'react-native-bottom-sheet-stack'; +import Animated, { + Extrapolation, + interpolate, + useAnimatedProps, +} from 'react-native-reanimated'; + +import { Badge, Button, SecondaryButton, Sheet } from '../components'; +import { colors, sharedStyles } from '../styles/theme'; + +/** + * The three things a backdrop config can be, stacked on top of each other so + * the resolution rules are visible rather than described. + * + * The provider sets no group default here, so every sheet below shows its own + * `backdrop` prop against the built-in `rgba(0,0,0,0.5)` scrim. + */ + +const AnimatedBlurView = Animated.createAnimatedComponent(BlurView); + +/** + * Defined at module scope on purpose: the config is compared by component + * identity, so an inline arrow would be a new type every render — remounting + * the backdrop and restarting the blur each time. + */ +function BlurBackdrop({ animatedIndex }: BackdropComponentProps) { + // Driven from the sheet's live position rather than a pre-computed opacity, + // so the blur ramps with the sheet on open, close and drag-to-dismiss. + const animatedProps = useAnimatedProps(() => ({ + intensity: interpolate( + animatedIndex.value, + [HIDDEN_ANIMATED_INDEX, 0], + [0, 60], + Extrapolation.CLAMP + ), + })); + + return ( + + ); +} + +export const BackdropDemo = forwardRef((_, ref) => { + const { open } = useBottomSheetManager(); + const { close } = useBottomSheetContext(); + + return ( + + + Backdrop + + This sheet passes no `backdrop` prop, so it gets the built-in + `rgba(0,0,0,0.5)` scrim. Push the others on top to compare — each one + keeps the same stack-aware layering and tap-to-dismiss. + + + +