This is AI assisted, I guided the model to produce what I think is the best solution for our issue
Summary
On Android, a bottom sheet whose initial target depends on a content-height detent can be mounted in the opened state while remaining visually off-screen. The sheet is present in the React Native tree and the app state says it is open, but users only see the underlying app.
iOS works as expected.
Toggling nativeOverlay to true and then back to false during hot reload makes the same sheet appear, which suggests the problem is not app state or z-index; the remount/reparent causes a later native layout pass after the content marker is measurable.
Environment where reproduced
- Platform: Android
- Device used for validation: physical Pixel 7, Android API 37
- Downstream dependency:
@swmansion/react-native-bottom-sheet 0.15.2
- Same initial layout path exists on the current
main branch
- Scenario:
animateIn initial open to a content detent before the content-height marker has a valid measured height
Root cause
In BottomSheetHostView.onLayout(), the initial animateIn path checks whether the target content detent is valid. On Android, the host layout can run before the content-height marker has a finite measured height.
When that happens today, the code moves the sheet container to the closed translation and returns early. Because the sheet is now parked closed while the target content detent is still invalid, later marker/layout updates do not reliably perform the intended initial snap. The end result is an opened JS state with the native sheet still below the viewport.
This explains why changing nativeOverlay during hot reload makes the issue disappear: it forces a native remount/re-layout after the marker has a valid size, so the normal initial-open path can run.
Draft fixes
I opened two draft PRs so maintainers can compare the tradeoff.
Option 1: retry scheduled refreshes
PR: #31
This approach makes the deferred initial state explicit, parks the sheet closed, then schedules a small number of frame callbacks to refresh the content marker and detents. Once the target content detent becomes valid, it performs the initial snap.
Pros:
- Simple and localized.
- Works even if the marker needs more than one frame to become measurable.
- Easy to reason about operationally.
Cons:
- Uses an arbitrary retry budget.
- Slightly more imperative than the underlying lifecycle event we are waiting for.
Option 2: pre-draw observer
PR: #32
This approach keeps the same explicit deferred initial state, but uses an Android ViewTreeObserver.OnPreDrawListener while the initial content-detent snap is pending. On each pre-draw it refreshes the content marker and detents, then removes the observer as soon as the target detent becomes valid and the initial snap is performed.
Pros:
- Event-driven rather than retry-count-driven.
- Avoids arbitrary frame limits.
- The observer only exists while the initial content-detent snap is pending.
- Cleanup is explicit on success, normal initial layout, and destroy.
Cons:
- Slightly more lifecycle-sensitive, so the PR stores the exact
ViewTreeObserver used for registration and removes the listener from that same live observer.
Validation
Both approaches were tested locally in the Rosk Android app. For the pre-draw observer approach in #32, I validated:
git diff --check
- Android rebuild of the Rosk app with the patched dependency, including successful compilation of
:swmansion_react-native-bottom-sheet:compileDebugKotlin
- Physical Pixel 7 screenshot showing the mission feedback sheet visibly in front of the app
- React Native component tree showing the the bottom sheet
- A 45-frame Metro reload capture where the app returned with the sheet visible and no settled frame showed the opened sheet hidden behind or below the app
Recommendation
I prefer #32 because it models the actual condition we are waiting for: Android is about to draw another frame, so refresh the marker/detents and complete the pending initial snap as soon as the content detent is valid. #31 is kept open as a simpler fallback if maintainers prefer avoiding a pre-draw listener.
This is AI assisted, I guided the model to produce what I think is the best solution for our issue
Summary
On Android, a bottom sheet whose initial target depends on a content-height detent can be mounted in the opened state while remaining visually off-screen. The sheet is present in the React Native tree and the app state says it is open, but users only see the underlying app.
iOS works as expected.
Toggling
nativeOverlaytotrueand then back tofalseduring hot reload makes the same sheet appear, which suggests the problem is not app state or z-index; the remount/reparent causes a later native layout pass after the content marker is measurable.Environment where reproduced
@swmansion/react-native-bottom-sheet0.15.2mainbranchanimateIninitial open to a content detent before the content-height marker has a valid measured heightRoot cause
In
BottomSheetHostView.onLayout(), the initialanimateInpath checks whether the target content detent is valid. On Android, the host layout can run before the content-height marker has a finite measured height.When that happens today, the code moves the sheet container to the closed translation and returns early. Because the sheet is now parked closed while the target content detent is still invalid, later marker/layout updates do not reliably perform the intended initial snap. The end result is an opened JS state with the native sheet still below the viewport.
This explains why changing
nativeOverlayduring hot reload makes the issue disappear: it forces a native remount/re-layout after the marker has a valid size, so the normal initial-open path can run.Draft fixes
I opened two draft PRs so maintainers can compare the tradeoff.
Option 1: retry scheduled refreshes
PR: #31
This approach makes the deferred initial state explicit, parks the sheet closed, then schedules a small number of frame callbacks to refresh the content marker and detents. Once the target content detent becomes valid, it performs the initial snap.
Pros:
Cons:
Option 2: pre-draw observer
PR: #32
This approach keeps the same explicit deferred initial state, but uses an Android
ViewTreeObserver.OnPreDrawListenerwhile the initial content-detent snap is pending. On each pre-draw it refreshes the content marker and detents, then removes the observer as soon as the target detent becomes valid and the initial snap is performed.Pros:
Cons:
ViewTreeObserverused for registration and removes the listener from that same live observer.Validation
Both approaches were tested locally in the Rosk Android app. For the pre-draw observer approach in #32, I validated:
git diff --check:swmansion_react-native-bottom-sheet:compileDebugKotlinRecommendation
I prefer #32 because it models the actual condition we are waiting for: Android is about to draw another frame, so refresh the marker/detents and complete the pending initial snap as soon as the content detent is valid. #31 is kept open as a simpler fallback if maintainers prefer avoiding a pre-draw listener.