androidengineers.Book a session

Performance & Internals

Custom Views & Drawing

article55 minHard

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 Paint and RectF as fields, never inside onDraw. onDraw is called on every frame.
  • Call invalidate() to trigger a redraw when state changes.
  • Override onSizeChanged to 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 onDraw or a DrawScope block that redraws frequently
  • Use drawWithCache modifier 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.

YOUR LEARNING JOURNEY

0 of 17 available lessons completed

Progress saved in this browser. No account needed.
Custom Views & Drawing | Senior Android Developer | Android Engineers