Row, Column, Box and LazyColumn cover most screens. Sooner or later you meet a design they cannot express: tags that wrap onto new lines, a circular menu, a staggered timeline, a chart, a progress ring, a ticket shape with notches. Compose lets you build those yourself, with just a few concepts: Layout to decide where children go, and Canvas / DrawScope to draw shapes, paths and text.
This lesson covers Introduction to drawing in Compose and Custom layouts and graphics from Pathway 2 of the Jetpack Compose for Android developers programme (https://developer.android.com/courses/pathways/jetpack-compose-for-android-developers-2), and follows the ideas of the Custom layouts in Jetpack Compose and Graphics in Compose documentation, with our own examples.
By the end you will be able to:
- Explain the three phases again, now from the layout author's point of view: constraints in, size out, placement.
- Write a custom layout with the
Layoutcomposable and withModifier.layout. - Use parent data, intrinsics and alignment lines at a basic level.
- Draw with
Canvas,DrawScope,drawBehindanddrawWithContent. - Use
Path,Brush,Stroke,BlendModeand clipping. - Draw text on a canvas, and handle touch on custom drawing.
- Use
graphicsLayerfor transformations, anddrawWithCachefor expensive objects. - Build a bar chart, a progress ring and a flow layout of tags.
Big idea: a layout is a function from constraints to a size and a set of positions. A drawing is a function from a size to draw commands. Neither knows anything about Material, state or business logic.
- The three phases: composition, layout, drawing (lesson 37).
- Modifiers, constraints and modifier order (lesson 38).
- State and animation (lessons 17 and 41).
- Basic geometry: coordinates, radius, angle. In Compose the origin
(0, 0)is the top-left; x grows rightward and y grows downward.
When a parent places a child, this conversation happens:
Parent -> Child: "You may be between 0 and 300 px wide and between 0 and 600 px tall." (constraints)
Child -> Parent: "Then I need 120 x 80 px." (measure)
Parent -> Child: "Place yourself at x = 40, y = 100." (place)
In code:
| Concept | Type | Meaning |
|---|---|---|
| Constraints | Constraints(minWidth, maxWidth, minHeight, maxHeight) |
The allowed size range from the parent (in pixels) |
| Measurable | Measurable |
A child that has not been measured yet |
| Placeable | Placeable |
A child after measuring; has width, height, and place(x, y) |
| MeasureResult | from layout(width, height) { ... } |
The layout's own size plus the placement block |
Compose's golden rule: each child is measured only once per layout pass. Trying to measure a child twice throws an exception. This is why Compose layouts nest deeply without the performance penalty that nested View layouts had. If you need information from children before measuring (for example, "what is your natural minimum width?"), use intrinsic measurements (below).
Pixels or dp?
Layout code works in pixels, not dp. Convert with Density: 4.dp.roundToPx() inside a MeasureScope, or with(LocalDensity.current) { 4.dp.toPx() } elsewhere.
Start small. Modifier.layout { measurable, constraints -> ... } changes how a single composable is measured and placed. Example: a firstBaselineToTop modifier that sets the distance from the top of a Text to its first baseline (the kind of precise typographic control Material uses).
package com.example.custom
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.FirstBaseline
import androidx.compose.ui.layout.layout
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
fun Modifier.firstBaselineToTop(firstBaselineToTop: Dp) = this.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
// Safe: the Text composable provides a FirstBaseline alignment line
val firstBaseline = placeable[FirstBaseline]
val placeableY = firstBaselineToTop.roundToPx() - firstBaseline
val height = placeable.height + placeableY
layout(placeable.width, height) {
placeable.placeRelative(0, placeableY)
}
}
@Preview(showBackground = true)
@Composable
fun BaselinePreview() {
Text("Hi there!", Modifier.firstBaselineToTop(32.dp))
}
Walk through:
measurable.measure(constraints)asks the child to measure itself within the same constraints, giving aPlaceable.placeable[FirstBaseline]reads an alignment line: the vertical position of the first text baseline, provided byText.- We compute the extra vertical offset needed so the baseline sits at the requested distance.
layout(width, height) { placeable.placeRelative(0, y) }reports our size and places the child.placeRelativerespects right-to-left layouts;placedoes not.
Use Modifier.layout for one-child tweaks (offsets, aspect ratios, baseline alignment, custom padding). For several children, use Layout.
Layout is the primitive under Row, Column and Box. Our first custom layout is a simple column:
package com.example.custom
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
@Composable
fun MyColumn(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
// 1. Measure every child. Children may be any height, but no wider than we allow.
val childConstraints = constraints.copy(minWidth = 0, minHeight = 0)
val placeables = measurables.map { it.measure(childConstraints) }
// 2. Decide our own size.
val width = placeables.maxOfOrNull { it.width } ?: constraints.minWidth
val height = placeables.sumOf { it.height }
// 3. Report the size and place the children.
layout(
width.coerceIn(constraints.minWidth, constraints.maxWidth),
height.coerceIn(constraints.minHeight, constraints.maxHeight)
) {
var y = 0
placeables.forEach { p ->
p.placeRelative(x = 0, y = y)
y += p.height
}
}
}
}
Use it like any composable:
MyColumn(Modifier.padding(8.dp)) {
Text("First")
Text("Second item")
Text("Third")
}
Three steps in every Layout: measure all children (measurables.map { it.measure(...) }), compute your size, then place them in the layout(width, height) { ... } block. Always make sure your size respects constraints (coerceIn), or the parent will throw an exception. Notice that Layout has no Modifier on children (Modifier on a child is applied by the child's own layout), but you can read per-child parent data (below).
Tags (chips) that fill a row and wrap onto the next line, like text wrapping. Compose now ships FlowRow and FlowColumn, which you should use in production, but building one yourself teaches the whole mechanism.
package com.example.custom
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun SimpleFlowRow(
modifier: Modifier = Modifier,
horizontalSpacing: Dp = 8.dp,
verticalSpacing: Dp = 8.dp,
content: @Composable () -> Unit
) {
Layout(content = content, modifier = modifier) { measurables, constraints ->
val hGap = horizontalSpacing.roundToPx()
val vGap = verticalSpacing.roundToPx()
val maxWidth = constraints.maxWidth
val childConstraints = constraints.copy(minWidth = 0, minHeight = 0)
val placeables = measurables.map { it.measure(childConstraints) }
// Split children into rows
val rows = mutableListOf<List<Placeable>>()
var current = mutableListOf<Placeable>()
var currentWidth = 0
for (p in placeables) {
val needed = if (current.isEmpty()) p.width else currentWidth + hGap + p.width
if (needed > maxWidth && current.isNotEmpty()) {
rows += current
current = mutableListOf()
currentWidth = 0
}
currentWidth = if (current.isEmpty()) p.width else currentWidth + hGap + p.width
current += p
}
if (current.isNotEmpty()) rows += current
val rowHeights = rows.map { row -> row.maxOf { it.height } }
val totalHeight = rowHeights.sum() + vGap * (rows.size - 1).coerceAtLeast(0)
val widest = rows.maxOfOrNull { row -> row.sumOf { it.width } + hGap * (row.size - 1) } ?: 0
layout(
width = widest.coerceIn(constraints.minWidth, constraints.maxWidth),
height = totalHeight.coerceIn(constraints.minHeight, constraints.maxHeight)
) {
var y = 0
rows.forEachIndexed { i, row ->
var x = 0
row.forEach { p ->
p.placeRelative(x, y + (rowHeights[i] - p.height) / 2) // centre vertically in the row
x += p.width + hGap
}
y += rowHeights[i] + vGap
}
}
}
}
Try it:
SimpleFlowRow(Modifier.padding(16.dp)) {
listOf("Kotlin", "Compose", "Room", "Coroutines", "Navigation", "Hilt", "Testing").forEach { tag ->
AssistChip(onClick = {}, label = { Text(tag) })
}
}
On a narrow phone the chips wrap to a second and third line. Our algorithm is a greedy line-breaker: add children to the current row until the next one will not fit. Rotate the device and watch it relayout.
Parent data: per-child instructions
A custom modifier can attach data that the parent layout reads. Example: a "weight" for our column.
import androidx.compose.ui.layout.ParentDataModifier
import androidx.compose.ui.unit.Density
private class MyWeightData(val weight: Float) : androidx.compose.ui.layout.ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any? = this@MyWeightData
}
fun Modifier.myWeight(weight: Float): Modifier = this.then(MyWeightData(weight))
// In the Layout block:
// val w = (measurable.parentData as? MyWeightData)?.weight ?: 0f
This is exactly how Modifier.weight() in Row works. Most apps never need it, but it explains why some modifiers only work inside particular parents (weight needs RowScope/ColumnScope).
Intrinsic measurements
Sometimes a parent needs to know its children's sizes before measuring them, for instance to draw a divider as tall as the tallest sibling. Compose forbids measuring twice, but offers intrinsics: questions like "what is your minimum intrinsic height for this width?" The usual use is Modifier.height(IntrinsicSize.Min) on a Row:
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.VerticalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun TwoColumnsWithDivider() {
Row(Modifier.height(IntrinsicSize.Min)) { // Row is as tall as its tallest child
Text("Short", Modifier.weight(1f))
VerticalDivider(Modifier.fillMaxHeight()) // divider fills that height
Text("A much longer second text that wraps onto several lines", Modifier.weight(1f))
}
}
Intrinsics have a cost (extra measurement queries), so use them for small subtrees only, and never inside lists with hundreds of items.
Alignment lines
An alignment line is a named position inside a child (like the text baseline) that parents can align to. Text publishes FirstBaseline and LastBaseline. Row(verticalAlignment = Alignment.CenterVertically) aligns by centre, but Modifier.alignByBaseline() aligns by baseline, which is what you want when mixing text of different sizes ("₦" small and "1,500" large):
Row {
Text("₦", fontSize = 16.sp, modifier = Modifier.alignByBaseline())
Text("1,500", fontSize = 40.sp, modifier = Modifier.alignByBaseline())
}
Layout decides where. Drawing decides what pixels. The entry point is Canvas, a composable that gives you a DrawScope:
package com.example.custom
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp
@Composable
fun ProgressRing(progress: Float, modifier: Modifier = Modifier) {
val track = MaterialTheme.colorScheme.surfaceVariant
val bar = MaterialTheme.colorScheme.primary
Canvas(modifier.size(120.dp)) {
val strokeWidth = 12.dp.toPx()
val diameter = size.minDimension - strokeWidth
val topLeft = Offset(
(size.width - diameter) / 2f,
(size.height - diameter) / 2f
)
val arcSize = Size(diameter, diameter)
// 1. The full circle track
drawArc(
color = track,
startAngle = 0f,
sweepAngle = 360f,
useCenter = false,
topLeft = topLeft,
size = arcSize,
style = Stroke(width = strokeWidth)
)
// 2. The progress arc, starting at 12 o'clock
drawArc(
color = bar,
startAngle = -90f,
sweepAngle = 360f * progress.coerceIn(0f, 1f),
useCenter = false,
topLeft = topLeft,
size = arcSize,
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
)
}
}
Key facts about DrawScope:
sizeis the canvas size in pixels (size.width,size.height,size.minDimension,size.center).- Colours must be read before the
Canvaslambda (likeMaterialTheme.colorScheme.primaryhere), because the draw lambda is not a composable context. Dp.toPx()converts insideDrawScope(it implementsDensity).- Angles are in degrees;
0fis 3 o'clock, positive is clockwise;-90fstarts at 12 o'clock. Strokedraws outlines; omitstyle(defaultFill) for filled shapes.
The drawing functions
| Function | Draws |
|---|---|
drawRect, drawRoundRect |
Rectangles |
drawCircle, drawOval, drawArc |
Circles, ovals, arcs |
drawLine, drawPoints |
Lines, points |
drawPath |
Any shape built with Path |
drawImage |
An ImageBitmap |
drawText |
Text with a TextMeasurer (below) |
Transformations inside DrawScope
rotate(degrees, pivot) { ... }, translate(left, top) { ... }, scale(...), inset(...), clipRect(...), clipPath(...) and withTransform { ... } temporarily change the coordinate system for the drawing calls inside:
import androidx.compose.ui.graphics.drawscope.rotate
// inside a DrawScope: draw 12 tick marks of a clock face
repeat(12) { i ->
rotate(degrees = i * 30f, pivot = center) {
drawLine(
color = tickColor,
start = Offset(center.x, 8.dp.toPx()),
end = Offset(center.x, 20.dp.toPx()),
strokeWidth = 3.dp.toPx()
)
}
}
Path: any shape
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp
@Composable
fun SparkLine(values: List<Float>, modifier: Modifier = Modifier) {
Canvas(modifier.fillMaxWidth().height(80.dp)) {
if (values.size < 2) return@Canvas
val max = values.max()
val min = values.min()
val range = (max - min).takeIf { it != 0f } ?: 1f
val stepX = size.width / (values.size - 1)
val line = Path().apply {
values.forEachIndexed { i, v ->
val x = i * stepX
val y = size.height - (v - min) / range * size.height
if (i == 0) moveTo(x, y) else lineTo(x, y)
}
}
// the area under the line
val area = Path().apply {
addPath(line)
lineTo(size.width, size.height)
lineTo(0f, size.height)
close()
}
drawPath(
area,
brush = Brush.verticalGradient(listOf(Color(0x551B6B3A), Color.Transparent))
)
drawPath(line, color = Color(0xFF1B6B3A), style = Stroke(width = 3.dp.toPx()))
}
}
Path commands: moveTo, lineTo, quadraticTo, cubicTo (curves), arcTo, addRect, addOval, addRoundRect, close. Compose has Path.op(...) to combine shapes (union, difference) when you need cut-outs such as a ticket with notched sides.
Brush: gradients and patterns
| Brush | Effect |
|---|---|
Brush.linearGradient(colors) |
Diagonal gradient |
Brush.horizontalGradient, verticalGradient |
Straight gradients |
Brush.radialGradient, sweepGradient |
Circular and angular gradients |
SolidColor(color) |
Plain colour as a brush |
ShaderBrush |
A custom shader (advanced, including AGSL on Android 13+) |
A brush can be used for fills, strokes and text.
Blend modes and layers
BlendMode controls how a drawing combines with what is already there: SrcOver (normal), Clear, SrcIn, DstOut, Multiply, Screen, and more. Blend modes that need to erase or mask only work properly in an offscreen layer: add Modifier.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen). Example: cut a circular hole in a coloured overlay (a spotlight):
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
@Composable
fun SpotlightOverlay(centre: Offset, radius: Float) {
Canvas(
Modifier
.fillMaxSize()
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
) {
drawRect(Color(0xCC000000)) // dark overlay
drawCircle(Color.Black, radius, centre, blendMode = BlendMode.Clear) // punch a hole
}
}
You do not always need a Canvas. Draw behind or in front of an existing composable with modifiers:
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Composable
fun BadgeText(text: String) {
Text(
text,
Modifier
.drawBehind { // behind the text
drawRoundRect(Color(0xFFFFE082), cornerRadius = CornerRadius(12.dp.toPx()))
}
.padding(horizontal = 12.dp, vertical = 4.dp)
)
}
@Composable
fun StrikeText(text: String) {
Text(
text,
Modifier.drawWithContent {
drawContent() // draw the text first, then...
drawLine(Color.Red, start = center.copy(x = 0f), end = center.copy(x = size.width), strokeWidth = 3f)
}
)
}
drawWithContent gives you control of when the original content is drawn (drawContent()), so you can draw under, over, or skip it.
drawWithCache: reuse expensive objects
Creating a Path or Brush in every draw call wastes time. Modifier.drawWithCache runs its outer block only when size or read state changes, and the inner onDrawBehind/onDrawWithContent block every frame:
import androidx.compose.foundation.layout.size
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
Modifier
.size(200.dp)
.drawWithCache {
val brush = Brush.linearGradient(listOf(Color.Red, Color.Blue), end = Offset(size.width, size.height))
onDrawBehind { drawRect(brush) }
}
Drawing text
import androidx.compose.foundation.Canvas
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
@Composable
fun LabelledPoint() {
val measurer = rememberTextMeasurer()
val style = MaterialTheme.typography.labelMedium
val colour = MaterialTheme.colorScheme.onSurface
Canvas(Modifier.size(200.dp)) {
drawCircle(colour, radius = 6.dp.toPx(), center = center)
drawText(
textMeasurer = measurer,
text = "Ibadan",
topLeft = Offset(center.x + 10.dp.toPx(), center.y - 8.dp.toPx()),
style = style.copy(color = colour)
)
}
}
TextMeasurer handles fonts, wrapping and scaling. Remember: text drawn on a canvas is not accessible by default. Add Modifier.semantics { contentDescription = ... } to describe the chart (lesson 47).
Touch on a canvas
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.input.pointer.pointerInput
var tapped by remember { mutableStateOf<Offset?>(null) }
Canvas(
Modifier
.fillMaxSize()
.pointerInput(Unit) { detectTapGestures { offset -> tapped = offset } }
) {
tapped?.let { drawCircle(Color.Red, radius = 20.dp.toPx(), center = it) }
}
For charts, convert the tap position into a data index (for a bar chart: (offset.x / barWidth).toInt()), then show a tooltip or highlight.
graphicsLayer: transform without relayout
Modifier.graphicsLayer {
rotationZ = angle
scaleX = scale
scaleY = scale
alpha = fade
translationX = shift
transformOrigin = TransformOrigin.Center
shadowElevation = 8.dp.toPx()
clip = true
shape = CircleShape
}
Transformations in graphicsLayer happen in the draw phase, so animating them does not trigger recomposition or relayout (lesson 41). They also do not change layout size: a scaled-up composable still occupies its original space.
Exercise 1: A bar chart
Draw a bar chart from List<Pair<String, Float>> (for example weekly attendance 0–100) with Canvas: bars with rounded top corners, a baseline, and the label under each bar drawn with TextMeasurer. Add a content description summarising the data for TalkBack.
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
@Composable
fun BarChart(data: List<Pair<String, Float>>, modifier: Modifier = Modifier) {
val measurer = rememberTextMeasurer()
val barColour = MaterialTheme.colorScheme.primary
val axisColour = MaterialTheme.colorScheme.outline
val textStyle = MaterialTheme.typography.labelSmall.copy(color = MaterialTheme.colorScheme.onSurface)
val summary = data.joinToString { "${it.first} ${it.second.toInt()} percent" }
Canvas(
modifier
.fillMaxWidth()
.height(200.dp)
.semantics { contentDescription = "Bar chart. $summary" }
) {
val labelSpace = 24.dp.toPx()
val chartHeight = size.height - labelSpace
val slot = size.width / data.size
val barWidth = slot * 0.6f
val max = 100f
drawLine(axisColour, Offset(0f, chartHeight), Offset(size.width, chartHeight), 2.dp.toPx())
data.forEachIndexed { i, (label, value) ->
val h = chartHeight * (value / max)
val left = i * slot + (slot - barWidth) / 2
drawRoundRect(
color = barColour,
topLeft = Offset(left, chartHeight - h),
size = Size(barWidth, h),
cornerRadius = CornerRadius(6.dp.toPx())
)
val layout = measurer.measure(label, textStyle)
drawText(
layout,
topLeft = Offset(left + (barWidth - layout.size.width) / 2, chartHeight + 4.dp.toPx())
)
}
}
}
The bars' bottom corners are also rounded in this simple version; to round only the tops, build a Path with addRoundRect(RoundRect(rect, topLeft = ..., topRight = ...)) and drawPath. The semantics summary makes the chart usable with TalkBack.
Exercise 2: A centred layout
Write a Layout called CenterStack that places all children centred over each other (like a Box with contentAlignment = Center), and sizes itself to the largest child. Explain why you must pass constraints through.
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
@Composable
fun CenterStack(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Layout(content, modifier) { measurables, constraints ->
val loose = constraints.copy(minWidth = 0, minHeight = 0)
val placeables = measurables.map { it.measure(loose) }
val w = (placeables.maxOfOrNull { it.width } ?: 0).coerceIn(constraints.minWidth, constraints.maxWidth)
val h = (placeables.maxOfOrNull { it.height } ?: 0).coerceIn(constraints.minHeight, constraints.maxHeight)
layout(w, h) {
placeables.forEach { p ->
p.placeRelative((w - p.width) / 2, (h - p.height) / 2)
}
}
}
}
Children must be measured within the parent's constraints (relaxed minimums) or they could be larger than allowed; and the layout's own size must respect the parent's constraints, or Compose throws an exception.
Exercise 3: Find the bug
This ring never shows the theme colour and crashes with "@Composable invocations can only happen from the context of a @Composable function". Fix it.
Canvas(Modifier.size(100.dp)) {
drawCircle(MaterialTheme.colorScheme.primary)
}
DrawScope lambdas are not composable. Read the colour outside the lambda: val c = MaterialTheme.colorScheme.primary then Canvas(Modifier.size(100.dp)) { drawCircle(c) }. The same applies to LocalDensity.current, stringResource and any @Composable call.
Mistake #1: Measuring a child twice. Symptom: IllegalStateException. Fix: measure once; use intrinsics if you need early size information.
Mistake #2: Ignoring parent constraints. Symptom: crash or clipped layout. Fix: coerceIn(minWidth, maxWidth).
Mistake #3: place instead of placeRelative. Symptom: wrong in RTL languages. Fix: placeRelative.
Mistake #4: Mixing dp and px. Symptom: tiny or huge drawing. Fix: convert with toPx() / roundToPx().
Mistake #5: Composable calls in DrawScope. Symptom: compile error. Fix: read first.
Mistake #6: Creating Path or Brush every frame. Symptom: jank. Fix: drawWithCache or remember.
Mistake #7: Blend modes without an offscreen layer. Symptom: black rectangles. Fix: CompositingStrategy.Offscreen.
Mistake #8: Drawing text and forgetting accessibility. Fix: semantics description.
Mistake #9: Reading animated state during composition for a canvas. Symptom: heavy recomposition. Fix: read inside the draw lambda.
Mistake #10: Rebuilding what exists. Fix: use FlowRow, Box, ConstraintLayout, charts libraries when they fit; build custom only when necessary.
Question 1. What are the three things a custom Layout must do?
Measure its children (each once, within constraints), decide its own size (respecting constraints), and place the children in the layout(width, height) { ... } block.
Question 2. Why do you read theme colours outside the Canvas lambda?
The draw lambda is not a composable context, so composable reads such as MaterialTheme.colorScheme.primary are not allowed inside it.
Question 3. What is the difference between drawBehind and drawWithContent?
drawBehind draws behind the composable's content. drawWithContent lets you call drawContent() yourself, so you decide whether to draw before, after or instead of the original content.
Question 4. Why is graphicsLayer efficient for animations?
It applies transformations in the draw phase, so animating rotation, scale or alpha does not trigger recomposition or relayout.
Question 5. When should you use drawWithCache?
When drawing needs objects that are expensive to create (a Path, Brush, shader) that depend only on size or state, so they are created once and reused until those change.
Go deeper: official resources
- Jetpack Compose for Android developers, Pathway 2
- Jetpack Compose layouts codelab
- Jetpack Compose course page
- Jetpack Compose basics codelab
Project goal: Build three reusable custom widgets for a school dashboard, with no charting library: a donut chart, a flow layout of subject tags, and an animated progress ring.
Requirements:
DonutChart(slices: List<Slice>)usingdrawArc, with a legend as a customLayoutorSimpleFlowRow.SubjectTagsusing your own flow layout, wrapping at any width.ProgressRing(progress)animated withanimateFloatAsState, reading the animated value inside the draw lambda.- Each widget has a
contentDescriptionsummary and a preview in light and dark. - A tap on a donut slice highlights it (use
pointerInputand angle maths). - Pass colours from
MaterialTheme, not hard-coded.
Stretch goal: add an animateFloatAsState entrance sweep to the donut, and respect reduced motion (lesson 41).
package com.example.custom
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import kotlin.math.atan2
import kotlin.math.hypot
data class Slice(val label: String, val value: Float, val color: Color)
@Composable
fun DonutChart(slices: List<Slice>, modifier: Modifier = Modifier) {
var selected by remember { mutableIntStateOf(-1) }
val total = slices.sumOf { it.value.toDouble() }.toFloat()
val summary = slices.joinToString { "${it.label} ${(it.value / total * 100).toInt()} percent" }
Canvas(
modifier
.size(200.dp)
.semantics { contentDescription = "Donut chart. $summary" }
.pointerInput(slices) {
detectTapGestures { tap ->
val c = Offset(size.width / 2f, size.height / 2f)
val dx = tap.x - c.x
val dy = tap.y - c.y
var angle = Math.toDegrees(atan2(dy, dx).toDouble()).toFloat() + 90f // 0 at 12 o'clock
if (angle < 0f) angle += 360f
var start = 0f
selected = -1
slices.forEachIndexed { i, s ->
val sweep = s.value / total * 360f
if (angle in start..(start + sweep) && hypot(dx, dy) > size.width * 0.2f) selected = i
start += sweep
}
}
}
) {
val stroke = 36.dp.toPx()
val diameter = size.minDimension - stroke
val topLeft = Offset((size.width - diameter) / 2, (size.height - diameter) / 2)
var start = -90f
slices.forEachIndexed { i, s ->
val sweep = s.value / total * 360f
val width = if (i == selected) stroke * 1.25f else stroke
drawArc(
color = s.color,
startAngle = start,
sweepAngle = sweep - 1f, // 1 degree gap
useCenter = false,
topLeft = topLeft,
size = Size(diameter, diameter),
style = Stroke(width, cap = StrokeCap.Butt)
)
start += sweep
}
}
}
@Composable
fun AnimatedRing(progress: Float, modifier: Modifier = Modifier) {
val animated by animateFloatAsState(progress, label = "ring")
val track = MaterialTheme.colorScheme.surfaceVariant
val bar = MaterialTheme.colorScheme.primary
Canvas(
modifier
.size(120.dp)
.semantics { contentDescription = "Progress ${(progress * 100).toInt()} percent" }
) {
val sw = 12.dp.toPx()
val d = size.minDimension - sw
val tl = Offset((size.width - d) / 2, (size.height - d) / 2)
drawArc(track, 0f, 360f, false, tl, Size(d, d), style = Stroke(sw))
drawArc(bar, -90f, 360f * animated, false, tl, Size(d, d), style = Stroke(sw, cap = StrokeCap.Round))
}
}
SubjectTags is SimpleFlowRow from Build 3 with a SuggestionChip per subject. The animated value (animated) is read inside the Canvas draw lambda, so the ring redraws each frame without recomposing the parent. Verify with the Layout Inspector's recomposition counts (lesson 44) that the widgets do not recompose while animating.
Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.
Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 56 project”.
On github.com click New repository, name it android-lesson-56-custom-layouts-and-graphics, set it to Public, leave Add a README unticked, and click Create repository.
Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.
Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉
Lesson 56 complete! 🎉
You covered:
- Lesson Introduction
- Prerequisite Concepts
- Conceptual Understanding: How a Layout Works
- Step-by-Step Build 1: Modifier.layout, a Custom Modifier
- Step-by-Step Build 2: The Layout Composable
- Step-by-Step Build 3: A Flow Layout for Tags
- Step-by-Step Build 4: Parent Data, Intrinsics and Alignment Lines
- Step-by-Step Build 5: Drawing with Canvas and DrawScope
- Step-by-Step Build 6: Paths, Brushes and Blend Modes
- Step-by-Step Build 7: drawBehind, drawWithContent and drawWithCache
- Step-by-Step Build 8: Text on Canvas, Touch, and graphicsLayer
- Guided Practice Exercises
- Common Mistakes (and How to Fix Them)
- Knowledge Check