Canvas Drawing in Jetpack Compose

Quick answer: use Canvas when a built-in composable cannot express the visual precisely—such as a chart, diagram, game-like surface, waveform, or custom indicator. Give the Canvas a real size, draw inside its DrawScope, and calculate positions from size so the result adapts to its measured bounds.

Compose Canvas is a composable drawing surface. Its onDraw lambda runs in the drawing phase and receives a DrawScope with the current size, density-aware conversion helpers, and primitives such as drawCircle, drawRect, drawLine, drawPath, and drawArc.

The Canvas API reference makes two constraints explicit: Canvas needs a size from its modifier, and its drawing lambda is not composition—calling another composable inside it is a runtime error.

When Canvas is the right tool

NeedPrefer
Text, icons, images, buttons, normal cardsRegular composables
A custom background or border on an existing composableA drawing modifier
A chart, path, geometry-driven indicator, or freeform visualCanvas
Android Drawable or platform-canvas interopdrawIntoCanvas inside a draw scope

Canvas gives pixel-level drawing control. It does not replace layout, semantics, focus behavior, or ordinary Material components. Start with a regular composable when the UI is fundamentally text, actions, and layout; use Canvas when the visual itself is the component.

The Canvas mental model

Canvas draws in its own local coordinate system:

  • (0f, 0f) is the top-left of the Canvas.
  • x grows to the right and y grows downward.
  • size is the measured drawing area in pixels.
  • center is the center of that measured area.
  • Draw calls use pixel values, so convert Dp with toPx() inside the draw scope.

The Compose drawing overview describes Canvas as a convenient wrapper around Modifier.drawBehind. That means it participates in the same Compose drawing system as custom drawing modifiers, but it is convenient when the drawing surface is the whole composable.

Draw a responsive progress ring

This example uses the Canvas size instead of hard-coded coordinates. It draws a track, a progress arc, and a center marker while leaving a safe inset around the edge.

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.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp
import kotlin.math.min

@Composable
fun ProgressRing(
    progress: Float,
    modifier: Modifier = Modifier,
) {
    Canvas(
        modifier = modifier
            .fillMaxWidth()
            .height(180.dp),
    ) {
        val safeProgress = progress.coerceIn(0f, 1f)
        val strokeWidth = 14.dp.toPx()
        val padding = strokeWidth / 2f
        val diameter = min(size.width, size.height) - strokeWidth
        val topLeft = Offset(
            x = (size.width - diameter) / 2f,
            y = (size.height - diameter) / 2f,
        )

        drawArc(
            color = Color(0xFF2A3158),
            startAngle = -90f,
            sweepAngle = 360f,
            useCenter = false,
            topLeft = topLeft,
            size = androidx.compose.ui.geometry.Size(diameter, diameter),
            style = Stroke(width = strokeWidth, cap = StrokeCap.Round),
        )
        drawArc(
            color = Color(0xFF00B7B1),
            startAngle = -90f,
            sweepAngle = 360f * safeProgress,
            useCenter = false,
            topLeft = topLeft,
            size = androidx.compose.ui.geometry.Size(diameter, diameter),
            style = Stroke(width = strokeWidth, cap = StrokeCap.Round),
        )
        drawCircle(
            color = Color(0xFF8B5CF6),
            radius = padding,
            center = center,
        )
    }
}

The fixed height gives the Canvas a measurable area; fillMaxWidth() supplies the width. The drawing code then derives the diameter from the smaller dimension, so the ring remains circular in a wide or narrow parent.

Draw calls are relative to the Canvas, not the screen. A coordinate copied from one layout will not automatically work in another—derive it from size, center, padding, and the data being visualized.

Draw a path for custom geometry

Path is useful when basic primitives do not describe the visual. Build the geometry, then render it with drawPath.

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.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp

@Composable
fun TrendLine(
    values: List<Float>,
    modifier: Modifier = Modifier,
) {
    Canvas(
        modifier = modifier
            .fillMaxWidth()
            .height(160.dp),
    ) {
        if (values.size < 2) return@Canvas

        val minValue = values.minOrNull() ?: return@Canvas
        val maxValue = values.maxOrNull() ?: return@Canvas
        val range = (maxValue - minValue).takeIf { it != 0f } ?: 1f
        val xStep = size.width / (values.lastIndex)

        val path = Path().apply {
            values.forEachIndexed { index, value ->
                val x = index * xStep
                val normalizedY = (value - minValue) / range
                val y = size.height - (normalizedY * size.height)

                if (index == 0) moveTo(x, y) else lineTo(x, y)
            }
        }

        drawPath(
            path = path,
            color = Color(0xFF14B8A6),
            style = Stroke(width = 4.dp.toPx()),
        )
    }
}

This is a visual trend line, not a complete chart component. A production chart also needs labels, empty/error states, range rules, touch exploration, and an accessible textual summary. Separate the data transformation from the drawing code so the Canvas only receives clear render-ready values.

