Skip to content

Android edge-to-edge: sheet content sits under system navigation bar / home indicator #67

Description

@lemon-squared

Summary

On Android with edge-to-edge enabled (RN edgeToEdgeEnabled=true, transparent navigation bar), ModalBottomSheet / BottomSheet content is laid out flush to the physical bottom of the window. Primary actions (Cancel / Done) sit under the gesture nav / 3-button bar.

The host already accounts for the top system inset (statusBars + displayCutoutcontentRegionInset / Yoga padding). There is no equivalent for navigationBars() (or iOS home-indicator) at the bottom.

JS workarounds (paddingBottom / marginBottom on footers from useSafeAreaInsets()) are unreliable under the library’s Yoga + native translation model. What works for apps today is a flex sibling spacer with a hardcoded floor (e.g. 64dp) — a brittle consumer-side hack.

Environment

  • @swmansion/react-native-bottom-sheet: ^0.16.2
  • React Native: 0.86 (New Architecture / Fabric)
  • edgeToEdgeEnabled=true in gradle.properties
  • Transparent navigationBarColor / translucent system bars
  • Reproduced on physical Android (gesture and 3-button nav)

iOS home indicator has the same class of problem for footers pinned to the sheet bottom.

Reproduction

import { useState } from "react";
import { Button, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ModalBottomSheet } from "@swmansion/react-native-bottom-sheet";

export function Example() {
  const [index, setIndex] = useState(0);
  const insets = useSafeAreaInsets();

  return (
    <>
      <Button title="Open" onPress={() => setIndex(1)} />
      <ModalBottomSheet
        detents={[0, "content"]}
        index={index}
        onIndexChange={setIndex}
        scrimColor="rgba(0,0,0,0.35)"
        surface={<View style={{ flex: 1, backgroundColor: "white" }} />}
      >
        <View style={{ padding: 16, gap: 12 }}>
          <Text>Actions at the bottom of the sheet</Text>
          {/* Even with insets.bottom, Cancel often still sits under the nav bar */}
          <View style={{ paddingBottom: insets.bottom }}>
            <Button title="Cancel" onPress={() => setIndex(0)} />
          </View>
        </View>
      </ModalBottomSheet>
    </>
  );
}

Expected: Cancel clears the system navigation / home indicator; sheet surface may still extend edge-to-edge underneath.

Actual: Cancel is obscured by the system nav. Footer padding/margin is often ineffective; apps need a consumer-side flex sibling gap + platform floor.

Why (from reading the native host)

In BottomSheetHostView.recomputeNativeGeometry() only the top inset is read:

val topInset =
  ViewCompat.getRootWindowInsets(this)
    ?.getInsets(WindowInsetsCompat.Type.statusBars() or WindowInsetsCompat.Type.displayCutout())
    ?.top ?: 0

That drives contentRegionInset / detent cap. The sheet container still anchors to the physical bottom of an edge-to-edge window. Absolutely positioned surface fills that region (correct for chrome), but in-flow content has no native bottom inset for navigationBars().

The README example of paddingBottom: insets.bottom assumes Yoga padding on children is enough; under Fabric + this host’s translation / content-offset path, that is not consistently true for footers.

Proposed API (either)

A) Automatic (preferred for edge-to-edge defaults)

When the window is edge-to-edge, apply navigationBars() (and iOS safe-area bottom) as a content-bottom inset, analogous to today’s top contentRegionInset:

  • Surface still edge-to-edge (full sheet chrome under the nav bar).
  • In-flow content / scrollables lay out above the nav bar.

B) Explicit prop

<ModalBottomSheet bottomInset={insets.bottom} ...>

or edges={['bottom']} / safeAreaBottom — applied natively in the content region (not only documented as a JS padding tip).

Related: #62 (native footer slot) and any detached-sheet bottomInset discussion — a shared native bottom inset would help both.

Sketch: Android inset change

Illustrative only — naming/state plumbing should match existing contentRegionInset / pushGeometryState patterns.

// BottomSheetHostView.recomputeNativeGeometry()

val rootInsets = ViewCompat.getRootWindowInsets(this)

val topInset =
  rootInsets
    ?.getInsets(WindowInsetsCompat.Type.statusBars() or WindowInsetsCompat.Type.displayCutout())
    ?.top ?: 0

val bottomInset =
  rootInsets
    ?.getInsets(WindowInsetsCompat.Type.navigationBars())
    ?.bottom ?: 0

val location = IntArray(2)
getLocationInWindow(location)

val topOverlap = (topInset - location[1]).coerceAtLeast(0)
val rootViewHeight = (rootView?.height ?: height)
val bottomOverlap =
  (location[1] + height - (rootViewHeight - bottomInset)).coerceAtLeast(0)

val cap =
  (if (extendUnderStatusBar) height else height - topOverlap)
    .toFloat()
    .coerceAtLeast(0f)

// Push both into state, e.g.:
// contentRegionInsetTop  → existing Yoga bottom-padding trick for top gap
// contentRegionInsetBottom → NEW: so in-flow content clears nav
pushGeometryState(
  width = width,
  height = height,
  insetTop = height - cap,
  insetBottom = bottomOverlap.toFloat(),
)

In the Fabric component descriptor / content wrapper, reserve insetBottom for in-flow children (Yoga padding or an extra content inset), while keeping surface on absoluteFill so the sheet chrome still draws under the system bar.

iOS: mirror with safeAreaInsets.bottom on BottomSheetHostingView when computing the content region.

Consumer workaround (today)

Until this lands, reserve a flex sibling under sheet children with a stable platform floor (avoid post-settle measureInWindow — it overshoots and causes layout jump):

<View style={{ flex: 1 }}>
  <View style={{ flex: 1, minHeight: 0 }}>{children}</View>
  <View style={{ height: Math.max(insets.bottom, Platform.OS === "android" ? 64 : 34) }} />
</View>

Footer paddingBottom: insets.bottom did not reliably clear the nav; a flex sibling spacer did.”

Happy to test a PR on RN 0.86 + edge-to-edge if useful.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions