Compose provides built-in layouts (Column, Row, Box, LazyColumn), but some UIs require precise control over measurement and placement. Custom layouts and custom modifiers give you that control without dropping down to the View system.
Layout Model: Measure Once, Place Once
Compose enforces a strict single-pass layout: each child is measured exactly once. This is more efficient than the View system, where ViewGroups may measure children multiple times.
// Layout is the primitive for all compose layouts
@Composable
fun CenteredWithOffset(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
// 1. Measure children
val placeables = measurables.map { measurable ->
measurable.measure(constraints) // pass constraints down
}
// 2. Determine own size
val width = constraints.maxWidth
val height = constraints.maxHeight
// 3. Place children
layout(width, height) {
placeables.forEach { placeable ->
val x = (width - placeable.width) / 2
val y = (height - placeable.height) / 2
placeable.placeRelative(x, y) // placeRelative handles RTL automatically
}
}
}
}
Practical Custom Layout: Flow Layout
@Composable
fun FlowRow(
modifier: Modifier = Modifier,
horizontalGap: Dp = 8.dp,
verticalGap: Dp = 8.dp,
content: @Composable () -> Unit
) {
val hGapPx = with(LocalDensity.current) { horizontalGap.roundToPx() }
val vGapPx = with(LocalDensity.current) { verticalGap.roundToPx() }
Layout(modifier = modifier, content = content) { measurables, constraints ->
val placeables = measurables.map { it.measure(constraints) }
var x = 0; var y = 0; var rowHeight = 0
val positions = mutableListOf<Pair<Int, Int>>()
placeables.forEach { placeable ->
if (x + placeable.width > constraints.maxWidth && x > 0) {
y += rowHeight + vGapPx
x = 0; rowHeight = 0
}
positions.add(x to y)
x += placeable.width + hGapPx
rowHeight = maxOf(rowHeight, placeable.height)
}
val totalHeight = (y + rowHeight).coerceIn(constraints.minHeight, constraints.maxHeight)
layout(constraints.maxWidth, totalHeight) {
placeables.forEachIndexed { i, placeable ->
placeable.placeRelative(positions[i].first, positions[i].second)
}
}
}
}
Custom Modifiers
Modifier.drawBehind: Draw Behind Content
fun Modifier.coloredBadge(color: Color, radius: Dp) = this.drawBehind {
drawCircle(
color = color,
radius = radius.toPx(),
center = Offset(size.width, 0f) // top-right corner
)
}
// Usage
Text(
text = "New",
modifier = Modifier.coloredBadge(Color.Red, radius = 8.dp)
)
Modifier.layout: Intercept Measurement
// Add extra space outside the composable's reported bounds (useful for touch targets)
fun Modifier.minimumTouchTarget(minSize: Dp = 48.dp) = this.layout { measurable, constraints ->
val minPx = minSize.roundToPx()
val placeable = measurable.measure(constraints)
val measuredWidth = maxOf(placeable.width, minPx)
val measuredHeight = maxOf(placeable.height, minPx)
layout(measuredWidth, measuredHeight) {
val offsetX = (measuredWidth - placeable.width) / 2
val offsetY = (measuredHeight - placeable.height) / 2
placeable.placeRelative(offsetX, offsetY)
}
}
composed: Stateful Modifiers
// composed allows modifiers that use Compose state (remember, LaunchedEffect)
fun Modifier.animatedBorder(color: Color, width: Dp = 2.dp): Modifier = composed {
val infiniteTransition = rememberInfiniteTransition()
val animatedColor by infiniteTransition.animateColor(
initialValue = color,
targetValue = color.copy(alpha = 0.3f),
animationSpec = infiniteRepeatable(tween(1000), RepeatMode.Reverse)
)
this.border(width, animatedColor, RoundedCornerShape(8.dp))
}
SubcomposeLayout: Deferred Composition
SubcomposeLayout measures some children based on the measured size of others:
@Composable
fun WithStickyFooter(
modifier: Modifier = Modifier,
footer: @Composable () -> Unit,
content: @Composable (footerHeight: Dp) -> Unit
) {
SubcomposeLayout(modifier) { constraints ->
// First: measure the footer
val footerPlaceable = subcompose("footer", footer).first().measure(constraints)
val footerHeight = footerPlaceable.height.toDp()
// Then: measure content knowing footer height
val contentPlaceable = subcompose("content") {
content(footerHeight)
}.first().measure(constraints.copy(maxHeight = constraints.maxHeight - footerPlaceable.height))
layout(constraints.maxWidth, constraints.maxHeight) {
contentPlaceable.placeRelative(0, 0)
footerPlaceable.placeRelative(0, constraints.maxHeight - footerPlaceable.height)
}
}
}
Key Takeaways
| API | When to use |
|---|---|
Layout | Custom multi-child layouts (flow, radial, staggered grid) |
Modifier.layout | Intercept measurement of a single composable |
Modifier.drawBehind / drawWithContent | Custom drawing without a separate Canvas composable |
composed | Stateful modifiers that need remember or other Compose APIs |
SubcomposeLayout | When child composition depends on another child's measured size |
placeRelative | Prefer over place — handles RTL layout direction automatically |