Screen size is the wrong thing to branch on. Window size is the right one.

Window size classes replace device-type checks with a measure of the window your app actually occupies. Three buckets per axis, recomputed live, and the reason isTablet() has been wrong for years.

6 min read
androidcomposekotlinadaptive

Day 29 — Screen size is the wrong thing to branch on. Window size is

Day 29 of 100, and the start of the adaptive pillar. Every app eventually grows a two-pane layout, and almost every one starts with the same wrong check.

The symptom

The check everyone writes first:

val isTablet = LocalConfiguration.current.smallestScreenWidthDp >= 600
if (isTablet) TwoPaneLayout() else SinglePaneLayout()

It works on a phone and a tablet, and it is wrong in four situations that are now completely ordinary:

  • Split screen. A tablet running your app in a 40% pane reports tablet-sized smallest width and renders a two-pane layout into a narrow column.
  • Foldables. Folded is a phone, unfolded is a tablet, and the transition happens while the app is running.
  • Desktop windowing on ChromeOS and Android 16 — the user drags a window handle and your layout doesn't move.
  • Landscape phones. Wide enough for two panes; smallestScreenWidthDp says no, because it reports the smallest of the two dimensions.

smallestScreenWidthDp describes the device. Your layout needs to know about the window, and since Android 7 those have not been the same thing.

Why the obvious fix fails

The obvious fix is to use the current width instead:

val width = LocalConfiguration.current.screenWidthDp
if (width >= 600) TwoPaneLayout() else SinglePaneLayout()

Better — it tracks the window. It also spreads a magic number through the codebase, and every screen picks a slightly different one. Someone uses 600, someone 640, someone 720, and the app changes layout at three different widths as you drag a window.

LocalConfiguration is also the wrong source in a multi-window world; it has historically lagged behind the actual window bounds in some configurations, which produces a layout that's correct one frame late.

Three width buckets and three height buckets, computed from the window your app occupies

The actual mechanism

Window size classes bucket the window into three named ranges per axis:

val sizeClass = currentWindowAdaptiveInfo().windowSizeClass

when (sizeClass.windowWidthSizeClass) {
    WindowWidthSizeClass.COMPACT  -> SinglePane()    // < 600dp — phone portrait
    WindowWidthSizeClass.MEDIUM   -> ListDetail()    // 600-840 — tablet portrait, unfolded
    WindowWidthSizeClass.EXPANDED -> ThreePane()     // >= 840 — tablet landscape, desktop
}

Height has the same three buckets — compact below 480dp, medium to 900, expanded above — and it matters more than people expect: a phone in landscape is EXPANDED wide and COMPACT tall, which is exactly when a vertically-stacked layout stops working.

Three things make this different from a width check:

It measures the window, not the display. Split screen, freeform and folded states all report what your app actually has.

It recomputes automatically. currentWindowAdaptiveInfo() reads Compose state, so unfolding a device or dragging a window recomposes the layout. No configuration-change handling, no listeners.

The breakpoints are shared. 600 and 840 are the same numbers Material uses, the same ones the design team's mockups are drawn at, and the same ones every other app uses. The value of a standard here is mostly that it's standard.

It comes from androidx.compose.material3.adaptive:adaptive, and the older calculateWindowSizeClass(activity) from the material3-window-size-class artifact is the previous spelling of the same idea — currentWindowAdaptiveInfo() is the current one and doesn't need an Activity.

Posture, in the same call

currentWindowAdaptiveInfo() also carries fold state, which is the other half of adaptive on real hardware:

val info = currentWindowAdaptiveInfo()
val folds = info.windowPosture.hingeList

if (info.windowPosture.isTabletop) {
    // Half-opened, hinge horizontal — video on top, controls below
    TabletopLayout(hinge = folds.firstOrNull()?.bounds)
}

The hinge bounds matter because a fold is a physical seam. A two-pane layout on an unfolded device should put the divider at the hinge rather than a few dp off it, and that's Day 33.

Where the check belongs

The mistake that survives the migration from isTablet() is scattering size checks through the tree:

// Every component asking independently
@Composable fun Header() {
    if (currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass == COMPACT) …
}

Read it once, near the top, and pass down what was decided:

@Composable
fun App() {
    val widthClass = currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass
    val layout = remember(widthClass) {
        when (widthClass) {
            COMPACT -> LayoutMode.Single
            MEDIUM  -> LayoutMode.ListDetail
            else    -> LayoutMode.ThreePane
        }
    }
    AppScaffold(layout = layout)
}

That's Day 11 again: the read site is the subscription site, so a size read scattered into forty components subscribes forty scopes to something that changes during a window drag. It also keeps components previewable — they take a LayoutMode, not an environment.

What it is not for

Size classes answer "how much room is there", not "what kind of thing is this". Two cases they don't cover:

Individual component sizing. A card that should be 2 columns on a wide screen is a BoxWithConstraints or an Adaptive grid question — the component's own slot, not the window.

Input capability. A large window doesn't mean a mouse. Touch-target sizing should follow LocalConfiguration's touchscreen and keyboard information, not width.

Content width. A 1400dp window does not mean a 1400dp-wide paragraph. Size classes choose the structure; a widthIn(max = 640.dp) on the text column keeps line length readable inside it. Those are two separate decisions and conflating them produces the "technically responsive, actually unreadable" desktop layout.

Three buckets, not a spectrum

It's tempting to treat the classes as a starting point and add your own intermediate breakpoints. Resist it for as long as you can.

Three buckets per axis means at most three layouts to design, build, test and screenshot. Adding a fourth at 720dp doubles the QA surface for a difference most users will never see, and it breaks the property that made the standard useful — that your app changes shape where every other app does, and where the design system's own components already change shape.

When a single screen genuinely needs finer control, that's usually a component-level question rather than a window-level one, and BoxWithConstraints answers it locally without adding a global breakpoint.

How to prove it

Previews take a size class directly, so all three layouts are checkable without a device:

@Preview(widthDp = 400, heightDp = 800, name = "compact")
@Preview(widthDp = 700, heightDp = 900, name = "medium")
@Preview(widthDp = 1000, heightDp = 800, name = "expanded")
@Composable fun AppAcrossSizes() = App()

On device, the real test is not rotation — it's split screen and resize. Open your app, split it with another, then drag the divider slowly. The layout should change at 600 and 840 and never flicker in between. Anything reading smallestScreenWidthDp won't move at all, which makes the bug obvious in about five seconds.

What this generalizes to

The principle is measure the container you're given, not the environment you're in. Device-type detection is a proxy for the thing you actually care about, and proxies break when the platform changes underneath them — which the Android form factor has done repeatedly.

The web went through this exactly: user-agent sniffing gave way to media queries, then to container queries, each step moving the question closer to "how much room does this element have". Anything that branches on device identity is a bug waiting for a form factor.

Tomorrow, Day 30: the canonical layouts — the three patterns those size classes are meant to switch between, and why inventing a fourth is usually a mistake.


Day 29 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Window size classes.