Techbase Academy
Android · Lesson 56

Custom Layouts and Graphics

14 phasesDashboard Widgets from ScratchAndroid Studio
Phase 1 of 14
Lesson Introduction

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 Layout composable and with Modifier.layout.
  • Use parent data, intrinsics and alignment lines at a basic level.
  • Draw with Canvas, DrawScope, drawBehind and drawWithContent.
  • Use Path, Brush, Stroke, BlendMode and clipping.
  • Draw text on a canvas, and handle touch on custom drawing.
  • Use graphicsLayer for transformations, and drawWithCache for 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.

✏️ Your Task
Practise what you just learned about Lesson Introduction. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 2 of 14
Prerequisite Concepts
  • 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.
✏️ Your Task
Practise what you just learned about Prerequisite Concepts. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 3 of 14
Conceptual Understanding: How a Layout Works

When a parent places a child, this conversation happens:

text
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.

✏️ Your Task
Practise what you just learned about Conceptual Understanding: How a Layout Works. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 4 of 14
Step-by-Step Build 1: Modifier.layout, a Custom Modifier

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).

FirstBaseline.kt
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:

  1. measurable.measure(constraints) asks the child to measure itself within the same constraints, giving a Placeable.
  2. placeable[FirstBaseline] reads an alignment line: the vertical position of the first text baseline, provided by Text.
  3. We compute the extra vertical offset needed so the baseline sits at the requested distance.
  4. layout(width, height) { placeable.placeRelative(0, y) } reports our size and places the child. placeRelative respects right-to-left layouts; place does not.

Use Modifier.layout for one-child tweaks (offsets, aspect ratios, baseline alignment, custom padding). For several children, use Layout.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 1: Modifier.layout, a Custom Modifier. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 5 of 14
Step-by-Step Build 2: The Layout Composable

Layout is the primitive under Row, Column and Box. Our first custom layout is a simple column:

MyColumn.kt
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:

kotlin
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).

✏️ Your Task
Practise what you just learned about Step-by-Step Build 2: The Layout Composable. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 6 of 14
Step-by-Step Build 3: A Flow Layout for Tags

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.

SimpleFlowRow.kt
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:

kotlin
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.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 3: A Flow Layout for Tags. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 7 of 14
Step-by-Step Build 4: Parent Data, Intrinsics and Alignment Lines

Parent data: per-child instructions

A custom modifier can attach data that the parent layout reads. Example: a "weight" for our column.

kotlin
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:

kotlin
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):

kotlin
Row {
 Text("₦", fontSize = 16.sp, modifier = Modifier.alignByBaseline())
 Text("1,500", fontSize = 40.sp, modifier = Modifier.alignByBaseline())
}
✏️ Your Task
Practise what you just learned about Step-by-Step Build 4: Parent Data, Intrinsics and Alignment Lines. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 8 of 14
Step-by-Step Build 5: Drawing with Canvas and DrawScope

Layout decides where. Drawing decides what pixels. The entry point is Canvas, a composable that gives you a DrawScope:

ProgressRing.kt
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:

  • size is the canvas size in pixels (size.width, size.height, size.minDimension, size.center).
  • Colours must be read before the Canvas lambda (like MaterialTheme.colorScheme.primary here), because the draw lambda is not a composable context.
  • Dp.toPx() converts inside DrawScope (it implements Density).
  • Angles are in degrees; 0f is 3 o'clock, positive is clockwise; -90f starts at 12 o'clock.
  • Stroke draws outlines; omit style (default Fill) 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:

kotlin
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()
 )
 }
}
✏️ Your Task
Practise what you just learned about Step-by-Step Build 5: Drawing with Canvas and DrawScope. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 9 of 14
Step-by-Step Build 6: Paths, Brushes and Blend Modes

Path: any shape

kotlin
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):

kotlin
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
 }
}
✏️ Your Task
Practise what you just learned about Step-by-Step Build 6: Paths, Brushes and Blend Modes. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 10 of 14
Step-by-Step Build 7: drawBehind, drawWithContent and drawWithCache

You do not always need a Canvas. Draw behind or in front of an existing composable with modifiers:

kotlin
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:

kotlin
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) }
 }
✏️ Your Task
Practise what you just learned about Step-by-Step Build 7: drawBehind, drawWithContent and drawWithCache. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 11 of 14
Step-by-Step Build 8: Text on Canvas, Touch, and graphicsLayer

Drawing text

kotlin
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

kotlin
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

kotlin
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.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 8: Text on Canvas, Touch, and graphicsLayer. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 12 of 14
Guided Practice Exercises
🎯 Your Challenge

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.

kotlin
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.

🎯 Your Challenge

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.

kotlin
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.

🎯 Your Challenge

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.

kotlin
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.

✏️ Your Task
Practise what you just learned about Guided Practice Exercises. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 13 of 14
Common Mistakes (and How to Fix Them)

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.

✏️ Your Task
Practise what you just learned about Common Mistakes (and How to Fix Them). Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 14 of 14
Knowledge Check

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

✏️ Your Task
Practise what you just learned about Knowledge Check. Open Android Studio, type the examples by hand, change a value, and observe what happens.
🏗️ Build It — Mini Project
Dashboard Widgets from Scratch

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:

  1. DonutChart(slices: List<Slice>) using drawArc, with a legend as a custom Layout or SimpleFlowRow.
  2. SubjectTags using your own flow layout, wrapping at any width.
  3. ProgressRing(progress) animated with animateFloatAsState, reading the animated value inside the draw lambda.
  4. Each widget has a contentDescription summary and a preview in light and dark.
  5. A tap on a donut slice highlights it (use pointerInput and angle maths).
  6. Pass colours from MaterialTheme, not hard-coded.

Stretch goal: add an animateFloatAsState entrance sweep to the donut, and respect reduced motion (lesson 41).

Widgets.kt
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.

1

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.

2

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”.

3

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.

4

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.

5

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
All 72 lessons in Google Android Studio ·Techbase Academy ·© 2025–2026 Techbase Consultant Services, Ibadan
Esc
↑ ↓ to moveEnter to openEsc to close