Mobile Engineering

Android 16 (API 36) edge-to-edge is enforced: the window insets contract your UI must satisfy

Targeting Android 16 (API 36) removes the practical opt-out from enforced edge-to-edge. The only stable fix is to define a single owner for each inset (status bars, navigation bars, display cutout, IME) and test for double-application in CI.

Android 16 targeting (API 36) flips edge-to-edge from “recommended” to “you’re doing it whether you like it or not”, and the opt-out attribute is deprecated and disabled. (developer.android.com) If you don’t make an explicit insets ownership decision, you’ll ship clipped headers, bottom bars under gesture navigation, and text inputs hidden by the keyboard.

The fix is not “sprinkle systemBarsPadding() everywhere”. The fix is a testable insets contract: decide which layer owns which insets, ensure everyone else consumes or ignores them, and add automated tests that fail the build when you double-apply.

What Android 16 actually changes (and why your old assumptions break)

Edge-to-edge enforcement started biting teams when targeting Android 15 (API 35), but Android 16 makes the situation unambiguous: for apps targeting API 36, R.attr#windowOptOutEdgeToEdgeEnforcement is deprecated and disabled, so you can’t opt out of edge-to-edge. (developer.android.com) In practice, that means your content can be laid out behind the status bar, navigation bar, display cutouts, and the IME, unless you handle window insets.

A useful mental model is this:

  • Your app no longer gets “free padding” from the framework to keep content out of system UI.
  • Insets are now data you must route to the right place.
  • If two places both apply the same inset as padding, your UI will be wrong.

On the implementation side, edge-to-edge is typically enabled by disabling decor fitting (WindowCompat.setDecorFitsSystemWindows(window, false)), either directly or via helper APIs. AndroidX’s edge-to-edge helpers do exactly that as part of setup. (android.googlesource.com)

The “insets contract”: one owner per inset type

Here’s the contract I recommend teams adopt and enforce in code review.

Define the inset types you care about

Treat these as separate responsibilities:

  • Status bars (top)
  • Navigation bars (bottom/side)
  • Display cutout (notch/rounded corners)
  • IME (keyboard)

In Compose terminology, “safe drawing” is commonly used as an aggregate: WindowInsets.safeDrawing is effectively the union of system bars, display cutout, and IME. (windowinsets.info) That is convenient, but it’s also how teams accidentally apply IME padding on screens that should scroll under the keyboard.

Ownership rule

  • Exactly one layer is allowed to apply padding/margin for a given inset type.
  • Everyone else must either:
  • consume the inset (so it isn’t applied again), or
  • avoid applying it in the first place.

If you remember one thing, make it this: double-inset is a product bug, not a cosmetic nit. It causes hidden buttons, broken bottom sheets, misaligned app bars, and layout jumps during IME animations.

Recommended ownership by UI stack

Pick one of these patterns and standardise it.

  • Views (XML + RecyclerView etc.)
  • Compose
  • React Native
  • Flutter

You can mix stacks (many apps do), but the contract still holds: only one place owns the insets, and boundaries between stacks must be explicit.

Views: make the root responsible, and push inset padding to scroll containers

For classic Views, the safest pattern is:

  • The Activity enables edge-to-edge.
  • The root content view (or a dedicated top-level container) receives insets.
  • You apply:
  • top inset to the app bar container (or toolbar)
  • bottom inset to the primary scrolling container (RecyclerView / NestedScrollView)
  • IME inset only on screens that need it (and usually as bottom padding on the scrolling container)

The “common inset patterns” guidance most teams end up rediscovering: apply bottom inset as RecyclerView padding and use clipToPadding=false so content can scroll behind the gesture area while keeping touch targets safe. (windowinsets.info)

Failure mode to watch for in Views:

  • A parent container applies system bar padding.
  • A child also applies system bar padding (often via a library view or a fragment container that does its own inset handling).
  • Result: your list starts too low (top) or your bottom CTA floats awkwardly high (bottom).

If you do one thing in Views code review: ban “mystery inset listeners” buried in custom views.

Compose: understand “padding consumes insets”, and don’t mix safeDrawing with imePadding blindly

Compose gives you great tools, but it’s easy to create double-application if you don’t internalise consumption.

