RizTech Academy logo
RizTech Academy
Layout and ThemingLesson 2 of 730 min

Edge-to-edge, and drawing under the system bars

Since module 1, MainActivity has called enableEdgeToEdge(), and module 2 promised that module 4 would explain what that obliges you to do. It means the app draws over the whole screen, under the status bar at the top and the navigation bar at the bottom, and the app, not Android, is responsible for keeping text and buttons out from under them. This lesson shows what goes wrong when you do not, adds a search field to Mandi Bhav, and deals with the one system bar that slides up from nowhere: the keyboard.

You cannot opt out any more

Your targetSdk Running on Edge-to-edge
34 or lower Any version Off unless you turn it on
35 Android 15 or later Enforced; a theme attribute, windowOptOutEdgeToEdgeEnforcement, could still opt out
36 or higher Android 16 or later Enforced; the opt-out is deprecated and ignored

Mandi Bhav targets 36. On Android 15 and later it draws edge to edge whether or not you call enableEdgeToEdge(). The call still matters for two reasons: on older versions (the app supports Android 8.0 and up) it turns the same behaviour on so that every phone looks alike, and it makes the bars transparent and picks dark or light bar icons to match the system theme. Lesson 5 meets the bug that last part causes.

Insets

An inset is the amount of the screen, on each side, that some piece of system UI covers. Compose exposes each kind as a WindowInsets value:

Inset What it covers
WindowInsets.statusBars The status bar at the top: clock, battery, notification icons
WindowInsets.navigationBars The gesture handle or the three-button bar, usually at the bottom
WindowInsets.systemBars Both of the above, plus the caption bar of a desktop-style window
WindowInsets.displayCutout The camera hole or notch
WindowInsets.ime The on-screen keyboard, when it is open
WindowInsets.safeDrawing Everything content must not be drawn under: bars, cutout and keyboard
WindowInsets.safeGestures Edges where the system's own swipe gestures win

Insets change while the app runs: the keyboard opens, the phone rotates and the cutout moves to a side, the user switches from gestures to three buttons. Compose's inset values update and recompose whatever reads them.

Scaffold's innerPadding, and the screen without it

You rarely read insets directly, because Scaffold does it for you. TopAppBar pads itself below the status bar, and the innerPadding that Scaffold passes to your content is the space taken by the app bar plus the system bars on the other sides. Module 3 said to apply it to the outermost element of the content, every time. Here is the evidence.

On the emulator, the finished Mandi Bhav screen was built once with the three inset lines at the top of the content Column's modifier chain deleted. The search field you will add below was completely hidden behind the app bar, with no way to scroll to it: it was the first item, and the first item starts at the very top of the window, under the bar. The first row of filter chips was cut in half by the bar's lower edge. At the bottom, the last visible card ran under the gesture handle. Nothing crashed, and the build succeeded.

Lint did catch it. Run it from the terminal (Android Studio can also run the same inspections from its Code menu):

./gradlew lintDebug

Your line number will differ; the message will not:

