AppMama

blog / compose · design

Edge-to-Edge on Android 15+: Insets in Compose and Views

· 9 min read · AppMama

Edge-to-edge on Android means your app draws behind the status bar and navigation bar, and you use window insets to keep important content clear of them. Since Android 15 it is no longer optional: once you target API 35, the system lays your app out edge-to-edge whether you planned for it or not, and from API 36 the opt-out is gone. This guide covers what changed, how to set it up, how to handle insets in Compose and in Views, the keyboard (IME), and the bugs almost everyone hits.

What changed in Android 15 and 16?

On Android 14 and lower, the system offset your content below the status bar and above the navigation bar unless you asked otherwise. On a device running Android 15 (API 35), an app that targets SDK 35 or higher gets these changes, per the Android 15 behavior changes:

Android 15 let you postpone this with the theme attribute android:windowOptOutEdgeToEdgeEnforcement. For apps that target Android 16 (API 36), that attribute is deprecated and disabled on Android 16 devices, per the Android 16 behavior changes. It still works on Android 15 devices, but that is a shrinking slice of users.

Because Google Play requires new apps and updates to target API 36 since 31 August 2026 (check the current target API requirements), every phone app you ship on Play now has to handle insets properly.

Treat edge-to-edge as the default layout model, not a feature. Your job is no longer to “enable” it but to decide, for each screen, which elements draw behind the bars and which get padded away from them.

How do I enable edge-to-edge on older Android versions?

Enforcement only happens on Android 15+. For a consistent look on Android 14 and below, call enableEdgeToEdge() from androidx.activity (1.8.0 and later) in every activity, before setContent or setContentView:

MainActivity.kt

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            AppTheme { AppRoot() }
        }
    }
}

By default it makes the status bar transparent, puts a translucent scrim on the 3-button navigation bar, and switches system icon colours between light and dark with the system theme. Newer versions of the docs show the equivalent WindowCompat.enableEdgeToEdge(window) from androidx.core 1.17+, which also works on a Dialog window. Either is fine; pick one and use it everywhere.

Then add adjustResize to each activity that has text input. Without it you do not receive IME insets, and keyboard handling below will not work:

AndroidManifest.xml

<activity
    android:name=".MainActivity"
    android:exported="true"
    android:windowSoftInputMode="adjustResize" />

If your app has a light header on a dark theme (or the reverse), the automatic icon colours can be wrong. Pass a style explicitly:

// Light status bar icons regardless of system theme,
// for a screen with a dark hero image at the top.
enableEdgeToEdge(
    statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT)
)

Which window insets should I use?

Insets are four distances (left, top, right, bottom) describing how far a piece of system UI reaches into your window. There are many types, but you only need a few:

ComposeViews (WindowInsetsCompat.Type)Use it for
WindowInsets.safeDrawingsystemBars() or displayCutout() or ime()The default for content that must stay visible and tappable
WindowInsets.systemBarssystemBars()Status + navigation + caption bar only
WindowInsets.displayCutoutdisplayCutout()Notches and camera holes, mainly in landscape
WindowInsets.imeime()The on-screen keyboard
WindowInsets.systemGesturessystemGestures()Keeping swipeable UI (carousels, sheets) away from back and home gestures

Use safeDrawing unless you have a reason not to. The most common landscape bug is padding for systemBars only, which leaves list text under the camera cutout on the left or right edge.

How do I handle insets in Jetpack Compose?

Material 3 components do most of the work for you. TopAppBar pads itself for the status bar, NavigationBar and BottomAppBar pad for the navigation bar, and Scaffold hands the remaining space to your content as innerPadding. The background of each bar still extends behind the system bar, which is exactly the edge-to-edge look you want.

InboxScreen.kt

Scaffold(
    topBar = { TopAppBar(title = { Text("Inbox") }) },
    bottomBar = { NavigationBar { /* items */ } },
) { innerPadding ->
    LazyColumn(
        modifier = Modifier
            .fillMaxSize()
            .consumeWindowInsets(innerPadding),
        contentPadding = innerPadding,
    ) {
        items(messages) { MessageRow(it) }
    }
}

Two details matter. Pass innerPadding as contentPadding, not as Modifier.padding, so items scroll behind the transparent bars and the last item can still scroll fully into view. And call consumeWindowInsets(innerPadding) so anything deeper in the tree that applies insets again knows that space is already handled.

