Layout 11 min read

Compose's New Adaptive Layout APIs: FlexBox, Grid, and mediaQuery() Explained

Something shifted quietly in the Compose adaptive story over the past few months, and it didn't get a big announcement. Buried alongside the 5-year anniversary post and the Compose 1.12 release notes are three new experimental layout APIs that Google has been quietly shipping documentation for: FlexBox, a structural Grid, and mediaQuery(). They're all gated behind @Experimental annotations. None of them made the keynote. And together, they represent the most meaningful shift in how you'll build adaptive layouts in Compose since the toolkit launched.

I've been working with adaptive layouts for a while — Nodat runs on phones, tablets, and foldables, and getting screen-level layouts to feel right across all three without maintaining separate layout trees is genuinely hard with what Compose has offered so far. Row and Column get you one axis. LazyVerticalGrid gives you uniform cells. Everything else — the irregular feature-card grid, the sidebar that only appears on tablets, the content that should rearrange for tabletop posture — has required custom Layout composables or careful abuse of Box offsets. These three APIs address that directly.

FlexBox
CSS flexbox model for 1D adaptive layouts
Grid
2D structural layout without lazy loading
mediaQuery
Device capability queries with auto-recomposition

1. FlexBox — CSS Flexbox, Finally in Compose

If you've ever worked on a web project, you know how much heavy lifting display: flex handles. Item distribution, wrapping when space runs out, grow/shrink ratios that respond to container width — the entire pattern is so ergonomic that web developers rarely think about it. On Android, we've never had an equivalent. Row and Column give you a main axis and a cross axis, but no concept of wrapping, no grow/shrink semantics, no basis from which to redistribute remaining space.

The new FlexBox composable (under androidx.compose.foundation.layout, gated behind @ExperimentalFlexBoxApi) changes that. It's explicitly modeled on the CSS Flexible Box Layout specification — same concepts, same terminology, nearly identical behavior:

@OptIn(ExperimentalFlexBoxApi::class)
@Composable
fun FilterBar(filters: List<String>) {
    FlexBox(
        config = FlexBoxConfig(
            direction = FlexDirection.Row,
            wrap = FlexWrap.Wrap,           // overflow to next line
            justifyContent = JustifyContent.FlexStart,
            alignItems = AlignItems.Center,
            columnGap = 8.dp,
            rowGap = 8.dp
        )
    ) {
        filters.forEach { label ->
            FilterChip(
                selected = false,
                onClick = {},
                label = { Text(label) }
            )
        }
    }
}

That example handles a filter bar that wraps to a second line on narrow screens without any manual line-break logic. The config block maps almost 1:1 to CSS flex properties — direction is flex-direction, wrap is flex-wrap, justifyContent and alignItems are exactly what you'd expect.

The Modifier.flex item properties

The real power is in item-level configuration. Each child can describe its own sizing behavior through Modifier.flex:

FlexBox(
    config = FlexBoxConfig(
        direction = FlexDirection.Row,
        wrap = FlexWrap.NoWrap
    )
) {
    // Primary action gets 2x share of leftover space
    Button(
        onClick = { /* … */ },
        modifier = Modifier.flex(basis = 120.dp, grow = 2f, shrink = 1f)
    ) { Text("Save Draft") }

    // Secondary action gets 1x share
    OutlinedButton(
        onClick = { /* … */ },
        modifier = Modifier.flex(basis = 120.dp, grow = 1f, shrink = 1f)
    ) { Text("Cancel") }
}

The basis sets the initial size before space distribution. grow and shrink work as ratios — if the container has 60dp of extra space and two items with grow = 2f and grow = 1f, the first gets 40dp and the second gets 20dp. The algorithm does five steps: calculate base sizes, sort by order, build lines considering gap and wrap, align/resize the main axis, then align/resize the cross axis. Exactly how the browser does it.

When to reach for FlexBox vs Row/Column

FlexBox adds meaningful overhead compared to Row or Column — it runs the full flex layout algorithm on every measure pass. The documentation is explicit about this: use it for small numbers of items within a screen layout, not for large item lists. If you find yourself wanting FlexBox with 50+ children, that's a signal to reach for a lazy list instead.

The genuinely useful cases are the ones I keep hitting in practice:

