RizTech Academy logo
RizTech Academy
Layout and ThemingLesson 6 of 735 min

Adapting to phones, foldables and tablets

Mandi Bhav has been designed on one phone held upright. Rotate that phone, open the app on a foldable, put it in split screen on a tablet, or run it on a Chromebook, and you get one column of cards stretched across a wide window: long empty stretches between "Min" and "Max", and only two cards visible where five would fit. This lesson teaches the app to ask how much room it has and use it, with the API Google currently recommends, and verifies it on the emulator at three sizes.

You can no longer lock the orientation

The old escape was android:screenOrientation="portrait" in the manifest. For apps targeting Android 16 (API 36), as Mandi Bhav does, Android ignores orientation, resizability and aspect-ratio restrictions on displays at least 600 dp wide in their smallest dimension: tablets and unfolded foldables. The app fills the window whatever the manifest says, and Google has said the temporary opt-out goes away for apps targeting API 37. So the app has to work at every size, and it might as well look good at them.

Window size classes

A window size class sorts the space your app has into a few buckets, separately for width and height. These are the breakpoints from the Android documentation:

Width class Width Typical case
Compact under 600 dp A phone in portrait
Medium 600 dp to under 840 dp A tablet in portrait, an unfolded foldable in portrait
Expanded 840 dp to under 1200 dp A tablet in landscape, an unfolded foldable in landscape
Large 1200 dp to under 1600 dp A large tablet
Extra-large 1600 dp and over A desktop display
Height class Height Typical case
Compact under 480 dp A phone in landscape
Medium 480 dp to under 900 dp A phone in portrait, a tablet in landscape
Expanded 900 dp and over A tablet in portrait

It measures the window, not the device. A tablet in split screen can give your app a compact window, and an unfolded phone can give it a medium one. Code that asks "is this a tablet?" gets split screen, desktop windows and foldables wrong. Ask how wide the window is instead.

Adding the library

Window size classes for Compose come from Material 3's adaptive library, androidx.compose.material3.adaptive:adaptive. The Compose BOM in this project manages its version (1.3.0 for BOM 2026.09.00), so the catalog entry has no version. In gradle/libs.versions.toml, under [libraries]:

androidx-compose-material3-adaptive = { group = "androidx.compose.material3.adaptive", name = "adaptive" }

and in app/build.gradle.kts:

implementation(libs.androidx.compose.material3.adaptive)

It brings the WindowSizeClass type with it, from androidx.window.core.layout.

The documentation is a version behind

The Android guide to window size classes, read on 8 November 2026, shows this:

val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass

It compiles, and the compiler warns:

w: PricesScreen.kt:45:40 'fun currentWindowAdaptiveInfo(supportLargeAndXLargeWidth: Boolean = ...): WindowAdaptiveInfo' is deprecated. Please use V2 version of this function to support L and XL width size classes.

In adaptive 1.3 the recommended call is currentWindowAdaptiveInfoV2(), which also reports the large and extra-large width classes. Two lessons in that. The documentation can lag the library: when a warning disagrees with a guide, the warning is about the code you are actually compiling. And read your build's warnings: this one was invisible in a build run with ./gradlew assembleDebug -q, because -q hides warnings as well as noise. It only appeared in a normal build.

Two columns when there is room

PricesScreen takes the size class as a parameter with a default, so the decision is made in one place and a preview or a test can supply any size it likes:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PricesScreen(
    prices: List<CommodityPrice>,
    modifier: Modifier = Modifier,
    windowSizeClass: WindowSizeClass = currentWindowAdaptiveInfoV2().windowSizeClass,
) {
    // Module 5 moves this state into a ViewModel; until then it is lost on rotation.
    var query by remember { mutableStateOf("") }
    var selectedCommodity by remember { mutableStateOf<String?>(null) }

    val commodities = commoditiesIn(prices)
    val visible = filterPrices(prices, query, selectedCommodity)
    val columns = if (
        windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND)
    ) 2 else 1

The Scaffold, search field and chips stay as they were. Replace the loop that emitted one PriceCard per price with rows of columns cards:

for (rowOfPrices in visible.chunked(columns)) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .height(IntrinsicSize.Min),
        horizontalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        for (price in rowOfPrices) {
            PriceCard(
                price = price,
                modifier = Modifier
                    .weight(1f)
                    .fillMaxHeight(),
            )
        }
        // An odd last row keeps its card at column width.
        repeat(columns - rowOfPrices.size) {
            Spacer(modifier = Modifier.weight(1f))
        }
    }
}

New imports: currentWindowAdaptiveInfoV2 from androidx.compose.material3.adaptive, WindowSizeClass from androidx.window.core.layout, and IntrinsicSize, Row, Spacer, fillMaxHeight and height from androidx.compose.foundation.layout.

