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.
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:
- Tag/chip rows that should wrap to a second line on narrow screens — previously required a custom
Layoutor a third-party FlowRow library - Action button groups where button widths should distribute proportionally based on available space rather than wrapping or overflowing
- Form sections where label and input fields should be side-by-side on wide screens and stacked on narrow ones, without writing that branch manually
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:
windowPosture—Flat,Tabletop, orBook; covers foldable postures directlypointerPrecision—Fine(stylus/mouse),Coarse(finger),Blunt(glove/stylus-tip),None; lets you enlarge touch targets when the user is wearing gloveskeyboardKind—Physical,Virtual,None; useful for showing keyboard-shortcut hints only when a physical keyboard is connectedviewingDistance—Near,Medium,Far; adjusts font size for TV-distance viewing without building a separate TV layouthasCamera,hasMicrophone— hide camera/mic UI elements on devices that don't have them
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:
- mediaQuery() — the outermost decision layer. Use it to branch between fundamentally different layout modes based on device posture, input precision, or window size. This is where you decide "are we in tabletop mode? wide screen? phone?"
- Grid — the structural layer. Use it for any screen region where you're placing a fixed, small number of items in a two-dimensional arrangement. Settings tiles, stat panels, card grids, control panels.
- FlexBox — the content layer. Use it within sections of a layout where items need to flow, distribute space, and optionally wrap. Tag rows, action button groups, form field pairs.
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.
No comments yet. Be the first to leave one!