A grid is not a list with two columns
LazyVerticalGrid adds cell strategy and span control on top of lazy lists. GridCells.Adaptive removes most breakpoint code, and span sizing is what lets headers live in the same grid as items.

Day 24 of 100. Yesterday's LazyColumn handles one item per row. Today: what changes
when a row holds several, and the two decisions a grid forces that a list doesn't.
The symptom
A photo grid built out of the tools already to hand:
LazyColumn {
items(photos.chunked(3)) { row ->
Row {
row.forEach { photo -> PhotoCell(photo, Modifier.weight(1f)) }
}
}
}
It renders. Then the last row has one photo stretched across the full width, the keys are per-chunk so Day 23's identity problem returns in a new costume, and on a tablet you still get three columns of enormous photos.
Why the obvious fix fails
The obvious fixes are patches on the chunking: pad the last row with spacers, compute the chunk size from the screen width, hash the chunk for a key.
val columns = if (screenWidth > 600.dp) 4 else 2
items(photos.chunked(columns), key = { it.first().id }) { row ->
Row {
row.forEach { PhotoCell(it, Modifier.weight(1f)) }
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) } // pad the tail
}
}
Every one of those is a workaround for the fact that the layout doesn't know it's a grid. Laziness is also now per-row rather than per-cell, and the key is the first item's id, so deleting that item invalidates a row that still contains two others.

The actual mechanism
LazyVerticalGrid takes a cell strategy rather than a column count, and that is the
whole design:
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 128.dp),
contentPadding = PaddingValues(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(photos, key = { it.id }) { photo -> PhotoCell(photo) }
}
Three strategies exist and the middle one is the interesting one:
GridCells.Fixed(n)— exactlyncolumns, whatever the width. Right when the count is part of the design (a 7-column calendar).GridCells.Adaptive(minSize)— as many columns as fit atminSizeor wider, sharing the leftover equally. Right for almost everything else.GridCells.FixedSize(size)— cells of exactly that size, whatever is left over stays empty.
Adaptive is the one that deletes code. A phone at 360dp with minSize = 128.dp gets
two columns; a tablet at 800dp gets six; a foldable resizes live as it unfolds. No
breakpoints, no screenWidth, no if. It handles the tail row correctly, keys are
per-item, and laziness is per-cell.
Spans: headers inside the grid
The reason to use a grid rather than a list is often that some items aren't grid
items. span lets one item claim the full row:
LazyVerticalGrid(columns = GridCells.Adaptive(128.dp)) {
item(span = { GridItemSpan(maxLineSpan) }) {
SectionHeader("Recent")
}
items(recent, key = { it.id }) { PhotoCell(it) }
item(span = { GridItemSpan(maxLineSpan) }) {
SectionHeader("Older")
}
items(older, key = { it.id }) { PhotoCell(it) }
}
maxLineSpan is available inside the span lambda and resolves to the current column
count — which is what makes a full-width header work under Adaptive, where you don't
know the count in advance. Hardcoding GridItemSpan(3) there is a bug that only shows
up on a tablet.
Spans also do the "first item is a feature tile" layout in one line:
itemsIndexed(photos, span = { i, _ ->
GridItemSpan(if (i == 0) maxLineSpan else 1)
}) { _, photo -> PhotoCell(photo) }
The staggered variant
LazyVerticalGrid gives every cell in a row the same height. When cells have natural
heights that differ — a Pinterest-style feed — that produces either cropping or
whitespace. LazyVerticalStaggeredGrid lets each column advance independently:
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Adaptive(160.dp),
verticalItemSpacing = 8.dp,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(photos, key = { it.id }) { photo ->
AsyncImage(photo.url, contentScale = ContentScale.Crop, modifier = Modifier.aspectRatio(photo.ratio))
}
}
One caveat that catches people: give staggered items a deterministic height —
aspectRatio from known dimensions is ideal. If the height only becomes known when an
image finishes loading, columns reflow as images arrive and the scroll position jitters.
That is not a Compose bug; it's the layout being asked to place something whose size
isn't known yet.
What carries over from Day 23
Everything, and it's worth being explicit because grids make the same mistakes costlier:
keyis still mandatory, for the same four reasons.contentTypestill matters for mixed grids, and mixed grids are common — headers, ads and cells in one scroll.contentPaddingversusModifier.paddingis the same distinction.- Nesting on the same axis still throws. A grid inside a column inside a grid is usually one grid with spans.
The grid-specific addition is Arrangement.spacedBy on both axes rather than padding
per cell — the same edge-gap argument from Day 18, now in two dimensions.
Cell aspect ratio, and the height question
A grid decides column width for you and says nothing about height. Left alone, each row is as tall as its tallest cell, which for images means the row height depends on whichever photo happened to land in it.
For a uniform grid, constrain the cell rather than the row:
items(photos, key = { it.id }) { photo ->
AsyncImage(
model = photo.url,
contentScale = ContentScale.Crop,
modifier = Modifier.aspectRatio(1f).clip(RoundedCornerShape(8.dp)),
)
}
aspectRatio(1f) derives height from the width the grid assigned, so cells stay square
at every column count. ContentScale.Crop is the other half — without it the image
letterboxes inside the square instead of filling it. The pair is the standard photo-grid
cell, and getting either wrong produces a grid that looks broken in a way that's hard to
articulate.
How to prove it
The adaptivity claim takes one preview and no device:
@Preview(widthDp = 360) @Preview(widthDp = 600) @Preview(widthDp = 840)
@Composable fun GridAcrossWidths() = PhotoGrid(samplePhotos)
Three renders, three column counts, no conditional code. If the count doesn't change,
you're on Fixed when you wanted Adaptive.
For spans, temporarily colour cells by span — a header that renders one column wide is
a maxLineSpan you forgot, and it's visible immediately rather than on a reviewer's
tablet.
What this generalizes to
The reusable idea is declare the constraint, not the outcome. Adaptive(128.dp)
states an intent — cells should be at least this big — and lets the layout derive the
column count for whatever width it gets. Fixed(3) states an outcome that happens to be
right on one class of device.
CSS Grid landed on the same distinction with repeat(auto-fill, minmax(128px, 1fr)),
and for the same reason: any layout expressed as a number eventually meets a screen that
number is wrong for. Preferring the constraint form is most of what "responsive" means
in practice.
It also explains why Adaptive is the better default even on a codebase that only
targets phones. The constraint form is correct on the device you have and on the
foldable, the tablet and the split-screen window you haven't tested yet; the number form
is a decision you'll have to revisit each time the surface changes.
Tomorrow, Day 25: HorizontalPager — paged scrolling, and the state object that makes
tab synchronisation work.
Day 24 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Lazy grids.