PricesScreen.kt:62: Error: Content padding parameter innerPadding is not used [UnusedMaterial3ScaffoldPaddingParameter from androidx.compose.material3]
    ) { innerPadding ->
        ~~~~~~~~~~~~

It is reported as an error, and lintDebug fails the task, but assembleDebug and the Run button do not run lint, so nothing stops you shipping it. Run lint before every release, and treat its errors as build failures.

Rotate the emulator to landscape with the finished screen and look at the left edge. On the Pixel emulator used here, the content sat about 66 dp from the left and 16 dp from the right: in landscape the camera cutout is on the left, innerPadding includes it, and the cards moved out of its way without any code of yours.

Screens without a Scaffold

A screen that does not use Scaffold, such as a full-screen confirmation, handles insets itself with one of these modifiers:

Modifier.safeDrawingPadding()                          // pad by everything unsafe
Modifier.windowInsetsPadding(WindowInsets.systemBars)  // pad by one kind
Modifier.statusBarsPadding()                           // shorthand for one side's bar

A search field, and the keyboard

Mandi Bhav now gets a search box above the cards, so a trader can type "lasal" and see only Lasalgaon. First, more prices to search through. Add these to SamplePrices before all, and add them to the list:

val vashiPotato = CommodityPrice(
    commodity = "Potato",
    market = "Vashi APMC, Navi Mumbai",
    date = day,
    minPrice = 1_600,
    maxPrice = 2_600,
    modalPrice = 2_100,
)

val vashiWheat = CommodityPrice(
    commodity = "Wheat",
    market = "Vashi APMC, Navi Mumbai",
    date = day,
    minPrice = 2_700,
    maxPrice = 3_300,
    modalPrice = 3_000,
)

val lasalgaonSoybean = CommodityPrice(
    commodity = "Soybean",
    market = "Lasalgaon APMC",
    date = day,
    minPrice = 4_200,
    maxPrice = 4_800,
    modalPrice = 4_550,
)

val puneTur = CommodityPrice(
    commodity = "Tur dal",
    market = "Pune (Gultekdi) APMC",
    date = day,
    minPrice = 6_800,
    maxPrice = 7_800,
    modalPrice = 7_300,
)

val all = listOf(
    lasalgaonOnion,
    puneOnion,
    nashikOnion,
    puneTomato,
    nashikTomato,
    vashiPotato,
    vashiWheat,
    lasalgaonSoybean,
    puneTur,
)

Nine cards are more than any phone shows at once, which is what the keyboard problem needs.

What the keyboard does to an edge-to-edge app

When the keyboard opens, it is one more inset, WindowInsets.ime, covering the bottom of the screen. An edge-to-edge window is not shrunk to make room. Without handling it, the keyboard sits over the bottom of your scrolling column: the column still thinks it reaches the bottom of the screen, so the last cards can only scroll as far as the space behind the keyboard, and a farmer who searched "pune" can never bring the last result into view while typing.

Two changes fix it. In AndroidManifest.xml, on the <activity>, add:

android:windowSoftInputMode="adjustResize"

That is what lets the app receive the keyboard's inset. Then, in the content Column, add the keyboard as padding with imePadding(). Here is the whole of PricesScreen with the search field:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PricesScreen(prices: List<CommodityPrice>, modifier: Modifier = Modifier) {
    var query by remember { mutableStateOf("") }
    val q = query.trim()
    val visible = prices.filter { price ->
        price.commodity.contains(q, ignoreCase = true) ||
            price.market.contains(q, ignoreCase = true)
    }

    Scaffold(
        modifier = modifier,
        topBar = {
            TopAppBar(title = { Text(text = stringResource(R.string.app_name)) })
        },
    ) { innerPadding ->
        Column(
            modifier = Modifier
                .padding(innerPadding)
                .consumeWindowInsets(innerPadding)
                .imePadding()
                .verticalScroll(rememberScrollState())
                .padding(horizontal = 16.dp, vertical = 8.dp),
            verticalArrangement = Arrangement.spacedBy(12.dp),
        ) {
            OutlinedTextField(
                value = query,
                onValueChange = { query = it },
                label = { Text("Search commodity or market") },
                singleLine = true,
                modifier = Modifier.fillMaxWidth(),
            )
            if (visible.isEmpty()) {
                Text(
                    text = "No prices match \"${query.trim()}\".",
                    style = MaterialTheme.typography.bodyLarge,
                    color = MaterialTheme.colorScheme.onSurfaceVariant,
                )
            }
            for (price in visible) {
                PriceCard(price = price, modifier = Modifier.fillMaxWidth())
            }
        }
    }
}

New imports: consumeWindowInsets and imePadding from androidx.compose.foundation.layout, OutlinedTextField from material3, and getValue, setValue, mutableStateOf and remember from androidx.compose.runtime. The query is state, as in module 3's text field: module 5 explains it properly, and until then it is lost when the phone rotates.

consumeWindowInsets: not counting the same bar twice

