Most screens are built from standard composables and views. But charts, gauges, progress rings, waveforms, signature pads, and game elements require custom drawing. Understanding Canvas and DrawScope gives you complete control over what appears on screen.
When to Use Custom Drawing
Use custom drawing when:
- The UI shape cannot be assembled from standard composables or views
- Performance is critical and standard views add unnecessary overhead
- You need pixel-precise control (charts, graphs, custom indicators)
Do not use custom drawing for layouts that are complex but buildable from Row, Column, Box, and modifiers.
Canvas Basics (Custom View)
A View subclass draws itself in onDraw(canvas: Canvas). The Canvas is the drawing surface; Paint describes how to draw.
class RingProgressView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : View(context, attrs) {
private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 20f
color = Color.LTGRAY
}
private val progressPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 20f
color = Color.BLUE
strokeCap = Paint.Cap.ROUND
}
var progress: Float = 0f
set(value) {
field = value.coerceIn(0f, 1f)
invalidate()
}
private val oval = RectF()
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
val padding = backgroundPaint.strokeWidth / 2
oval.set(padding, padding, w - padding, h - padding)
}
override fun onDraw(canvas: Canvas) {
canvas.drawArc(oval, -90f, 360f, false, backgroundPaint)
canvas.drawArc(oval, -90f, 360f * progress, false, progressPaint)
}
}
Key points:
- Allocate
PaintandRectFas fields, never insideonDraw.onDrawis called on every frame. - Call
invalidate()to trigger a redraw when state changes. - Override
onSizeChangedto compute geometry that depends on dimensions.
onMeasure
Override onMeasure when your view needs a specific size or aspect ratio:
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val size = minOf(
MeasureSpec.getSize(widthMeasureSpec),
MeasureSpec.getSize(heightMeasureSpec)
)
setMeasuredDimension(size, size) // force square
}
Always call setMeasuredDimension. Failing to do so causes an exception.
Custom Drawing in Compose with DrawScope
In Compose, use the Canvas composable or the drawBehind / drawWithContent modifiers. Drawing happens in DrawScope, which provides a coordinate space, size, and all drawing primitives.
@Composable
fun RingProgress(
progress: Float,
modifier: Modifier = Modifier
) {
val primaryColor = MaterialTheme.colorScheme.primary
Canvas(modifier = modifier.size(120.dp)) {
val strokeWidth = 16.dp.toPx()
val radius = (size.minDimension - strokeWidth) / 2
val center = Offset(size.width / 2, size.height / 2)
// Background ring
drawCircle(
color = Color.LightGray,
radius = radius,
center = center,
style = Stroke(width = strokeWidth)
)
// Progress arc
drawArc(
color = primaryColor,
startAngle = -90f,
sweepAngle = 360f * progress,
useCenter = false,
style = Stroke(width = strokeWidth, cap = StrokeCap.Round),
topLeft = Offset(center.x - radius, center.y - radius),
size = Size(radius * 2, radius * 2)
)
}
}
Compose coordinates are in dp but DrawScope operates in pixels. Use .toPx() to convert.
Animating Custom Drawing
Animate a value and pass it to the drawing function:
@Composable
fun AnimatedRingProgress(targetProgress: Float) {
val animatedProgress by animateFloatAsState(
targetValue = targetProgress,
animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing)
)
RingProgress(progress = animatedProgress)
}
animateFloatAsState returns a State<Float>. Reading it inside the Canvas block triggers recomposition (and therefore a redraw) on every frame of the animation.
Path for Complex Shapes
Use Path when the shape cannot be drawn with primitives.
Canvas(modifier = Modifier.size(200.dp)) {
val path = Path().apply {
moveTo(0f, size.height)
lineTo(size.width / 2, 0f)
lineTo(size.width, size.height)
close()
}
drawPath(path, color = Color.DarkGray)
}
Paths support curves (cubicTo, quadraticBezierTo), arcs, and boolean operations. They are the building block for waveforms, sparklines, and organic shapes.
Text on Canvas
Canvas(modifier = Modifier.fillMaxSize()) {
drawContext.canvas.nativeCanvas.drawText(
"75%",
center.x,
center.y + textSize / 3,
android.graphics.Paint().apply {
textAlign = android.graphics.Paint.Align.CENTER
textSize = 48f
color = android.graphics.Color.BLACK
}
)
}
Drawing text on a Compose Canvas requires accessing the native canvas. For most text, use a Text() composable with Box alignment instead.
Performance Rules
- No object allocation inside
onDrawor aDrawScopeblock that redraws frequently - Use
drawWithCachemodifier to create paths and paint objects that survive across redraws without reallocation - Avoid overdraw — drawing transparent layers on top of each other increases GPU load
Practice
Build an animated waveform composable that takes a List<Float> (values 0f to 1f) and renders vertical bars. Animate the bars smoothly when the list changes. Add a baseline color and an accent color for values above 0.7.
Summary
Custom drawing gives you pixel-level control. In the View system, override onDraw and onMeasure, allocate Paint objects as fields, and call invalidate() to trigger redraws. In Compose, use Canvas with DrawScope. Use animateFloatAsState to animate drawing values, and never allocate objects inside the draw block.