Graphics in Compose

Jetpack Compose makes it easier to work with custom graphics. Many apps need to be able to precisely control exactly what's drawn on the screen. This might be as small as putting a box or a circle on the screen in just the right place, or it might be an elaborate arrangement of graphic elements in many different styles. With Compose's declarative approach, all the graphic configuration happens in one place, instead of being split between a method call and a Paint helper object. Compose takes care of creating and updating the needed objects in an efficient way.

Declarative graphics with Compose

Compose extends its declarative approach to how it handles graphics. Compose's approach offers a number of advantages:

  • Compose minimizes the state in its graphic elements, helping you avoid state's programming pitfalls.
  • When you draw something, all the options are right where you'd expect them, in the composable function.
  • Compose's graphics APIs take care of creating and freeing objects in an efficient way.

Canvas

The core composable for custom graphics is Canvas. You place the Canvas in your layout the same way you would with any other Compose UI element. Within the Canvas, you can draw elements with precise control over their style and location.

For example, this code creates a Canvas composable that fills all the available space in its parent element:

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

The Canvas automatically exposes a DrawScope, a scoped drawing environment that maintains its own state. This lets you set the parameters for a group of graphical elements. The DrawScope provides several useful fields, like size, a Size object specifying the current and maximum dimensions of the DrawScope.

So, for example, suppose you want to draw a diagonal line from the top-right corner of the canvas to the bottom-left corner. You would do this by adding a drawLine composable:

Canvas(modifier = Modifier.fillMaxSize()) {
    val canvasWidth = size.width
    val canvasHeight = size.height

    drawLine(
        start = Offset(x = canvasWidth, y = 0f),
        end = Offset(x = 0f, y = canvasHeight),
        color = Color.Blue
    )
}

A phone with a thiin line drawn diagonally across the screen.

Figure 1. Using drawLine to draw a line across the canvas. The code sets the line's color but uses the default width.

You can use other parameters to customize the drawing. For example, by default, the line is drawn with a hairline width, which displays as one pixel wide regardless of the drawing's scale. You can override the default by setting a strokeWidth value:

Canvas(modifier = Modifier.fillMaxSize()) {
    val canvasWidth = size.width
    val canvasHeight = size.height

    drawLine(
        start = Offset(x = canvasWidth, y = 0f),
        end = Offset(x = 0f, y = canvasHeight),
        color = Color.Blue,
        strokeWidth = 5F
    )
}

A phone with a thicker line drawn diagonally across the screen.

Figure 2. Modifying the line from figure 1 by overriding the default width.

There are many other simple drawing functions, such as drawRect and drawCircle. For example, this code draws a filled circle in the middle of the canvas, with a diameter equal to half of the canvas's shorter dimension:

Canvas(modifier = Modifier.fillMaxSize()) {
    val canvasWidth = size.width
    val canvasHeight = size.height
    drawCircle(
        color = Color.Blue,
        center = Offset(x = canvasWidth / 2, y = canvasHeight / 2),
        radius = size.minDimension / 4
    )
}

A phone with a blue circle centered in the screen.

Figure 3. Using drawCircle to put a circle in the center of the canvas. By default, drawCircle draws a filled circle, so we don't need to specify that setting explicitly.

The drawing functions have useful default parameters. For example, by default drawRectangle() fills its entire parent scope, and drawCircle() has a radius equal to half of its parent's shorter dimension. As always with Kotlin, you can make your code much simpler and clearer by taking advantage of the default parameter values and only setting the parameters that you need to change. You can take advantage of this by providing explicit parameters to the DrawScope drawing methods, since your drawn elements will base their default settings on the settings of the parent scope.

DrawScope

As noted, each Compose Canvas exposes a DrawScope, a scoped drawing environment, where you actually issue your drawing commands.

For example, the following code draws a rectangle in the upper-left corner of the canvas:

Canvas(modifier = Modifier.fillMaxSize()) {
    val canvasQuadrantSize = size / 2F
    drawRect(
        color = Color.Green,
        size = canvasQuadrantSize
    )
}

You can use the function DrawScope.inset() to adjust the default parameters of the current scope, changing the drawing boundaries and translating the drawings accordingly. Operations like inset() apply to all drawing operations within the corresponding lambda:

val canvasQuadrantSize = size / 2F
inset(50F, 30F) {
    drawRect(
        color = Color.Green,
        size = canvasQuadrantSize
    )
}

DrawScope offers other simple transformations, like rotate(). For example, this code draws a rectangle that fills the middle ninth of the canvas:

val canvasSize = size
val canvasWidth = size.width
val canvasHeight = size.height
drawRect(
    color = Color.Gray,
    topLeft = Offset(x = canvasWidth / 3F, y = canvasHeight / 3F),
    size = canvasSize / 3F
)

A phone with a filled rectangle in the middle of the screen.

Figure 4. Using drawRect to draw a filled rectangle in the middle of the screen.

You can rotate the rectangle by applying a rotation to its DrawScope:

rotate(degrees = 45F) {
    drawRect(
        color = Color.Gray,
        topLeft = Offset(x = canvasWidth / 3F, y = canvasHeight / 3F),
        size = canvasSize / 3F
    )
}

A phone with a rectangle rotated by 45 degrees in the center of the screen.

Figure 5. We use rotate() to apply a rotation to the current drawing scope, which rotates the rectangle by 45 degrees.

If you want to apply multiple transformations to your drawings, the best approach is not to create nested DrawScope environments. Instead, you should use the withTransform() function, which creates and applies a single transformation that combines all your desired changes. Using withTransform() is more efficient than making nested calls to individual transformations, because all the transformations are performed together in a single operation, instead of Compose needing to calculate and save each of the nested transformations.

For example, this code applies both a translation and a rotation to the rectangle:

withTransform({
    translate(left = canvasWidth / 5F)
    rotate(degrees = 45F)
}) {
    drawRect(
        color = Color.Gray,
        topLeft = Offset(x = canvasWidth / 3F, y = canvasHeight / 3F),
        size = canvasSize / 3F
    )
}

A phone with a rotated rectangle shifted to the side of the screen.

Figure 6. Here we use withTransform to apply both a rotation and a translation, rotating the rectangle and shifting it to the left.