There are three adaptive layouts, and you probably don't need a fourth

Material's canonical layouts give three tested patterns for adapting across window sizes. Knowing which one a screen is saves designing an adaptive layout from scratch, and the library implements two of them directly.

6 min read
androidcomposekotlinadaptive

Day 30 — There are three adaptive layouts, and you probably don't nee

Day 30 of 100. Yesterday gave you a way to detect the window. Today: what to actually do with the extra space, and why the answer is a menu of three rather than a blank canvas.

The symptom

The first adaptive layout most teams build:

when (widthClass) {
    COMPACT  -> Column { Header(); List() }
    EXPANDED -> Row { List(Modifier.weight(1f)); Detail(Modifier.weight(2f)) }
    MEDIUM   -> /* ...what goes here? */
}

MEDIUM is where it stalls. Half a two-pane? A narrower list? The same as compact? Each screen answers differently, so the app changes shape inconsistently — one screen splits at 600dp, another at 840, a third never does.

Then the back button question arrives: on a phone, detail is a separate destination and back returns to the list. On a tablet both are visible, so what does back do? Every screen invents its own answer.

Why the obvious approach fails

The obvious approach is to design each screen's adaptive behaviour on its own merits. It's the natural instinct, and it produces an app with no internal consistency, three different breakpoints, and a navigation model that varies by screen.

It's also a lot of work being repeated. The list-detail back-behaviour problem has one correct answer, and every team that solves it from scratch spends a week arriving at it.

Three canonical layouts and how each adapts from compact to expanded

The actual mechanism

Material defines three canonical layouts. Nearly every screen is one of them.

List-detail. A collection and one item's details. Mail, messages, contacts, settings, file browsers.

  • Compact: list fills the window; selecting pushes detail as a destination.
  • Medium/Expanded: list and detail side by side, detail wider.

Supporting pane. One primary thing plus secondary content that supports it. A video with its comments, a document with its outline, a map with a trip list.

  • Compact: primary fills the window; supporting content is a bottom sheet or a tab.
  • Expanded: supporting pane docked beside the primary, primary wider.

Feed. A collection of equally-weighted items with no detail pane. Home screens, dashboards, photo grids, news.

  • Compact: one or two columns.
  • Expanded: more columns, via GridCells.Adaptive — which is Day 24, and needs no size-class branch at all.

That last point is worth pausing on: a feed doesn't need a size class. If your screen is a feed, Adaptive(minSize) handles every window width already, and reaching for windowWidthSizeClass is a sign you've mistaken which layout you're in.

The library implements two of them

You don't build list-detail or supporting-pane by hand:

val navigator = rememberListDetailPaneScaffoldNavigator<ItemId>()

ListDetailPaneScaffold(
    directive = navigator.scaffoldDirective,
    value = navigator.scaffoldValue,
    listPane = {
        AnimatedPane {
            ItemList(onSelect = { id ->
                navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, id)
            })
        }
    },
    detailPane = {
        AnimatedPane {
            navigator.currentDestination?.contentKey?.let { ItemDetail(it) }
        }
    },
)

The scaffold decides how many panes fit, and the navigator handles the part that's genuinely fiddly: on compact it treats detail as a pushed destination so back returns to the list; on expanded both are visible so back exits the screen. That behaviour is the reason to use it rather than a Row with a when.

Wire the system back gesture to the same navigator and the model stays consistent:

BackHandler(enabled = navigator.canNavigateBack()) {
    scope.launch { navigator.navigateBack() }
}

Which one am I in?

Two questions settle it:

  1. Is there a detail view for individual items? Yes → list-detail. No → feed or supporting pane.
  2. Is the secondary content about the primary content? Yes → supporting pane. No, it's a peer collection → feed.

The one that's regularly misdiagnosed is a feed with a detail screen — a photo grid where tapping opens a photo. That's list-detail with a grid as the list, not a feed. Treating it as a feed means the expanded window opens a full-screen detail over a grid, wasting the space you detected.

When you genuinely need something else

The three don't cover everything, and the honest exceptions are real: a canvas editor, a game, a media player in landscape, a dashboard with fixed regions. If your screen is one of those, build what it needs.

The test worth applying first: can I describe this screen as one of the three with a different-looking list? A settings screen with categories and panels is list-detail. A music player with a queue is supporting pane. Quite a lot of "we're special" screens turn out to be a canonical layout with unusual content, and using the named pattern gets you the navigation semantics for free.

How to prove it

The scaffold's behaviour is the part to verify, and previews cover both states:

@Preview(widthDp = 400, name = "compact")
@Preview(widthDp = 1000, name = "expanded")
@Composable fun MailScreenAdaptive() = MailScreen(sampleMail)

The back behaviour needs a device or an instrumented test, and it's the thing most likely to be wrong:

@Test fun backFromDetailReturnsToListOnCompact() { … }
@Test fun backOnExpandedExitsScreen() { … }

If both tests pass with the same implementation, the navigator is doing its job. If you wrote the branching yourself, one of them usually doesn't.

The trap of the empty pane

One detail the scaffold makes you decide, and it's worth deciding deliberately: what the detail pane shows when nothing is selected.

On compact this never comes up — detail only exists once you've navigated to it. On expanded both panes are always visible, so on first open the detail pane has no content. Three reasonable answers, in rough order of preference:

Select the first item automatically. Right for mail, contacts, settings — anywhere the collection is non-empty and picking one is harmless.

Show a placeholder. A short "Select a message" with an illustration. Right when auto-selecting would be presumptuous, such as a destructive or stateful detail view.

Show a summary. Occasionally the best answer: aggregate information about the whole collection, which turns dead space into something useful.

What to avoid is an empty pane, which reads as a rendering bug rather than a state. It's the most common visible flaw in a first adaptive implementation, and it only appears on hardware most teams test last.

What this generalizes to

The value here is a small shared vocabulary. Three named layouts mean a designer can say "this is list-detail" and the whole team knows the compact behaviour, the expanded behaviour, and what back does — with no spec written.

The constraint is the feature. A framework offering infinite adaptive freedom would let every screen be inconsistent, and consistency across screens is most of what makes an app feel coherent on a large window. Naming three patterns and implementing two of them is a deliberate reduction of the design space, and it's why adaptive Compose is less work than adaptive layouts usually are.

Design systems on every platform converge on this. Apple's split-view controllers and the web's common page archetypes are the same idea: a handful of named shapes that carry their own interaction rules, so the work becomes choosing one rather than inventing one.

Tomorrow, Day 31: ListDetailPaneScaffold in detail — the navigator, pane roles, and adapting the scaffold's own directive.


Day 30 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Canonical layouts.