The Compose Measure Phase: Measurable, Constraints, Placeable, layout(), place()
One-line definition: The measure phase is a top-down conversation where the parent hands a child a budget (Constraints), the child returns a sized result (Placeable), and the middleman in between can rewrite both — passing different constraints down and reporting a different size up.
1. Why this matters (the problem it solves)
Every custom layout modifier (Modifier.layout { }, LayoutModifierNode) and every custom Layout composable lives inside the measure phase. Understanding it correctly is the difference between writing a 5-line modifier that works and getting hopelessly confused about why constraints “won’t let” you do something.
The most common confusion: thinking the parent’s constraints bind what you can give to your child. They don’t. The parent’s constraints bind only the size YOU report. What you hand to your child is entirely your choice. This is the mental shift that unlocks bleed modifiers, scrollable content larger than the viewport, custom min-size enforcers, etc.
2. The four roles in Modifier.layout { measurable, constraints -> ... }
fun Modifier.bleedHorizontal(padding: Dp): Modifier = layout { measurable, constraints ->
val extraPx = padding.roundToPx() * 2
val placeable = measurable.measure(constraints.offset(horizontal = extraPx))
layout(placeable.width - extraPx, placeable.height) {
placeable.place(-padding.roundToPx(), 0)
}
}
measurable — the unmeasured child
A Measurable is the child before it has decided its size. The only thing you can do with it is call .measure(someConstraints), which runs measurement and returns a Placeable. Until you call .measure(), the child has no width or height yet.
Mental model: a recipe with no oven temperature yet.
constraints (the parameter) — the budget your parent gave YOU
A Constraints is a range: min..max for width and height. It says “you must report a size within this range”.
Critically: this binds only what you report back via layout(...). It does NOT bind what you give to your child. You are a middleman; you choose what budget to hand down, separately from what budget you received.
Mental model: rules for your own final size.
placeable — the measured child, ready to be placed
When you call measurable.measure(someConstraints), the child sizes itself within the constraints you gave it (not the parent’s original constraints) and returns a Placeable. Its .width and .height are now fixed — already chosen, baked in. Only its (x, y) position is up to you.
Mental model: a sized rectangle waiting for an
(x, y)placement.
layout(width, height) { ... } — what YOU report to your parent + where you place children
The width and height you pass here is your reported size; it must satisfy the parent’s constraints. Inside the lambda, you call placeable.place(x, y) for each child to position them in your coordinate space.
Mental model: handing a rectangle back upstream + filling it (or overfilling it) with your placeables.
3. The four sizes that exist (keeping them straight is the whole game)
For the bleed example, with a 332dp-wide LazyColumn applying 16dp horizontal padding, four distinct widths flow through the function:
| Width | Where it comes from | Value | Meaning |
|---|---|---|---|
constraints.maxWidth |
Parent → us | 300 | What the parent allows US to be |
(constraints.offset(...)).maxWidth |
Us → child | 332 | What we let the CHILD be |
placeable.width |
Child → us | 332 | What the child actually became |
layout(width, ...) arg |
Us → parent | 300 | What we REPORT to the parent |
The bleed lives in the gap between row 3 (332) and row 4 (300). The child paints 332 wide; we admit only 300. The 32dp difference spills out — half on each side, controlled by place(-16, 0).
4. Visual flow
┌──────────────┐
│ PARENT │ hands DOWN: constraints (max=300)
│ (LazyColumn) │
└───────┬──────┘
│ Constraints(min=300, max=300)
▼
┌────────────────────────────────────────────────────┐
│ bleedHorizontal { measurable, constraints -> ... │
│ │
│ STEP A — receive constraints (max=300) │
│ │
│ STEP B — build NEW constraints (max=332) │
│ constraints.offset(horizontal = 32) │
│ │
│ STEP C — measurable.measure(newConstraints) │
│ ↓ child measures itself at 332 │
│ ↓ returns a Placeable of width 332 │
│ │
│ STEP D — layout(placeable.width - 32, ...) │
│ = layout(300, height) │
│ ↓ tells PARENT we are 300 │
│ │
│ STEP E — placeable.place(-16, 0) │
│ ↓ paints child shifted left │
└────────────────────────────────────────────────────┘
│
│ reported up: width=300 (parent is happy)
▼
┌──────────────┐
│ PARENT │ thinks the slot was 300; actual paint is 332
└──────────────┘
The resulting paint vs reported slot:
0 16 316 332
parent's view: │ │ │ │
reported slot │ ╞════ 300 ═══════╡ │ ← what LazyColumn knows
actual paint ╞═════════ 332 ═════════╡ ← what the user sees
▲ ▲
placed at x=-16 ends at x=316
(left bleed) (right bleed, from width)
5. Why specifically placeable.width - extraPx and not just 300?
Both work for HorizontalDivider (which uses fillMaxWidth). But placeable.width - extraPx is the correct general expression.
The expression reads: “however wide the child became, subtract the extra room we gave it.”
- If the child is
fillMaxWidth:placeable.width = 332→332 - 32 = 300. Same as the original max. - If the child is a
Text("Hi")that needed only 40dp:placeable.width = 40→40 - 32 = 8. We report 8dp. - If the child is a fixed
Box(width = 100.dp):placeable.width = 100→100 - 32 = 68.
In every case, this reports the size the child would have been if we hadn’t lied.
placeable.width - extraPx is the inverse of constraints.offset(horizontal = extraPx). We added going down; we subtract coming back up. The parent never sees that we expanded.
Hard-coding 300 would report 300 even for a 40dp text. The LazyColumn would reserve a 300dp slot for what’s really an 8dp item. Wrong for general-purpose use.
6. Full code with annotations
fun Modifier.bleedHorizontal(padding: Dp): Modifier = layout { measurable, constraints ->
val extraPx = padding.roundToPx() * 2
// Hand the CHILD a wider budget than our parent gave us.
// Constraints.offset(horizontal = px) adds to BOTH min and max,
// so a fillMaxWidth() child picks up the new (wider) size.
val placeable = measurable.measure(
constraints.offset(horizontal = extraPx)
)
// Report OUR OWN size to the parent. Subtract the extra we added
// so the parent sees the size the child WOULD have been.
// The parent's constraints bind THIS line, not the measure() call above.
layout(placeable.width - extraPx, placeable.height) {
// Place the child shifted left by `padding`. Its measured width
// (which is `placeable.width`, larger than what we report)
// carries the right edge `padding` past our reported right edge.
placeable.place(-padding.roundToPx(), 0)
}
}
7. Key APIs
Measurable— child before measurement. Call.measure(constraints)once to size it.Constraints—min..maxbudget. Helpers worth knowing:Constraints.offset(horizontal, vertical)— adds to both min and max.Constraints.copy(maxWidth = ...)— change only the upper bound.constraints.constrainWidth(value)/constrainHeight(value)— clamp a value into the constraints (defensive).
Placeable— measured child. Read-only.width/.height. Call.place(x, y)(or.placeRelativefor RTL-aware) inside thelayoutlambda.MeasureScope.layout(width, height) { ... }— return aMeasureResultdescribing your reported size and child placements.Modifier.layout { measurable, constraints -> ... }— the lambda form. Stateless one-shot layout tweaks.LayoutModifierNode— the production form of the same. Use when state, animation, or hot-path performance is on the table.
8. Common mistakes
- Believing parent’s constraints limit what you can give the child. They don’t. They limit only what you
layout(width, height, ...)back. - Hard-coding the reported size (
layout(300, ...)) instead of computing it fromplaceable.width. Works for one specific child, breaks for any other. - Calling
measurable.measure()more than once. It’s a one-shot call. Multiple calls are an error inModifier.layout/LayoutModifierNode. (Multiple measures are only legal inSubcomposeLayout— separate concept.) - Reporting a size outside the parent’s constraints without clamping. The layout system will clamp anyway, but you’ll get unexpected results. Use
constraints.constrainWidth(...)defensively. - Forgetting that
place()takes one(x, y)and expecting symmetric bleed by giving two coordinates. The width carries the right edge automatically — seecompose-custom-modifiers.mdfor the math.
9. Best practices
- For a stateless layout tweak, use
Modifier.layout { }. Promote toLayoutModifierNodeonly when state, lifecycle, or performance matters. - Compute the reported size as a function of
placeable.width / .height, not hard-coded values — it generalizes the modifier to any child. - Always invert what you did: if you offset constraints by
+Ngoing down, subtractNgoing up. - Use
constraints.constrainWidth(...)/constrainHeight(...)to defensively keep your reported size within the parent’s allowed range. - Apply layout-affecting modifiers before decorations like
background/borderin the chain — chain order is application order, and decorations care about the post-layout size.
10. Related concepts / what to learn next
compose-custom-modifiers.md— the production form (Modifier.Node+ModifierNodeElement) of layout modifiers.- Custom
Layout(...)composables — same measure phase, but for arranging multiple children. - Intrinsic measurements —
min/maxIntrinsicWidth/Height, how parents pre-query child sizes. SubcomposeLayout— the only place you can measure first, then compose new content based on the result. Multiple measures of the same subtree are also legal here.LookaheadScope— pre-measurement for shared-element transitions.
11. Quick recall
measurable= child BEFORE sizing. Call.measure(constraints)once → returns aPlaceable.constraints(the param) = budget for YOUR reported size. Not for the child.placeable= sized child..width/.heightare baked in; only(x, y)is yours.layout(width, height) { ... }= report YOUR size + place children inside.placeable.place(x, y)= position the child in your coordinate space. The child’s width carries the right edge.- You can hand the child any constraints you want, including a wider budget than the parent gave you.
- You must report a size within the parent’s constraints. The “bleed” lives in the gap between what you measured and what you report.
placeable.width - extraPx(the reported size) is the inverse ofconstraints.offset(horizontal = extraPx)(the budget you gave the child). Add going down, subtract coming up.- For
HorizontalDividerin a paddedLazyColumn: measure withconstraints.offset(horizontal = 2*padding), reportplaceable.width - 2*padding, place at(-padding, 0). Done. - Every layout modifier has the same shape: tweak constraints down, get a placeable back, decide a reported size, place the placeable.