Compose Custom Layouts — From Layout {} to SubcomposeLayout, with Real Use Cases
Every Android developer hits the wall the same way. You’re building a product detail screen for a shopping app. You need a row of size chips that wraps to the next line when it runs out of horizontal space. Row doesn’t wrap. FlowRow almost works but you want different gap sizes between rows than within them. You search Stack Overflow, find five different answers using Modifier.onGloballyPositioned and remembered offsets, all of them feel hacky, and you walk away thinking custom layouts in Compose are dark magic.
They’re not. The Layout composable has exactly one mental model and once it clicks, every “impossible” layout becomes 30 lines of code. This post: the measure/place model from first principles, then four custom layouts in increasing complexity — basic flow, vertical stack with measured gaps, a staggered grid, and finally SubcomposeLayout for the cases the others can’t handle.
The Mental Model: Measure, Then Place
Compose layouts are a single-pass system. Every Layout does two things, in order:
- Measure each child. You hand the child a
Constraintsobject (min/max width and height) and the child returns aPlaceablewith its actual measured size. - Place the children. Once you know everyone’s size, you decide your own size and call
placeable.place(x, y)for each child at coordinates relative to your top-left.
That’s the whole API. No invalidation cycles, no second-pass measurement (that’s what SubcomposeLayout is for and we’ll get there), no “measure-then-remeasure” like the View system’s onMeasure. Compose enforces single-pass by construction, which is why it’s genuinely faster than the View system at deep hierarchies.
The bare-bones Layout call:
@Composable
fun MyCustomLayout(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Layout(
// Layout is a COMPOSABLE FUNCTION from compose.ui
// The lowest-level layout primitive — everything else (Row, Column, Box) is built on this
modifier = modifier,
content = content
) { measurables, constraints ->
// measurables is a List<Measurable> — one per child composable
// constraints is the Constraints handed down by our parent
val placeables = measurables.map { it.measure(constraints) }
// measure() returns a Placeable — the child’s final size
val width = placeables.maxOf { it.width }
val height = placeables.sumOf { it.height }
layout(width, height) {
// layout() is a FUNCTION on MeasureScope — declares this layout’s own size
// The lambda is the placement step
var y = 0
placeables.forEach { placeable ->
placeable.place(x = 0, y = y)
// place() positions the child relative to our top-left
y += placeable.height
}
}
}
}
That’s a working vertical stack — an inferior Column. Six lines of actual logic. Get this skeleton in your fingers and we can build anything from here.
Constraints — The One Thing That Trips Everyone Up
Constraints are what your parent passes down. They have four numbers: minWidth, maxWidth, minHeight, maxHeight. When you measure a child, you’re telling it “your size must fall within this range.”
The trap: you almost always want to measure children with looser constraints than what you received. If your parent told you “you must be exactly 400px wide” (minWidth == maxWidth == 400), you don’t want to force every child to also be exactly 400px. You want to give children freedom to be their natural width.
// ❌ Common mistake: passing parent constraints straight to children
val placeables = measurables.map { it.measure(constraints) }
// Now if parent forced exact 400px width, every child is also forced to 400px
// ✅ Loosen the minimums, keep the maximums
val childConstraints = constraints.copy(minWidth = 0, minHeight = 0)
val placeables = measurables.map { it.measure(childConstraints) }
// Children can now be smaller than the parent’s minimum — their natural size
For most custom layouts, you’ll want one of these patterns:
// Children measure freely up to a max width (most common)
val childConstraints = Constraints(maxWidth = constraints.maxWidth)
// Children get exact size (rare — used for grid cells)
val cellWidth = constraints.maxWidth / columns
val childConstraints = Constraints.fixed(cellWidth, cellHeight)
// Children unconstrained (children pick their own size)
val childConstraints = Constraints()
Think of constraints as “the contract you’re offering each child.” Loose constraints = “be whatever size you want.” Tight constraints = “be exactly this.”
Example 1: A Real FlowRow (Tag Cloud)
Now the original problem — size chips that wrap to the next line. Compose’s built-in FlowRow works for the common case, but understanding the mechanics matters because FlowRow doesn’t solve every variant (different vertical/horizontal gaps, alignment per row, different overflow strategies).
@Composable
fun FlowingChips(
modifier: Modifier = Modifier,
horizontalGap: Dp = 8.dp,
verticalGap: Dp = 8.dp,
content: @Composable () -> Unit
) {
Layout(modifier = modifier, content = content) { measurables, constraints ->
val hGap = horizontalGap.roundToPx()
val vGap = verticalGap.roundToPx()
val maxWidth = constraints.maxWidth
// ═══ MEASURE ═══
// Children measure freely, capped at our max width
val childConstraints = Constraints(maxWidth = maxWidth)
val placeables = measurables.map { it.measure(childConstraints) }
// ═══ LAYOUT INTO ROWS ═══
// Group placeables into rows based on width budget
val rows = mutableListOf<MutableList<Placeable>>()
var currentRow = mutableListOf<Placeable>()
var currentRowWidth = 0
placeables.forEach { placeable ->
val widthWithGap = if (currentRow.isEmpty()) {
placeable.width
} else {
placeable.width + hGap
}
if (currentRowWidth + widthWithGap > maxWidth && currentRow.isNotEmpty()) {
// ✅ This chip doesn’t fit — start a new row
rows.add(currentRow)
currentRow = mutableListOf(placeable)
currentRowWidth = placeable.width
} else {
currentRow.add(placeable)
currentRowWidth += widthWithGap
}
}
if (currentRow.isNotEmpty()) rows.add(currentRow)
// ═══ COMPUTE TOTAL HEIGHT ═══
val totalHeight = rows.sumOf { row -> row.maxOf { it.height } } +
vGap * (rows.size - 1).coerceAtLeast(0)
// ═══ PLACE ═══
layout(width = maxWidth, height = totalHeight) {
var y = 0
rows.forEach { row ->
var x = 0
val rowHeight = row.maxOf { it.height }
row.forEach { placeable ->
placeable.place(x = x, y = y)
x += placeable.width + hGap
}
y += rowHeight + vGap
}
}
}
}
Usage:
FlowingChips(
horizontalGap = 12.dp,
verticalGap = 8.dp,
modifier = Modifier.fillMaxWidth().padding(16.dp)
) {
listOf(“XS”, “Small”, “Medium”, “Large”, “XL”, “2XL”).forEach { size ->
SizeChip(label = size, onClick = { /* ... */ })
}
}
Three things worth pointing out. First, the algorithm is exactly what you’d write on paper — iterate, accumulate width, wrap when it exceeds the budget. No magic. Second, we computed our own width and height in the layout() call — we’re not bound by what the parent told us, only by the constraint range. Third, the placement step is just integer arithmetic. No callbacks, no onGloballyPositioned, no remembered offsets.
For the shopping app, this is exactly what you want for the size selector, color swatches, filter chips, or any other “wrap when full” situation. Production note: real FlowRow in Compose’s foundation library handles edge cases like single-child overflow, RTL layouts, alignment within rows. For 80% of cases your custom version is fine; for shipped product, prefer the built-in unless you need behavior it doesn’t support.
Example 2: A Staggered Grid (Pinterest-Style Product List)
Shopping apps love staggered grids — products with different image heights flow into multiple columns without big gaps. LazyVerticalStaggeredGrid exists, but understanding the mechanics matters because it doesn’t cover the non-lazy case (small known item sets) and it doesn’t let you customize the column-assignment algorithm.
@Composable
fun StaggeredGrid(
modifier: Modifier = Modifier,
columns: Int = 2,
horizontalGap: Dp = 12.dp,
verticalGap: Dp = 12.dp,
content: @Composable () -> Unit
) {
Layout(modifier = modifier, content = content) { measurables, constraints ->
val hGap = horizontalGap.roundToPx()
val vGap = verticalGap.roundToPx()
val totalWidth = constraints.maxWidth
val columnWidth = (totalWidth - hGap * (columns - 1)) / columns
// Each child gets fixed column width, free height
val childConstraints = Constraints(
minWidth = columnWidth,
maxWidth = columnWidth
)
// Track current y-position per column — shortest column wins next item
val columnHeights = IntArray(columns) { 0 }
val placements = mutableListOf<Triple<Placeable, Int, Int>>()
// Triple of (placeable, x, y)
measurables.forEach { measurable ->
val placeable = measurable.measure(childConstraints)
// ✅ Find the shortest column — that’s where this item goes
val shortestCol = columnHeights.indices.minBy { columnHeights[it] }
val x = shortestCol * (columnWidth + hGap)
val y = columnHeights[shortestCol]
placements.add(Triple(placeable, x, y))
// Update that column’s height (add gap if not first item)
columnHeights[shortestCol] = y + placeable.height +
if (y > 0) vGap else 0
}
val totalHeight = columnHeights.max()
layout(totalWidth, totalHeight) {
placements.forEach { (placeable, x, y) ->
placeable.place(x, y)
}
}
}
}
The staggered placement algorithm is one line: columnHeights.indices.minBy { columnHeights[it] }. Whichever column is currently shortest gets the next item. Over many items, the columns end up roughly the same height. Replace that with shortestCol = index % columns and you have a regular grid; replace it with weighted logic and you have a custom-priority grid. The layout primitive doesn’t care — you decide.
For a shopping app product list with 8–12 visible items, this is plenty. Beyond that, you want lazy — LazyVerticalStaggeredGrid handles viewport recycling. The non-lazy version above is for shorter lists where item recycling isn’t worth the complexity.
Example 3: Vertical Stack with Aware Gaps (Receipt Layout)
Sometimes you need siblings to know about each other. A receipt for a shopping order: line items with small gaps, then a divider with a larger gap above it, then the totals section. The standard answer is Spacer components — but Spacer is verbose, and you can’t easily condition a gap on properties of the next child.
Custom layouts let you handle this with a parent data modifier — a way for children to communicate metadata to their parent.
// The marker class — children attach this via a custom modifier
data class GapBefore(val gap: Dp) : ParentDataModifier {
// ParentDataModifier is an INTERFACE from compose.ui.layout
// Whatever modifyParentData returns becomes accessible to the parent during measure
override fun Density.modifyParentData(parentData: Any?): Any = this@GapBefore
}
// Extension function for ergonomic use
fun Modifier.gapBefore(gap: Dp): Modifier = this then GapBefore(gap)
@Composable
fun ReceiptStack(
modifier: Modifier = Modifier,
defaultGap: Dp = 4.dp,
content: @Composable () -> Unit
) {
Layout(modifier = modifier, content = content) { measurables, constraints ->
val defaultGapPx = defaultGap.roundToPx()
val placeables = measurables.map { it.measure(constraints) }
// Read parent data attached by children
val gapsBefore = measurables.map { measurable ->
// measurable.parentData was set by GapBefore.modifyParentData
(measurable.parentData as? GapBefore)?.gap?.roundToPx() ?: defaultGapPx
}
// First child has no gap before it
val totalHeight = placeables.zip(gapsBefore).foldIndexed(0) { i, acc, (p, gap) ->
acc + p.height + (if (i == 0) 0 else gap)
}
val width = placeables.maxOf { it.width }
layout(width, totalHeight) {
var y = 0
placeables.forEachIndexed { index, placeable ->
if (index > 0) y += gapsBefore[index]
placeable.place(x = 0, y = y)
y += placeable.height
}
}
}
}
Usage:
ReceiptStack(modifier = Modifier.padding(16.dp), defaultGap = 4.dp) {
ReceiptLine(“Wireless Earbuds”, “$129.00”)
ReceiptLine(“Phone Case”, “$24.99”)
ReceiptLine(“Charging Cable”, “$12.50”)
Divider(modifier = Modifier.gapBefore(16.dp))
// ✅ This child opts into a 16dp gap above it
ReceiptLine(“Subtotal”, “$166.49”, modifier = Modifier.gapBefore(8.dp))
ReceiptLine(“Tax”, “$13.32”)
ReceiptLine(“Total”, “$179.81”, modifier = Modifier.gapBefore(12.dp))
}
The ParentDataModifier pattern is the trick that lets Row’s weight and Box’s align work. Children attach metadata via modifiers, parents read it during measure. This is how you build the kind of expressive layout APIs the Compose foundation library uses internally — once you can do it, custom layouts feel like part of the language, not a foreign mechanism.
SubcomposeLayout — The Escape Hatch
Single-pass measurement is fast, but it has a real limitation: you can’t decide what to measure based on something you measured first. Three real cases that need this:
- A pager that needs to know its tallest possible page before sizing itself
- A “match this child’s width to that child’s width” layout
- A bottom sheet that needs to measure its content before deciding its own height
SubcomposeLayout lets you compose and measure children in multiple passes, in any order. The trade-off: it’s noticeably slower than Layout — each subcomposition pass re-runs the composables. Use it when you need it. Don’t reach for it first.
An example for the shopping app — a product card with a title that should be sized to match the longest title across all sibling cards (so the “Add to cart” buttons line up):
@Composable
fun MatchingHeightRow(
modifier: Modifier = Modifier,
contentSlots: List<@Composable () -> Unit>
) {
SubcomposeLayout(modifier = modifier) { constraints ->
val slotCount = contentSlots.size
val slotWidth = constraints.maxWidth / slotCount
// Pass 1: subcompose just to measure intrinsic heights
val measurePass = contentSlots.mapIndexed { index, slot ->
subcompose(“measure-$index”, slot).map {
// subcompose() is a FUNCTION on SubcomposeMeasureScope
// First arg is a slot ID, second is the @Composable to compose
it.measure(Constraints(maxWidth = slotWidth))
}
}
// Find the tallest measured height across all slots
val maxHeight = measurePass.flatten().maxOf { it.height }
// Pass 2: re-measure with the unified height
val finalPass = contentSlots.mapIndexed { index, slot ->
subcompose(“final-$index”, slot).map {
it.measure(Constraints.fixed(slotWidth, maxHeight))
}
}
layout(constraints.maxWidth, maxHeight) {
finalPass.forEachIndexed { index, placeables ->
placeables.forEach { it.place(x = index * slotWidth, y = 0) }
}
}
}
}
Notice we composed each slot twice — once to measure, once to actually place. That’s the cost. For a row of 3 product cards it’s fine. For a list of 50 it’s a problem. The general advice: if a single-pass Layout can do it, use that. Reach for SubcomposeLayout only when child sizing genuinely depends on sibling measurements.
Real internal uses of SubcomposeLayout: BoxWithConstraints (decides what to compose based on size it’s given), Scaffold (positions FAB based on bottom bar height), LazyColumn (only composes visible items, then measures them).
Custom Modifiers vs Custom Layouts
One more concept worth knowing because it’s often confused: a layout modifier changes how a single composable measures and places itself, without needing a wrapping Layout. Useful when you want behavior on a single component, not a container.
// A modifier that adds vertical padding equal to the composable’s own height
fun Modifier.symmetricVerticalPadding() = this.layout { measurable, constraints ->
// layout is a MODIFIER FUNCTION from compose.ui
val placeable = measurable.measure(constraints)
val pad = placeable.height
layout(placeable.width, placeable.height + pad * 2) {
placeable.place(0, pad)
}
}
The Modifier.layout { } form is for single-element transformations. Layout() the composable is for containers with multiple children. Same primitives underneath, different ergonomic forms.
Pitfalls Worth Calling Out
Forgetting to handle min constraints. If your parent passed minWidth = 200 and your computed width is 150, you’re violating the contract and Compose will throw. Always coerce: width.coerceIn(constraints.minWidth, constraints.maxWidth).
Measuring children twice in Layout. You can’t. Calling measurable.measure() twice on the same measurable in the same pass throws. If you need different sizes for the same logical content, use SubcomposeLayout.
Returning a size outside the constraints. If your layout(w, h) call returns dimensions outside the range your parent gave you, Compose will clamp them — but your placement logic was working with the un-clamped numbers, so things end up misaligned. Compute your size, coerce to constraints, then place.
Using SubcomposeLayout when Layout would do. Subcomposition is expensive. If you find yourself reaching for it frequently, you’re probably solving the wrong problem. Most layouts genuinely don’t need it.
Writing custom layouts when a built-in works. FlowRow, LazyVerticalStaggeredGrid, Scaffold all exist. Build your own only when the built-ins don’t fit, not as a default. The lesson is in understanding the primitives, not always reaching for them.
When to Reach For Each Tool
┌──────────────────────────────────────────────────────────────────────┐
│ Need │ Use │
├─────────────────────────────────────────────────┼────────────────────┤
│ Container of children with custom positioning │ Layout {} │
│ Modify a single composable’s measure/place │ Modifier.layout {} │
│ Children pass metadata to parent │ ParentDataModifier │
│ Sizing depends on sibling measurements │ SubcomposeLayout │
│ Measure huge or virtualized lists │ LazyLayout │
│ It’s a flow, grid, or stack and built-in works │ Use the built-in │
└──────────────────────────────────────────────────────────────────────┘
Closing
The product detail screen from the opening — the size chips that wrap, the staggered grid of related products, the receipt with non-uniform gaps, the row of cards with matched heights — that’s every example in this post, in the same screen. Once the measure/place model is in your head, none of those problems requires hacks. The Layout primitive is enough for almost everything; SubcomposeLayout exists for the rare cases that genuinely need it.
The biggest mindset shift is that custom layouts aren’t advanced Compose. They’re foundational. Every Row and Column you’ve used is built on the same primitive. Once you understand what they’re doing internally, the question changes from “can I write a custom layout” to “why didn’t I write a custom layout for this sooner.”
Happy coding!
Comments (0)
Sign in to leave a comment.
No comments yet. Be the first to share your thoughts.