Recently I blogged about using shapes in Jetpack Compose. As you have seen, RectangleShape
, CircleShape
and GenericShape
are great for applying simple forms (shapes) to composables. But what about drawing lines, dots or circles that are not filled or have open sides? Let's investigate how we can become an artist. Now, that may sound a little over the top. But I did it on purpose. Because artists often paint on canvas, and Canvas
is very important for us, too. Take a look:
@Composable
fun SimpleCanvas() {
Canvas(modifier = Modifier.fillMaxWidth().preferredHeight(128.dp),
onDraw = {
drawLine(
Color.Black, Offset(0f, 0f),
Offset(size.width - 1, size.height - 1)
)
drawLine(
Color.Black, Offset(0f, size.height - 1),
Offset(size.width - 1, 0f)
)
drawCircle(
Color.Red, 64f,
Offset(size.width / 2, size.height / 2)
)
})
}
Canvas
is a composable that allows you to
specify an area on the screen and perform canvas drawing
on this area. You MUST specify size with modifier, whether
with exact sizes viaModifier.size
modifier, or relative to
parent, viaModifier.fillMaxSize
,ColumnScope.weight
, etc.
If parent wraps this child, only exact sizes must be specified.
Drawing instructions are given inside onDraw
. My example produces two lines and a filled circle. Instead of solid red we could for example use a linear gradient. But before I show you how to achieve this, let's create an outline instead.
drawCircle(
Color.Red, 64f,
Offset(size.width / 2, size.height / 2),
style = Stroke(width = 8f,
pathEffect = DashPathEffect(floatArrayOf(10f, 10f), 0f)
),
)
Stroke
is no composable but a class. Its pathEffect
receives a NativePathEffect
, which is a typealias for android.graphics.PathEffect
. It is extended by (among others) android.graphics.DashPathEffect
. Its documentation explains:
The intervals array must contain an even number of entries
(>=2), with the even indices specifying the "on" intervals,
and the odd indices specifying the "off" intervals.
So, in my example the distances for "on" and "off" are equal (10f
). The Stroke
has a width (or thickness) of 8f
. How big this is depends on the device configuration. Now, let's turn to gradients, shall we?
@Composable
fun CanvasWithGradient() {
Canvas(modifier = Modifier.fillMaxWidth().preferredHeight(128.dp),
onDraw = {
val gradient = LinearGradient(
listOf(Color.Blue, Color.Black),
startX = size.width / 2 - 64, startY = size.height / 2 - 64,
endX = size.width / 2 + 64, endY = size.height / 2 + 64,
tileMode = TileMode.Clamp
)
drawCircle(
gradient, 64f,
)
})
}
Have you noticed that I did not provide the center of the circle? Thanks to the default value in drawCircle()
this is not necessary. Nonetheless I am using (that is, computing) the values (size.width / 2
and size.height / 2
), because I need them for the definition of my linear gradient. I want it to cover only the area of the circle. Its upper left corner is startX
, startY
and the lower right corner is endX
, endY
.
A radial gradient looks like this:
val gradient = RadialGradient(
listOf(Color.Black, Color.Blue),
centerX = center.x, centerY = center.y,
radius = 64f
)
drawCircle(
gradient, 64f,
)
center.x
and center.y
specify the center of my canvas. This is used for the center of the circle (its default value), so I can reuse it for my radial gradient. There are more gradients. You may, for example, want to take a look at VerticalGradient
or HorizontalGradient
.
To conclude this post, let's draw some individual pixels. Here is a composable that draws a sinus curve in a carthesian coordinate system.
@Composable
fun SinusPlotter() {
Canvas(modifier = Modifier.fillMaxSize(),
onDraw = {
val middleW = size.width / 2
val middleH = size.height / 2
drawLine(Color.Gray, Offset(0f, middleH), Offset(size.width - 1, middleH))
drawLine(Color.Gray, Offset(middleW, 0f), Offset(middleW, size.height - 1))
val points = mutableListOf<Offset>()
for (x in 0 until size.width.toInt()) {
val y = (sin(x * (2f * PI / size.width)) * middleH + middleH).toFloat()
points.add(Offset(x.toFloat(), y))
}
drawPoints(
points = points,
strokeWidth = 4f,
pointMode = PointMode.Points,
color = Color.Blue
)
}
)
}
drawPoints()
receives a list if Offset
instances. PointMode.Points
means: draw individual points. Please note that I deliberately set strokeWidth
because its default Stroke.HairlineWidth
led to no visible output.
One more thing... You may be wondering how the axes of the coordinate system can look like arrows. While drawLine()
can recieve both strokeWidth
and cap
, the latter one is used for both ends. Also, while there is StrokeCap.Round
, Square
and Butt
, there seems to be no Arrow
. So for now my resolution would be to draw the arrow by myself. Here is how this might look like for the horizontal axis:
drawPath(
path = Path().apply {
moveTo(size.width - 1, middleH)
relativeLineTo(-20f, 20f)
relativeLineTo(0f, -40F)
close()
},
Color.Gray,
)
As you can see, I am using an old friend, Path
, which is then passed to drawPath()
. The vertical axis has just a slightly changed set of drawing instructions:
moveTo(size.width - 1, middleH)
relativeLineTo(-20f, 20f)
relativeLineTo(0f, -40F)
That's it for today. I hope to follow up on this soon. Kindly share your thoughts in the comments.
Top comments (6)
Inside the Jetpack Compose Beta 02 version
Thank you! The above fixed the issue with the beta.
From the documentation
This is done in alpha09 release.
Sorry to reply late; last couple of days have been very busy. Thanks for sharing the resolution. If I am not mistaken, in one of my later posts I used PathEffect, too. Again, thank you very much for keeping me posted. Greatly appreciated.
I keep getting this error after upgrading to beta. How do we solve that?
"Type mismatch: inferred type is DashPathEffect but PathEffect was expected."
Got a workaround for this.
This has to be changed to this:
I'll loook into this shortly. Will keep you posted.