Android 15 edge-to-edge: toolbar and buttons drawn under the system bars
Bumping targetSdk to 35 put our toolbar under the status bar and the Pay button under the navigation bar on Android 15 devices. Edge-to-edge is now enforced, so we handled window insets properly in both Views and Compose screens.
Our parking payments app had to move to targetSdkVersion 35 before Google Play's deadline later in
the year, so we did it early, in a quiet sprint. The change was one line in build.gradle.kts. The
first internal test build on a Pixel 8 running Android 15 looked broken:
- The toolbar title sat underneath the status bar clock and battery icons.
- The "Pay now" button at the bottom of the checkout screen was half covered by the navigation bar on three-button navigation, and fully tappable only by accident on gesture navigation.
- In the zone map screen, written in Jetpack Compose, the last list item was hidden under the navigation bar and could not be scrolled into view.
- The status bar had become transparent and white icons on our light toolbar were unreadable.
There was no crash or log message. On an Android 14 device, the same build looked exactly like before.
Reproducing it
- Set
targetSdk = 35and install on any device or emulator running Android 15 (API 35). - Open any screen that does not already handle window insets.
- Compare with the same APK on Android 14: only Android 15 shows the overlap.
Switching the emulator between gesture navigation and three-button navigation changed how much of the bottom button was covered, which told us the overlap came from system bar insets, not from our layouts changing.
What we ruled out
- A theme regression. We had not touched
themes.xml, and reverting the Material Components update in the same branch did not change anything. - A device-specific bug. The same overlap appeared on a Samsung Galaxy S24 with Android 15 and on the API 35 emulator image.
- Our own
enableEdgeToEdge()call. We had never called it. That was the point: the system now did it for us.
Root cause
Android 15 enforces edge-to-edge for apps that target SDK 35. The app window extends behind the status
bar and the navigation bar, regardless of whether the app opts in, and Window.setStatusBarColor and
setNavigationBarColor no longer have any effect. Our layouts assumed the system would reserve
space for the bars, which had been the default since the first Android release. None of our screens
applied window insets, so content was drawn under the bars.
What is happening under the hood
The system bars are not part of your layout. When an app draws edge-to-edge, the system reports how
much of each edge is covered through WindowInsets. Before Android 15, apps were "fit" to the
space between the bars by default and only had to deal with insets if they opted into edge-to-edge.
With target SDK 35 on an Android 15 device, the default flips:
| Behaviour | targetSdk 34 on Android 15 | targetSdk 35 on Android 15 |
|---|---|---|
| Window draws behind status bar | no | yes |
| Window draws behind navigation bar | no | yes |
setStatusBarColor() |
works | no effect |
| Gesture navigation bar | opaque or app-coloured | transparent |
| Three-button navigation bar | opaque | translucent by default |
There are several inset types, and picking the right one matters:
systemBars()combines status, navigation and caption bars. Use it for padding content.displayCutout()covers notches and camera holes, relevant in landscape.ime()is the on-screen keyboard. Our "Pay now" button also needs to move above it.systemGestures()marks edges reserved for back and home gestures; avoid placing sliders there.
There was a temporary escape hatch, android:windowOptOutEdgeToEdgeEnforcement, a theme attribute
that restores the old behaviour on Android 15. Android 16's behaviour changes document it as
deprecated and disabled for apps that target API 36, so it only postpones the work by a year.
Environment
| Component | Version / value |
|---|---|
| Build | Android Gradle Plugin 8.8.0, Kotlin 2.1.10 |
| SDK | compileSdk = 35, targetSdk = 35, minSdk = 24 |
| AndroidX | activity-ktx 1.10.0, core-ktx 1.15.0, appcompat 1.7.0 |
| UI | Views with Material Components 1.12.0, plus Compose BOM 2025.02.00 for two screens |
| Devices | Pixel 8 (Android 15), Galaxy S24 (Android 15), API 35 emulator |
Minimal reproduction
<!-- activity_checkout.xml (excerpt): nothing here knows about insets -->
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" />
</com.google.android.material.appbar.AppBarLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/payButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:layout_margin="16dp" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
Who is affected
Every app that sets targetSdk to 35 and runs on Android 15 or later. Apps that target 34 or lower
are unaffected on Android 15, which is why many teams only see this when they bump the target SDK
for Play's annual requirement. Apps already using enableEdgeToEdge() with proper insets handling
see no change.
The fix
1. Opt in explicitly in every activity, so behaviour is the same on Android 14 and lower as on
15. enableEdgeToEdge() from androidx.activity also picks status bar icon colours that contrast
with light or dark themes:
class CheckoutActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_checkout)
applyInsets()
}
}
2. Views: let the app bar take the top inset, and pad the bottom button. Material's
AppBarLayout understands fitsSystemWindows and extends its background under the status bar while
keeping the toolbar below it:
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
+ android:fitsSystemWindows="true">
For the bottom button we combined navigation bar and keyboard insets, keeping the 16dp design margin:
private fun CheckoutActivity.applyInsets() {
val button = findViewById<MaterialButton>(R.id.payButton)
val baseMargin = resources.getDimensionPixelSize(R.dimen.space_16)
ViewCompat.setOnApplyWindowInsetsListener(button) { view, insets ->
val bars = insets.getInsets(
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()
)
val ime = insets.getInsets(WindowInsetsCompat.Type.ime())
view.updateLayoutParams<ViewGroup.MarginLayoutParams> {
leftMargin = baseMargin + bars.left
rightMargin = baseMargin + bars.right
bottomMargin = baseMargin + maxOf(bars.bottom, ime.bottom)
}
insets
}
}
We return insets instead of WindowInsetsCompat.CONSUMED because sibling views also need them.
For scrolling lists we padded the RecyclerView and set android:clipToPadding="false", so content
can scroll behind the transparent navigation bar but the last item still ends above it.
3. Compose: use the Scaffold's padding and inset-aware modifiers. The zone map list ignored the
innerPadding that Material 3's Scaffold provides:
@Composable
fun ZoneListScreen(zones: List<Zone>) {
Scaffold(
topBar = { TopAppBar(title = { Text(stringResource(R.string.zones)) }) },
) { innerPadding ->
LazyColumn(
contentPadding = innerPadding, // includes status and navigation bar insets
modifier = Modifier.fillMaxSize(),
) {
items(zones, key = { it.id }) { ZoneRow(it) }
}
}
}
On the one custom screen without a Scaffold we used Modifier.safeDrawingPadding() on the root,
which combines system bars, display cutout and keyboard.
Alternatives and trade-offs
windowOptOutEdgeToEdgeEnforcementin the theme. Gets a release out quickly, but it is deprecated and stops working when you target API 36. We used it for zero builds.fitsSystemWindows="true"on the root of every layout. Quick, but it pads the whole screen, so backgrounds stop at the bars and you lose the edge-to-edge look. It also behaves differently depending on the parent view type. We used it only onAppBarLayout.- Hard-coded 24dp or 48dp paddings. Bar heights vary by device, navigation mode and orientation. Never guess; read the insets.
How to test the fix
We tested a matrix rather than one device:
# Toggle three-button and gesture navigation on an API 35 emulator
adb shell cmd overlay enable com.android.internal.systemui.navbar.threebutton
adb shell cmd overlay enable com.android.internal.systemui.navbar.gestural
# Landscape with the display cutout on the left
adb shell settings put system user_rotation 1
Plus an Espresso check that the pay button is fully above the navigation bar:
@Test
fun payButtonIsAboveNavigationBar() {
ActivityScenario.launch(CheckoutActivity::class.java).onActivity { activity ->
val button = activity.findViewById<View>(R.id.payButton)
val insets = ViewCompat.getRootWindowInsets(button)!!
.getInsets(WindowInsetsCompat.Type.navigationBars())
val location = IntArray(2).also { button.getLocationOnScreen(it) }
val screenHeight = activity.window.decorView.height
assertTrue(location[1] + button.height <= screenHeight - insets.bottom)
}
}
Confirming in production
- We rolled out to 10 percent through a staged release. Play Console showed no rise in ANRs or crashes, and the checkout conversion rate on Android 15 devices stayed at 71 percent, matching Android 14.
- Support tickets mentioning "can't tap pay" or "button hidden", which we had seen sporadically on some foldables even before, dropped to zero over the following month.
- Screenshots from Firebase Test Lab on 12 device models, including a Galaxy Z Fold, showed no overlap in portrait or landscape.
Regression guard
- Screenshot tests with Paparazzi for every screen in light and dark themes, with a frame that simulates status and navigation bars.
- A lint check in our build fails if a theme contains
windowOptOutEdgeToEdgeEnforcement. - Code review checklist: every new screen must state how it handles top, bottom and keyboard insets.
Security implications
None beyond the bug itself. One user-facing risk is worth noting: a payment confirmation button
hidden under system UI can lead to mis-taps on an adjacent element or the home gesture, which matters
for screens that confirm money transfers. Edge-to-edge does not change what the app exposes: the
status bar can draw over your content but cannot read it, and our existing FLAG_SECURE on the
card entry screen still blocks screenshots and recording.
Prevention checklist
- Call
enableEdgeToEdge()in every activity, even before targeting SDK 35. - Handle top, bottom, cutout and keyboard insets explicitly on every screen.
- Use
clipToPadding="false"orcontentPaddingso lists can scroll behind bars and still end above them. - Test with both gesture and three-button navigation, and in landscape.
- Do not ship with
windowOptOutEdgeToEdgeEnforcement; it is a temporary attribute.
References
- Android Developers: Behavior changes for apps targeting Android 15 — https://developer.android.com/about/versions/15/behavior-changes-15
- Android Developers: Display content edge-to-edge in views — https://developer.android.com/develop/ui/views/layout/edge-to-edge
- Android Developers: Window insets in Compose — https://developer.android.com/develop/ui/compose/system/insets
- Android Developers: Behavior changes for Android 16 (edge-to-edge opt-out) — https://developer.android.com/about/versions/16/behavior-changes-16
Add a label
Embed this post
<iframe src="https://hibug.me/android-15-edge-to-edge-ui-under-system-bars" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.