Production note: If you've been using Accompanist's FlowRow or any custom flow layout composable, FlexBox is the in-tree replacement to watch. It's not a drop-in API, but the concept maps cleanly. Wait for it to stabilize before migrating production code — "experimental" on a Compose layout API has meant breaking signature changes before final release more than once.

2. Grid — Two-Dimensional Layouts Without Lazy Loading

Compose has had grid layouts since early on, but they've always been lazy — LazyVerticalGrid, LazyHorizontalGrid, LazyStaggeredGrid. That's the right tool for large datasets rendered on demand. But a lot of screen-level layouts are two-dimensional without being data-heavy: a settings page with 8 tiles, a media player control panel, a dashboard header with 4 stat cards. For those, LazyVerticalGrid is overkill and Row/Column nesting is a maintenance nightmare.

The new structural Grid composable lives at androidx.compose.foundation.layout.Grid and is explicitly designed for this case. It's not lazy — every child is composed immediately — but it handles two-dimensional placement cleanly:

@OptIn(ExperimentalGridApi::class)
@Composable
fun StatsPanel() {
    Grid(
        columns = GridCells.Fixed(2),
        rows = GridRows.Fixed(2),
        horizontalArrangement = Arrangement.spacedBy(16.dp),
        verticalArrangement = Arrangement.spacedBy(16.dp)
    ) {
        StatCard(label = "Crash-free", value = "99.6%")
        StatCard(label = "Downloads", value = "30K+")
        StatCard(label = "Apps", value = "10+")
        StatCard(label = "Experience", value = "5 yrs")
    }
}

That's a clean 2×2 grid — simpler than nesting two Rows, and easier to refactor when the design changes to a 1×4 column on narrow screens. The GridCells and GridRows parameters accept Fixed(count), Adaptive(minSize), and FixedSize(size) variants, so the layout adapts to available space without manual breakpoint math.

How it differs from LazyVerticalGrid

The distinction matters enough to be explicit about it. Use LazyVerticalGrid when you have a large, homogeneous dataset — a photo gallery, a product catalog, a music library — where lazy loading is the point. Use the structural Grid when you have a fixed, small set of structurally important items — a control panel, a stats grid, a feature card layout. The structural Grid does not support lazy loading by design; that's a feature, not a limitation.

There's also a meaningful difference in how children are placed. In LazyVerticalGrid, items fill cells sequentially with optional spans. In the structural Grid, the API is built around the grid model itself — tracks (the spaces between grid lines), cells (intersections of tracks), and areas (groups of cells). It's closer to CSS Grid's mental model, and that makes complex placements easier to reason about.

The Grid API works especially well with mediaQuery() — change the column count based on window width and the layout adapts automatically. I'd build any new screen-level layout that's even slightly 2D with this combo rather than the nested Row/Column approach that's still most common in codebases I look at.

3. mediaQuery() — Device Capability Queries That Drive Recomposition

WindowSizeClass has been the standard way to make Compose layouts adaptive for a couple of years now. Query the window size bucket, branch your layout on Compact/Medium/Expanded. It works, but it's coarse — you're branching on the window dimension, not on what the device is actually capable of doing.

mediaQuery() is a more expressive alternative. It evaluates conditions inside a UiMediaScope and automatically triggers recomposition when those conditions change — which means you write the condition once, and the layout updates whenever the device state changes, without managing any state yourself:

@OptIn(ExperimentalMediaQueryApi::class)
@Composable
fun VideoControlBar() {
    val showExtendedControls by derivedMediaQuery {
        windowWidth >= WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND.dp
    }

    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.spacedBy(12.dp)
    ) {
        PlayPauseButton()
        SeekBar(modifier = Modifier.weight(1f))
        if (showExtendedControls) {
            SpeedControl()
            QualitySelector()
            FullscreenButton()
        } else {
            FullscreenButton()
        }
    }
}

The derivedMediaQuery variant is specifically for frequently-changing values like window dimensions — it wraps the result in derivedStateOf internally, so you don't get unnecessary recompositions from continuous resizing. For stable conditions like device posture or input method, plain mediaQuery() is cleaner.

The full UiMediaScope parameter set

What makes this API genuinely useful is what it exposes beyond window size:

