Compose Custom Drawing Techniques: Create Stunning UIs in Jetpack Compose

Jetpack Compose, Android’s modern UI toolkit, provides powerful and flexible tools for creating stunning user interfaces. One of the key features is the ability to perform custom drawing directly within your composables. By leveraging custom drawing techniques, you can create unique and visually appealing components that go beyond the standard UI elements. This article will guide you through various custom drawing techniques in Jetpack Compose, complete with detailed explanations and code samples.

Why Custom Drawing in Jetpack Compose?

Custom drawing allows you to:

  • Create unique UI elements not available in the standard library.
  • Implement advanced animations and visual effects.
  • Fine-tune the appearance of your UI components.

Getting Started with Custom Drawing

The primary API for custom drawing in Jetpack Compose is the Canvas composable within the DrawModifier. You interact with the Canvas inside the drawBehind, drawWithContent, or drawWithCache modifiers. Each provides different benefits, which we will explore.

Step 1: Setting up a Basic Canvas

First, let’s set up a basic canvas to draw on:


import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp

@Composable
fun BasicCanvasExample() {
    Canvas(
        modifier = Modifier.size(200.dp),
        onDraw = {
            // Drawing operations will go here
            drawCircle(
                color = Color.Blue,
                center = center,
                radius = 50.dp.toPx()
            )
        }
    )
}

@Preview(showBackground = true)
@Composable
fun PreviewBasicCanvasExample() {
    BasicCanvasExample()
}

Explanation:

  • We use the Canvas composable and set its size to 200dp.
  • The onDraw lambda provides a DrawScope where you can perform drawing operations.
  • drawCircle draws a blue circle in the center of the canvas with a radius of 50dp.

Step 2: Drawing Shapes

Compose provides functions to draw various shapes:


import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp

@Composable
fun DrawingShapesExample() {
    Canvas(
        modifier = Modifier.size(200.dp),
        onDraw = {
            drawCircle(
                color = Color.Blue,
                center = center,
                radius = 50.dp.toPx()
            )
            drawRect(
                color = Color.Red,
                topLeft = Offset(50.dp.toPx(), 50.dp.toPx()),
                size = Size(100.dp.toPx(), 50.dp.toPx())
            )
            drawLine(
                color = Color.Green,
                start = Offset(0.dp.toPx(), 0.dp.toPx()),
                end = Offset(size.width, size.height),
                strokeWidth = 5.dp.toPx()
            )
        }
    )
}

@Preview(showBackground = true)
@Composable
fun PreviewDrawingShapesExample() {
    DrawingShapesExample()
}

Explanation:

  • drawCircle draws a circle.
  • drawRect draws a rectangle with a specified top-left corner and size.
  • drawLine draws a line between two points with a specified stroke width.

Step 3: Using DrawModifiers: drawBehind, drawWithContent, and drawWithCache

Jetpack Compose offers several modifiers to customize the drawing process:

  • drawBehind: Draw content behind the composable.
  • drawWithContent: Draw custom content around the existing content.
  • drawWithCache: Cache drawing instructions to improve performance for complex and static drawings.
Example: drawBehind

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp

@Composable
fun DrawBehindExample() {
    Box(
        modifier = Modifier
            .size(200.dp)
            .drawBehind {
                drawCircle(
                    color = Color.Cyan,
                    radius = 70.dp.toPx(),
                    center = center
                )
            }
            .background(Color.LightGray)
    )
}

@Preview(showBackground = true)
@Composable
fun PreviewDrawBehindExample() {
    DrawBehindExample()
}

Explanation:

  • drawBehind draws a cyan circle behind the Box composable, which is filled with a light gray background.
Example: drawWithContent

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp

@Composable
fun DrawWithContentExample() {
    Box(
        modifier = Modifier
            .size(200.dp)
            .drawWithContent {
                drawContent() // Draw the original content first
                drawRect(
                    color = Color.Black,
                    topLeft = Offset(0.dp.toPx(), 0.dp.toPx()),
                    size = size,
                    alpha = 0.2f // Semi-transparent
                )
            }
            .background(Color.White)
    )
}

@Preview(showBackground = true)
@Composable
fun PreviewDrawWithContentExample() {
    DrawWithContentExample()
}

Explanation:

  • drawWithContent first draws the original content using drawContent() and then draws a semi-transparent black rectangle over it.
Example: drawWithCache

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp

@Composable
fun DrawWithCacheExample() {
    Box(
        modifier = Modifier
            .size(200.dp)
            .drawWithCache {
                val circleRadius = size.minDimension / 4
                val circleColor = Color.Red
                onDrawWithContent {
                    drawContent()
                    drawCircle(
                        color = circleColor,
                        radius = circleRadius,
                        center = center
                    )
                }
            }
            .background(Color.Yellow)
    )
}

@Preview(showBackground = true)
@Composable
fun PreviewDrawWithCacheExample() {
    DrawWithCacheExample()
}

Explanation:

  • drawWithCache calculates the circle radius once and caches it. The onDrawWithContent lambda then uses the cached radius to draw a red circle over the existing content. This is efficient for static drawing instructions.

Step 4: Transformations

You can apply transformations like rotation, scale, and translation within the DrawScope:


import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
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.drawscope.rotate
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp

@Composable
fun TransformationExample() {
    Canvas(
        modifier = Modifier.size(200.dp),
        onDraw = {
            translate(left = size.width / 4, top = size.height / 4) {
                rotate(degrees = 45f) {
                    scale(scaleX = 0.7f, scaleY = 0.7f) {
                        drawRect(
                            color = Color.Magenta,
                            topLeft = Offset(0f, 0f),
                            size = size / 2f
                        )
                    }
                }
            }
        }
    )
}

@Preview(showBackground = true)
@Composable
fun PreviewTransformationExample() {
    TransformationExample()
}

Explanation:

  • translate moves the drawing origin.
  • rotate rotates the drawing.
  • scale scales the drawing.
  • We’re drawing a magenta rectangle after applying these transformations.

Step 5: Paths

For complex shapes, use Path:


import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
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.Path
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp

@Composable
fun PathExample() {
    Canvas(
        modifier = Modifier.size(200.dp),
        onDraw = {
            val path = Path().apply {
                moveTo(size.width / 2, 0f)
                lineTo(size.width, size.height / 2)
                lineTo(size.width / 2, size.height)
                lineTo(0f, size.height / 2)
                close()
            }
            drawPath(
                path = path,
                color = Color.DarkGray,
                style = Fill
            )
        }
    )
}

@Preview(showBackground = true)
@Composable
fun PreviewPathExample() {
    PathExample()
}

Explanation:

  • We create a Path and define its points.
  • moveTo sets the starting point of the path.
  • lineTo adds a line to the path.
  • close closes the path, creating a closed shape.
  • drawPath draws the defined path with a gray color.

Best Practices for Custom Drawing

  • Optimize Performance: Use drawWithCache for static drawing to avoid redundant calculations.
  • Use Transformations Wisely: Minimize complex transformations to avoid performance issues.
  • Keep it Modular: Break down complex drawings into smaller, reusable functions.
  • Use Vector Graphics: For resolution-independent graphics, consider using vector-based drawing techniques.

Conclusion

Custom drawing in Jetpack Compose allows you to create unique and visually appealing UI elements beyond what the standard library provides. By understanding the use of Canvas, drawBehind, drawWithContent, drawWithCache, transformations, and paths, you can unleash your creativity and design truly custom UIs. Optimize your drawing operations and follow best practices to ensure your app remains performant and visually stunning.