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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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