Outside a Scaffold, use the inset padding modifiers. They behave like Modifier.padding but with live inset values:

@Composable
fun PhotoDetail(photo: Photo) {
    Box(Modifier.fillMaxSize()) {
        // Draws behind both system bars: no inset padding.
        AsyncImage(
            model = photo.url,
            contentDescription = null,
            modifier = Modifier.fillMaxSize(),
            contentScale = ContentScale.Crop,
        )
        // Controls stay clear of bars, cutouts and gestures.
        Row(
            Modifier
                .align(Alignment.BottomCenter)
                .safeDrawingPadding()
                .padding(16.dp)
        ) { /* buttons */ }
    }
}

The helpers are safeDrawingPadding(), systemBarsPadding(), statusBarsPadding(), navigationBarsPadding(), displayCutoutPadding() and imePadding(), plus the general windowInsetsPadding(insets). You can restrict sides with WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal).

These padding modifiers consume what they apply. A nested safeDrawingPadding() inside one that has already padded for the status bar adds nothing extra for the status bar. That is why stacking them is usually safe, and why a plain Modifier.padding(innerPadding) (which does not consume) followed by an inset modifier produces double padding.

Prefer the modifiers over reading values in composition. Compose updates inset values after composition and before layout, so code like WindowInsets.ime.getBottom(density) read during composition is one frame behind; the modifiers read during layout and stay in sync with keyboard animations. For a Spacer sized to an inset, use Modifier.windowInsetsBottomHeight(WindowInsets.systemBars).

If you are still deciding between toolkits, see Jetpack Compose vs XML views; inset handling is one of the places Compose is clearly less work.

How do I keep the keyboard from covering a text field?

With adjustResize set, the keyboard is just another inset. For a chat-style screen, pad the column that holds the list and the input row:

ChatScreen.kt

Scaffold(topBar = { TopAppBar(title = { Text("Chat") }) }) { innerPadding ->
    Column(
        Modifier
            .fillMaxSize()
            .padding(innerPadding)
            .consumeWindowInsets(innerPadding)
            .imePadding()
    ) {
        LazyColumn(Modifier.weight(1f)) { /* messages */ }
        MessageInput()
    }
}

The order is what makes this work. consumeWindowInsets(innerPadding) tells imePadding() that the bottom navigation-bar space has already been applied, so when the keyboard opens the input row rises by exactly the keyboard height minus what is already padded, instead of floating a navigation-bar-height above it. The IME inset animates frame by frame, so the input row tracks the keyboard smoothly, back to API 21.

In a LazyColumn with text fields at the bottom, the docs recommend a trailing Spacer with windowInsetsBottomHeight instead of contentPadding for the bottom bar, because contentPadding does not participate in consumption and the IME can end up hiding the last field.

How do I handle insets with Views and XML layouts?

In the View system you apply insets yourself with ViewCompat.setOnApplyWindowInsetsListener. The Empty Views Activity template already adds one on the root view; the common mistake is leaving it on the root, which pads everything and puts solid bars back. Move it to the views that actually need it:

InboxFragment.kt

// Root of the layout, call once before any listener consumes insets.
ViewGroupCompat.installCompatInsetsDispatch(binding.root)

val initialBottom = binding.recyclerView.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
    val bars = insets.getInsets(
        WindowInsetsCompat.Type.systemBars() or
            WindowInsetsCompat.Type.displayCutout()
    )
    v.updatePadding(
        left = bars.left,
        right = bars.right,
        bottom = initialBottom + bars.bottom,
    )
    insets
}

fragment_inbox.xml

<androidx.recyclerview.widget.RecyclerView
    android:id="@+id/recyclerView"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:clipToPadding="false" />

Notes on this snippet:

Material Components for Views handle insets in BottomNavigationView, BottomAppBar, NavigationRailView and NavigationView. AppBarLayout does not: add android:fitsSystemWindows="true" to it.

For the keyboard in Views, read WindowInsetsCompat.Type.ime() in the same listener and pad the bottom by whichever is larger, the IME or the navigation bar:

ViewCompat.setOnApplyWindowInsetsListener(binding.inputBar) { v, insets ->
    val ime = insets.getInsets(WindowInsetsCompat.Type.ime())
    val nav = insets.getInsets(WindowInsetsCompat.Type.navigationBars())
    v.updatePadding(bottom = maxOf(ime.bottom, nav.bottom))
    insets
}