The foldable posture case is the one I'm most excited about. Right now, detecting tabletop posture in Compose requires pulling in WindowInfoTracker, observing the WindowLayoutInfo flow, and checking hinge states — a non-trivial amount of boilerplate. With mediaQuery, it collapses to one condition:

@OptIn(ExperimentalMediaQueryApi::class)
@Composable
fun MediaPlayerLayout(player: ExoPlayer) {
    when {
        mediaQuery { windowPosture == UiMediaScope.Posture.Tabletop } -> {
            // Hinge at center: video on top half, controls on bottom
            TabletopMediaPlayer(player)
        }
        mediaQuery { windowWidth >= 600.dp } -> {
            // Wide layout: side-by-side video and playlist
            LandscapeMediaPlayer(player)
        }
        else -> {
            // Phone portrait: standard stacked layout
            PortraitMediaPlayer(player)
        }
    }
}

For Samachar's video feed I'm rebuilding with ExoPlayer, this pattern would have saved a significant amount of boilerplate. WindowInfoTracker setup, flow collection, mapping hinge states to layout decisions — it's all abstracted away.

Enabling mediaQuery

One setup step the documentation requires that's easy to miss: you need to enable the API in your Application class. It's a flag, not a dependency:

class MyApp : Application() {
    override fun onCreate() {
        ComposeUiFlags.isMediaQueryIntegrationEnabled = true
        super.onCreate()
    }
}

There's also preview support. Override LocalUiMediaScope in your preview to test specific device states without needing a physical foldable:

@Preview(name = "Tabletop posture")
@Composable
fun PreviewTabletop() {
    ComposeUiFlags.isMediaQueryIntegrationEnabled = true

    val base = LocalUiMediaScope.current
    val tabletopScope = remember(base) {
        object : UiMediaScope by base {
            override val windowPosture = UiMediaScope.Posture.Tabletop
        }
    }

    CompositionLocalProvider(LocalUiMediaScope provides tabletopScope) {
        MediaPlayerLayout(player = previewPlayer())
    }
}

How the Three APIs Fit Together

These aren't three separate experiments — they're a cohesive set of primitives that compose (pun intended) into a full adaptive layout system. Here's how I think about when to reach for each:

A typical adaptive screen might use all three: mediaQuery decides if the layout is single-pane or two-pane, a Grid arranges the main content cards in the content pane, and a FlexBox handles the action row at the bottom of each card. The result is a layout that's genuinely responsive to device state — not just window width — with far less code than the current approach of nested Row/Column trees.

All three are experimental. The annotations are @ExperimentalFlexBoxApi, @ExperimentalGridApi, and @ExperimentalMediaQueryApi respectively. Treat this as their beta phase — the APIs are real and usable, but signatures and behaviors can change before stable. File issues on the official tracker if you run into rough edges; these are actively being shaped by community feedback.

The Bigger Picture: Compose Catching Up to the Web

What I find significant about these three APIs isn't any one of them in isolation — it's that Google is explicitly borrowing from CSS. FlexBox is CSS flexbox. The structural Grid is CSS Grid (without named areas, at least not yet). mediaQuery is CSS media queries adapted for Android device capabilities. The names, the mental models, the layout algorithms — they're intentional translations.

That's a smart call. The CSS layout model has been battle-tested for decades. Web developers who move to Android already know it. Android developers who read CSS don't have to context-switch. And the model has proven it can handle the kind of adaptive, multi-screen-size, multi-input-mode complexity that Android's device landscape now demands. Reinventing it would have been slower and less certain.

The practical impact: once these APIs stabilize (and based on the documentation investment I see, that feels like a 1.13 or 1.14 timeframe), the standard advice for new Compose screen layouts will shift. Not "use Row and Column, and add LazyVerticalGrid for grids" — but "use FlexBox for flowing content sections, Grid for structural panels, and mediaQuery to drive the whole thing from device state." That's a meaningfully better foundation.

For now: prototype with these on internal screens. Get familiar with the FlexBox mental model if you haven't already — it's worth understanding even before the APIs stabilize. And watch the Compose release notes starting from 1.13; I expect at least one of these to hit stable then.

Comments 0

No comments yet. Be the first to leave one!

Leave a comment