Custom Drawing with Canvas in Jetpack Compose

Any GUI framework provides pre-built UI components for developers, but sometimes these aren't enough for specialized design requirements. For Android developers, custom Views have been the go-to solution for achieving unique visual effects. Jetpack Compose offers the same capability through its Canvas API, which allows direct custom drawing within your composables.

The Canvas Composable

In Compose, the Canvas composable serves as the foundation for custom drawing operations. Think of it as a functional equivalent to custom Views, but with a more declarative approach where you pass drawing instructions directly:

val textMeasurer = rememberTextMeasurer()

Canvas(modifier = Modifier.fillMaxSize()) {
    drawRect(Color.DarkGray)

    drawText(
        textMeasurer = textMeasurer,
        text = "Compose Canvas",
        topLeft = Offset(size.width * 0.25f, size.height * 0.45f)
    )

    drawCircle(
        color = Color.Cyan,
        radius = size.width * 0.1f,
        center = Offset(size.width * 0.55f, size.height * 0.33f)
    )
    drawCircle(
        color = Color.Red,
        radius = size.width * 0.08f,
        center = Offset(size.width * 0.62f, size.height * 0.22f)
    )
    drawCircle(
        color = Color.Blue,
        radius = size.width * 0.07f,
        center = Offset(size.width * 0.68f, size.height * 0.14f)
    )
}

Coordinate System

The Canvas coordinate system follows the same conventions as traditional Android Views and most GUI frameworks. The origin (0, 0) is positioned at the top-left corner, with the X-axis extending rightward and the Y-axis extending downward.

The DrawScope Context

The drawing instructions within Canvas are provided through a trailing lambda, which represents a common pattern in Compose. This lambda is defined as an extension function on DrawScope, giving you implicit access to all drawing capabilities through the receiver object.

When searching for API documentation, look for DrawScope rather than Canvas itself, as Canvas is primarily a wrapper. There's also a lower-level Canvas object that parallels the Android SDK's Canvas concept.

Drawing Shapes

Basic geometric shapes form the foundation of most custom drawing operations, including circles, ellipses, rectangles, lines, and arcs:

Canvas(modifier = Modifier.fillMaxSize()) {
    drawRect(Color.DarkGray)

    drawOval(
        color = Color.Blue,
        topLeft = Offset(80f, 80f),
        size = Size(size.width * 0.1f, size.height * 0.08f)
    )

    drawLine(
        color = Color.Yellow,
        start = Offset(80f + size.width * 0.05f, 80f + size.height * 0.04f),
        end = Offset(size.width * 0.55f, size.height * 0.33f),
        strokeWidth = Stroke.DefaultMiter
    )

    drawCircle(
        color = Color.Cyan,
        radius = size.width * 0.2f,
        center = Offset(size.width * 0.55f, size.height * 0.33f)
    )

    drawPoints(
        color = Color.Magenta,
        pointMode = PointMode.Points,
        strokeWidth = 40f,
        points = generatePoints(size.width * 0.5f, size.height * 0.33f)
    )
}

Drawing Paths

Paths translate mathematical instructions into drawing commands, enabling flexible curve and shape rendering. Here's an example drawing a sine wave:

@Composable
fun SineWavePath() {
    val textMeasurer = rememberTextMeasurer()

    Canvas(modifier = Modifier.fillMaxSize()) {
        drawRect(Color.DarkGray)

        drawText(
            textMeasurer,
            "sine of [-PI, PI]",
            Offset(size.width * 0.33f, 50f)
        )

        drawLine(
            color = Color.Gray,
            start = Offset(0f, size.height * 0.5f),
            end = Offset(size.width, size.height * 0.5f),
            strokeWidth = 2f
        )

        drawLine(
            color = Color.Gray,
            start = Offset(size.width * 0.5f, size.height * 0.33f),
            end = Offset(size.width * 0.5f, size.height * 0.67f),
            strokeWidth = 2f
        )

        drawPath(
            path = buildSinePath(size.width, size.height),
            color = Color.Cyan,
            style = Stroke(width = 8f)
        )
    }
}

fun buildSinePath(width: Float, height: Float): Path {
    val segments = 50
    val path = Path()
    path.moveTo(0f, height * 0.5f)
    
    for (i in 1..segments) {
        val normalizedX = i.toFloat() / segments.toFloat()
        val x = normalizedX * width
        val xRadians = normalizedX * 2f * PI.toFloat() - PI.toFloat()
        val y = height * 0.5f - sin(xRadians) * height * 0.15f
        
        path.lineTo(x, y)
    }
    path.close()
    return path
}