Line by line:

  • isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND) is true from 600 dp up. Compare against the named constant, never against a number of your own, so the app follows the platform if the breakpoints are ever revised.
  • chunked(columns) turns nine prices into nine lists of one, or five lists of two with one left over. One column is the same code path as two, which keeps the phone layout from rotting.
  • weight(1f) on each card shares the row's width equally (lesson 1).
  • height(IntrinsicSize.Min) with fillMaxHeight() makes the two cards in a row the same height. Without it each card is as tall as its own content, and a market name that wraps onto two lines makes one card taller than its neighbour. IntrinsicSize.Min asks the children how tall they need to be before measuring them, sets the row to the tallest answer, and fillMaxHeight() stretches the other card to match. What it costs: an extra query pass over the children, nothing for two cards, and a reason not to use intrinsics across a whole long list.
  • The Spacer with weight(1f) fills the empty half of an odd last row, so the ninth card is a column wide, not stretched across the screen.

Two previews make both layouts visible in Android Studio. The wide one passes its size class explicitly, so it does not depend on how the preview tool reports its window:

@Preview(name = "Unfolded, 700 dp", showBackground = true, widthDp = 700, heightDp = 840)
@Composable
private fun PricesScreenWidePreview() {
    MandiBhavTheme {
        // Passed explicitly, so the preview does not depend on how the
        // preview tool reports its window size.
        PricesScreen(
            prices = SamplePrices.all,
            windowSizeClass = WindowSizeClass(minWidthDp = 700, minHeightDp = 840),
        )
    }
}

Why not a grid component? LazyVerticalGrid exists and is what a long list of prices will use. It is lazy, like LazyColumn, and module 6 introduces both together. For nine cards inside a scrolling Column, chunked and a Row are enough, and they use nothing new.

Checking it on the emulator

The emulator in this course is a 1080 × 2400 phone at 420 dpi: about 411 dp wide, compact.

Rotate it with the rotate buttons in the emulator's toolbar. In landscape the window is about 914 dp wide, expanded width, and the app showed two columns of cards, with the search field and chips across the full width above them, and the content kept clear of the camera cutout on the left. Rotating also recreates the activity, so a search you had typed is gone; module 5 fixes that.

Resize it to the shape of an unfolded foldable from a terminal:

adb shell wm size 1840x2208
Physical size: 1080x2400
Override size: 1840x2208

At 420 dpi that is about 701 dp wide, medium width. Launched like that, Mandi Bhav showed the chips on one line and the cards in two equal columns, every pair the same height, with Tur dal alone on the last row at column width and empty space beside it. Put the emulator back afterwards:

adb shell wm size reset

The landscape phone deserves a second look. It is expanded width but compact height: about 411 dp tall, of which the status bar, app bar, search field and chips take most. Two columns showed barely one row of cards. Mandi Bhav decides on width alone, as most apps should, but a screen with a tall header is where the height class earns a check too.

Check your work

Why screenOrientation="portrait" is no longer a fix. For apps targeting API 36, Android ignores it on displays at least 600 dp in their smallest width.

What a window size class measures. The app's window, not the device's screen.

The width at which Mandi Bhav switches to two columns. 600 dp, the start of the medium class.

Why currentWindowAdaptiveInfoV2() and not the function in the guide. The original is deprecated in adaptive 1.3; the V2 version also reports the large and extra-large classes.

What IntrinsicSize.Min with fillMaxHeight() does here. Makes both cards in a row as tall as the taller one.

Why the size class is a parameter. So previews and tests can supply any size, and the decision is made in one place.

Practice 5. One answer:

fun columnsFor(windowSizeClass: WindowSizeClass): Int = when {
    windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND) -> 3
    windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) -> 2
    else -> 1
}

Check the larger breakpoint first, or every expanded window matches the medium branch. A unit test can now cover it with WindowSizeClass(minWidthDp = 914, minHeightDp = 411) and get 3, and with 411 dp wide get 1; the constants are 600 and 840. Whether three columns of cards are better in landscape is a design call: on a landscape phone each card is about 270 dp wide, enough for the content, but even fewer rows show.

Practice

  1. Add the dependency, the parameter, the columns calculation and the rows. Run it on the phone, then rotate it.
  2. Remove .height(IntrinsicSize.Min) and .fillMaxHeight(). Find a pair of cards whose heights now differ (change a market name to something long in SamplePrices if none do), then put them back.
  3. Resize the emulator with adb shell wm size 1840x2208, launch the app, look, and adb shell wm size reset.
  4. Put the app in split screen with another app on the rotated emulator and drag the divider. Watch the layout switch between one and two columns as the window crosses 600 dp.
  5. Harder: move the decision into columnsFor(windowSizeClass: WindowSizeClass): Int that returns 3 from the expanded breakpoint, 2 from medium and 1 otherwise. Write a unit test for it, then decide whether Mandi Bhav should ship three columns.
  6. Break it on purpose: change currentWindowAdaptiveInfoV2() to currentWindowAdaptiveInfo(), build without -q, and find the warning. Then build with -q and confirm it is gone. Put V2 back.

Official documentation

Next: accessibility, so the same screen works with TalkBack, large touch targets and large fonts.

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