Key facts worth grounding:

  • windowInsetsPadding(...) and helpers such as safeDrawingPadding() apply padding and automatically consume the portion of insets they apply. (developer.android.com)
  • If you add normal padding and want that padding to be considered “consumed insets” for children, you can explicitly consume via consumeWindowInsets(...). (developer.android.com)
  • Older guidance (and some existing codebases) used Accompanist Insets; its docs explicitly call out double-padding pitfalls and how consumption affects the result. (android.googlesource.com)

A practical Compose contract that scales:

  • At the top of each screen, decide whether the screen is:
  • chrome-driven (top app bar + bottom bar) or
  • content-driven (full-bleed content like maps, media, or web)

Then:

  • For chrome-driven screens:
  • Apply WindowInsets.systemBars (and cutout) to the chrome containers (top bar, bottom bar).
  • Apply IME handling only to the content container that should move/resize.
  • For content-driven screens:
  • Apply safeDrawing to the container hosting “foreign” content (WebView, map view) rather than expecting the embedded content to be inset-aware. The window insets patterns guide explicitly calls out padding the WebView’s container, not the page. (windowinsets.info)

Two concrete “do not do this” rules:

  • Do not put safeDrawingPadding() on a whole screen and then also use imePadding() somewhere inside unless you’ve reasoned about what safeDrawing already includes. safeDrawing includes IME. (windowinsets.info)
  • Do not let both your navigation scaffold and individual screens apply systemBarsPadding(). Pick one owner.

React Native: decide whether React Navigation or your app owns safe areas

React Native teams get hit hard because the historical Android mental model was “system bars are opaque, layout is safe by default”. Edge-to-edge makes Android behave much closer to iOS.

There are two separate concerns:

  • How the native activity/window is configured.
  • How JS layout handles safe areas and keyboard.

The React Native community discussion around Android 16 is blunt: “You can no longer opt-out of forced edge-to-edge on Android 16+”, and newer RN versions introduce edge-to-edge controls (for example an edgeToEdgeEnabled Gradle property). (github.com)

The contract to enforce in RN:

  • Choose one owner for top/bottom safe area padding:
  • either a global Safe Area provider + per-screen usage, or
  • the navigation library’s built-in inset handling
  • Avoid nesting SafeAreaViews or combining a navigation container that already insets with additional manual padding via useSafeAreaInsets() unless you can point to a specific reason.

Typical failure mode:

  • React Navigation (or a screen container) already accounts for bottom inset.
  • A screen also adds paddingBottom: insets.bottom.
  • Result: bottom tabs float too high; modals leave a dead zone.

IME is a separate trap: if you rely on old Android adjustResize assumptions, your TextInputs can end up behind the keyboard under edge-to-edge. Treat keyboard avoidance as an explicit feature you test, not a “platform default”.

Flutter: accept that edge-to-edge is the default, and stop trying to opt out

Flutter has made this migration explicit. Flutter’s breaking change documentation states that when targeting Android SDK 16 (API 36) or later, Flutter uses SystemUiMode.edgeToEdge by default on Android and there is no way to opt out. (api.flutter.dev)

Flutter also warns that using the opt-out mechanism on Android 16+ might crash, and recommends version-specific resources for migrations. (docs.flutter.dev)

Your Flutter insets contract generally becomes:

  • Use SafeArea deliberately (and avoid wrapping everything in it by habit).
  • Treat MediaQuery values as inputs to layout, not as “padding you always apply everywhere”.
  • For keyboard handling, decide whether a screen should resize, scroll, or allow overlap, and implement that per screen.

Failure mode:

  • Global SafeArea at app root + per-screen padding based on MediaQuery.padding/viewInsets.
  • Result: inconsistent spacing between screens and unpredictable behaviour on devices with gesture navigation and cutouts.

What breaks when two layers both apply insets (the regressions you’ll see in QA)

When the contract is violated, regressions are fairly consistent across stacks:

  • Double top inset: content starts too low; app bar has excess height; “blank band” under the status bar.
  • Double bottom inset: bottom navigation floats; primary CTA sits above where users expect; bottom sheets look “detached”.
  • IME + nav inset stacking: when keyboard opens, content jumps too far up (or oscillates during animation).
  • Cutout mismatches: on notched devices, one container respects the cutout and another also pads, producing asymmetric gutters.

