Introduction
When you need to render charts, custom graphs, or interactive visual components in modern Android apps, Jetpack Compose's Canvas composable gives you direct access to drawing primitives without the overhead of custom View subclasses. Custom drawing with Canvas in Compose: a practical guide teaches you how to build performant, animated visuals by leveraging DrawScope and its rich set of drawing functions. We'll walk through real production scenarios where this approach has proven faster to implement and maintain than traditional Canvas-based Views.
Understanding Canvas and DrawScope
The Canvas composable in Compose is your gateway to low-level drawing operations. Unlike Views, Canvas integrates seamlessly with Compose's recomposition model and state system. When you call Canvas, you provide a modifier and a drawing lambda that receives a DrawScope receiver.
DrawScope is the key abstraction. It gives you a coordinate system with its origin at the top-left corner, width and height based on the composable's measured size, and access to a variety of drawing functions. The drawing lambda is suspendable, though for simple shapes you'll rarely need that feature.
Here's a minimal example that draws a simple rectangle:
Canvas(modifier = Modifier.size(200.dp)) {
drawRect(
color = Color.Blue,
topLeft = Offset(50f, 50f),
size = Size(100f, 100f)
)
}The size is in pixels within the DrawScope's coordinate system. Compose handles the density scaling for you, so you don't need to manually convert dp to px inside the drawing lambda. The DrawScope receiver gives you access to size (the canvas dimensions) and density (for manual conversions if needed).
Drawing Lines, Circles, and Paths
Most custom visuals combine multiple primitive drawing operations. Let's build a simple chart scenario that a team working on an analytics dashboard might encounter.
For lines, use drawLine. For circles, use drawCircle. For complex shapes, build a Path and pass it to drawPath. Here's a practical example that draws a bar chart with gridlines:
@Composable
fun SimpleBarChart(
values: List,
modifier: Modifier = Modifier
) {
Canvas(modifier = modifier.size(300.dp, 200.dp)) {
val chartWidth = size.width
val chartHeight = size.height
val barWidth = chartWidth / values.size
// Draw horizontal gridlines
for (i in 0..4) {
val y = (chartHeight / 4) * i
drawLine(
color = Color.LightGray,
start = Offset(0f, y),
end = Offset(chartWidth, y),
strokeWidth = 1f
)
}
// Draw bars
values.forEachIndexed { index, value ->
val barHeight = (value / 100f) * chartHeight
val left = index * barWidth + 10f
val top = chartHeight - barHeight
drawRect(
color = Color.Blue,
topLeft = Offset(left, top),
size = Size(barWidth - 20f, barHeight)
)
}
// Draw axis lines
drawLine(
color = Color.Black,
start = Offset(0f, chartHeight),
end = Offset(chartWidth, chartHeight),
strokeWidth = 2f
)
drawLine(
color = Color.Black,
start = Offset(0f, 0f),
end = Offset(0f, chartHeight),
strokeWidth = 2f
)
}
}For more complex shapes like curves or irregular polygons, build a Path object. Paths support common operations: moveTo (set starting point), lineTo (draw a line to a point), quadraticBezierTo (draw a quadratic curve), cubicTo (draw a cubic Bézier curve), and close (finish the path).
Here's a simple line chart using paths:
@Composable
fun LineChart(points: List, modifier: Modifier = Modifier) {
Canvas(modifier = modifier.size(300.dp, 200.dp)) {
val chartWidth = size.width
val chartHeight = size.height
if (points.size < 2) return@Canvas
val path = Path()
val stepX = chartWidth / (points.size - 1)
points.forEachIndexed { index, value ->
val x = index * stepX
val y = chartHeight - (value / 100f) * chartHeight
if (index == 0) {
path.moveTo(x, y)
} else {
path.lineTo(x, y)
}
}
drawPath(
path = path,
color = Color.Blue,
style = Stroke(width = 3f)
)
}
}Notice the Stroke style parameter. By default, drawPath fills the shape. Pass Stroke to outline it instead. You can customize stroke width, cap style (round, square, butt), and join style (round, bevel, miter).
Combining Canvas with Animations
Canvas drawings respond beautifully to Compose's animation system. Store animated values outside the drawing lambda and reference them inside it. The most common pattern uses animateFloatAsState or Animatable.
Here's a chart that animates its bars on first composition:
@Composable
fun AnimatedBarChart(values: List, modifier: Modifier = Modifier) {
var isVisible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
isVisible = true
}
val animatedValues = values.map { targetValue ->
animateFloatAsState(
targetValue = if (isVisible) targetValue else 0f,
animationSpec = tween(durationMillis = 800, easing = EaseOutCubic),
label = "barAnimation"
)
}
Canvas(modifier = modifier.size(300.dp, 200.dp)) {
val chartWidth = size.width
val chartHeight = size.height
val barWidth = chartWidth / values.size
animatedValues.forEachIndexed { index, animState ->
val barHeight = (animState.value / 100f) * chartHeight
val left = index * barWidth + 10f
val top = chartHeight - barHeight
drawRect(
color = Color.Blue,
topLeft = Offset(left, top),
size = Size(barWidth - 20f, barHeight)
)
}
}
}For interactive visuals that track touch or other events, use Animatable directly inside a coroutine. This gives you more control over timing and lets you respond to gestures in real time, such as animating a chart element when the user taps it.
Keeping Drawing Code Efficient
Canvas recomposition happens whenever its parameters change, but the drawing lambda itself is called during layout and composition. A few practices keep it performant:
Cache complex calculations outside the drawing lambda. If you're computing bar positions or scaling factors, do that in the composable function itself or use remember:
@Composable
fun OptimizedChart(values: List, modifier: Modifier = Modifier) {
val chartMetrics = remember(values) {
values.map { it / 100f }
}
Canvas(modifier = modifier.size(300.dp, 200.dp)) {
chartMetrics.forEachIndexed { index, normalized ->
val barHeight = normalized * size.height
// draw bar...
}
}
}Avoid object allocation inside the drawing lambda. Creating Color, Offset, Size, or Paint objects repeatedly can pressure garbage collection. Reuse or precompute them when possible.
Use drawIntoCanvas sparingly. If you need to use a native Android Canvas directly, drawIntoCanvas gives you access, but prefer the DrawScope API which is optimized for Compose.
Profile large datasets. If your chart has thousands of data points, consider decimation: render every Nth point and draw connecting lines. Users rarely perceive the difference, and performance improves dramatically.
Here's a decimation example:
fun decimate(data: List, targetPoints: Int): List {
if (data.size <= targetPoints) return data
val step = data.size / targetPoints
return data.filterIndexed { index, _ -> index % step == 0 }
}Common mistakes
Forgetting that Canvas uses pixel coordinates, not dp. The size you pass to Modifier.size is in dp, but inside DrawScope, all coordinates are in pixels. Compose handles scaling automatically, but it's a common source of confusion when debugging layouts.
Creating new objects in every recomposition. If you pass a list of Color objects or paths as parameters, wrap them in remember to avoid recreation on every recomposition.
Not accounting for density when drawing text. If you use drawContext.canvas.nativeCanvas to draw text directly, remember to scale by density. Use the density value from DrawScope: drawText positions should account for it.
Overcomplicating animations. Resist the urge to use rememberCoroutineScope and manual frame updates. Compose's animation primitives (animateFloatAsState, Animatable, updateTransition) handle the complexity and integrate seamlessly with recomposition.
Key takeaways
- Canvas and DrawScope provide direct drawing access without custom View subclasses; leverage them for charts, graphs, and custom visuals.
- Use drawLine, drawCircle, drawRect, and drawPath to build complex shapes; combine them to create full charts.
- Animate canvas content by storing animated values outside the drawing lambda and referencing them inside it; use animateFloatAsState for simple cases and Animatable for more control.
- Cache calculations, avoid object allocation inside the drawing lambda, and consider decimation for large datasets to maintain smooth frame rates.
- Profile your canvas drawings with Android Studio's profiler; if frame rates drop, focus on reducing object allocations and simplifying path geometry.






Comments
Loading comments…