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:
Fillfor 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
| Need | Use |
|---|---|
| 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 UI | Modifier.drawBehind, Modifier.drawWithContent |
| Non-standard child positioning | Layout composable |
| Complex custom drawing with state | Canvas + 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
| Function | What 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:
Related Tutorials
- Tutorial #3: Modifiers —
drawBehindmodifier introduced - Tutorial #15: Animations — animate Canvas drawings
- Tutorial #7: Theming — use theme colors in Canvas
- Jetpack Compose Cheat Sheet — quick reference for every component and modifier.
- Full Series: Jetpack Compose Tutorial — all 25 tutorials from zero to publishing.
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.