Skip to content

Android content-detent sheet can mount off-screen on initial open #33

Description

@Titozzz

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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions