Building complex layouts with Layout() and understanding measure/placement
Building complex layouts with Layout() and understanding measure/placement
Building complex user interfaces in Jetpack Compose often requires going beyond the standard Box, Row, and Column layouts. While these composables handle most common scenarios beautifully, there are times when you need complete control over how children are measured and positioned. This is where the Layout composable becomes essential—the fundamental building block that powers every layout in Compose, including the standard ones you use daily.
In this article, you'll dive deep into the Layout composable, exploring how measurement and placement work under the hood. You'll examine real implementations from the Compose UI library, understand the constraint system, and learn patterns for building sophisticated custom layouts. This isn't a basic tutorial—it's an exploration of the layout system's internals and the design decisions that make it powerful.
Understanding the core abstraction: What makes Layout special
At its heart, the Layout composable is a function that takes content and a measurement policy, then produces a UI element with specific dimensions and child positions. What distinguishes it from higher-level layouts is its adherence to two fundamental principles: single-pass measurement and constraint-based sizing.
Single-pass measurement
Single-pass measurement means each child is measured exactly once per layout pass. This constraint exists for performance—measuring the same child multiple times would create exponential complexity as layout hierarchies deepen. The implication is significant: you must make all measurement decisions with the information available in a single pass.
Layout(content) { measurables, constraints ->
// Each measurable can only be measured ONCE
val placeables = measurables.map { it.measure(constraints) }
// After measurement, you work with Placeables, not Measurables
layout(width, height) {
placeables.forEach { it.place(x, y) }
}
}
This differs fundamentally from traditional Android Views, where onMeasure could be called multiple times with different MeasureSpec configurations. Compose's single-pass model is faster but requires more upfront planning.
Constraint-based sizing
Constraint-based sizing means parents communicate size expectations to children through Constraints objects, and children respond with their chosen size through Placeable objects. This bidirectional communication enables flexible layouts that adapt to available space.
Parent
│
├─── Constraints(minWidth, maxWidth, minHeight, maxHeight) ───→ Child
│
└─── Placeable(width, height) ←───────────────────────────────── Child
The Constraints class encapsulates four values: minWidth, maxWidth, minHeight, and maxHeight. A child must choose dimensions within these bounds. This is more expressive than Android's MeasureSpec, which could only communicate one dimension's constraints at a time.
These properties aren't just implementation details—they're architectural constraints that enable predictable performance and composable layout logic.
The Layout function signature: Anatomy of a custom layout
Let's examine the Layout function signature to understand its components:
@Composable
inline fun Layout(
content: @Composable () -> Unit,
modifier: Modifier = Modifier,
measurePolicy: MeasurePolicy
)
The three parameters serve distinct roles:
-
content - A composable lambda that defines the children. These become
Measurableobjects during measurement. -
modifier - Applied to the layout itself, affecting its measurement and drawing. Modifiers can intercept and transform constraints before they reach your measure policy.
-
measurePolicy - The brain of the layout. It receives
Measurablechildren and parentConstraints, then returns aMeasureResultcontaining the layout's size and placement logic.
The MeasurePolicy interface is where the real work happens:
interface MeasurePolicy {
fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult
}
The MeasureScope receiver provides density information and the layout() function for creating results. The measurables list contains one entry per child composable. The constraints represent what the parent allows.
Real-world case study: Box implementation
Let's examine how Box is implemented in the Compose UI library. The source is located at foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/Box.kt:
@Composable
inline fun Box(
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
propagateMinConstraints: Boolean = false,
content: @Composable BoxScope.() -> Unit,
) {
val measurePolicy = maybeCachedBoxMeasurePolicy(contentAlignment, propagateMinConstraints)
Layout(
content = { BoxScopeInstance.content() },
measurePolicy = measurePolicy,
modifier = modifier,
)
}
Notice several sophisticated patterns:
MeasurePolicy caching
Box doesn't create a new MeasurePolicy on every recomposition. Instead, it uses maybeCachedBoxMeasurePolicy:
private val Cache1 = cacheFor(true)
private val Cache2 = cacheFor(false)
internal fun maybeCachedBoxMeasurePolicy(
alignment: Alignment,
propagateMinConstraints: Boolean,
): MeasurePolicy {
val cache = if (propagateMinConstraints) Cache1 else Cache2
return cache[alignment] ?: BoxMeasurePolicy(alignment, propagateMinConstraints)
}
The cache contains pre-created MeasurePolicy objects for the 9 standard alignments (TopStart, TopCenter, TopEnd, etc.) for both propagateMinConstraints values. This avoids allocating new objects for common configurations—a performance optimization that matters when layouts are recomposed frequently.
The BoxMeasurePolicy implementation
The actual measurement logic reveals important patterns:
private data class BoxMeasurePolicy(
private val alignment: Alignment,
private val propagateMinConstraints: Boolean,
) : MeasurePolicy {
override fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints,
): MeasureResult {
if (measurables.isEmpty()) {
return layout(constraints.minWidth, constraints.minHeight) {}
}
val contentConstraints =
if (propagateMinConstraints) {
constraints
} else {
constraints.copyMaxDimensions()
}
// ... measurement logic continues
}
}
Pattern 1: Empty content handling - When there are no children, the Box sizes itself to the minimum constraints. This is a common pattern: layouts should have sensible behavior even when empty.
Pattern 2: Constraint propagation control - The propagateMinConstraints parameter determines whether children receive the parent's minimum constraints. When false, constraints.copyMaxDimensions() creates new constraints with minWidth = 0 and minHeight = 0, giving children more flexibility.
Two-phase measurement for matchParentSize
Box has a sophisticated feature: children with Modifier.matchParentSize() size themselves to match the Box, but don't contribute to determining the Box's size. This requires two-phase measurement:
// First measure non-match parent size children to get the size of the Box.
var hasMatchParentSizeChildren = false
var boxWidth = constraints.minWidth
var boxHeight = constraints.minHeight
measurables.fastForEachIndexed { index, measurable ->
if (!measurable.matchesParentSize) {
val placeable = measurable.measure(contentConstraints)
placeables[index] = placeable
boxWidth = max(boxWidth, placeable.width)
boxHeight = max(boxHeight, placeable.height)
} else {
hasMatchParentSizeChildren = true
}
}
// Now measure match parent size children, if any.
if (hasMatchParentSizeChildren) {
val matchParentSizeConstraints = Constraints(
minWidth = if (boxWidth != Constraints.Infinity) boxWidth else 0,
minHeight = if (boxHeight != Constraints.Infinity) boxHeight else 0,
maxWidth = boxWidth,
maxHeight = boxHeight,
)
measurables.fastForEachIndexed { index, measurable ->
if (measurable.matchesParentSize) {
placeables[index] = measurable.measure(matchParentSizeConstraints)
}
}
}
This demonstrates a key pattern: you can defer measuring some children until you know the layout size, as long as each child is still measured exactly once. The matchesParentSize property comes from parent data attached via the modifier.
This article continues for subscribers
Subscribe to Dove Letter for full access to 40+ deep-dive articles about Android and Kotlin development.
Become a Sponsor