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.

6 min read
androidcomposekotlinlayout

Day 24 — A grid is not a list with two columns

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.

GridCells.Fixed picks a column count; GridCells.Adaptive picks a minimum cell size and derives the count

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) — exactly n columns, whatever the width. Right when the count is part of the design (a 7-column calendar).
  • GridCells.Adaptive(minSize) — as many columns as fit at minSize or 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:

  • key is still mandatory, for the same four reasons.
  • contentType still matters for mixed grids, and mixed grids are common — headers, ads and cells in one scroll.
  • contentPadding versus Modifier.padding is 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.