That jumps when the keyboard opens. For a frame-by-frame animation, add a WindowInsetsAnimationCompat.Callback with ViewCompat.setWindowInsetsAnimationCallback and translate the view in onProgress.

Common edge-to-edge bugs and fixes

SymptomCauseFix
Top bar title under the clockMaterial 2 or custom top bar, no status bar insetMaterial 3 TopAppBar, or statusBarsPadding() on the custom bar
Gap twice the bar heightInsets applied twice: Modifier.padding(innerPadding) plus an inset modifier, or a nested ScaffoldconsumeWindowInsets(innerPadding); inner Scaffold(contentWindowInsets = WindowInsets(0, 0, 0, 0))
Keyboard covers the inputNo adjustResize, or no imePadding()Add both; drop adjustPan
Last list item stuck under nav barPadding the list with Modifier.padding, or no clipToPadding="false"Use contentPadding / list padding with clipToPadding="false"
FAB under 3-button nav barCustom FAB placement outside ScaffoldUse Scaffold(floatingActionButton = …) or safeDrawingPadding()
Grey band behind 3-button navNavigation bar contrast enforcementwindow.isNavigationBarContrastEnforced = false if your own bottom bar already provides contrast
Status bar colour ignoredsetStatusBarColor is a no-op on Android 15+Draw a background behind the status bar, e.g. a Box with windowInsetsTopHeight(WindowInsets.statusBars)
Text under the notch in landscapeUsing systemBars instead of safeDrawingInclude displayCutout horizontally
Crash on launch after targeting 35Old core-splashscreen and cutout modeUpdate androidx.core:core-splashscreen to 1.2.0-alpha01 or newer
Full-screen dialog has barsDialog windows are separate windowsCall WindowCompat.enableEdgeToEdge on the dialog window in onStart

Pay special attention to screens people rarely open: onboarding, sign-in, settings and error states. They are where occluded buttons ship unnoticed. Test each screen on both gesture navigation and 3-button navigation, in portrait and landscape, with the keyboard open.

Should I still use windowOptOutEdgeToEdgeEnforcement?

Only as a short-term patch on an app that targets exactly API 35. It looks like this:

res/values-v35/themes.xml

<style name="Theme.MyApp" parent="Theme.Material3.DayNight.NoActionBar">
    <item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>
</style>

Once you raise targetSdk to 36, which Play now requires, it is ignored on Android 16+ devices. Spending time on it is wasted; spend it on insets instead. If your app is a big legacy XML codebase, start by fixing the top bar, the bottom navigation and every scrolling list, which covers most screens, then sweep the rest.

Rule of thumb: backgrounds and scrolling content go behind the bars; text, buttons and anything tappable get padded with safeDrawing.

Colours behind the bars come from your theme, so it is worth getting Material 3 theming right at the same time, especially surface and surfaceContainer, which is what users see under a transparent status bar. And if raising targetSdk or compileSdk breaks your build, see common Android Gradle build errors.

Frequently asked questions

Can I disable edge-to-edge on Android 16?

Not if your app targets API 36. The windowOptOutEdgeToEdgeEnforcement attribute is ignored on Android 16 devices for apps targeting 36, and it only ever worked for apps targeting 35. Since Google Play requires API 36 for new apps and updates, plan to handle insets instead.

Do I still need to call enableEdgeToEdge if I target API 35 or higher?

Yes. Enforcement only applies on devices running Android 15 or later. Calling enableEdgeToEdge gives the same transparent bars and icon colour handling on Android 14 and lower, so your layout looks and behaves the same on every version.

What is the difference between safeDrawing and systemBars insets?

systemBars covers the status, navigation and caption bars only. safeDrawing adds the display cutout and the keyboard on top of that, so it is the safer default for anything that must stay visible. Use systemBars only when you deliberately want to ignore cutouts and the IME.

Why does my Compose screen have double padding at the top or bottom?

Insets are being applied twice, usually by Modifier.padding(innerPadding) plus an inset modifier further down, or by a Scaffold nested inside another Scaffold. Add consumeWindowInsets(innerPadding) after the padding, or set contentWindowInsets to WindowInsets(0, 0, 0, 0) on the inner Scaffold.

Why is there a grey bar behind the navigation buttons?

With 3-button navigation, Android adds a translucent scrim for contrast by default. If your bottom bar already provides enough contrast, set window.isNavigationBarContrastEnforced to false to make the bar fully transparent. Gesture navigation is always transparent.