From d55f54d0dcf887b540da5ef0257dc09393650407 Mon Sep 17 00:00:00 2001 From: Arkadiusz Kubaczkowski Date: Tue, 2 Jun 2026 12:49:25 +0200 Subject: [PATCH 1/8] fix(swmansion): smooth backdrop fade on first open of a content-sized sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backdrop fade is position-coupled: animatedIndex = position / openHeight. For a `'content'` detent the open height isn't known up front, and the previous code learned it by tracking the max position during the very open animation — so the divisor chased the current position and the ratio was always ~1, making the backdrop snap to fully opaque instantly. Stop chasing the position. Learn the open height from the position at the first open settle (so drag-to-dismiss and reopens get the position-coupled fade), and on that first open — before the height is known — fade the backdrop with a timing (matching the modal adapter) instead of jumping. Numeric detents are still seeded up front and unaffected. No onLayout, no extra re-render (shared value + refs only). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../swmansion/SwmansionSheetAdapter.tsx | 56 ++++++++++++++----- 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/src/adapters/swmansion/SwmansionSheetAdapter.tsx b/src/adapters/swmansion/SwmansionSheetAdapter.tsx index 46ae264..9022d61 100644 --- a/src/adapters/swmansion/SwmansionSheetAdapter.tsx +++ b/src/adapters/swmansion/SwmansionSheetAdapter.tsx @@ -14,6 +14,7 @@ import { type ViewStyle, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { withTiming } from 'react-native-reanimated'; import type { BottomSheetProps, @@ -175,6 +176,9 @@ export interface SwmansionSheetAdapterProps const DEFAULT_DETENTS: Detent[] = [0, 'content']; const DEFAULT_SURFACE_RADIUS = 20; +// Backdrop fade used on the first open, before the open height is known (so the +// position-coupled fade can't run yet). Matches the modal adapter's timing. +const BACKDROP_FADE_DURATION = 300; const DEFAULT_HANDLE_COLOR = 'rgba(255, 255, 255, 0.25)'; const DEFAULT_HANDLE_WIDTH = 40; const DEFAULT_HANDLE_HEIGHT = 4; @@ -243,7 +247,8 @@ function renderHandle(handle: boolean | SwmansionHandleConfig | ReactElement): { * - `onSettle` reports completed animations → `handleOpened` / `handleClosed`. * - `onIndexChange` (user-driven only) reaching `0` → `handleDismiss`. * - `onPositionChange` drives the shared `animatedIndex` for a smooth backdrop - * fade, falling back to a binary value until the open height is known. + * fade once the open height is known; the first open of a content-sized sheet + * (height not yet known) fades the backdrop with a timing instead. * * It also layers opt-in conveniences over the native sheet — a grab handle, * full-height/fill-content sizing, and keyboard avoidance — each off by default @@ -380,13 +385,16 @@ export const SwmansionSheetAdapter = React.forwardRef< ); } - // Open height, captured for a continuous backdrop fade. Seeded from a - // numeric expanded detent when possible; otherwise learned on first settle. - const openPositionRef = useRef( + // Open height, used to turn the native position (points from the bottom) + // into a 0→1 backdrop-fade progress. Known up front for a numeric expanded + // detent; for a `'content'` detent it's unknown until the sheet first opens, + // so it's learned from the position at the first open settle. + const openHeightRef = useRef( typeof expandedDetentValue === 'number' && expandedDetentValue > 0 ? expandedDetentValue : null ); + const lastPositionRef = useRef(0); // The native sheet animates in to its mounted index (0 = collapsed) and // emits a settle at that detent before the coordinator drives expand(). That @@ -398,10 +406,27 @@ export const SwmansionSheetAdapter = React.forwardRef< useImperativeHandle( ref, () => ({ - expand: () => setIndex(openIndex), - close: () => setIndex(0), + expand: () => { + // Until the open height is known, the position-coupled fade can't run + // (it would divide by an unknown target and jump straight to opaque), + // so drive the backdrop with a timing on the first open instead. + if (openHeightRef.current == null) { + animatedIndex.value = withTiming(0, { + duration: BACKDROP_FADE_DURATION, + }); + } + setIndex(openIndex); + }, + close: () => { + if (openHeightRef.current == null) { + animatedIndex.value = withTiming(-1, { + duration: BACKDROP_FADE_DURATION, + }); + } + setIndex(0); + }, }), - [openIndex] + [animatedIndex, openIndex] ); const handleNativeSettle = (settledIndex: number) => { @@ -416,6 +441,11 @@ export const SwmansionSheetAdapter = React.forwardRef< } } else { hasOpenedRef.current = true; + // Learn the open height from the settled position so subsequent moves + // (drag-to-dismiss, reopen) get the position-coupled fade. + if (openHeightRef.current == null && lastPositionRef.current > 0) { + openHeightRef.current = lastPositionRef.current; + } animatedIndex.set(0); handleOpened(); } @@ -440,17 +470,15 @@ export const SwmansionSheetAdapter = React.forwardRef< }; const handleNativePositionChange = (position: number) => { - if (position > 0 && position > (openPositionRef.current ?? 0)) { - openPositionRef.current = position; - } - const target = openPositionRef.current; + lastPositionRef.current = position; + const target = openHeightRef.current; if (target && target > 0) { - const ratio = Math.max(0, Math.min(position / target, 1)); // animatedIndex range: -1 (closed) → 0 (open). + const ratio = Math.max(0, Math.min(position / target, 1)); animatedIndex.set(ratio - 1); - } else { - animatedIndex.set(position > 0 ? 0 : -1); } + // While the open height is unknown (first open of a `'content'` sheet), the + // backdrop is driven by the timing kicked in `expand()`/`close()` instead. onPositionChange?.(position); }; From f850d61d42d92ae080563294cac82ef5ca942881 Mon Sep 17 00:00:00 2001 From: Arkadiusz Kubaczkowski Date: Tue, 2 Jun 2026 13:17:40 +0200 Subject: [PATCH 2/8] fix(swmansion): also fade backdrop on mount-at-open and use compiler-safe set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inline/portal sheets mount directly at the open index and animate in via the native animateIn — expand() is never called, so the first-open timing wasn't kicked for them (the common case; every open is a fresh mount since the sheet unmounts on close). Kick the timed backdrop fade in a mount effect too. Use animatedIndex.set(withTiming(...)) instead of `.value =` — the React Compiler disallows assigning to a custom hook's returned value; .set is the compiler-safe equivalent and still animates. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../swmansion/SwmansionSheetAdapter.tsx | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/adapters/swmansion/SwmansionSheetAdapter.tsx b/src/adapters/swmansion/SwmansionSheetAdapter.tsx index 9022d61..1d1bae4 100644 --- a/src/adapters/swmansion/SwmansionSheetAdapter.tsx +++ b/src/adapters/swmansion/SwmansionSheetAdapter.tsx @@ -411,17 +411,17 @@ export const SwmansionSheetAdapter = React.forwardRef< // (it would divide by an unknown target and jump straight to opaque), // so drive the backdrop with a timing on the first open instead. if (openHeightRef.current == null) { - animatedIndex.value = withTiming(0, { - duration: BACKDROP_FADE_DURATION, - }); + animatedIndex.set( + withTiming(0, { duration: BACKDROP_FADE_DURATION }) + ); } setIndex(openIndex); }, close: () => { if (openHeightRef.current == null) { - animatedIndex.value = withTiming(-1, { - duration: BACKDROP_FADE_DURATION, - }); + animatedIndex.set( + withTiming(-1, { duration: BACKDROP_FADE_DURATION }) + ); } setIndex(0); }, @@ -429,6 +429,17 @@ export const SwmansionSheetAdapter = React.forwardRef< [animatedIndex, openIndex] ); + // Inline/portal sheets mount already at the open index and animate in via + // the native `animateIn` — without an `expand()` call to kick the fade. So + // when such a sheet mounts open with an unknown height (content-sized), fade + // the backdrop with a timing here too. Numeric detents are seeded, so they + // keep the position-coupled fade. Runs once on mount. + useEffect(() => { + if (defaultIndex >= 0 && animateIn && openHeightRef.current == null) { + animatedIndex.set(withTiming(0, { duration: BACKDROP_FADE_DURATION })); + } + }, [animateIn, animatedIndex, defaultIndex]); + const handleNativeSettle = (settledIndex: number) => { if (settledIndex <= 0) { animatedIndex.set(-1); From 811f04eb3292175055eb8ebcb91e3667f6ffcbcb Mon Sep 17 00:00:00 2001 From: Arkadiusz Kubaczkowski Date: Tue, 2 Jun 2026 13:33:57 +0200 Subject: [PATCH 3/8] =?UTF-8?q?fix(swmansion):=20coherent=20backdrop=20fad?= =?UTF-8?q?e=20=E2=80=94=20don't=20let=20settle=20cut=20the=20timing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single contract for animatedIndex: position-coupled when the open height is known (exact, follows native open/close/drag), a timing fallback otherwise, and settle only confirms the endpoint when position-coupled or non-animated. The open settle previously hard-set animatedIndex to 0, cancelling the in-flight withTiming — so the backdrop snapped instead of fading (visible at any duration). Use a module-level resolveBackdropTarget() for the timed/instant fade, and strict typeof checks (no loose equality). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../swmansion/SwmansionSheetAdapter.tsx | 65 ++++++++++++------- 1 file changed, 41 insertions(+), 24 deletions(-) diff --git a/src/adapters/swmansion/SwmansionSheetAdapter.tsx b/src/adapters/swmansion/SwmansionSheetAdapter.tsx index 1d1bae4..05adf2a 100644 --- a/src/adapters/swmansion/SwmansionSheetAdapter.tsx +++ b/src/adapters/swmansion/SwmansionSheetAdapter.tsx @@ -197,6 +197,18 @@ function resolveDetentValue(detent: Detent): DetentValue { return detent; } +/** + * Target `animatedIndex` for an open/close transition that the position-coupled + * fade can't drive (open height not yet known): a timing toward the endpoint, or + * an instant set when the sheet doesn't animate. `0` = open, `-1` = closed. + */ +function resolveBackdropTarget(open: boolean, animate: boolean): number { + const target = open ? 0 : -1; + return animate + ? withTiming(target, { duration: BACKDROP_FADE_DURATION }) + : target; +} + /** * Builds the grab-handle overlay (rendered over the surface) and the top inset * the content needs to clear it. `handle` is already known to be truthy. @@ -301,7 +313,7 @@ export const SwmansionSheetAdapter = React.forwardRef< // double-dark overlay. The manager backdrop starts at opacity 0 behind a // short init delay, so toggling it off here is invisible (no flash). const usesNativeScrim = - scrimColor !== 'transparent' || scrimOpacities != null; + scrimColor !== 'transparent' || scrimOpacities !== undefined; useEffect(() => { if (!usesNativeScrim) return; setBackdrop(id, false); @@ -407,42 +419,45 @@ export const SwmansionSheetAdapter = React.forwardRef< ref, () => ({ expand: () => { - // Until the open height is known, the position-coupled fade can't run - // (it would divide by an unknown target and jump straight to opaque), - // so drive the backdrop with a timing on the first open instead. - if (openHeightRef.current == null) { - animatedIndex.set( - withTiming(0, { duration: BACKDROP_FADE_DURATION }) - ); + // Position-coupling needs the open height; until it's known the + // backdrop is driven by a timing (instant when not animating). + if (typeof openHeightRef.current !== 'number') { + animatedIndex.set(resolveBackdropTarget(true, animateIn)); } setIndex(openIndex); }, close: () => { - if (openHeightRef.current == null) { - animatedIndex.set( - withTiming(-1, { duration: BACKDROP_FADE_DURATION }) - ); + if (typeof openHeightRef.current !== 'number') { + animatedIndex.set(resolveBackdropTarget(false, animateIn)); } setIndex(0); }, }), - [animatedIndex, openIndex] + [animateIn, animatedIndex, openIndex] ); // Inline/portal sheets mount already at the open index and animate in via - // the native `animateIn` — without an `expand()` call to kick the fade. So - // when such a sheet mounts open with an unknown height (content-sized), fade - // the backdrop with a timing here too. Numeric detents are seeded, so they - // keep the position-coupled fade. Runs once on mount. + // the native `animateIn` — there's no `expand()` call to kick the fade. When + // such a sheet mounts open with an unknown height, drive the backdrop here + // the same way. Numeric detents are seeded, so they keep the position-coupled + // fade and skip this. Runs once on mount. useEffect(() => { - if (defaultIndex >= 0 && animateIn && openHeightRef.current == null) { - animatedIndex.set(withTiming(0, { duration: BACKDROP_FADE_DURATION })); + if (defaultIndex >= 0 && typeof openHeightRef.current !== 'number') { + animatedIndex.set(resolveBackdropTarget(true, animateIn)); } }, [animateIn, animatedIndex, defaultIndex]); const handleNativeSettle = (settledIndex: number) => { + // When the height is known the position-coupled fade already reached the + // endpoint, so settling just confirms it. When a timed fade owns the + // transition (height unknown + animating), DON'T snap — it would cut the + // timing short. A non-animated sheet has no fade to protect, so snap. + const confirmEndpoint = + typeof openHeightRef.current === 'number' || !animateIn; if (settledIndex <= 0) { - animatedIndex.set(-1); + if (confirmEndpoint) { + animatedIndex.set(-1); + } // Ignore the collapsed-detent settle that fires during the initial // animate-in (before the sheet has ever opened). A real close only // happens after an open, so reporting it here would dismiss the sheet @@ -452,12 +467,14 @@ export const SwmansionSheetAdapter = React.forwardRef< } } else { hasOpenedRef.current = true; - // Learn the open height from the settled position so subsequent moves - // (drag-to-dismiss, reopen) get the position-coupled fade. - if (openHeightRef.current == null && lastPositionRef.current > 0) { + if (confirmEndpoint) { + animatedIndex.set(0); + } else if (lastPositionRef.current > 0) { + // First animated open of a content-sized sheet: learn the open height + // so the next move (drag-to-dismiss, reopen) is position-coupled. The + // timed fade kicked on open owns this animation — don't snap. openHeightRef.current = lastPositionRef.current; } - animatedIndex.set(0); handleOpened(); } onSettle?.(settledIndex); From fa8e7bd745da7e05a80a3b01303eed8f41cd27f0 Mon Sep 17 00:00:00 2001 From: Arkadiusz Kubaczkowski Date: Tue, 2 Jun 2026 13:44:11 +0200 Subject: [PATCH 4/8] refactor(swmansion): omit lib-controlled animateIn, simplify backdrop, trim comments - animateIn is part of the manager's open mechanism, not the consumer's to set: omit it from the props (like index/modal) and force it on internally. The fade is therefore always animated, so resolveBackdropTarget drops its animate flag. - Drive the backdrop coherently from one model: index changes / mount fade via timing, onPositionChange overrides with the exact position-coupled value once the open height is known, settle never snaps (it was cutting the fade short). - Reduce inline comments to the few non-obvious "why"s; keep the public JSDoc. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../swmansion/SwmansionSheetAdapter.tsx | 179 ++++++------------ 1 file changed, 59 insertions(+), 120 deletions(-) diff --git a/src/adapters/swmansion/SwmansionSheetAdapter.tsx b/src/adapters/swmansion/SwmansionSheetAdapter.tsx index 05adf2a..1507244 100644 --- a/src/adapters/swmansion/SwmansionSheetAdapter.tsx +++ b/src/adapters/swmansion/SwmansionSheetAdapter.tsx @@ -35,8 +35,8 @@ import { useBackHandler } from '../../useBackHandler'; import { useBottomSheetContext } from '../../useBottomSheetContext'; import { SwmansionKeyboardInset } from './SwmansionKeyboardInset'; -// Loaded lazily so the main bundle never requires the native module unless -// this adapter is actually imported (it ships as an optional peer dependency). +// Lazy require so the main bundle never loads the native module unless this +// adapter is imported (it's an optional peer dependency). const { BottomSheet, programmatic } = require('@swmansion/react-native-bottom-sheet') as typeof import('@swmansion/react-native-bottom-sheet'); @@ -70,11 +70,12 @@ export interface SwmansionHandleConfig { * - `modal` — the sheet always renders inline inside the manager's `QueueItem` * layer so its z-index participates in the stack and the manager's shared * `BottomSheetBackdrop` provides the scrim. + * - `animateIn` — the manager controls the open animation, so this is forced on. * - * Every other native prop (`detents`, `style`, `animateIn`, - * `disableScrollableNegotiation`) is forwarded. The lifecycle callbacks - * (`onIndexChange`, `onSettle`, `onPositionChange`) are wrapped by the adapter - * and your handlers are still invoked afterwards. + * Every other native prop (`detents`, `style`, `disableScrollableNegotiation`) + * is forwarded. The lifecycle callbacks (`onIndexChange`, `onSettle`, + * `onPositionChange`) are wrapped by the adapter and your handlers are still + * invoked afterwards. * * **Backdrop.** By default the manager renders its own shared, stack-aware * `BottomSheetBackdrop` and the native scrim is disabled (`scrimColor` defaults @@ -93,7 +94,7 @@ export interface SwmansionHandleConfig { * native sheet. */ export interface SwmansionSheetAdapterProps - extends Omit { + extends Omit { /** * Index into `detents` the sheet expands to when opened. * @@ -176,18 +177,15 @@ export interface SwmansionSheetAdapterProps const DEFAULT_DETENTS: Detent[] = [0, 'content']; const DEFAULT_SURFACE_RADIUS = 20; -// Backdrop fade used on the first open, before the open height is known (so the -// position-coupled fade can't run yet). Matches the modal adapter's timing. const BACKDROP_FADE_DURATION = 300; const DEFAULT_HANDLE_COLOR = 'rgba(255, 255, 255, 0.25)'; const DEFAULT_HANDLE_WIDTH = 40; const DEFAULT_HANDLE_HEIGHT = 4; -// Chrome padding around the pill, plus a gap before the content begins. const HANDLE_CHROME_TOP = 12; const HANDLE_CHROME_BOTTOM = 8; const HANDLE_CHROME_GAP = 8; -// Top inset given to the content for a custom-element handle, whose height the -// adapter can't measure (matches the default pill's inset: 12 + 4 + 8 + 8). +// Inset for a custom-element handle, whose height the adapter can't measure +// (matches the default pill's inset: 12 + 4 + 8 + 8). const CUSTOM_HANDLE_CONTENT_INSET = 32; function resolveDetentValue(detent: Detent): DetentValue { @@ -198,15 +196,12 @@ function resolveDetentValue(detent: Detent): DetentValue { } /** - * Target `animatedIndex` for an open/close transition that the position-coupled - * fade can't drive (open height not yet known): a timing toward the endpoint, or - * an instant set when the sheet doesn't animate. `0` = open, `-1` = closed. + * Backdrop target for an open/close transition: a timing toward the endpoint + * (`0` = open, `-1` = closed). Overridden by the position-coupled fade once the + * open height is known. */ -function resolveBackdropTarget(open: boolean, animate: boolean): number { - const target = open ? 0 : -1; - return animate - ? withTiming(target, { duration: BACKDROP_FADE_DURATION }) - : target; +function resolveBackdropTarget(open: boolean): number { + return withTiming(open ? 0 : -1, { duration: BACKDROP_FADE_DURATION }); } /** @@ -258,9 +253,8 @@ function renderHandle(handle: boolean | SwmansionHandleConfig | ReactElement): { * - `close()` → moves `index` back to `0` (collapsed). * - `onSettle` reports completed animations → `handleOpened` / `handleClosed`. * - `onIndexChange` (user-driven only) reaching `0` → `handleDismiss`. - * - `onPositionChange` drives the shared `animatedIndex` for a smooth backdrop - * fade once the open height is known; the first open of a content-sized sheet - * (height not yet known) fades the backdrop with a timing instead. + * - `onPositionChange` drives the shared `animatedIndex` for a position-coupled + * backdrop fade once the open height is known; until then the fade is a timing. * * It also layers opt-in conveniences over the native sheet — a grab handle, * full-height/fill-content sizing, and keyboard avoidance — each off by default @@ -280,11 +274,8 @@ export const SwmansionSheetAdapter = React.forwardRef< children, detents: detentsProp, expandedIndex, - animateIn = true, - // The manager renders its own shared `BottomSheetBackdrop`; the sheet's - // native scrim would double up with it, so it is disabled by default. - // Consumers can still opt into the native scrim by passing these (see the - // backdrop note on `SwmansionSheetAdapterProps`). + // Disabled by default so the sheet's native scrim doesn't double up with + // the manager backdrop; consumers can opt in (see the props' Backdrop note). scrimColor = 'transparent', scrimOpacities, onIndexChange, @@ -308,10 +299,9 @@ export const SwmansionSheetAdapter = React.forwardRef< const { height: windowHeight } = useWindowDimensions(); const insets = useSafeAreaInsets(); - // Opting into the native scrim means this sheet owns its backdrop, so - // suppress the manager's shared one — otherwise the two stack into a - // double-dark overlay. The manager backdrop starts at opacity 0 behind a - // short init delay, so toggling it off here is invisible (no flash). + // A native scrim means this sheet owns its backdrop — suppress the manager's + // shared one so the two don't stack. The manager backdrop starts invisible + // behind a short init delay, so toggling it off here causes no flash. const usesNativeScrim = scrimColor !== 'transparent' || scrimOpacities !== undefined; useEffect(() => { @@ -320,8 +310,6 @@ export const SwmansionSheetAdapter = React.forwardRef< return () => setBackdrop(id, true); }, [id, usesNativeScrim, setBackdrop]); - // Explicit `detents` always win; otherwise `fullHeight` derives a numeric - // open detent, falling back to the content-sized default. const detents = detentsProp ?? (fullHeight ? [0, windowHeight - insets.top] : DEFAULT_DETENTS); @@ -329,20 +317,14 @@ export const SwmansionSheetAdapter = React.forwardRef< const openIndex = expandedIndex ?? Math.max(0, detents.length - 1); const expandedDetentValue = resolveDetentValue(detents[openIndex] ?? 0); - // Hide the grab handle when dismissal is blocked — the sheet can't be - // swiped down, so a handle would mislead. const handleResult = handle && !preventDismiss ? renderHandle(handle) : null; - // A fixed-height sheet lets the content flex to fill it, so scrollables bind - // and footers pin to the bottom. Content-detent sheets stay natural so they - // size to their content. The heuristic is overridable via `fillContent`. const isContentSized = expandedDetentValue === 'content'; const shouldFill = fillContent ?? !isContentSized; - // The default surface owns its radius; a custom surface owns its own, so we - // only clip content to a known radius (default, or one the consumer states - // via `cornerRadius`). + // Only clip content to a radius we actually know: the default surface's, or + // one the consumer states for a custom surface via `cornerRadius`. const usingDefaultSurface = surface === undefined || surface === null; const surfaceRadius = cornerRadius ?? (usingDefaultSurface ? DEFAULT_SURFACE_RADIUS : 0); @@ -359,8 +341,8 @@ export const SwmansionSheetAdapter = React.forwardRef< ]} /> ); - // Layer the grab handle over the (possibly user-provided) surface so the - // surface stays fully customizable while the adapter owns the handle. + // Layer the handle over the (possibly user-provided) surface so the surface + // stays customizable while the adapter owns the handle. const composedSurface = handleResult ? ( {baseSurface} @@ -373,17 +355,11 @@ export const SwmansionSheetAdapter = React.forwardRef< const { handleDismiss, handleOpened, handleClosed } = createSheetEventHandlers(id); - // Android hardware back dismisses the top, fully-open sheet — the same - // contract the other adapters honor. useBackHandler(id, handleDismiss); - // Mount directly at the index the manager wants instead of mounting - // collapsed and waiting for the coordinator to call expand(). Open sheets - // (defaultIndex >= 0) mount at `openIndex` so the native animates straight in - // to the open detent — there is no post-mount expand() round-trip to race - // against (which previously caused intermittent no-op opens and, once the - // spurious-close was suppressed, a stuck `opening` status). Persistent/hidden - // sheets (defaultIndex < 0) mount collapsed and are expanded on demand. + // Open sheets mount straight at `openIndex` (and animate in) rather than + // mounting collapsed and waiting for expand() — that round-trip caused + // intermittent no-op opens. Persistent/hidden sheets mount collapsed. const defaultIndex = useBottomSheetDefaultIndex(); const [index, setIndex] = useState(() => defaultIndex < 0 ? 0 : openIndex @@ -397,10 +373,9 @@ export const SwmansionSheetAdapter = React.forwardRef< ); } - // Open height, used to turn the native position (points from the bottom) - // into a 0→1 backdrop-fade progress. Known up front for a numeric expanded - // detent; for a `'content'` detent it's unknown until the sheet first opens, - // so it's learned from the position at the first open settle. + // Open height (points from the bottom), used to map the native position to a + // 0→1 backdrop fade. Known for a numeric detent; for `'content'` it's learned + // from the position at the first open settle. const openHeightRef = useRef( typeof expandedDetentValue === 'number' && expandedDetentValue > 0 ? expandedDetentValue @@ -408,71 +383,50 @@ export const SwmansionSheetAdapter = React.forwardRef< ); const lastPositionRef = useRef(0); - // The native sheet animates in to its mounted index (0 = collapsed) and - // emits a settle at that detent before the coordinator drives expand(). That - // initial settle must NOT be reported as a close, otherwise the sheet is - // finished/removed before it ever opens — racing expand() and making opens - // (especially stacked pushes) intermittently no-op. + // The initial animate-in emits a collapsed-detent settle before the sheet has + // ever opened; that one must not be reported as a close (it would dismiss the + // sheet prematurely and race the open). const hasOpenedRef = useRef(false); useImperativeHandle( ref, () => ({ + // An index change always animates, so fade the backdrop toward the + // target; `onPositionChange` overrides this once the height is known. expand: () => { - // Position-coupling needs the open height; until it's known the - // backdrop is driven by a timing (instant when not animating). - if (typeof openHeightRef.current !== 'number') { - animatedIndex.set(resolveBackdropTarget(true, animateIn)); - } + animatedIndex.set(resolveBackdropTarget(true)); setIndex(openIndex); }, close: () => { - if (typeof openHeightRef.current !== 'number') { - animatedIndex.set(resolveBackdropTarget(false, animateIn)); - } + animatedIndex.set(resolveBackdropTarget(false)); setIndex(0); }, }), - [animateIn, animatedIndex, openIndex] + [animatedIndex, openIndex] ); - // Inline/portal sheets mount already at the open index and animate in via - // the native `animateIn` — there's no `expand()` call to kick the fade. When - // such a sheet mounts open with an unknown height, drive the backdrop here - // the same way. Numeric detents are seeded, so they keep the position-coupled - // fade and skip this. Runs once on mount. + // Sheets that mount already open have no expand() call, so seed the fade + // here. Overridden by `onPositionChange` once the height is known. useEffect(() => { - if (defaultIndex >= 0 && typeof openHeightRef.current !== 'number') { - animatedIndex.set(resolveBackdropTarget(true, animateIn)); + if (defaultIndex >= 0) { + animatedIndex.set(resolveBackdropTarget(true)); } - }, [animateIn, animatedIndex, defaultIndex]); + }, [animatedIndex, defaultIndex]); const handleNativeSettle = (settledIndex: number) => { - // When the height is known the position-coupled fade already reached the - // endpoint, so settling just confirms it. When a timed fade owns the - // transition (height unknown + animating), DON'T snap — it would cut the - // timing short. A non-animated sheet has no fade to protect, so snap. - const confirmEndpoint = - typeof openHeightRef.current === 'number' || !animateIn; + // Don't touch `animatedIndex` here: the fade (timing or position-coupled) + // already reaches the endpoint, and snapping would cut it short. if (settledIndex <= 0) { - if (confirmEndpoint) { - animatedIndex.set(-1); - } - // Ignore the collapsed-detent settle that fires during the initial - // animate-in (before the sheet has ever opened). A real close only - // happens after an open, so reporting it here would dismiss the sheet - // prematurely and race expand(). if (hasOpenedRef.current) { handleClosed(); } } else { hasOpenedRef.current = true; - if (confirmEndpoint) { - animatedIndex.set(0); - } else if (lastPositionRef.current > 0) { - // First animated open of a content-sized sheet: learn the open height - // so the next move (drag-to-dismiss, reopen) is position-coupled. The - // timed fade kicked on open owns this animation — don't snap. + // Learn the open height so the next move (drag, reopen) is position-coupled. + if ( + typeof openHeightRef.current !== 'number' && + lastPositionRef.current > 0 + ) { openHeightRef.current = lastPositionRef.current; } handleOpened(); @@ -481,15 +435,12 @@ export const SwmansionSheetAdapter = React.forwardRef< }; const handleNativeIndexChange = (nextIndex: number) => { - // onIndexChange fires only for user-driven snaps. Reaching the collapsed - // detent means the user swiped the sheet down to dismiss it. + // onIndexChange fires only for user-driven snaps; reaching `0` means a + // swipe-down dismiss. if (nextIndex <= 0) { if (preventDismiss) { - // Re-snap up: dismissal is blocked for this sheet. setIndex(openIndex); } else { - // Keep the controlled index in sync with the native position so a - // later expand() is a real 0 → openIndex transition. setIndex(0); handleDismiss(); } @@ -501,35 +452,25 @@ export const SwmansionSheetAdapter = React.forwardRef< lastPositionRef.current = position; const target = openHeightRef.current; if (target && target > 0) { - // animatedIndex range: -1 (closed) → 0 (open). const ratio = Math.max(0, Math.min(position / target, 1)); animatedIndex.set(ratio - 1); } - // While the open height is unknown (first open of a `'content'` sheet), the - // backdrop is driven by the timing kicked in `expand()`/`close()` instead. onPositionChange?.(position); }; - // When dismissal is blocked, mark the collapsed detent (index 0) as - // programmatic so the native sheet cannot be dragged down to it. This is the - // native equivalent of "prevent pan-to-close" — `close()` still collapses it - // via the controlled `index`. The JS re-snap in `handleNativeIndexChange` - // alone cannot block the native gesture. + // When dismissal is blocked, make the collapsed detent programmatic so the + // native sheet can't be dragged down to it — `close()` still collapses it via + // the controlled `index`. The JS re-snap above can't block the native gesture. const resolvedDetents = preventDismiss ? detents.map((detent, detentIndex) => detentIndex === 0 ? programmatic(resolveDetentValue(detent)) : detent ) : detents; - // Wrap the content only when a convenience needs it, so raw sheets pass - // their children straight through with no extra view in the tree. const fillStyle = shouldFill ? stylesheet.fill : null; const handleInsetStyle = handleResult ? { paddingTop: handleResult.contentInset } : null; - // Clip content to the surface's rounded top so opaque content can't square - // off the corners. The content layer sits on top of the surface and isn't - // otherwise bounded by its radius. const clipStyle: ViewStyle | null = surfaceRadius > 0 ? { @@ -559,14 +500,12 @@ export const SwmansionSheetAdapter = React.forwardRef< Date: Tue, 2 Jun 2026 13:48:40 +0200 Subject: [PATCH 5/8] =?UTF-8?q?fix(swmansion):=20drop=20redundant=20mount?= =?UTF-8?q?=20fade=20effect=20=E2=80=94=20coordinator=20always=20calls=20e?= =?UTF-8?q?xpand()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coordinator invokes expand() on every open (including inline/mount-at-open sheets), so it already kicks the backdrop fade. The mount useEffect was a redundant second kick — removed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../swmansion/SwmansionSheetAdapter.tsx | 23 ++++--------------- 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/src/adapters/swmansion/SwmansionSheetAdapter.tsx b/src/adapters/swmansion/SwmansionSheetAdapter.tsx index 1507244..a7f21ae 100644 --- a/src/adapters/swmansion/SwmansionSheetAdapter.tsx +++ b/src/adapters/swmansion/SwmansionSheetAdapter.tsx @@ -304,6 +304,7 @@ export const SwmansionSheetAdapter = React.forwardRef< // behind a short init delay, so toggling it off here causes no flash. const usesNativeScrim = scrimColor !== 'transparent' || scrimOpacities !== undefined; + useEffect(() => { if (!usesNativeScrim) return; setBackdrop(id, false); @@ -341,8 +342,7 @@ export const SwmansionSheetAdapter = React.forwardRef< ]} /> ); - // Layer the handle over the (possibly user-provided) surface so the surface - // stays customizable while the adapter owns the handle. + const composedSurface = handleResult ? ( {baseSurface} @@ -357,9 +357,6 @@ export const SwmansionSheetAdapter = React.forwardRef< useBackHandler(id, handleDismiss); - // Open sheets mount straight at `openIndex` (and animate in) rather than - // mounting collapsed and waiting for expand() — that round-trip caused - // intermittent no-op opens. Persistent/hidden sheets mount collapsed. const defaultIndex = useBottomSheetDefaultIndex(); const [index, setIndex] = useState(() => defaultIndex < 0 ? 0 : openIndex @@ -373,9 +370,6 @@ export const SwmansionSheetAdapter = React.forwardRef< ); } - // Open height (points from the bottom), used to map the native position to a - // 0→1 backdrop fade. Known for a numeric detent; for `'content'` it's learned - // from the position at the first open settle. const openHeightRef = useRef( typeof expandedDetentValue === 'number' && expandedDetentValue > 0 ? expandedDetentValue @@ -390,9 +384,10 @@ export const SwmansionSheetAdapter = React.forwardRef< useImperativeHandle( ref, + // The coordinator calls these on every open/close. Fade the backdrop + // toward the target; `onPositionChange` overrides it once the height is + // known. () => ({ - // An index change always animates, so fade the backdrop toward the - // target; `onPositionChange` overrides this once the height is known. expand: () => { animatedIndex.set(resolveBackdropTarget(true)); setIndex(openIndex); @@ -405,14 +400,6 @@ export const SwmansionSheetAdapter = React.forwardRef< [animatedIndex, openIndex] ); - // Sheets that mount already open have no expand() call, so seed the fade - // here. Overridden by `onPositionChange` once the height is known. - useEffect(() => { - if (defaultIndex >= 0) { - animatedIndex.set(resolveBackdropTarget(true)); - } - }, [animatedIndex, defaultIndex]); - const handleNativeSettle = (settledIndex: number) => { // Don't touch `animatedIndex` here: the fade (timing or position-coupled) // already reaches the endpoint, and snapping would cut it short. From 45a76b15f4ad062151d36fbfafcf428e20fa1eef Mon Sep 17 00:00:00 2001 From: Arkadiusz Kubaczkowski Date: Tue, 2 Jun 2026 13:58:26 +0200 Subject: [PATCH 6/8] refactor(swmansion): drop open-height seeding/learning, keep drag-follow Replace the seeded-from-detent + learned-at-settle openHeightRef (and the timed-vs-position duality it implied) with a single live peak-position ref, gating position-coupling on hasOpenedRef instead of on a known height. First open is the timing; once open, the drag follows the finger. Fewer comments. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../swmansion/SwmansionSheetAdapter.tsx | 36 ++++++------------- 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/src/adapters/swmansion/SwmansionSheetAdapter.tsx b/src/adapters/swmansion/SwmansionSheetAdapter.tsx index a7f21ae..4e50d07 100644 --- a/src/adapters/swmansion/SwmansionSheetAdapter.tsx +++ b/src/adapters/swmansion/SwmansionSheetAdapter.tsx @@ -254,7 +254,8 @@ function renderHandle(handle: boolean | SwmansionHandleConfig | ReactElement): { * - `onSettle` reports completed animations → `handleOpened` / `handleClosed`. * - `onIndexChange` (user-driven only) reaching `0` → `handleDismiss`. * - `onPositionChange` drives the shared `animatedIndex` for a position-coupled - * backdrop fade once the open height is known; until then the fade is a timing. + * backdrop fade once the sheet has opened (so a drag follows the finger); the + * opening fade itself is a timing. * * It also layers opt-in conveniences over the native sheet — a grab handle, * full-height/fill-content sizing, and keyboard avoidance — each off by default @@ -370,23 +371,13 @@ export const SwmansionSheetAdapter = React.forwardRef< ); } - const openHeightRef = useRef( - typeof expandedDetentValue === 'number' && expandedDetentValue > 0 - ? expandedDetentValue - : null - ); - const lastPositionRef = useRef(0); - - // The initial animate-in emits a collapsed-detent settle before the sheet has - // ever opened; that one must not be reported as a close (it would dismiss the - // sheet prematurely and race the open). + // Peak position (the open height), to normalize the drag-to-dismiss fade. + const openPositionRef = useRef(0); + // Guards against reporting the initial collapsed-detent settle as a close. const hasOpenedRef = useRef(false); useImperativeHandle( ref, - // The coordinator calls these on every open/close. Fade the backdrop - // toward the target; `onPositionChange` overrides it once the height is - // known. () => ({ expand: () => { animatedIndex.set(resolveBackdropTarget(true)); @@ -409,13 +400,6 @@ export const SwmansionSheetAdapter = React.forwardRef< } } else { hasOpenedRef.current = true; - // Learn the open height so the next move (drag, reopen) is position-coupled. - if ( - typeof openHeightRef.current !== 'number' && - lastPositionRef.current > 0 - ) { - openHeightRef.current = lastPositionRef.current; - } handleOpened(); } onSettle?.(settledIndex); @@ -436,10 +420,12 @@ export const SwmansionSheetAdapter = React.forwardRef< }; const handleNativePositionChange = (position: number) => { - lastPositionRef.current = position; - const target = openHeightRef.current; - if (target && target > 0) { - const ratio = Math.max(0, Math.min(position / target, 1)); + if (position > openPositionRef.current) { + openPositionRef.current = position; + } + // After it has opened, follow the position so a drag fades with the finger. + if (hasOpenedRef.current && openPositionRef.current > 0) { + const ratio = Math.min(position / openPositionRef.current, 1); animatedIndex.set(ratio - 1); } onPositionChange?.(position); From 451639b1d0083af1159a024b9bfb5d14c3b73631 Mon Sep 17 00:00:00 2001 From: Arkadiusz Kubaczkowski Date: Tue, 2 Jun 2026 14:14:19 +0200 Subject: [PATCH 7/8] =?UTF-8?q?feat(swmansion):=20adopt=200.14=20=E2=80=94?= =?UTF-8?q?=20drive=20backdrop=20on=20the=20UI=20thread=20via=20wrapNative?= =?UTF-8?q?View?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump @swmansion/react-native-bottom-sheet to 0.14 (lib + example). 0.14 makes onPositionChange a native direct event and adds wrapNativeView, so the position can be handled on the UI thread with a Reanimated worklet. The backdrop fade now runs entirely on the UI thread: pass Animated.createAnimatedComponent as wrapNativeView and a useEvent worklet as onPositionChange. The peak position and the has-opened guard become shared values; the JS-thread onPositionChange handler is gone (no per-frame bridge traffic). onPositionChange/wrapNativeView are adapter-owned (omitted from props). Open is still the timed fade; once open, the worklet follows the drag. Co-Authored-By: Claude Opus 4.8 (1M context) --- example/package.json | 2 +- package.json | 4 +- .../swmansion/SwmansionSheetAdapter.tsx | 70 ++++++++++++------- yarn.lock | 14 ++-- 4 files changed, 54 insertions(+), 36 deletions(-) diff --git a/example/package.json b/example/package.json index e1a6eed..f6e01cb 100644 --- a/example/package.json +++ b/example/package.json @@ -13,7 +13,7 @@ "@expo/metro-runtime": "^6.1.2", "@gorhom/bottom-sheet": "^5.2.8", "@react-native-clipboard/clipboard": "^1.16.3", - "@swmansion/react-native-bottom-sheet": "0.13.0-next.1", + "@swmansion/react-native-bottom-sheet": "0.14.0", "expo": "^54.0.31", "expo-dev-client": "~6.0.13", "expo-linking": "~8.0.11", diff --git a/package.json b/package.json index cf77756..afba8bd 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "@gorhom/bottom-sheet": "^5.2.8", "@react-native/eslint-config": "^0.78.0", "@release-it/conventional-changelog": "^9.0.2", - "@swmansion/react-native-bottom-sheet": "0.13.0-next.1", + "@swmansion/react-native-bottom-sheet": "0.14.0", "@types/jest": "^29.5.5", "@types/react": "^19.0.12", "babel-plugin-react-compiler": "^1.0.0", @@ -94,7 +94,7 @@ }, "peerDependencies": { "@gorhom/bottom-sheet": ">=5.0.0", - "@swmansion/react-native-bottom-sheet": ">=0.13.0-next.1", + "@swmansion/react-native-bottom-sheet": ">=0.14.0", "react": "*", "react-native": "*", "react-native-actions-sheet": ">=0.9.0", diff --git a/src/adapters/swmansion/SwmansionSheetAdapter.tsx b/src/adapters/swmansion/SwmansionSheetAdapter.tsx index 4e50d07..3ba2d9e 100644 --- a/src/adapters/swmansion/SwmansionSheetAdapter.tsx +++ b/src/adapters/swmansion/SwmansionSheetAdapter.tsx @@ -4,22 +4,27 @@ import React, { type ReactNode, useEffect, useImperativeHandle, - useRef, useState, } from 'react'; import { + type NativeSyntheticEvent, StyleSheet, useWindowDimensions, View, type ViewStyle, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { withTiming } from 'react-native-reanimated'; +import Animated, { + useEvent, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; import type { BottomSheetProps, Detent, DetentValue, + PositionChangeEventData, } from '@swmansion/react-native-bottom-sheet'; import type { SheetAdapterRef } from '../../adapter.types'; @@ -71,11 +76,13 @@ export interface SwmansionHandleConfig { * layer so its z-index participates in the stack and the manager's shared * `BottomSheetBackdrop` provides the scrim. * - `animateIn` — the manager controls the open animation, so this is forced on. + * - `onPositionChange` / `wrapNativeView` — the adapter consumes these to drive + * the backdrop fade on the UI thread (via a Reanimated worklet), so they are + * not forwarded. * * Every other native prop (`detents`, `style`, `disableScrollableNegotiation`) - * is forwarded. The lifecycle callbacks (`onIndexChange`, `onSettle`, - * `onPositionChange`) are wrapped by the adapter and your handlers are still - * invoked afterwards. + * is forwarded. The `onIndexChange` / `onSettle` callbacks are wrapped by the + * adapter and your handlers are still invoked afterwards. * * **Backdrop.** By default the manager renders its own shared, stack-aware * `BottomSheetBackdrop` and the native scrim is disabled (`scrimColor` defaults @@ -94,7 +101,10 @@ export interface SwmansionHandleConfig { * native sheet. */ export interface SwmansionSheetAdapterProps - extends Omit { + extends Omit< + BottomSheetProps, + 'index' | 'modal' | 'animateIn' | 'onPositionChange' | 'wrapNativeView' + > { /** * Index into `detents` the sheet expands to when opened. * @@ -281,7 +291,6 @@ export const SwmansionSheetAdapter = React.forwardRef< scrimOpacities, onIndexChange, onSettle, - onPositionChange, surface, handle, fullHeight, @@ -371,10 +380,10 @@ export const SwmansionSheetAdapter = React.forwardRef< ); } - // Peak position (the open height), to normalize the drag-to-dismiss fade. - const openPositionRef = useRef(0); + // Peak position (open height), to normalize the drag-to-dismiss fade. + const openPositionSV = useSharedValue(0); // Guards against reporting the initial collapsed-detent settle as a close. - const hasOpenedRef = useRef(false); + const hasOpenedSV = useSharedValue(false); useImperativeHandle( ref, @@ -392,14 +401,14 @@ export const SwmansionSheetAdapter = React.forwardRef< ); const handleNativeSettle = (settledIndex: number) => { - // Don't touch `animatedIndex` here: the fade (timing or position-coupled) - // already reaches the endpoint, and snapping would cut it short. + // Don't touch `animatedIndex` here: the fade (timing or worklet) already + // reaches the endpoint, and snapping would cut it short. if (settledIndex <= 0) { - if (hasOpenedRef.current) { + if (hasOpenedSV.value) { handleClosed(); } } else { - hasOpenedRef.current = true; + hasOpenedSV.value = true; handleOpened(); } onSettle?.(settledIndex); @@ -419,17 +428,25 @@ export const SwmansionSheetAdapter = React.forwardRef< onIndexChange?.(nextIndex); }; - const handleNativePositionChange = (position: number) => { - if (position > openPositionRef.current) { - openPositionRef.current = position; - } - // After it has opened, follow the position so a drag fades with the finger. - if (hasOpenedRef.current && openPositionRef.current > 0) { - const ratio = Math.min(position / openPositionRef.current, 1); - animatedIndex.set(ratio - 1); - } - onPositionChange?.(position); - }; + // Backdrop fade runs on the UI thread: `wrapNativeView` makes the sheet view + // animated so this worklet receives every position frame. After the sheet has + // opened, follow the position so a drag fades the backdrop with the finger. + const onPositionChange = useEvent< + NativeSyntheticEvent + >( + (event) => { + 'worklet'; + const position = event.position; + if (position > openPositionSV.value) { + openPositionSV.value = position; + } + if (hasOpenedSV.value && openPositionSV.value > 0) { + const ratio = Math.min(position / openPositionSV.value, 1); + animatedIndex.set(ratio - 1); + } + }, + ['onPositionChange'] + ); // When dismissal is blocked, make the collapsed detent programmatic so the // native sheet can't be dragged down to it — `close()` still collapses it via @@ -479,9 +496,10 @@ export const SwmansionSheetAdapter = React.forwardRef< index={index} modal={false} animateIn + wrapNativeView={Animated.createAnimatedComponent} onIndexChange={handleNativeIndexChange} onSettle={handleNativeSettle} - onPositionChange={handleNativePositionChange} + onPositionChange={onPositionChange} surface={composedSurface} > {content} diff --git a/yarn.lock b/yarn.lock index 4453224..b71704d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3798,14 +3798,14 @@ __metadata: languageName: node linkType: hard -"@swmansion/react-native-bottom-sheet@npm:0.13.0-next.1": - version: 0.13.0-next.1 - resolution: "@swmansion/react-native-bottom-sheet@npm:0.13.0-next.1" +"@swmansion/react-native-bottom-sheet@npm:0.14.0": + version: 0.14.0 + resolution: "@swmansion/react-native-bottom-sheet@npm:0.14.0" peerDependencies: react: ">=18.0.0" react-native: ">=0.76.0" react-native-safe-area-context: ">=4.0.0" - checksum: c0452132833a6390cc02cf97344bdc8a055c74f0a6e2372529bcfd4cafd609185d4f28db33aba29c16f8720414437a7a5e38decb79c8049b0997df765a5f6002 + checksum: 804d0333400fe8e47a4652e815283384500b09a173d17db6baaf27ab04d8f31d8ddc985dd3ea13e445d581e1eee5fa179dcda57d10965e5f81c533220892bfcd languageName: node linkType: hard @@ -12162,7 +12162,7 @@ __metadata: "@expo/metro-runtime": ^6.1.2 "@gorhom/bottom-sheet": ^5.2.8 "@react-native-clipboard/clipboard": ^1.16.3 - "@swmansion/react-native-bottom-sheet": 0.13.0-next.1 + "@swmansion/react-native-bottom-sheet": 0.14.0 babel-plugin-module-resolver: ^5.0.2 expo: ^54.0.31 expo-dev-client: ~6.0.13 @@ -12198,7 +12198,7 @@ __metadata: "@gorhom/bottom-sheet": ^5.2.8 "@react-native/eslint-config": ^0.78.0 "@release-it/conventional-changelog": ^9.0.2 - "@swmansion/react-native-bottom-sheet": 0.13.0-next.1 + "@swmansion/react-native-bottom-sheet": 0.14.0 "@types/jest": ^29.5.5 "@types/react": ^19.0.12 babel-plugin-react-compiler: ^1.0.0 @@ -12222,7 +12222,7 @@ __metadata: zustand: ^5.0.3 peerDependencies: "@gorhom/bottom-sheet": ">=5.0.0" - "@swmansion/react-native-bottom-sheet": ">=0.13.0-next.1" + "@swmansion/react-native-bottom-sheet": ">=0.14.0" react: "*" react-native: "*" react-native-actions-sheet: ">=0.9.0" From 8e0418ac69f2e5c7a56c71ee337c49a9ed1eaa70 Mon Sep 17 00:00:00 2001 From: Arkadiusz Kubaczkowski Date: Wed, 3 Jun 2026 12:30:22 +0200 Subject: [PATCH 8/8] feat(swmansion): drive backdrop from native detent index Bump @swmansion/react-native-bottom-sheet to 0.15.0-next.1, which adds the fractional `index` field to onPositionChange, and simplify the adapter to drive the shared backdrop with `animatedIndex.set(event.index - 1)` on the UI thread. Removes the JS-side reconstruction: peak-position tracking, the hasOpened shared-value gate (now a plain ref), and the timed open/close fade. Open, close, and drag-to-dismiss now all fade the backdrop from the native per-frame index. --- example/package.json | 4 +- package.json | 8 +-- .../swmansion/SwmansionSheetAdapter.tsx | 69 ++++--------------- yarn.lock | 28 ++++---- 4 files changed, 33 insertions(+), 76 deletions(-) diff --git a/example/package.json b/example/package.json index f6e01cb..9008c5c 100644 --- a/example/package.json +++ b/example/package.json @@ -13,7 +13,7 @@ "@expo/metro-runtime": "^6.1.2", "@gorhom/bottom-sheet": "^5.2.8", "@react-native-clipboard/clipboard": "^1.16.3", - "@swmansion/react-native-bottom-sheet": "0.14.0", + "@swmansion/react-native-bottom-sheet": "0.15.0-next.1", "expo": "^54.0.31", "expo-dev-client": "~6.0.13", "expo-linking": "~8.0.11", @@ -26,7 +26,7 @@ "react-native-modal": "^14.0.0-rc.1", "react-native-reanimated": "^4.2.1", "react-native-safe-area-context": "^5.6.2", - "react-native-teleport": "^0.5.6", + "react-native-teleport": "^1.1.7", "react-native-web": "~0.19.13", "react-native-worklets": "^0.7.1", "use-sync-external-store": "^1.6.0", diff --git a/package.json b/package.json index afba8bd..6be4f01 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "@gorhom/bottom-sheet": "^5.2.8", "@react-native/eslint-config": "^0.78.0", "@release-it/conventional-changelog": "^9.0.2", - "@swmansion/react-native-bottom-sheet": "0.14.0", + "@swmansion/react-native-bottom-sheet": "0.15.0-next.1", "@types/jest": "^29.5.5", "@types/react": "^19.0.12", "babel-plugin-react-compiler": "^1.0.0", @@ -86,7 +86,7 @@ "react-native-gesture-handler": "^2.30.0", "react-native-reanimated": "^4.2.1", "react-native-safe-area-context": "^5.6.2", - "react-native-teleport": "^0.5.6", + "react-native-teleport": "^1.1.7", "react-native-worklets": "^0.7.1", "release-it": "^17.10.0", "typescript": "^5.2.2", @@ -94,7 +94,7 @@ }, "peerDependencies": { "@gorhom/bottom-sheet": ">=5.0.0", - "@swmansion/react-native-bottom-sheet": ">=0.14.0", + "@swmansion/react-native-bottom-sheet": ">=0.15.0-next.1", "react": "*", "react-native": "*", "react-native-actions-sheet": ">=0.9.0", @@ -103,7 +103,7 @@ "react-native-modal": ">=11.0.0", "react-native-reanimated": ">=3.0.0", "react-native-safe-area-context": ">=5.0.0", - "react-native-teleport": ">=0.5.0", + "react-native-teleport": ">=0.1.0", "react-native-worklets": ">=0.7.0", "zustand": ">=5.0.0" }, diff --git a/src/adapters/swmansion/SwmansionSheetAdapter.tsx b/src/adapters/swmansion/SwmansionSheetAdapter.tsx index 3ba2d9e..9294ed0 100644 --- a/src/adapters/swmansion/SwmansionSheetAdapter.tsx +++ b/src/adapters/swmansion/SwmansionSheetAdapter.tsx @@ -4,6 +4,7 @@ import React, { type ReactNode, useEffect, useImperativeHandle, + useRef, useState, } from 'react'; import { @@ -14,11 +15,7 @@ import { type ViewStyle, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import Animated, { - useEvent, - useSharedValue, - withTiming, -} from 'react-native-reanimated'; +import Animated, { useEvent } from 'react-native-reanimated'; import type { BottomSheetProps, @@ -187,15 +184,13 @@ export interface SwmansionSheetAdapterProps const DEFAULT_DETENTS: Detent[] = [0, 'content']; const DEFAULT_SURFACE_RADIUS = 20; -const BACKDROP_FADE_DURATION = 300; const DEFAULT_HANDLE_COLOR = 'rgba(255, 255, 255, 0.25)'; const DEFAULT_HANDLE_WIDTH = 40; const DEFAULT_HANDLE_HEIGHT = 4; const HANDLE_CHROME_TOP = 12; const HANDLE_CHROME_BOTTOM = 8; const HANDLE_CHROME_GAP = 8; -// Inset for a custom-element handle, whose height the adapter can't measure -// (matches the default pill's inset: 12 + 4 + 8 + 8). + const CUSTOM_HANDLE_CONTENT_INSET = 32; function resolveDetentValue(detent: Detent): DetentValue { @@ -205,19 +200,6 @@ function resolveDetentValue(detent: Detent): DetentValue { return detent; } -/** - * Backdrop target for an open/close transition: a timing toward the endpoint - * (`0` = open, `-1` = closed). Overridden by the position-coupled fade once the - * open height is known. - */ -function resolveBackdropTarget(open: boolean): number { - return withTiming(open ? 0 : -1, { duration: BACKDROP_FADE_DURATION }); -} - -/** - * Builds the grab-handle overlay (rendered over the surface) and the top inset - * the content needs to clear it. `handle` is already known to be truthy. - */ function renderHandle(handle: boolean | SwmansionHandleConfig | ReactElement): { overlay: ReactNode; contentInset: number; @@ -263,9 +245,9 @@ function renderHandle(handle: boolean | SwmansionHandleConfig | ReactElement): { * - `close()` → moves `index` back to `0` (collapsed). * - `onSettle` reports completed animations → `handleOpened` / `handleClosed`. * - `onIndexChange` (user-driven only) reaching `0` → `handleDismiss`. - * - `onPositionChange` drives the shared `animatedIndex` for a position-coupled - * backdrop fade once the sheet has opened (so a drag follows the finger); the - * opening fade itself is a timing. + * - `onPositionChange` drives the shared `animatedIndex` straight from the native + * fractional detent `index`, so the backdrop fades with the sheet on open, + * close, and drag-to-dismiss — no JS-side position normalization. * * It also layers opt-in conveniences over the native sheet — a grab handle, * full-height/fill-content sizing, and keyboard avoidance — each off by default @@ -380,43 +362,31 @@ export const SwmansionSheetAdapter = React.forwardRef< ); } - // Peak position (open height), to normalize the drag-to-dismiss fade. - const openPositionSV = useSharedValue(0); // Guards against reporting the initial collapsed-detent settle as a close. - const hasOpenedSV = useSharedValue(false); + const hasOpenedRef = useRef(false); useImperativeHandle( ref, () => ({ - expand: () => { - animatedIndex.set(resolveBackdropTarget(true)); - setIndex(openIndex); - }, - close: () => { - animatedIndex.set(resolveBackdropTarget(false)); - setIndex(0); - }, + expand: () => setIndex(openIndex), + close: () => setIndex(0), }), - [animatedIndex, openIndex] + [openIndex] ); const handleNativeSettle = (settledIndex: number) => { - // Don't touch `animatedIndex` here: the fade (timing or worklet) already - // reaches the endpoint, and snapping would cut it short. if (settledIndex <= 0) { - if (hasOpenedSV.value) { + if (hasOpenedRef.current) { handleClosed(); } } else { - hasOpenedSV.value = true; + hasOpenedRef.current = true; handleOpened(); } onSettle?.(settledIndex); }; const handleNativeIndexChange = (nextIndex: number) => { - // onIndexChange fires only for user-driven snaps; reaching `0` means a - // swipe-down dismiss. if (nextIndex <= 0) { if (preventDismiss) { setIndex(openIndex); @@ -428,29 +398,16 @@ export const SwmansionSheetAdapter = React.forwardRef< onIndexChange?.(nextIndex); }; - // Backdrop fade runs on the UI thread: `wrapNativeView` makes the sheet view - // animated so this worklet receives every position frame. After the sheet has - // opened, follow the position so a drag fades the backdrop with the finger. const onPositionChange = useEvent< NativeSyntheticEvent >( (event) => { 'worklet'; - const position = event.position; - if (position > openPositionSV.value) { - openPositionSV.value = position; - } - if (hasOpenedSV.value && openPositionSV.value > 0) { - const ratio = Math.min(position / openPositionSV.value, 1); - animatedIndex.set(ratio - 1); - } + animatedIndex.set(event.index - 1); }, ['onPositionChange'] ); - // When dismissal is blocked, make the collapsed detent programmatic so the - // native sheet can't be dragged down to it — `close()` still collapses it via - // the controlled `index`. The JS re-snap above can't block the native gesture. const resolvedDetents = preventDismiss ? detents.map((detent, detentIndex) => detentIndex === 0 ? programmatic(resolveDetentValue(detent)) : detent diff --git a/yarn.lock b/yarn.lock index b71704d..75ade70 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3798,14 +3798,14 @@ __metadata: languageName: node linkType: hard -"@swmansion/react-native-bottom-sheet@npm:0.14.0": - version: 0.14.0 - resolution: "@swmansion/react-native-bottom-sheet@npm:0.14.0" +"@swmansion/react-native-bottom-sheet@npm:0.15.0-next.1": + version: 0.15.0-next.1 + resolution: "@swmansion/react-native-bottom-sheet@npm:0.15.0-next.1" peerDependencies: react: ">=18.0.0" react-native: ">=0.76.0" react-native-safe-area-context: ">=4.0.0" - checksum: 804d0333400fe8e47a4652e815283384500b09a173d17db6baaf27ab04d8f31d8ddc985dd3ea13e445d581e1eee5fa179dcda57d10965e5f81c533220892bfcd + checksum: 4037d7f9264bde4cb512d14620c8dc14b49acb09e2ca9fb341543f73608615c52a342505deacb66bb3b1958fb7a57d693b89b37988843aba021d5f504fc871bc languageName: node linkType: hard @@ -12162,7 +12162,7 @@ __metadata: "@expo/metro-runtime": ^6.1.2 "@gorhom/bottom-sheet": ^5.2.8 "@react-native-clipboard/clipboard": ^1.16.3 - "@swmansion/react-native-bottom-sheet": 0.14.0 + "@swmansion/react-native-bottom-sheet": 0.15.0-next.1 babel-plugin-module-resolver: ^5.0.2 expo: ^54.0.31 expo-dev-client: ~6.0.13 @@ -12177,7 +12177,7 @@ __metadata: react-native-modal: ^14.0.0-rc.1 react-native-reanimated: ^4.2.1 react-native-safe-area-context: ^5.6.2 - react-native-teleport: ^0.5.6 + react-native-teleport: ^1.1.7 react-native-web: ~0.19.13 react-native-worklets: ^0.7.1 use-sync-external-store: ^1.6.0 @@ -12198,7 +12198,7 @@ __metadata: "@gorhom/bottom-sheet": ^5.2.8 "@react-native/eslint-config": ^0.78.0 "@release-it/conventional-changelog": ^9.0.2 - "@swmansion/react-native-bottom-sheet": 0.14.0 + "@swmansion/react-native-bottom-sheet": 0.15.0-next.1 "@types/jest": ^29.5.5 "@types/react": ^19.0.12 babel-plugin-react-compiler: ^1.0.0 @@ -12215,14 +12215,14 @@ __metadata: react-native-gesture-handler: ^2.30.0 react-native-reanimated: ^4.2.1 react-native-safe-area-context: ^5.6.2 - react-native-teleport: ^0.5.6 + react-native-teleport: ^1.1.7 react-native-worklets: ^0.7.1 release-it: ^17.10.0 typescript: ^5.2.2 zustand: ^5.0.3 peerDependencies: "@gorhom/bottom-sheet": ">=5.0.0" - "@swmansion/react-native-bottom-sheet": ">=0.14.0" + "@swmansion/react-native-bottom-sheet": ">=0.15.0-next.1" react: "*" react-native: "*" react-native-actions-sheet: ">=0.9.0" @@ -12231,7 +12231,7 @@ __metadata: react-native-modal: ">=11.0.0" react-native-reanimated: ">=3.0.0" react-native-safe-area-context: ">=5.0.0" - react-native-teleport: ">=0.5.0" + react-native-teleport: ">=0.1.0" react-native-worklets: ">=0.7.0" zustand: ">=5.0.0" peerDependenciesMeta: @@ -12342,14 +12342,14 @@ __metadata: languageName: node linkType: hard -"react-native-teleport@npm:^0.5.6": - version: 0.5.6 - resolution: "react-native-teleport@npm:0.5.6" +"react-native-teleport@npm:^1.1.7": + version: 1.1.7 + resolution: "react-native-teleport@npm:1.1.7" peerDependencies: react: "*" react-dom: "*" react-native: "*" - checksum: febeec41d207b938f9c82c9623e5d9d96eab23685bcd8f5d1bef4c27ad4b24aa547a84d8385032de13a7104b4755366e29362af48f2f809a3bbc291daf99b529 + checksum: 70d36cd44deb38b0b499f6d94da13ae4ca0435b5c8661e88c90547473ba25b25ee92d580324c92048be6f035241704f07415e05653a2f7604925dd534bdc8780 languageName: node linkType: hard