innerPadding already includes the navigation bar at the bottom. The keyboard inset is measured from the bottom of the screen, so it covers the navigation bar's area too. Add imePadding() alone and, with the keyboard open, the column is pushed up by the keyboard plus the navigation bar a second time, leaving an empty band above the keyboard.

consumeWindowInsets(innerPadding) tells everything after it in the chain: "this much of the insets is already dealt with." imePadding() then adds only the part of the keyboard that is not already covered. The order matters, as every modifier chain's does:

  1. padding(innerPadding): keep clear of the app bar and system bars.
  2. consumeWindowInsets(innerPadding): record that those are handled.
  3. imePadding(): add whatever extra the keyboard needs, and nothing when it is closed.
  4. verticalScroll(…): scroll inside what is left.
  5. padding(horizontal = 16.dp, vertical = 8.dp): margins that scroll with the cards.

Checking it on the emulator. The course emulator has a hardware keyboard enabled, so tapping the search field shows only a small floating keyboard toolbar, not a full keyboard, and the inset is too small to show anything. To see the real behaviour, edit the virtual device in Device Manager and turn off its hardware keyboard option, or run on your own phone. Then tap the field, scroll to the bottom, and check the last card ends right above the keyboard.

What edge-to-edge costs. Every screen has to handle insets, and a screen that forgets looks fine on your phone and broken on a phone with a different cutout or navigation mode. It also shows a limit of this layout: because innerPadding is applied outside the scroll, the cards stop above the gesture handle instead of scrolling behind it, which is the look edge-to-edge is for. Module 6 replaces this Column with a LazyColumn, whose contentPadding puts the inset inside the scroll and gets both right.

Check your work

Can an app targeting 36 opt out of edge-to-edge on Android 16? No. The opt-out attribute is deprecated and ignored for apps targeting 36.

Why call enableEdgeToEdge() anyway. For the same behaviour on older Android versions, and transparent bars with icon colours that follow the system theme.

What innerPadding contains. The app bar's height and the system bars (and cutout) on the other sides.

What happened without it. The search field was hidden under the app bar and could not be scrolled to, and the last card ran under the gesture handle.

Which tool caught it. Lint, as the error UnusedMaterial3ScaffoldPaddingParameter. The build and the Run button did not.

Why consumeWindowInsets(innerPadding) comes before imePadding(). So the navigation bar, already in innerPadding, is not added again inside the keyboard's padding.

Practice 3. The display cutout. In landscape it is on one side, and innerPadding includes it.

Practice 5. One answer:

@Composable
fun AlertSetScreen(onDone: () -> Unit, modifier: Modifier = Modifier) {
    Column(
        modifier = modifier
            .fillMaxSize()
            .safeDrawingPadding()
            .padding(16.dp),
        verticalArrangement = Arrangement.SpaceBetween,
    ) {
        Text("Price alert set", style = MaterialTheme.typography.headlineSmall)
        Button(onClick = onDone, modifier = Modifier.fillMaxWidth()) { Text("Done") }
    }
}

safeDrawingPadding() keeps the title below the status bar and the button above the navigation bar, and above the keyboard if one is open. SpaceBetween puts the title at the top and the button at the bottom.

Practice

  1. Add the four prices, the manifest attribute and the search field. Run it and type "lasal", then "vashi", then "xyz".
  2. Break it on purpose: delete .padding(innerPadding) and .consumeWindowInsets(innerPadding). Run it and find the search field. Then run ./gradlew lintDebug and read the error. Put the lines back.
  3. Rotate the emulator to landscape. Why is the left margin wider than the right?
  4. On a device with a full on-screen keyboard, remove only .consumeWindowInsets(innerPadding), open the keyboard and scroll to the end. Measure the gap above the keyboard by eye and say which bar it is the height of.
  5. Harder: write AlertSetScreen, a full-screen confirmation with no Scaffold: a title at the top and a full-width "Done" Button at the bottom that never sits under a system bar. Preview it with showSystemUi = true.

Official documentation

Next: ConstraintLayout, when it earns its place, and FlowRow for the commodity filter.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.

About the internship