Paint the Stars - Drawing with Compose and Canvas
Reading time: about 8 minutes
Published
I've always struggled with drawing in Canvas, not just with Compose's Canvas but with any technology. I've understood enough to be able to do the tasks I needed to do, but at the same time, I've tried to avoid working with it.
On one Sunday, I wanted to do something creative. I also wanted to code, so I started with the idea of creating a loading spinner with Canvas. One thing led to another, and I made a nice animation, that's more of a piece of illustration than a loading spinner:
In this and the following blog post, I'll share how I did it and some things I learned along the way. The first blog post is about drawing the elements on canvas, and the following is about animations.
The Idea
The initial idea for the space-themed illustration came from a t-shirt I have. It's this I Need Space t-shirt from the Spark Company. I initially thought I could try to reproduce the same picture, but once I was done with the Saturnus, I decided to go rogue.
I chose to add other elements and make the stars differently. I'm delighted with how it turned out! Let's dive into creating the illustration next. You can find the complete code for this blog post from this code snippet.
Components
Before talking about the components, I want to note something: The exact numbers I'm showing here fit this particular drawing and can't be generalized for the most part. I've calculated the positions and sizes based on the defined canvas, not for a resizeable canvas.
The first component is the background and canvas itself. There's a Column
as the parent component and then Canvas
as a child. This lets us define the animations and other properties you can't define in Canvas
.
For drawing the background, we'll use a drawBehind
-modifier in the parent Column
, draw a round rectangle and define a radial gradient brush with some colors:
Modifier.drawBehind {
drawRoundRect(
size = size,
cornerRadius = CornerRadius(44f),
brush =
Brush.radialGradient(
colors = listOf(
Color(0xFF02010a),
Color(0xFF04052e),
Color(0xFF140152),
Color(0xFF22007c),
Color.Transparent,
),
radius = size.width * 0.75f,
),
)
},
At this point, the drawing looks like this:
Saturn
The first planet we're going to add is Saturn. To draw it, we'll need three elements: The outline of the planet, the rim, and the line inside the planet. Let's define an extension function, drawSaturn
, which takes in the center offset of the planet and the outline style and draw the outline:
fun DrawScope.drawSaturn(
center: Offset,
outlineStyle: Stroke,
) {
drawCircle(
center = center,
color = Colors.white,
radius = 100f,
style = outlineStyle,
)
}
It's a circle, which utilizes the center coordinates and outline style (which is, by the way, a stroke with a width of 4f). We also set a radius for it; in this case, it's a hardcoded value of 100f.
Next, we draw the line inside the planet:
drawArc(
topLeft = Offset(
center.x - 80f,
center.y - 80f
),
color = Colors.white,
startAngle = 180f,
sweepAngle = 90f,
useCenter = false,
style =
Stroke(
width = 2f,
),
size = Size(160f, 160f),
)
For that, we use the drawArc
-function. It's a bit different from the drawCircle
-function, which takes in the center coordinates and radius. For an arc, we need to define the top left coordinate for the rectangle area around the arc and the size of the arc.
The startAngle
defines the angle where the drawing starts, and the sweepAngle
for how long it continues. Position 0 for the angles is at three o'clock, so we want to start at 180f
degrees, and the sweep angle is 90f to accomplish the look we want.
We must also set the useCenter
to true
; otherwise, it would draw an extra line through the center.
The last element to complete the Saturn is the rim. As it's not a complete circle, we're using drawArc
. The code looks like this:
rotate(40f, center) {
drawArc(
color = Colors.white,
startAngle = 217f,
sweepAngle = 285f,
useCenter = false,
topLeft = Offset(
center.x - 50f,
center.y - 150f
),
style = outlineStyle,
size =
Size(100f, 300f),
)
}
Note that the size of the area where the arc is drawn is not square - because of the shape of the rim, we need to use a non-square size to make it more like an ellipsis than a circle.
At this point, the drawing looks like this:
Planet
The next element to add is another planet. I didn't want to name the planet, and I wanted to make it more generic, so we'll just call it a planet.
The planet has two elements: The planet itself and the moon orbiting around it. Let's start with the planet's outline, which is a circle:
drawCircle(
center = center,
color = Colors.white,
radius = 80f,
style = outlineStyle,
)
This code is straightforward; we draw a circle into the position defined by the center
-variable, and with a radius of 80. Next, we draw the lines in the planet. For that, we use three paths, two of which have a dashed path effect, and one is a solid line. The code for the first line (the topmost) looks like this:
drawPath(
path =
Path().apply {
moveTo(center.x - 82f, center.y)
quadraticTo(
center.x - 45f, center.y + 5f,
center.x, center.y
)
},
color = Colors.white,
style =
Stroke(
width = 3f,
pathEffect = PathEffect.dashPathEffect(
floatArrayOf(60f, 10f, 50f, 10f),
0f
),
),
)
The others are similar; the numbers and path effects are slightly different. You can find the link to the complete code at the start of the blog post.
So, we're drawing a path from the edge of the circle towards the center. As the line needs to be slightly curvy, we're using quadraticTo
to accomplish the effect.
The other component of the planet is the moon. First, it has the moon drawn as a circle:
drawCircle(
center = Offset(
center.x - 100f,
center.y + 80f
),
color = Colors.white,
radius = 15f,
style = outlineStyle,
)
And then, for the line that makes it look like the moon is orbiting around the planet, we use drawArc
:
drawArc(
topLeft = Offset(center.x - 125f, center.y - 125f),
color = Colors.white,
startAngle = 160f,
sweepAngle = 200f,
useCenter = false,
style =
Stroke(
width = 2f,
pathEffect =
PathEffect.dashPathEffect(
floatArrayOf(160f, 70f, 50f, 80f, 40f, 40f),
0f,
),
),
size = Size(250f, 250f),
)
There's nothing new with the arc - it takes in the top left coordinates relative to the moon. The start angle is 160 degrees, and it goes around 200 degrees. It doesn't use the center to avoid the third line through the center, and it has dashedPathEffect
to create the look.
At this point, the drawing looks like this:
Stars
The final elements of the illustration are the stars. Let's create an extension function for DrawScope
to draw a star and then use it to draw all the stars.
The function takes in the size of the star, center offset, color of the star, and outline style. Let's define it:
fun DrawScope.drawStar(
starSize: Size,
center: Offset,
color: Color,
outlineStyle: Stroke,
) {
...
}
We're using a path with quadratic bezier curves from one point of the start to another. The star has four points, and if we imagine the space for a star as a square, the points are in the middle of the outlines. Let's start from the middle of the top side:
val path = Path()
path.moveTo(center.x, center.y - starSize.height * 0.5f)
After that, we want to draw a quadratic bezier curve from one point to another and use the center of the area for the star as the curve point. For the line from the top side to the right side, it would look like this:
path.quadraticTo(
center.x,
center.y,
center.x + starSize.width / 2,
center.y,
)
The first two parameters are the coordinates for the control point, and the latter two are the point where the line ends. The other three lines look the same - just the end coordinates change based on the target coordinates.
After drawing those four lines, we just need to draw the path:
drawPath(
path = path,
color = color,
style = outlineStyle,
)
Now that we have the drawStar
-function, we can draw the stars. We want to place them around the planets, and we want them to have different colors. We'll also need to store the size of the star. Let's define a data class to handle all this:
data class Star(
val size: Float,
val topLeft: Offset,
val color: Color,
)
Then, we can define a list of stars. It would look something like this, with an example of one star in a list:
val starsList =
listOf(
Star(
size = 30f,
topLeft = Offset(size.width * 0.5f, size.height * 0.5f),
color = Colors.stars[0],
),
...
)
We can define different star sizes and have a couple of different colors. The position for each star is calculated manually.
We can then use the list of stars and draw them with the function:
starsList.forEach { (starSize, offset, color) ->
drawStar(
starSize = Size(starSize, starSize),
color = color,
center = offset,
outlineStyle =
Stroke(
width = 2f,
),
)
}
After these code changes, the illustration looks like this:
Wrapping Up
In this blog post, we've created an illustration with Canvas. It is currently static, but in the following blog post, we will animate the items in the illustration.
How do you feel about your skills with Canvas? Are you confident, or is it something you're avoiding altogether?