Sometimes Column, Row, and Box are not enough. You need a circular progress bar. A custom chart. A drawing canvas. A shape that doesn’t exist in Material Design.

That is when you use Canvas — Compose’s drawing API that lets you draw anything pixel by pixel.

What is Canvas?

Canvas is a Composable that gives you a blank area to draw on. You can draw shapes, lines, arcs, text — anything.

Canvas(
    modifier = Modifier.size(200.dp)
) {
    // "this" is DrawScope — it has all the drawing functions
    drawCircle(color = Color.Blue)
}

That draws a blue circle that fills the entire 200x200dp area.

Drawing Basics

drawCircle

Canvas(modifier = Modifier.size(200.dp)) {
    // Full circle — fills the canvas
    drawCircle(
        color = Color.Blue,
        radius = size.minDimension / 2,  // Half of the smallest dimension
        center = center                   // Center of the canvas
    )
}

drawRect

Canvas(modifier = Modifier.size(200.dp)) {
    drawRect(
        color = Color.Red,
        topLeft = Offset(20f, 20f),
        size = Size(160f, 100f)
    )
}

drawRoundRect

Canvas(modifier = Modifier.size(200.dp)) {
    drawRoundRect(
        color = Color.Green,
        cornerRadius = CornerRadius(16f, 16f),
        size = Size(size.width, size.height)
    )
}

drawLine

Canvas(modifier = Modifier.size(200.dp)) {
    drawLine(
        color = Color.Black,
        start = Offset(0f, 0f),
        end = Offset(size.width, size.height),
        strokeWidth = 4f
    )
}

drawArc

Arcs are the most useful for progress indicators and charts:

Canvas(modifier = Modifier.size(200.dp)) {
    drawArc(
        color = Color.Blue,
        startAngle = -90f,     // Start at the top (12 o'clock)
        sweepAngle = 270f,     // Draw 270 degrees (75%)
        useCenter = false,     // Don't connect to center (ring, not pie)
        style = Stroke(width = 12f, cap = StrokeCap.Round)
    )
}
  • startAngle: 0 = 3 o’clock, -90 = 12 o’clock, 90 = 6 o’clock
  • sweepAngle: How many degrees to draw (360 = full circle)
  • useCenter: true = pie slice, false = arc ring
  • style: Fill for solid, Stroke(width) for outline

Practical Example: Circular Progress Indicator

The most common custom component — a circular progress bar:

@Composable
fun CircularProgress(
    progress: Float,  // 0f to 1f
    modifier: Modifier = Modifier,
    color: Color = MaterialTheme.colorScheme.primary,
    backgroundColor: Color = MaterialTheme.colorScheme.surfaceVariant,
    strokeWidth: Dp = 12.dp
) {
    Canvas(modifier = modifier.size(120.dp)) {
        val stroke = strokeWidth.toPx()
        val arcSize = size.minDimension - stroke

        // Background circle (full ring)
        drawArc(
            color = backgroundColor,
            startAngle = 0f,
            sweepAngle = 360f,
            useCenter = false,
            topLeft = Offset(stroke / 2, stroke / 2),
            size = Size(arcSize, arcSize),
            style = Stroke(width = stroke, cap = StrokeCap.Round)
        )

        // Progress arc
        drawArc(
            color = color,
            startAngle = -90f,              // Start at top
            sweepAngle = 360f * progress,   // Progress percentage
            useCenter = false,
            topLeft = Offset(stroke / 2, stroke / 2),
            size = Size(arcSize, arcSize),
            style = Stroke(width = stroke, cap = StrokeCap.Round)
        )
    }
}

Use it:

@Composable
fun ProgressDemo() {
    var progress by remember { mutableStateOf(0.75f) }

    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        CircularProgress(progress = progress)
        Spacer(modifier = Modifier.height(16.dp))
        Text("${(progress * 100).toInt()}%", fontSize = 24.sp)
        Slider(
            value = progress,
            onValueChange = { progress = it }
        )
    }
}

Animated Circular Progress

Add animation with animateFloatAsState:

@Composable
fun AnimatedCircularProgress(targetProgress: Float) {
    val animatedProgress by animateFloatAsState(
        targetValue = targetProgress,
        animationSpec = tween(durationMillis = 1000),
        label = "progress"
    )

    CircularProgress(progress = animatedProgress)
}

Now when targetProgress changes, the arc smoothly animates to the new value.

Practical Example: Simple Bar Chart

@Composable
fun BarChart(
    data: List<Float>,
    labels: List<String>,
    modifier: Modifier = Modifier,
    barColor: Color = MaterialTheme.colorScheme.primary
) {
    val maxValue = data.maxOrNull() ?: 1f

    Row(
        modifier = modifier
            .fillMaxWidth()
            .height(200.dp),
        horizontalArrangement = Arrangement.SpaceEvenly,
        verticalAlignment = Alignment.Bottom
    ) {
        data.forEachIndexed { index, value ->
            Column(
                horizontalAlignment = Alignment.CenterHorizontally
            ) {
                // The bar
                val barHeight = (value / maxValue) * 160f
                Box(
                    modifier = Modifier
                        .width(32.dp)
                        .height(barHeight.dp)
                        .background(barColor, RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp))
                )

                Spacer(modifier = Modifier.height(4.dp))

                // The label
                Text(
                    text = labels.getOrElse(index) { "" },
                    fontSize = 12.sp,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }
        }
    }
}

Use it:

BarChart(
    data = listOf(65f, 40f, 85f, 55f, 95f, 70f, 50f),
    labels = listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
)

Drawing with Modifier.drawBehind and drawWithContent

You don’t always need a separate Canvas. You can draw directly on any Composable:

drawBehind — Draw Behind the Content

Text(
    text = "Highlighted",
    modifier = Modifier
        .drawBehind {
            // Draw a yellow highlight behind the text
            drawRoundRect(
                color = Color.Yellow,
                cornerRadius = CornerRadius(8f, 8f),
                size = size
            )
        }
        .padding(horizontal = 8.dp, vertical = 4.dp)
)

drawWithContent — Draw Behind AND On Top

Text(
    text = "Badge Text",
    modifier = Modifier
        .drawWithContent {
            drawContent()  // Draw the text first

            // Then draw a red dot on top-right
            drawCircle(
                color = Color.Red,
                radius = 8f,
                center = Offset(size.width, 0f)
            )
        }
)

Custom Layout

When you need to position children in a way that Column, Row, and Box can’t handle, use the Layout composable:

@Composable
fun OverlappingRow(
    overlapPx: Float = 30f,
    content: @Composable () -> Unit
) {
    Layout(content = content) { measurables, constraints ->
        // Measure all children
        val placeables = measurables.map { it.measure(constraints) }

        // Calculate total width (with overlap)
        val totalWidth = if (placeables.isEmpty()) 0
        else placeables.sumOf { it.width } - (overlapPx * (placeables.size - 1)).toInt()

        val height = placeables.maxOfOrNull { it.height } ?: 0

        layout(totalWidth, height) {
            var xOffset = 0
            placeables.forEach { placeable ->
                placeable.placeRelative(x = xOffset, y = 0)
                xOffset += placeable.width - overlapPx.toInt()
            }
        }
    }
}

Use it for overlapping avatars:

OverlappingRow(overlapPx = 20f) {
    repeat(5) { index ->
        Box(
            modifier = Modifier
                .size(40.dp)
                .clip(CircleShape)
                .background(
                    listOf(Color.Red, Color.Blue, Color.Green, Color.Yellow, Color.Cyan)[index]
                )
                .border(2.dp, Color.White, CircleShape),
            contentAlignment = Alignment.Center
        ) {
            Text("${index + 1}", color = Color.White, fontWeight = FontWeight.Bold)
        }
    }
}

This creates a row of overlapping circular avatars — something impossible with Row alone.

Practical Example: Donut Chart

@Composable
fun DonutChart(
    values: List<Float>,
    colors: List<Color>,
    modifier: Modifier = Modifier
) {
    val total = values.sum()

    Canvas(modifier = modifier.size(180.dp)) {
        val stroke = 40f
        val arcSize = size.minDimension - stroke

        var startAngle = -90f

        values.forEachIndexed { index, value ->
            val sweepAngle = (value / total) * 360f
            drawArc(
                color = colors[index % colors.size],
                startAngle = startAngle,
                sweepAngle = sweepAngle,
                useCenter = false,
                topLeft = Offset(stroke / 2, stroke / 2),
                size = Size(arcSize, arcSize),
                style = Stroke(width = stroke, cap = StrokeCap.Butt)
            )
            startAngle += sweepAngle
        }
    }
}

Use it:

DonutChart(
    values = listOf(35f, 25f, 20f, 15f, 5f),
    colors = listOf(
        Color(0xFF6200EE), Color(0xFF03DAC5), Color(0xFFFF5722),
        Color(0xFF4CAF50), Color(0xFFFF9800)
    )
)

When to Use What

NeedUse
Standard UI (lists, cards, forms)Column, Row, Box, Material components
Custom shapes (progress bars, charts)Canvas with drawArc, drawCircle, etc.
Drawing behind/on top of existing UIModifier.drawBehind, Modifier.drawWithContent
Non-standard child positioningLayout composable
Complex custom drawing with stateCanvas + remember + animateFloatAsState

Common Mistakes

Mistake 1: Wrong Coordinate System

// Canvas uses PIXELS, not dp
// BAD — mixing dp and pixels
drawCircle(radius = 50.dp)  // Won't compile

// GOOD — convert dp to pixels
drawCircle(radius = 50.dp.toPx())

Mistake 2: Forgetting size

// BAD — Canvas with no size is invisible
Canvas(modifier = Modifier) {
    drawCircle(Color.Blue)
}

// GOOD — always set a size
Canvas(modifier = Modifier.size(200.dp)) {
    drawCircle(Color.Blue)
}

Mistake 3: Drawing Outside Bounds

// Canvas clips by default. Drawing outside the bounds is invisible.
// Make sure your coordinates fit within size.width and size.height

Quick Reference

FunctionWhat It Draws
drawCircle(color, radius, center)Circle
drawRect(color, topLeft, size)Rectangle
drawRoundRect(color, cornerRadius)Rounded rectangle
drawLine(color, start, end, strokeWidth)Line
drawArc(color, startAngle, sweepAngle, useCenter)Arc or pie slice
drawOval(color, topLeft, size)Oval / Ellipse
drawPath(path, color)Custom path (any shape)
drawPoints(points, pointMode, color)Points or polygon

Source Code

The complete working code for this tutorial is on GitHub:

View source code on GitHub →

What’s Next?

In the next tutorial, we will learn about Performance — how recomposition works under the hood, stable vs unstable types, and how to make your Compose UI fast.

See you there.