Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 067 / 100

Custom drawing with Canvas in Compose: a practical guide

Charts and custom visuals.

Expona Admin7 min read

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:

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

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

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

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

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

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

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles