Engineering

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.

MobileByteSensei 8 min read
Day 24 — A grid is not a list with two columns
androidcomposekotlinlayout

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 case this post is about

One photo grid, built by chunking the list into rows:

LazyColumn {
    items(photos.chunked(3)) { row ->
        Row {
            row.forEach { photo -> PhotoCell(photo, Modifier.weight(1f)) }
        }
    }
}

It renders a grid and brings three problems. The chunk size is a guess that is wrong on a tablet. The keys are per row, so inserting one photo invalidates every row after it. And the final row is short, so its cells are wider than the rest unless you pad it by hand.

This post follows that one grid, because the component that replaces it takes a cell strategy rather than a column count, and the difference between declaring four columns and declaring a minimum cell size is the difference between a phone layout and a layout.

The symptom: a photo grid built by chunking rows

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 padding and computing the chunk size only patches it

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 mechanism: LazyVerticalGrid takes a cell strategy, not a column count

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.

Adaptive is twelve lines, and reading them is what makes the difference from Fixed concrete — the leftover is shared, not left at the edge:

data class Resolved(val columns: Int, val cellWidth: Int, val leftover: Int)

fun adaptive(available: Int, minSize: Int, gap: Int): Resolved {
    // How many cells of AT LEAST minSize fit, counting the gaps between them.
    var n = (available + gap) / (minSize + gap)
    if (n < 1) n = 1                              // always at least one column
    val gaps = gap * (n - 1)
    val cell = (available - gaps) / n             // leftover is SHARED, not trailing
    return Resolved(n, cell, available - gaps - cell * n)
}

fun fixed(available: Int, columns: Int, gap: Int): Resolved {
    val gaps = gap * (columns - 1)
    val cell = (available - gaps) / columns       // can go below usable; no clamp exists
    return Resolved(columns, cell, available - gaps - cell * columns)
}

The photo grid at three widths, minSize = 128, gap = 8:

 360dp  adaptive -> 2 columns, cells 176dp, 0 left over
 800dp  adaptive -> 6 columns, cells 126dp, 4 left over
1280dp  adaptive -> 9 columns, cells 134dp, 2 left over

And Fixed(2), which is what the same screen shipped with before:

 360dp  fixed(2) -> 2 columns, cells 176dp      (identical — this is the device it was written on)
 800dp  fixed(2) -> 2 columns, cells 396dp      thumbnails at 3x their intended size
1280dp  fixed(2) -> 2 columns, cells 636dp

Note what adaptive returns at 800dp: cells of 126dp, which is below the 128dp minimum you asked for. That is correct and it surprises people — minSize decides how many columns fit, and then the row is divided evenly, so the final cell can be a few pixels under. The alternative is a trailing gap the width of most of a cell, which looks like a layout bug rather than a rounding one.

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: one preview, no device

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: declare the constraint, not the outcome

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.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS