androidengineers.Book a session

Jetpack Compose System Design

Custom Layouts & Modifiers

article20 minHard

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

APIWhen to use
LayoutCustom multi-child layouts (flow, radial, staggered grid)
Modifier.layoutIntercept measurement of a single composable
Modifier.drawBehind / drawWithContentCustom drawing without a separate Canvas composable
composedStateful modifiers that need remember or other Compose APIs
SubcomposeLayoutWhen child composition depends on another child's measured size
placeRelativePrefer over place — handles RTL layout direction automatically

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Custom Layouts & Modifiers | Android System Design | Android Engineers