Canvas Drawing in Jetpack Compose

Quick answer: use
Canvaswhen 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 itsDrawScope, and calculate positions fromsizeso 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
| Need | Prefer |
|---|---|
| Text, icons, images, buttons, normal cards | Regular composables |
| A custom background or border on an existing composable | A drawing modifier |
| A chart, path, geometry-driven indicator, or freeform visual | Canvas |
Android Drawable or platform-canvas interop | drawIntoCanvas 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.xgrows to the right andygrows downward.sizeis the measured drawing area in pixels.centeris the center of that measured area.- Draw calls use pixel values, so convert
DpwithtoPx()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
sizeinside the drawing scope. - Do not allocate expensive
Path,Brush, or text-measurement objects every frame just because Canvas makes it convenient. - Use
drawWithCacheonly 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.