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 assafeDrawingPadding()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
safeDrawingto 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 useimePadding()somewhere inside unless you’ve reasoned about whatsafeDrawingalready includes.safeDrawingincludes 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
SafeAreadeliberately (and avoid wrapping everything in it by habit). - Treat
MediaQueryvalues 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
SafeAreaat app root + per-screen padding based onMediaQuery.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.
- 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.)
- 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.
- 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)
- 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.



