๐ŸŽจ Jetpack Compose

Custom Layouts and Drawing in Compose

Row, Column and Box cover most screens, but sooner or later you need a chart, a tag cloud or a timeline that no built-in component draws. Compose makes custom layout and drawing far simpler than custom Views. Here's how measurement works, plus two complete examples.

โœ๏ธ Yunok Tech ๐Ÿ“… Updated โฑ 3 min read ๐ŸŽฏ Advanced
On this page
  1. How Compose layout works
  2. Example 1: A simple wrapping tag layout
  3. The layout modifier for a single element
  4. Drawing: Canvas, drawBehind and drawWithCache
  5. Example 2: An accessible progress ring chart
  6. When to go custom
  7. Common mistakes
  8. Frequently asked questions
  9. Conclusion

With Views, a custom component meant subclassing View, overriding onMeasure, onLayout and onDraw, and declaring XML attributes. In Compose, layout is one function that measures children and places them, and drawing is a lambda with a canvas.

How Compose layout works

Compose single-pass layout: constraints down, sizes up, then placement A parent passes constraints, minimum and maximum width and height, down to each child. Each child measures itself and returns a size. The parent decides its own size and places each child at a position. Parent layout 1. constraints โ†“ 2. size โ†‘ 3. place(x, y) Child A Child B
Each child is measured once per layout pass, which is why Compose avoids the exponential cost of nested View measurement.

Example 1: A simple wrapping tag layout

Compose already provides FlowRow for this. Building a simplified version yourself is the best way to learn the Layout API:

TagWrapLayout.ktKotlin
@Composable
fun TagWrapLayout(
    modifier: Modifier = Modifier,
    horizontalGap: Dp = 8.dp,
    verticalGap: Dp = 8.dp,
    content: @Composable () -> Unit,
) {
    Layout(content = content, modifier = modifier) { measurables, constraints ->
        val hGap = horizontalGap.roundToPx()
        val vGap = verticalGap.roundToPx()
        // Children may be any width up to ours; never force a minimum on them.
        val childConstraints = constraints.copy(minWidth = 0, minHeight = 0)
        val placeables = measurables.map { it.measure(childConstraints) }

        // Compute positions row by row.
        val positions = ArrayList<IntOffset>(placeables.size)
        var x = 0; var y = 0; var rowHeight = 0
        placeables.forEach { p ->
            if (x > 0 && x + p.width > constraints.maxWidth) {   // wrap to next row
                x = 0; y += rowHeight + vGap; rowHeight = 0
            }
            positions += IntOffset(x, y)
            x += p.width + hGap
            rowHeight = maxOf(rowHeight, p.height)
        }
        val height = (y + rowHeight).coerceIn(constraints.minHeight, constraints.maxHeight)

        layout(width = constraints.maxWidth, height = height) {
            placeables.forEachIndexed { i, p -> p.place(positions[i]) }
        }
    }
}

This assumes a bounded maximum width, which is the normal case inside a screen. Inside a horizontally scrolling parent, maxWidth would be infinite, and you'd need to handle that.

The layout modifier for a single element

Baseline-to-top paddingKotlin
fun Modifier.firstBaselineToTop(distance: Dp) = layout { measurable, constraints ->
    val placeable = measurable.measure(constraints)
    val baseline = placeable[FirstBaseline]
    check(baseline != AlignmentLine.Unspecified)
    val y = distance.roundToPx() - baseline
    layout(placeable.width, placeable.height + y) { placeable.placeRelative(0, y) }
}

Drawing: Canvas, drawBehind and drawWithCache

  • Canvas(modifier) { โ€ฆ }: a composable that is purely a drawing surface.
  • Modifier.drawBehind: draw behind a composable's content, for example custom backgrounds.
  • Modifier.drawWithCache: create objects (paths, brushes) once and reuse them until the size or state changes.

Example 2: An accessible progress ring chart

ProgressRing.ktKotlin
@Composable
fun ProgressRing(
    progress: Float,                  // 0f..1f
    label: String,
    modifier: Modifier = Modifier,
    strokeWidth: Dp = 12.dp,
    trackColor: Color = MaterialTheme.colorScheme.surfaceVariant,
    progressColor: Color = MaterialTheme.colorScheme.primary,
) {
    val animated by animateFloatAsState(progress.coerceIn(0f, 1f), label = "ring")
    Box(
        modifier = modifier
            .size(120.dp)
            .semantics(mergeDescendants = true) {
                contentDescription = "$label: ${(progress * 100).roundToInt()} percent"
                progressBarRangeInfo = ProgressBarRangeInfo(progress, 0f..1f)
            }
            .drawWithCache {
                val stroke = Stroke(width = strokeWidth.toPx(), cap = StrokeCap.Round)
                val inset = stroke.width / 2
                val arcSize = Size(size.width - stroke.width, size.height - stroke.width)
                onDrawBehind {
                    drawArc(trackColor, 0f, 360f, false, Offset(inset, inset), arcSize, style = stroke)
                    drawArc(progressColor, -90f, 360f * animated, false, Offset(inset, inset), arcSize, style = stroke)
                }
            },
        contentAlignment = Alignment.Center,
    ) {
        Text("${(animated * 100).roundToInt()}%", style = MaterialTheme.typography.titleLarge)
    }
}

Because animated is read inside the draw lambda, each animation frame only redraws, with no recomposition of the ring. The semantics block makes the chart understandable to TalkBack users, which custom drawing would otherwise lose.

When to go custom

Charts and gauges

Lightweight bar, line or ring charts drawn with Canvas, avoiding a heavy charting library for simple needs.

Special arrangements

Timelines, staggered badges and overlapping avatars that Row and Column can't express cleanly.

Decorations

Dashed borders, gradient strokes and ticket notches with drawBehind or custom Shapes.

Common mistakes

  • Measuring a child twice in one layout pass, which isn't allowed. Use intrinsic measurements or SubcomposeLayout if you truly need it.
  • Allocating Paths and Brushes in drawBehind every frame. Use drawWithCache.
  • Ignoring constraints, for example returning a size larger than maxWidth.
  • Forgetting semantics, which makes custom components invisible to screen readers.
  • Ignoring right-to-left layouts. Use placeRelative for direction-aware placement.

Frequently asked questions

When should I use SubcomposeLayout?

When a child's content depends on another child's measured size, as in BoxWithConstraints or some table layouts. It's more expensive, so prefer Layout when possible.

Can I use Android's Canvas APIs?

Yes, via drawIntoCanvas { it.nativeCanvas } for things like advanced text drawing, though Compose's DrawScope covers most needs.

How do I handle touches on a custom drawing?

Use Modifier.pointerInput with gesture detectors such as detectTapGestures, and map touch positions to your drawn shapes.

Conclusion

Custom Compose components come down to two tools: Layout (constraints down, sizes up, then place) and drawing modifiers (Canvas, drawBehind, drawWithCache). Read animated state in the draw phase, cache drawing objects, and always add semantics.

Next step: lock in your UI's correctness with Compose UI Testing and Screenshot Testing.

Official references

๐Ÿ“ฑ Apps built by Yunok Tech

Real Android apps on Google Play, free to download.

See all our apps โ†’

Ready to build real Android applications?

Jetpack Compose clicks once you build real screens with real state. Practise modern Android UI development on projects that go beyond toy examples.

Move beyond theory and strengthen your Android development skills with Yunoktech's Android course. Learn core concepts, practical implementation, modern Android development techniques, and the reasoning behind building maintainable applications.

Explore the Android course and take your next step as an Android developer.

Share: LinkedIn X / Twitter WhatsApp