Paths enable sophisticated visual effects and are particularly valuable for creating smooth animations.

Drawing Text

Text rendering in custom drawing contexts requires careful consideration of layout metrics. The TextMeasurer handles complex calculations for text boundaries, font styles, and sizing. Since text measurement depends on various factors, it should be stored as state to trigger proper recomposition when measurements change:

@Composable
fun CustomTextSample() {
    val textMeasurer = rememberTextMeasurer()

    Canvas(modifier = Modifier.fillMaxSize()) {
        val textResult = textMeasurer.measure(
            text = AnnotatedString(
                text = "Canvas provides powerful text drawing capabilities. " +
                       "Text can be styled with various attributes including " +
                       "font size, weight, color, and gradients.",
                spanStyle = SpanStyle(
                    fontSize = 22.sp,
                    fontWeight = FontWeight.Bold,
                    brush = Brush.horizontalGradient(
                        listOf(Color.Red, Color.Blue, Color.Green)
                    )
                )
            ),
            constraints = Constraints.fixed(
                width = (size.width * 0.7f).toInt(),
                height = (size.height * 0.6f).toInt()
            ),
            overflow = TextOverflow.Visible,
            style = TextStyle(fontSize = 16.sp)
        )

        drawText(
            textLayoutResult = textResult,
            topLeft = Offset(40f, 80f)
        )
    }
}

Drawing Images

Images are essential UI elements—icons, avatars, backgrounds, and content images all require rendering. While the Image composable handles most cases, Canvas can render images for custom drawing scenarios:

val landscapeBitmap = ImageBitmap.imageResource(id = R.drawable.landscape)

Canvas(modifier = Modifier.fillMaxSize()) {
    drawImage(landscapeBitmap)
}

Transformations

DrawScope provides transformation functions that modify how drawing instructions are rendered, including scaling, translation, and rotation applied directly to the drawing operations.

Scaling

The scale function multiplies dimensions by specified factors. Values greater than 1 enlarge, while values less than 1 shrink. The transformation center defaults to the shape's geometric center:

Canvas(modifier = Modifier.fillMaxSize()) {
    scale(scaleX = 8f, scaleY = 12f) {
        drawCircle(Color.Red, radius = 25.dp.toPx())
    }
}

Translation

Translation shifts drawing operations along axes. Positive values move in the axis direction, negative values move opposite:

Canvas(modifier = Modifier.fillMaxSize()) {
    translate(left = 150f, top = -250f) {
        drawCircle(Color.Red, radius = 180.dp.toPx())
    }
}

Rotation

Rotation pivots around a center point. Positive angles rotate clockwise, negative angles rotate counterclockwise:

Canvas(modifier = Modifier.fillMaxSize()) {
    rotate(degrees = 30f) {
        drawRect(
            color = Color.Gray,
            topLeft = Offset(size.width * 0.33f, size.height * 0.33f),
            size = size * 0.33f
        )
    }
}

Canvas Resizing

The inset function adjusts the drawing area by applying padding to all four sides. This affects the available size within the lambda:

Canvas(modifier = Modifier.fillMaxSize()) {
    val halfSize = size * 0.5f
    inset(horizontal = 60f, vertical = 40f) {
        drawRect(color = Color.Blue, size = halfSize)
    }
}

After inset, available dimensions become width minus twice the horizontal value, and height minus twice the vertical value.

Combined Transformations

Transformations can be combined using withTransform for more complex effects:

Canvas(modifier = Modifier.fillMaxSize()) {
    withTransform({
        translate(left = size.width * 0.2f)
        rotate(degrees = 60f)
    }) {
        drawRect(
            color = Color.Gray,
            topLeft = Offset(size.width * 0.33f, size.height * 0.33f),
            size = size * 0.33f
        )
    }
}

API Reference

The drawing capabilities in Compose are provided through the DrawScope interface, which offers methods for rendering shapes, paths, text, and images, along with transformation operations for scaling, rotating, translating, and adjusting the drawing context.

Tags: Android Jetpack Compose Canvas Custom Drawing UI

Posted on Thu, 07 May 2026 09:35:32 +0000 by claire