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.