Paint the Stars - Drawing with Compose and Canvas

Eevis - Sep 26 - - Dev Community

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,
        ),
    )
},
Enter fullscreen mode Exit fullscreen mode

At this point, the drawing looks like this:

Rectangle with radial gradient starting from the center with dark blue and getting lighter towards the edges.

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,
    )
}
Enter fullscreen mode Exit fullscreen mode

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),
)
Enter fullscreen mode Exit fullscreen mode

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),
    )
}
Enter fullscreen mode Exit fullscreen mode

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:

At the top left corner of the rectangle, there is now a planet with a rim around it. The planet takes about a quarter of the rectangle.

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,
)
Enter fullscreen mode Exit fullscreen mode

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
            ),
        ),
)
Enter fullscreen mode Exit fullscreen mode

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,
)
Enter fullscreen mode Exit fullscreen mode

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),
)
Enter fullscreen mode Exit fullscreen mode

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:

The illustration has now a planet with three lines from the left edge towards center. It also has a moon going around it, with a line going around for about 200 degrees. The Planet is positioned at the bottom right corner.

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,
) {
   ...
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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,
)
Enter fullscreen mode Exit fullscreen mode

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,
)
Enter fullscreen mode Exit fullscreen mode

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,
)
Enter fullscreen mode Exit fullscreen mode

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],
        ),
    ...
)
Enter fullscreen mode Exit fullscreen mode

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,
            ),
    )
}
Enter fullscreen mode Exit fullscreen mode

After these code changes, the illustration looks like this:

The illustration has now stars, that are in three sizes and in three colors all around the planets.

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?

Links in the Blog Post

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .