8 min read

Compose Custom Layouts: When Row and Column Stop Being Enough

The signs you've outgrown the standard containers, the four-rung ladder from Modifier.layout to SubcomposeLayout, a real Layout implementation in a dozen lines, and the single-measurement rule that makes Compose layout fast.

Jetpack ComposeAndroidUIPerformanceKotlin
HR

Hessam Rastegari

Senior Android Developer · 12 years shipping Android

TL;DR — If you're nesting Boxes with hand-tuned offsets, or measuring with onGloballyPositioned and recomposing with the result, you've outgrown Row/Column — and the measure-then-recompose loop costs you a frame every time. Escalate deliberately: Modifier.layout for one child, IntrinsicSize for "as tall as the tallest sibling", Layout when you need to measure and place several children yourself (usually the right answer, and about twelve lines), and SubcomposeLayout only when you must measure before deciding what to compose. Two rules: a child may be measured once, and use placeRelative so RTL works.

The tells

You've outgrown the standard containers when you notice any of these:

  • Nested Boxes with offset values arrived at by trial and error, which break the moment the font scale changes.
  • onGloballyPositioned writing to state that drives layout. The loop is: compose → layout → measure → set state → recompose → layout again. You've turned a one-pass problem into a two-frame one, and on a slow device the user sees the intermediate state flash.
  • A design where one child's size depends on a sibling's — a label column as wide as the widest label, a badge that hugs the text above it, a scale that aligns to a chart's plot area.
  • Reaching for Modifier.weight and finding it doesn't express the thing — because weight distributes leftover space, and what you want is proportional to content.

The onGloballyPositioned one is the most common and the most expensive. It's what writing a custom layout looks like when you don't know you're allowed to.

The ladder

Escalate one rung at a time. Each is meaningfully cheaper than the next.

1. Modifier.layout — one child, one adjustment

// Let a child extend past its parent's padding
fun Modifier.fullBleed(horizontal: Dp) = layout { measurable, constraints ->
    val extra = horizontal.roundToPx() * 2
    val placeable = measurable.measure(
        constraints.copy(maxWidth = constraints.maxWidth + extra)
    )
    layout(placeable.width, placeable.height) {
        placeable.place(-horizontal.roundToPx(), 0)
    }
}

No new composable, no allocation beyond the modifier, and it composes with everything else. Reach here first — a surprising number of "I need a custom layout" problems are one of these.

2. IntrinsicSize — the four-character fix

// Divider matches the taller of the two columns
Row(Modifier.height(IntrinsicSize.Min)) {
    Text(summary, Modifier.weight(1f))
    VerticalDivider(Modifier.fillMaxHeight())
    Text(detail, Modifier.weight(1f))
}

IntrinsicSize asks the subtree "what would you like to be?" before the real measurement. It's honest about its cost: an extra pass over that subtree. Fine for a card, questionable inside a LazyColumn item rendered sixty times a second. Measure before assuming it's free — and note that not every composable implements intrinsics usefully, so results can surprise you.

3. Layout — measure and place them yourself

This is the rung people skip, and it's the one that solves most of these problems properly. Layout is a composable that receives its children as Measurables and gives you two jobs: measure each one with constraints you choose, then place them.

Here's the label/value list — every label column the width of the widest label, which is genuinely awkward with Row/Column and straightforward here:

@Composable
fun LabelValueList(
    modifier: Modifier = Modifier,
    gap: Dp = 16.dp,
    content: @Composable () -> Unit,
) {
    Layout(content, modifier) { measurables, constraints ->
        val gapPx = gap.roundToPx()

        // Labels are the even children, values the odd ones.
        val (labels, values) = measurables.withIndex()
            .partition { it.index % 2 == 0 }

        // 1. Measure labels unbounded to learn their natural width.
        val labelPlaceables = labels.map {
            it.value.measure(Constraints())
        }
        val labelColumn = labelPlaceables.maxOf { it.width }

        // 2. Values get whatever is left.
        val valueMax = (constraints.maxWidth - labelColumn - gapPx)
            .coerceAtLeast(0)
        val valuePlaceables = values.map {
            it.value.measure(constraints.copy(minWidth = 0, maxWidth = valueMax))
        }

        // 3. Place rows, each as tall as its taller half.
        val rowHeights = labelPlaceables.indices.map { i ->
            maxOf(labelPlaceables[i].height, valuePlaceables[i].height)
        }

        layout(constraints.maxWidth, rowHeights.sum()) {
            var y = 0
            labelPlaceables.indices.forEach { i ->
                labelPlaceables[i].placeRelative(0, y)
                valuePlaceables[i].placeRelative(labelColumn + gapPx, y)
                y += rowHeights[i]
            }
        }
    }
}

Twelve lines of real logic. One measurement per child, one layout pass, no state, nothing to invalidate. The onGloballyPositioned version of this is longer, slower, and flickers on first composition.

Note Constraints() with no arguments: unbounded in both axes, i.e. "tell me what you'd naturally be." That's how you learn a child's intrinsic width without an intrinsics pass.

4. SubcomposeLayout — the expensive one

Use it only when you must measure something before you can decide what to compose — not merely how big to make it.

// Legitimate: don't compose the expanded detail
// at all unless it will fit.
SubcomposeLayout { constraints ->
    val header = subcompose("header") { Header() }
        .map { it.measure(constraints) }
    val remaining = constraints.maxHeight - header.sumOf { it.height }
    val detail = if (remaining > 96.dp.roundToPx()) {
        subcompose("detail") { Detail() }.map { it.measure(constraints) }
    } else emptyList()
    …
}

The cost is structural: composition happens during layout, so that subtree can't be skipped by the usual recomposition optimisations, and it doesn't benefit from normal composition reuse.

Worth knowing: BoxWithConstraints is a SubcomposeLayout. It gets used casually — often inside a LazyColumn item to pick a layout by width — and each item pays for it. If all you need is a size-dependent value, a Modifier.layout or reading LocalConfiguration/LocalWindowInfo is usually cheaper.

The two rules that bite

A child may be measured exactly once

val p = measurable.measure(loose)
val q = measurable.measure(tight)   // throws

This isn't a limitation to route around — it's the reason Compose layout is single-pass and O(n) rather than the multi-pass negotiation that made deep RelativeLayout trees slow. Design within it: measure with unbounded constraints to learn a natural size, then place accordingly, rather than re-measuring at a computed size.

In the example above, labels are measured once, unbounded, and then simply placed at x=0 inside a wider column. No second measurement needed — the column width is a placement concern, not a measurement one. That reframing solves most cases where you think you need to measure twice.

placeRelative, not place

placeable.placeRelative(x, y)  // mirrors under RTL
placeable.place(x, y)          // absolute, ignores RTL

Use placeRelative by default. Use place only when the position is genuinely absolute — a decorative element anchored to a physical corner. This bug ships constantly: a layout that's pixel-perfect in English and mirrored wrong in Arabic and Hebrew, found weeks later by a user.

When not to write one

  • FlowRow / FlowColumn exist in Compose Foundation. Don't write a wrapping chip layout by hand.
  • ConstraintLayout for Compose is reasonable for genuinely relational, designer-driven screens — though for most cases a Layout is less machinery and easier to read.
  • LookaheadScope handles animated size and position changes between layouts, which is a different problem from computing a static one. Don't try to hand-roll it in a Layout.

The rule

When one child's size depends on another's, that's a layout problem — solve it in the layout pass. The measure-then-recompose loop is a custom layout written by someone who didn't know they were allowed to write one. Climb the ladder deliberately, measure each child once, and place with placeRelative.

Keep reading