Convert units at the drawing boundary

Compose layouts commonly use Dp and Sp, but DrawScope works in pixels. Use dp.toPx() or sp.toPx() inside the draw lambda, where the current density is available.

Canvas(Modifier.size(96.dp)) {
    val borderWidth = 2.dp.toPx()
    drawCircle(
        color = Color.Magenta,
        radius = size.minDimension / 2f - borderWidth,
        style = Stroke(width = borderWidth),
    )
}

Do not treat 24f as “24 dp” inside Canvas. It is 24 pixels and will look different across density buckets. The Compose drawing quick guide calls out this pixel coordinate system directly.

Transform the drawing scope, not your data model

rotate, scale, translate, and inset temporarily transform the drawing coordinates for the commands in their block. This keeps geometry code focused on the logical shape.

import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp

Canvas(Modifier.size(120.dp)) {
    rotate(degrees = 45f, pivot = center) {
        drawRect(
            color = Color(0xFF8B5CF6),
            topLeft = Offset(center.x - 24.dp.toPx(), center.y - 24.dp.toPx()),
            size = androidx.compose.ui.geometry.Size(48.dp.toPx(), 48.dp.toPx()),
        )
    }
}

Use a transform when all children of a local drawing operation need the same coordinate change. Do not permanently encode a rotation or screen offset into business data just because one Canvas renders it differently.

Canvas and accessibility

A Canvas is pixels, not automatically understandable content. If it conveys important information, expose an equivalent semantic summary and, where appropriate, controls that do not rely on touch position alone.

  • Treat a decorative Canvas as decoration; do not add redundant noisy announcements.
  • Give an informative chart or custom indicator a concise state description or adjacent text that explains its value.
  • Provide normal buttons, sliders, or menu actions for an interaction that would otherwise require drawing-aware touch gestures.
  • Test large font, TalkBack, keyboard, and non-touch input paths.

The Canvas overload with contentDescription can describe a drawing surface, but rich data visualization often needs a fuller textual alternative than one sentence. Accessibility is part of the component contract, not a label added after the artwork looks correct.

Keep drawing work proportionate

Canvas code runs during drawing. Reading a state value in the draw lambda can limit a visual-only update to the drawing phase; it does not make complex work free. The Compose UI phases guide explains why the phase of a state read matters.

Practical rules:

  • Precompute data normalization, grouping, and sorting outside the draw lambda when possible.
  • Derive coordinates from size inside the drawing scope.
  • Do not allocate expensive Path, Brush, or text-measurement objects every frame just because Canvas makes it convenient.
  • Use drawWithCache only when it actually avoids recreating drawing-only objects; the next article covers this modifier in detail.

The graphics-modifier documentation warns that drawWithCache adds unnecessary allocations if there is nothing meaningful to cache. Measure a real issue before treating it as a default wrapper.

Interoperate with platform drawing only when needed

DrawScope.drawIntoCanvas exposes the underlying Compose Canvas, including nativeCanvas on Android. This is useful for a legacy Drawable or a platform API that cannot be expressed through Compose’s draw functions.

import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas

Modifier.drawWithContent {
    drawContent()
    drawIntoCanvas { canvas ->
        legacyDrawable.setBounds(0, 0, size.width.toInt(), size.height.toInt())
        legacyDrawable.draw(canvas.nativeCanvas)
    }
}

Keep this bridge narrow. The drawIntoCanvas reference positions it as access for alternative drawing logic, not a reason to abandon Compose primitives for every shape.

Common mistakes

Forgetting to give Canvas a size

Canvas has no useful drawing surface without a size modifier or parent constraints. Use size, fillMaxSize, fillMaxWidth plus a height, or another intentional layout constraint.

Calling composables in onDraw

Drawing is not composition. Compute composable state outside Canvas and pass simple values into the drawing lambda; call draw functions inside it.

Hard-coding coordinates in pixels

Use size, center, and toPx() so the visual scales across available space and device density.

Building an inaccessible custom control

If a visual needs interaction, provide semantics and an alternative input path. A beautiful custom knob that only responds to an exact drag path is not a complete UI control.

FAQ

Should I use Canvas for a rounded card background?

Usually no. Modifier.background, Surface, shape APIs, and a drawing modifier are clearer for normal composables. Canvas is better when the entire visual is custom geometry.

Can Canvas display text?

Yes, using drawText and a TextMeasurer. Prefer the Text composable unless you need text positioned or transformed as part of custom drawing.

Is Canvas faster than normal Compose UI?

Neither is inherently faster. Canvas is a precise rendering tool. Choose it for the visual problem, then measure the actual drawing, layout, and state work if performance is a concern.

Summary

Use Canvas for custom visuals that are naturally expressed as geometry. Give it intentional constraints, calculate from size, convert density-aware units at the draw boundary, and provide an accessible explanation whenever the pixels convey meaning.