Compose Layout, Lists & Modifiers Explained
UI › Compose UI
Interviewers ask about Compose layout constantly, not because they want you to recite the modifier list, but because layout code is where declarative thinking gets tested. Draw a modifier chain on a whiteboard and ask what renders, and you find out fast whether someone actually understands Compose or just pattern-matches examples they've seen before.
The reason modifier order, list configuration, and scoped modifiers all behave the way they do traces back to one rule: Compose measures each child **once** per layout pass. The old View system could measure the same view more than once as parents negotiated sizes back and forth; Compose deliberately gives that up in exchange for speed and predictability. Everything in this lesson, modifier order, why some modifiers only work inside certain parents, why lazy lists need extra configuration, follows from that single constraint.
Three containers do almost all the layout work in Compose: Column stacks children vertically, Row lays them out horizontally, and Box stacks them front-to-back. Everything else, lists, grids, custom layouts, builds on the same measure-once contract.
Column, Row, and Box each expose parameters for controlling spacing and alignment, and mixing them up is an easy way to fumble a live-coding question.
Column takes verticalArrangement (how children space out along the stacking axis) and horizontalAlignment (how each child aligns on the cross axis). Row is the mirror image: horizontalArrangement and verticalAlignment. Box takes a single contentAlignment that applies to every child by default.
For spacing between children specifically, reach for Arrangement.spacedBy:
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Item 1")
Text("Item 2")
Text("Item 3")
}
This puts an 8.dp gap **between** each pair of children only, nothing above the first item and nothing below the last. Padding each child individually would double the gap between items, since both children's padding would stack, and it would add unwanted space at the top and bottom edges too.
Modifiers apply **outside-in**: each one in a chain wraps everything written after it, so Modifier.A().B() means 'A wraps B wraps the content.' Reordering two modifiers can completely change what renders, and interviewers love drawing exactly this kind of trap.
Take padding and background on the same Box:
Box(Modifier.size(100.dp).background(Color.Blue).padding(20.dp))
// background is outermost here: the whole box is blue, content sits inset 20.dp inside it
Box(Modifier.size(100.dp).padding(20.dp).background(Color.Blue))
// padding is outermost here: it shrinks the space handed to background,
// so only the inner 60.dp square turns blue
The same swap matters for clickable. Put it before padding and the click target and ripple cover the padded region too, because clickable is wrapping the padding. Put padding first and the touch target shrinks down to just the content, since by the time clickable runs, the measured area has already been reduced:
Text("Tap", Modifier.clickable { }.padding(16.dp)) // ripple covers the full padded region
Text("Tap", Modifier.padding(16.dp).clickable { }) // clickable area is content only
Whenever you see a modifier chain, read it top to bottom and ask what each one is wrapping.
Modifier.size() and Modifier.requiredSize() sound like the same thing with an extra word, but they resolve constraints in opposite ways.
size() **proposes** a dimension. If the parent's incoming constraints are tighter, the parent wins and the child gets clamped down to whatever actually fits:
Box(Modifier.size(60.dp)) {
Box(Modifier.size(90.dp).background(Color.Red)) // clamped down to 60.dp
Box(Modifier.requiredSize(90.dp).background(Color.Blue)) // forced to 90.dp, overflows
}
requiredSize() ignores the parent's constraints entirely and forces the exact size you asked for, even if the child ends up overflowing its parent and getting clipped or overlapping a sibling. Reach for requiredSize only when you deliberately need to break out of a parent's bounds, a badge that overhangs its container's corner, say. Everywhere else, plain size is what you want, because it keeps the layout honest about its own bounds.
For long or open-ended lists, reach for LazyColumn instead of a Column wrapped in Modifier.verticalScroll, and LazyRow or LazyVerticalGrid for the horizontal and grid equivalents. The difference isn't just scroll direction, it's composition cost.
A scrollable Column composes, measures, and lays out **every** child eagerly, even the hundreds of rows nowhere near the viewport. A LazyColumn only composes and measures items in, and just around, the visible viewport, recycling compositions as items scroll off:
LazyColumn {
items(bigList) { item -> ItemRow(item) }
}
LazyVerticalGrid(columns = GridCells.Fixed(2)) {
items(bigList) { item -> GridCell(item) }
}
For a handful of items, either approach works fine. The moment a dataset can grow unbounded, a feed, search results, chat history, lazy layouts are the only ones that stay cheap. Getting the most out of them takes a bit more configuration than a plain Column, though: stable keys, contentType, and contentPadding all matter for a lazy list to behave correctly and stay performant.
Nesting a LazyColumn inside a Column(Modifier.verticalScroll(...)) looks like a reasonable way to mix a fixed header with a scrolling list. It crashes at runtime instead, with an IllegalStateException.
The problem is constraints, not composition. A vertically scrolling Column hands its children **unbounded height**, because it's prepared to lay out content taller than the screen. A LazyColumn needs a finite height to know how much of its, potentially huge, item list it even needs to measure. Handed infinite height, it has no way to decide where to stop, so it throws instead of guessing.
// Crashes: the scrolling Column offers unbounded height
Column(Modifier.verticalScroll(rememberScrollState())) {
Header()
LazyColumn { items(list) { DataRow(it) } } // IllegalStateException
}
// Fix: let one LazyColumn own the whole scroll
LazyColumn {
item { Header() }
items(list) { DataRow(it) }
}
The fix is never to give the inner list a magic fixed height, it's to stop nesting two scroll mechanisms and let a single lazy list own the scrolling.
Lazy list items need a **stable key** to preserve identity across data changes:
LazyColumn {
items(myList, key = { it.id }) { item -> ItemRow(item) }
}
Without key, Compose falls back to positional identity: item at index 3 is treated as 'whatever the data currently at index 3 is.' Reorder the underlying list and Compose has no way to know the same logical item moved, it just sees the data at each position change. Remembered state, an expanded flag, rememberSaveable fields, scroll anchoring, gets reassigned to the wrong row or reset outright.
A key drawn from the data itself, an id, fixes this by tracking identity through the data rather than the position. It also has to be usable inside a Bundle, so that state survives things like Activity recreation, not just simple recomposition.
Keys are also a prerequisite for Modifier.animateItem(). Without a stable key, Compose can't tell that the item now at position 2 is the same item that used to be at position 5, so it has nothing to animate a move between; with keys, animateItem() can detect the move, insertion, or removal and animate it.
In a mixed list, section headers next to data rows, add contentType alongside key:
LazyColumn {
items(headers, key = { it.id }, contentType = { "header" }) { HeaderRow(it) }
items(rows, key = { it.id }, contentType = { "row" }) { DataRow(it) }
}
When an item scrolls off screen, Compose tries to reuse its composition for a new item scrolling on, which is cheaper than composing from scratch. Without contentType, Compose might try to reuse a scrolled-off header's composition for an incoming data row, a worse fit than reusing another row's, since the two shapes don't match. contentType tags each item with its shape, so reuse only happens between items of the same type, which keeps recycling efficient even when the list mixes very different layouts.
Two ways to add space around a LazyColumn's content look similar but behave very differently:
LazyColumn(contentPadding = PaddingValues(16.dp)) { ... } // A
LazyColumn(modifier = Modifier.padding(16.dp)) { ... } // B
contentPadding (A) pads **inside** the scrollable area: the first item gets a top inset and the last gets a bottom inset, but as you scroll, content still passes all the way through that padded space, right up to the true edges. Modifier.padding (B) shrinks the entire viewport from the outside, so content is clipped at the smaller boundary and never reaches where the padding used to be.
For a list that should breathe at the edges while still letting its content scroll all the way to them, contentPadding is almost always the one you want.
Some modifiers only compile inside a particular parent, because they need information only that parent can provide.
Modifier.weight() lives on RowScope and ColumnScope. It distributes a container's remaining space proportionally among siblings along the stacking axis, so it only makes sense, and only compiles, as a direct child of a Row or Column:
Row {
Text("Left", Modifier.weight(1f)) // fine, inside RowScope
Text("Right", Modifier.weight(1f)) // fine, inside RowScope
}
// Box { Text("X", Modifier.weight(1f)) } // compile error, Box has no weight concept
A Box has no notion of 'remaining space along an axis' to divide up, so weight isn't available there at all.
Modifier.align(), by contrast, lives on BoxScope. A Box sets one default alignment for all its children through contentAlignment, but align lets a single child override that default for itself:
Box(contentAlignment = Alignment.Center) {
Image(painter, contentDescription = null) // uses the Box default: Center
Badge(modifier = Modifier.align(Alignment.TopEnd)) // overrides for this child only
}
Both modifiers exist because a Row, Column, or Box exposes extra layout information, remaining space, or default alignment, that only makes sense inside that specific parent.
One more BoxScope modifier worth knowing: Modifier.matchParentSize(). It sizes a child to match the Box's final resolved size, while being deliberately **excluded** from the measurement pass that determines that size in the first place.
That distinction matters for something like a dimming overlay:
Box {
Image(painter, contentDescription = null) // this determines the Box's size
Box(
Modifier
.matchParentSize()
.background(Color.Black.copy(alpha = 0.4f))
)
}
Here the Image is the only child that participates in sizing the outer Box. The overlay Box is told to match whatever size the Box ends up being, but it never gets a vote in what that size is. Compare that to Modifier.fillMaxSize(), which fills the incoming maximum constraints and does participate in the sizing pass, using it here instead could change the outer Box's size rather than just decorating it.
CompositionLocal passes a value implicitly down the composition tree, without threading it through every parameter list, using CompositionLocalProvider:
val LocalSpacing = compositionLocalOf { 8.dp }
CompositionLocalProvider(LocalSpacing provides 16.dp) {
Screen() // any descendant can read LocalSpacing.current
}
Reach for it when a value, a theme, spacing, density, needs to reach many nested composables and adding a parameter to every function in between would be pure plumbing with no other purpose.
There are two flavors, and picking wrong costs you recomposition scope. compositionLocalOf tracks individual reads: when the value changes, only the composables that actually called .current recompose. staticCompositionLocalOf skips that tracking, which makes reads cheaper, but in exchange, a change forces the **entire provided subtree** to recompose, whether a given composable reads the value or not. Use the static version for values that almost never change once set, a theme fixed at app start, and the tracked version for anything that can update while the app is running.
Compose's single-pass model normally means a parent can't ask 'how tall would this child be' without fully measuring it, which is exactly why intrinsics exist as an opt-in exception rather than the default. Modifier.height(IntrinsicSize.Min) (or .width(IntrinsicSize.Max)) makes a Row or Column pre-query each child's intrinsic size along the cross axis before its real measurement pass, so the container can size itself to fit them.
Row(Modifier.height(IntrinsicSize.Min)) {
Text("Short")
Divider(Modifier.fillMaxHeight().width(1.dp))
Text("A longer\nmultiline text")
}
Here the Row queries both Texts' intrinsic heights first, resolves its own height to the taller one, and only then does the Divider's fillMaxHeight() have an actual height to fill. Skip IntrinsicSize.Min and the Row just wraps to whatever height it naturally needs, with no defined tallest child for fillMaxHeight to target. Intrinsics cost an extra measurement pass, so reach for them only when you genuinely need this kind of cross-child sizing, not as a default habit.
Every rule in this lesson traces back to the same source: Compose measures once, and information only flows where it's explicitly given a path to flow.
Modifier order matters because each modifier wraps the ones after it, there's no renegotiation once background has already painted or padding has already shrunk the space. Scope-restricted modifiers like weight, align, and matchParentSize exist because a Row, Column, or Box has information, remaining space, a default alignment, its own resolved size, that only makes sense to expose inside that specific parent. Lazy lists need key and contentType for exactly the same reason state gets lost anywhere else in Compose: without an explicit identity to track, Compose has nothing to hang remembered state or a recycled composition on. CompositionLocal and intrinsics are both escape hatches, implicit value passing and an extra measurement pass, used deliberately and sparingly because the default model intentionally doesn't offer them for free.
If an interviewer hands you a modifier chain or a list snippet, the fastest way to sound competent isn't reciting API names. It's naming the constraint each piece of code is working within: what's been measured, what scope a modifier needs, and what identity a list item is being tracked by.