Most "tablet support" I see in real Android codebases is the same anti-pattern: take the phone layout, let it stretch wider, ship it. It compiles. It doesn't crash. It also doesn't survive five minutes with a real tablet or a foldable, because the actual problem was never "not enough width" — it was "the UI never decided what to do with extra width."
The fix is smaller than people expect, and it starts with throwing out if (screenWidthDp > 600).
The problem with raw dp checks
Hardcoded breakpoints are everywhere because they're the first thing that occurs to you:
@Composable
fun SettingsScreen(screenWidthDp: Int) {
if (screenWidthDp > 600) {
SettingsTwoPane()
} else {
SettingsSinglePane()
}
}
This works, right up until it doesn't. 600 isn't a real device boundary — it's a guess. It doesn't account for height. It doesn't move when Google refines what actually counts as "enough room for two panes." And because it's just an Int, nothing stops five different screens in the same app from picking five slightly different thresholds over time.
WindowSizeClass: the boundary that isn't yours to invent
WindowSizeClass replaces the guess with a shared, device-independent classification — COMPACT, MEDIUM, EXPANDED — computed once and passed down like any other piece of app state:
@Composable
fun AppRoot() {
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
SettingsScreen(widthSizeClass = windowSizeClass.windowWidthSizeClass)
}
@Composable
fun SettingsScreen(widthSizeClass: WindowWidthSizeClass) {
when (widthSizeClass) {
WindowWidthSizeClass.COMPACT -> SettingsSinglePane()
WindowWidthSizeClass.MEDIUM -> SettingsSinglePaneWide()
WindowWidthSizeClass.EXPANDED -> SettingsTwoPane()
else -> SettingsSinglePane()
}
}
Three things about this that matter more than the syntax:
Compute it once, near the root, not per-screen. currentWindowAdaptiveInfo() is cheap, but that's not really the point — the point is that if five different composables each call it independently, you've reintroduced the "five slightly different thresholds" problem, just with an API instead of a magic number. Treat it like NavController: resolved once, handed down explicitly.
The bucket is the contract, not the pixel value. Today EXPANDED is roughly "more than 840dp." That number has already been refined once, and it'll likely be refined again as the definition of "large enough for a persistent second pane" gets tuned. Code that branches on EXPANDED keeps working through that. Code that branches on 840 doesn't.
Height has a size class too, and it's the one people forget. WindowHeightSizeClass follows the identical three-bucket shape and matters the moment anything reflows vertically — a bottom sheet, a two-row app bar, a video player with controls beneath it. A tablet in landscape split-screen can be EXPANDED width and COMPACT height at the same time, and a layout that only checks width will get that configuration wrong.
One screen, not one screen per form factor
The other habit worth breaking: writing SettingsScreenPhone, SettingsScreenTablet, SettingsScreenDesktop as three separate composables. That's the exact same drift problem sw600dp resource qualifiers had in the View system, just moved into Kotlin.
The better shape is one screen that takes its pieces as slots, and rearranges them internally:
@Composable
fun SettingsScreen(
widthSizeClass: WindowWidthSizeClass,
accountSection: @Composable () -> Unit,
preferencesSection: @Composable () -> Unit,
) {
when (widthSizeClass) {
WindowWidthSizeClass.EXPANDED -> Row(Modifier.fillMaxSize()) {
Box(Modifier.weight(1f)) { accountSection() }
Box(Modifier.weight(1f)) { preferencesSection() }
}
else -> Column(Modifier.verticalScroll(rememberScrollState())) {
accountSection()
preferencesSection()
}
}
}
accountSection and preferencesSection don't know or care which arrangement they end up in. The arrangement is the only thing that varies by size class — which is exactly the part that should vary, and the only part you now have to touch when a designer asks for a different large-screen layout.
Where this stops being enough
WindowSizeClass gets you correct phone-vs-tablet behavior, and it's genuinely most of the win for most screens. It does not, on its own, cover:
- A folding device's hinge — which isn't a width, it's a physical region with its own state (flat, half-opened) and orientation, and content sitting directly under it is either occluded or awkward to tap
- Canonical list-detail / supporting-pane layouts with correct predictive-back behavior baked in, instead of hand-rolled navigation state
- The newer Compose adaptive surface —
NavigationSuiteScaffold, environment-signal queries, CSS-Grid-style layout primitives — that's landed in just the last few months
I ended up writing all of that up properly — foldable posture handling, canonical layouts, the new adaptive APIs, and the production concerns (multi-window, predictive back, testing across size classes, and the actual Play Console checklist for the large-screen badge) — as a 12-topic playbook, since none of it fit cleanly into a single post. If you want the deeper version: Jetpack Compose Adaptive UI & Foldables Playbook.
Either way — if you take one thing from this post, let it be: the next time you're tempted to write if (width > 600), that's the signal to reach for WindowSizeClass instead. The bug you're avoiding isn't a crash, it's the slow drift of five screens each guessing a slightly different number.











