Sometimes the right grid is a Column of Rows

Not every grid needs LazyVerticalGrid. For a small fixed set — a keypad, a colour picker, a dashboard — a Column of Rows is simpler, cheaper and easier to reason about. Knowing the cutoff matters.

6 min read
androidcomposekotlinlayout

Day 27 — Sometimes the right grid is a Column of Rows

Day 27 of 100. Days 24 and 26 added two grid-shaped tools. Today: the case where neither is right, and how to tell which of the three you're in.

The symptom

A calculator keypad, built with the most powerful tool available:

LazyVerticalGrid(columns = GridCells.Fixed(4)) {
    items(keys, key = { it.label }) { key -> KeyButton(key) }
}

Twenty buttons, all visible, none scrolling. It works, and it drags in a scroll container that will never scroll, an item-recycling machine that will never recycle, and a layout whose height is now unbounded inside a screen that needs it fixed.

Then the "0" key needs to be double-width, and you're writing a span lambda for a static layout you could have typed out.

Why the obvious fix fails

The obvious fix is to keep the lazy grid and constrain it:

LazyVerticalGrid(
    columns = GridCells.Fixed(4),
    modifier = Modifier.height(320.dp),      // a number, again
    userScrollEnabled = false,
) { … }

Two problems. The height is a guess that breaks at large font scales — the buttons grow, the container doesn't, and the bottom row is clipped. And userScrollEnabled = false disables the gesture without removing the scroll container, so the layout still advertises unbounded height to its parent.

You've spent three parameters suppressing the features that made you pick the component.

Three grid tools, three different questions about the item count

The actual mechanism

For a small, known set of items, nested Rows inside a Column is the whole layout:

@Composable
fun Keypad(onKey: (Key) -> Unit, modifier: Modifier = Modifier) {
    Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
        keys.chunked(4).forEach { row ->
            Row(
                horizontalArrangement = Arrangement.spacedBy(8.dp),
                modifier = Modifier.fillMaxWidth(),
            ) {
                row.forEach { key ->
                    KeyButton(key, onClick = { onKey(key) }, modifier = Modifier.weight(key.span))
                }
            }
        }
    }
}

Day 24 warned against chunking for a photo feed. The difference is that here the item count is fixed and known at compile time — a keypad has twenty keys forever — so the chunk size isn't a guess about rendered width, it's part of the design.

Three properties fall out:

Height is intrinsic. The column is as tall as its rows, which are as tall as their buttons. Font scale grows the whole thing correctly with no height(320.dp) to maintain.

Spans are just weights. Modifier.weight(2f) on the "0" key makes it double-width, using Day 18's mechanism rather than a grid-specific API.

Nothing is virtualised, which is correct — virtualising twenty always-visible items is pure overhead.

Choosing between the three

The decision is about the item count, and it has clean boundaries:

Use Because
Small, fixed, known at build time Column of Rows Intrinsic height, weights as spans, no scroll container
Uniform cells, large or unknown count LazyVerticalGrid Virtualisation, key, adaptive columns
Variable widths, small count FlowRow Wrapping decided after measuring
Variable heights, large count LazyVerticalStaggeredGrid Independent column advance

The one that gets picked wrongly most often is the first. LazyVerticalGrid is the answer that comes to mind for anything grid-shaped, and for a colour picker, a settings grid, a keypad or a seven-day week header it brings machinery you then have to disable.

A rough cutoff: if every item is on screen at once and the count doesn't change, it isn't a lazy layout.

The nesting rule still applies

The reason this matters beyond tidiness: a Column of Rows composes fine inside another scrollable, and a LazyVerticalGrid does not.

Column(Modifier.verticalScroll(rememberScrollState())) {
    Header()
    Keypad(onKey = ::press)          // fine — intrinsic height
    // LazyVerticalGrid(…) here would throw: unbounded height inside unbounded height
}

That's the practical trigger. A dashboard screen that scrolls, containing a small grid of stat tiles, is a Column of Rows — not because it's more elegant, but because the lazy version doesn't compose there without a hardcoded height.

When the count is small but variable

The awkward middle: eight to fifteen items, count known only at runtime. Chunking still works, and the honest version computes the column count from the available width rather than assuming:

BoxWithConstraints {
    val columns = (maxWidth / 96.dp).toInt().coerceAtLeast(2)
    Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
        items.chunked(columns).forEach { row ->
            Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                row.forEach { Cell(it, Modifier.weight(1f)) }
                repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
            }
        }
    }
}

The trailing Spacer loop is the tell that you're near the boundary — that's the tail-row padding Day 24 called a workaround. At that point FlowRow or LazyVerticalGrid is usually the better trade, and the BoxWithConstraints subcomposition isn't free either.

Arrangement does the spacing, not padding

One detail that separates a grid that looks designed from one that looks approximate: put the gaps in Arrangement.spacedBy on both axes, never in per-cell padding.

// Ragged: a gap before the first cell and after the last
Row { items.forEach { Cell(it, Modifier.padding(4.dp)) } }

// Even: gaps only BETWEEN cells, edges flush
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { items.forEach { Cell(it) } }

This is Day 18's point, and it matters more in a grid because the error compounds in two dimensions — every row gains leading and trailing padding, every column gains it too, and the whole grid ends up inset by an amount nobody chose. If the grid itself needs an outer margin, that's one padding on the Column, where it can be seen and changed.

The weight(1f) on each cell is what keeps columns aligned across rows. Without it, cells size to content and row two's second column doesn't line up with row one's — a grid that isn't actually a grid, which reads as sloppy without being obviously wrong.

How to prove it

The scroll-container question answers itself:

Column(Modifier.verticalScroll(rememberScrollState())) { YourGrid() }

If it compiles and scrolls, the grid has intrinsic height. If it throws, you have a lazy layout, and the question becomes whether you actually need one.

For the font-scale claim, the preview annotation from yesterday applies unchanged:

@Preview(fontScale = 2f) @Composable fun KeypadLargeText() = Keypad(onKey = {})

A Column of Rows grows. A height(320.dp) lazy grid clips, and clips silently.

What this generalizes to

The reusable instinct is match the tool's cost to the problem's size. Virtualisation is not free — it buys you not composing off-screen items, and if there are no off-screen items it is pure overhead plus an unbounded-height constraint you have to work around.

The same trade shows up everywhere: a hash map for three entries, pagination for forty rows, a state machine for two states. Reaching for the general tool feels safe because it handles the case you don't have yet, and the cost is complexity you carry today for a scale that may never arrive.

The counter-argument is real and worth stating: a layout that starts small sometimes grows, and rewriting it later costs something too. The tiebreaker is whether growth is plausible. A keypad will never have two hundred keys; a photo feed always will. Where the answer is genuinely unknown, take the lazy version — but note that "unknown" is much rarer than the reflex to reach for it suggests.

Tomorrow, Day 28: what to reach for when you're porting a FlexboxLayout, and which of Compose's layouts each flex property maps onto.


Day 27 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Grids in Compose.