In Compose specifically, these often come from mixing padding modifiers that consume insets with parents that also pad for the same inset type, or from using safeDrawing (which includes IME) plus additional IME padding. (developer.android.com)

How to catch it before Play review: CI screenshots plus IME tests

Teams tend to discover these issues late because manual testing rarely covers:

  • multiple navigation modes (gesture vs 3-button)
  • cutout devices
  • IME animation paths
  • OEM differences

You want tests that fail deterministically when insets are applied twice.

  1. Pick two device profiles for baseline screenshots
  • A modern gesture-navigation phone with a cutout.
  • A device/profile with 3-button navigation.

(If you only pick one, you will miss a class of bottom inset bugs.)

  1. Add screenshot tests for “inset-sensitive” screens
  • Home/feed (scroll container)
  • Any screen with a persistent bottom CTA
  • Bottom sheet / modal
  • Login / form screen

Your assertions should be visual: you are detecting padding errors, not business logic.

  1. Add an IME regression test that opens the keyboard

For each form screen you care about:

  • Focus the lowest TextInput/TextField.
  • Open IME.
  • Assert the focused field and its submit action remain visible.
  • Capture a screenshot during IME-open state.

Compose and AndroidX insets APIs support IME animations, so you can and should validate the animated end state rather than hoping it “looks ok on my phone”. (developer.android.com)

  1. Add a “double inset” sentinel assertion

This is the simplest high-signal check:

  • Define an expected minimum distance from the bottom edge for a known element (for example a bottom bar).
  • If it is too high above the bottom edge on a gesture-nav device, you are probably double-padding.

Do not make this too clever. The goal is to catch “obviously wrong” layouts early.

Where Codeversols fits (briefly, honestly)

Insets work is deceptively cross-cutting: it touches Activity/window setup, design system components, navigation scaffolds, and test automation. Codeversols typically helps teams by doing two things quickly: defining an insets contract that matches your stack mix (Views/Compose/RN/Flutter), and wiring up the automated screenshot + IME regression suite so future targetSdk bumps don’t turn into a fire drill.

Close

Android 16 (API 36) edge-to-edge enforcement isn’t a styling preference; it’s a behavioural default you inherit the moment you raise targetSdkVersion. (developer.android.com) Treat window insets as a contract with a single owner per inset type, and back it with CI tests that deliberately exercise gesture nav, cutouts, and IME. That’s how you stop “routine targetSdk upgrade” from becoming a UI regression release.

More From Our Blog

TestFlight ‘Waiting for Review’ for days? Ship anyway with a dual‑lane release pipeline (and what to automate in App Store Connect)
Mobile Engineering • Oct 15, 2026

TestFlight ‘Waiting for Review’ for days? Ship anyway with a dual‑lane release pipeline (and what to automate in App Store Connect)

When TestFlight Beta App Review and App Store Review queues stall, you don’t need to miss your launch date. This playbook describes a dual‑lane pipeline (internal TestFlight vs external TestFlight vs App Store), deterministic rollout rules, and the App Store Connect automation to keep shipping under review latency.

Read more →
Swift 6 strict concurrency default isolation footguns: how teams accidentally @MainActor their whole app (and a CI check to prove it)
iOS / Swift • Oct 14, 2026

Swift 6 strict concurrency default isolation footguns: how teams accidentally @MainActor their whole app (and a CI check to prove it)

Swift 6’s strict concurrency checks get teams over the line, but one build setting can quietly turn large parts of your app into MainActor-only code. Here’s how that happens, how it shows up as UI jank, and a simple CI audit that catches “main-actor creep” early.

Read more →
Swift 6.2 Approachable Concurrency in production: which Xcode 26 flags actually cut warning noise (without hiding races)
Mobile Engineering • Oct 13, 2026

Swift 6.2 Approachable Concurrency in production: which Xcode 26 flags actually cut warning noise (without hiding races)

Swift 6.2’s “Approachable Concurrency” switches on a small set of upcoming features that can dramatically change the shape of strict-concurrency diagnostics. This guide maps the Xcode 26 toggles to the warning classes they affect, and gives a staged rollout that keeps CI meaningful.

Read more →