Skip to content

feat(swmansion): drive backdrop from native detent index#28

Merged
arekkubaczkowski merged 8 commits into
mainfrom
fix/swmansion-backdrop-first-open
Jun 3, 2026
Merged

feat(swmansion): drive backdrop from native detent index#28
arekkubaczkowski merged 8 commits into
mainfrom
fix/swmansion-backdrop-first-open

Conversation

@arekkubaczkowski

@arekkubaczkowski arekkubaczkowski commented Jun 2, 2026

Copy link
Copy Markdown
Owner

Problem

The manager backdrop fade used to be reconstructed in JS from onPositionChange, which only reported the sheet's position (points from the bottom). To turn that into a 0..1 open fraction the adapter had to know the open height — and for a 'content' detent that height isn't known up front. The old code learned it by tracking the peak position during the open animation, which made ratio = position / maxSoFar ≈ 1 every frame, so the backdrop snapped to fully opaque on the first open instead of fading. Working around it needed peak tracking, a hasOpened gate, and a timed-fade fallback — fragile.

Fix

Bump @swmansion/react-native-bottom-sheet to 0.15.0-next.1, which adds a native fractional detent index to onPositionChange (the continuous counterpart of onIndexChange; upstreamed in software-mansion-labs/react-native-bottom-sheet#10). The sheet now reports, per frame, exactly how far it is between detents — no height math required.

The adapter drives the shared backdrop straight from it, on the UI thread (via wrapNativeView):

animatedIndex.set(event.index - 1); // -1 closed → 0 first open detent → …

This removes all the JS reconstruction:

  • peak-position / open-height tracking,
  • the hasOpened shared value (now a plain useRef, used only to gate the lifecycle callback),
  • the timed open/close fade.

Open, close, and drag-to-dismiss now all fade from the native per-frame index — including the first open of a 'content'-sized sheet, which is correct for free.

Net: −121 / +64 in the adapter; the backdrop is now driven by one line instead of a hand-rolled position pipeline.

Validation

  • yarn typecheck, yarn eslint, yarn prepare (bob build incl. React Compiler) pass.
  • Backdrop verified in the example app on iOS (open/close/drag fade smoothly, including content-sized sheets on first open).

arekkubaczkowski and others added 8 commits June 2, 2026 12:49
… sheet

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) <noreply@anthropic.com>
…safe set

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…, 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) <noreply@anthropic.com>
… calls expand()

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…pNativeView

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) <noreply@anthropic.com>
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.
@arekkubaczkowski arekkubaczkowski changed the title fix(swmansion): smooth backdrop fade on first open of a content-sized sheet feat(swmansion): drive backdrop from native detent index Jun 3, 2026
@arekkubaczkowski arekkubaczkowski merged commit 6ff140c into main Jun 3, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant