Column, Row, Box — and the single-pass rule that makes them fast

Compose's layout system measures every child exactly once. Understanding that single-pass guarantee explains Column, Row and Box, and why some things you could do in View layouts are deliberately impossible.

6 min read
androidcomposekotlinlayout

Day 18 — Column, Row, Box — and the single-pass rule that makes them

Day 18 of 100, and the start of the layout pillar. Yesterday closed the state story. Today: how Compose decides where things go, and the one rule the whole system is built on.

The symptom

A row with a label and a value. The value is long, and it pushes the label off screen:

Row {
    Text("Description")
    Text(veryLongValue)          // eats all the space
}

The instinct from Views is to set a weight on one and let the other wrap. That instinct is right, but the reason it works in Compose is different, and the difference matters the moment layouts get nested.

Why the obvious fix fails

The obvious fix is to give the long text a weight:

Row {
    Text("Description")
    Text(veryLongValue, modifier = Modifier.weight(1f))
}

This works. Then someone wraps the row in a horizontally scrolling container:

Row(modifier = Modifier.horizontalScroll(rememberScrollState())) {
    Text("Description")
    Text(veryLongValue, modifier = Modifier.weight(1f))   // crash
}

"Asking for weight of a child in a Row with infinite width constraint is not allowed." The error names the symptom. The cause is that weight means "divide the remaining space", and a scrolling row has infinite space to divide.

Compose measures parent to child and places child to parent, exactly once per node

The actual mechanism

Compose lays out a tree in a single pass, and every node does the same three things:

  1. Measure children — pass each child a set of Constraints (min/max width and height), get back a Placeable with a concrete size.
  2. Decide its own size, within the constraints its own parent gave it.
  3. Place children at coordinates inside itself.

Constraints travel down. Sizes travel up. Placement happens on the way back down. The rule that makes it fast: a child may be measured only once.

That single-measure guarantee is why Compose has no equivalent of the View system's double-measure RelativeLayout pass, and why deep hierarchies don't degrade exponentially. It also produces the restrictions people meet as errors:

  • weight needs a bounded axis, because "share the leftover" is meaningless when the leftover is infinite. Hence the crash above.
  • You can't measure a child, look at the result, and re-measure it differently. SubcomposeLayout exists for the cases that genuinely need it, and it costs an extra composition — which is exactly why it isn't the default.

The three containers are thin wrappers over this:

Column { }   // children stacked vertically, each measured with the column's width constraint
Row { }      // children side by side, each measured with the row's height constraint
Box { }      // children overlaid, aligned within the box

Alignment and arrangement are different axes

These get mixed up constantly, and the rule is mechanical:

  • Arrangement positions children along the container's main axis — vertical for Column, horizontal for Row.
  • Alignment positions them along the cross axis.
Column(
    verticalArrangement = Arrangement.spacedBy(8.dp),   // main axis: gaps between
    horizontalAlignment = Alignment.CenterHorizontally, // cross axis: centred
) { … }

Arrangement.spacedBy is worth adopting as a default. The alternative — padding on each child — puts a gap before the first item and after the last, which is why lists built that way have subtly wrong edges.

Box has neither, because both of its axes are cross axes; children take an Alignment individually:

Box {
    Image(…)
    Text("caption", modifier = Modifier.align(Alignment.BottomStart))
}

The weight rule, precisely

weight runs in two rounds inside a single measure pass, which is why it doesn't violate the single-measure rule:

  1. Measure every unweighted child first, with the container's constraints.
  2. Subtract their total size from the available space.
  3. Divide what remains among weighted children, in proportion.

So a weighted child is measured once — just later, with a constraint computed from its siblings. Two consequences:

Row {
    Text("A", Modifier.weight(1f))
    Text("B", Modifier.weight(2f))    // B gets twice A's share of the REMAINDER
}

And fill = false opts a child out of being forced to occupy its whole share:

Text("short", Modifier.weight(1f, fill = false))   // takes its share, uses what it needs

That second parameter is the fix for "why is my centred text not centred" — the child was filling its slot, so the text sat at the slot's start rather than the row's centre.

Intrinsics: the escape hatch, and its cost

Sometimes a parent genuinely needs to know a child's size before deciding its own — two columns that should match the taller one, say. IntrinsicSize handles it:

Row(modifier = Modifier.height(IntrinsicSize.Min)) {
    Text("left")
    Divider(Modifier.fillMaxHeight().width(1.dp))
    Text("right side is longer\nand taller")
}

The divider now matches the tallest text rather than collapsing to nothing.

The cost is real: querying an intrinsic runs an extra measurement of the subtree. It doesn't break the single-pass guarantee — intrinsics are a separate query, not a re-measure — but it is extra work, and inside a LazyColumn item it happens per visible item per scroll frame. Fine occasionally; not something to reach for by habit.

How to prove it

Layout Inspector shows the tree with each node's measured size and position, which answers most "why is this the wrong size" questions directly.

For the reasoning underneath, a one-off custom layout is the clearest possible demonstration:

@Composable
fun Debug(content: @Composable () -> Unit) {
    Layout(content) { measurables, constraints ->
        Log.d("Layout", "constraints in: $constraints")
        val placeables = measurables.map { it.measure(constraints) }
        Log.d("Layout", "children: ${placeables.map { it.width to it.height }}")
        layout(constraints.maxWidth, placeables.sumOf { it.height }) {
            var y = 0
            placeables.forEach { it.placeRelative(0, y); y += it.height }
        }
    }
}

Wrap something in it and read the log. Constraints coming in, sizes going out, placement last — the whole model in one function, and it's the same function Column is.

The constraint vocabulary, briefly

Most sizing confusion resolves once the four modifier families are separated by what they do to the constraints passed inward:

Modifier Effect on the child's constraints
size(100.dp) min = max = 100dp. The child has no choice.
requiredSize(100.dp) Same, but ignores the parent's bounds — it may overflow.
fillMaxWidth() min width = max width = whatever the parent allowed.
wrapContentWidth() min width = 0, so the child may be smaller than the slot.
widthIn(min, max) Clamps the incoming range rather than replacing it.

This is why fillMaxWidth() sometimes appears to do nothing: if the incoming max width is already unbounded — inside a horizontalScroll, say — there is no "max" to fill, and the modifier has nothing to work with. The fix is to bound the axis higher up, not to add another fillMaxWidth.

It also explains the most-reported layout surprise: a Box with size(100.dp) whose child declares size(200.dp) renders at 100dp, because the parent's constraint wins. Swap the child to requiredSize(200.dp) and it overflows instead. Neither is a bug; they're the two available answers to "who decides", and the modifier name says which.

What this generalizes to

The reusable idea is a one-way data flow for geometry. Constraints down, sizes up, no cycles. CSS Flexbox and SwiftUI reach for the same shape, and for the same reason: the moment a layout system permits "measure, look, re-measure", worst-case cost becomes exponential in depth and nothing about performance is predictable any more.

Compose picked the restriction and gave you SubcomposeLayout as a deliberately expensive escape hatch. Tomorrow, Day 19: modifiers — the chain that decorates all of this, and what each link actually is.


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