DEV Community

Cover image for Using SVGs on Canvas with Compose Multiplatform
Eevis
Eevis

Posted on • Originally published at eevis.codes

Using SVGs on Canvas with Compose Multiplatform

One thing that has continued to amaze me with building my Compose Multiplatform app is how easily everything has worked with Canvas. When I started building Neule.art, I assumed that Canvas would cause some problems, but it has worked smoothly on both Android and iOS.

I've been writing posts about creative coding on Canvas (see, for example, Not a Phase - Text with Compose and Canvas), and also about my First Impressions of Compose Multiplatform, and this post combines elements from both themes.

There are different ways to parse an SVG to be used with Compose, and in this blog post, I'm looking into using path data. This approach requires some manual work, but it also allows better flexibility for controlling, e.g., colors of the individual elements within the SVG.

What We're Building

Even though I'd love to show how I've built the shirt I'm using in Neule.art, simplifying the process into the form of a blog post is too difficult a task. I decided to create a smaller SVG, which we're going to convert into Canvas. It looks like this:

Eighth hand drawn hearts in yellow, white, purple and black.

The hearts I'm using in the SVG are from Sarah Laroche's Vector Heart Figma resource. And if you've ever seen the colors anywhere, you might recognize them as being from the non-binary flag.

Getting the Paths from an SVG

We first need something from the original SVG to draw it on Canvas: the paths of individual components within the SVG.

In an SVG, the path's d-attribute contains the commands for defining the shape being drawn, and we're using that to parse the SVG to the path on Canvas. If you're unfamiliar with SVGs, I recommend checking MDN's documentation on SVGs, as it contains much in-depth information about SVGs.

So, to prepare for drawing paths on Canvas, we'll need an SVG image as code. Then, we need to copy the path's d-attribute's content and finally store them somewhere inside our code. There are several options for opening the SVG's code - for example, open the file in an IDE or inspect it in the browser's developer tools.

In our example, we store the path strings in a list with a variable called pathStrings. We also want to attach the color information to each path to make the drawing phase easier, so we store the path string together with the color it will be drawn with.

The following example lists only two of the hearts' paths, as the complete list would take up too much space. You can find the complete list of paths in the snippet linked at the end of the blog post.

val pathStrings = listOf(
    Pair("M185.52 80.2313C172.773 67.8803 159.24 53.9097 154.36 36.3463C153.373 32.8076 152.633 29.1043 153.193 25.4737C154.813 14.8697 163.753 16.4547 168.74 23.722C175.593 33.7001 180.946 44.7021 184.58 56.2492C186.3 43.9382 188.04 31.5685 191.666 19.6794C192.786 16.0068 195.16 11.6456 198.98 12.0229C201.993 12.3195 203.8 15.5202 204.526 18.453C205.733 23.2988 205.426 28.3831 204.926 33.3509C202.806 52.8032 201.753 71.7397 198.673 91.1093C193.953 87.8965 189.62 84.204 185.52 80.2313", Colors.white),
    Pair("M32.5198 119.231C19.7732 106.88 6.2398 92.9097 1.3598 75.3463C0.373129 71.8076 -0.366871 68.1043 0.193129 64.4737C1.81313 53.8697 10.7531 55.4547 15.7398 62.722C22.5931 72.7001 27.9465 83.7021 31.5798 95.2492C33.2998 82.9382 35.0398 70.5685 38.6664 58.6794C39.7864 55.0068 42.1598 50.6456 45.9798 51.0229C48.9931 51.3195 50.7998 54.5202 51.5265 57.453C52.7331 62.2988 52.4265 67.3831 51.9265 72.3509C49.8065 91.8032 48.7532 110.74 45.6732 130.109C40.9532 126.897 36.6198 123.204 32.5198 119.231", Colors.black),
)
Enter fullscreen mode Exit fullscreen mode

Now that we have the path string and the color, we can move on to parsing the path strings into Compose's Path objects.

Parsing Paths

Compose has this great thing called PathParser, which is something we can use for, well, parsing paths. Inside a Canvas component's block, we map through the path strings, parse them, and then draw the path on canvas:

Canvas(...) {
    paths.map { (pathString, color) ->
        val parsedPath = PathParser()
          .parsePathString(pathString)
          .toPath()

        drawPath(
            path = parsedPath,
            color = color,
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

PathParser's method parsePathString takes care of the parsing and returns a PathParser-object. Then, we call the toPath conversion method to get the Path out of PathParser.

With these changes, we get the following image:

Eighth hand-drawn hearts in yellow, white, purple, and black, that take only the top left quarter of the image.

As you might notice, the image doesn't scale to the entire space available. The reason is that SVG code is hard-coded to fit a specific size. If you look at the path strings above, you can see that they contain numbers as coordinates - they're inside the (in our case) 278 x 270 area defined in the original SVG as size.

To fix this problem, we can add some scaling functions into the mix. Let's talk about that next.

Scaling

We'll need to convert the parsed path to PathNodes to scale the paths. This way, we can scale every moveTo, curveTo, and other SVG drawing functions to the correct size.

We'll need a couple of helper functions to scale the paths on Canvas. One is a function for transforming float values from one size to another, and the other is a function that handles PathNode's scaling.

This is how we define the float scaling:

private fun Float.scaleToSize(
    oldSize: Float,
    newSize: Float,
): Float {
    val ratio = newSize / oldSize
    return this * ratio
}
Enter fullscreen mode Exit fullscreen mode

The function takes in the old size (so, for example, the full old width), and new size (the new full width). With those values, we calculate a ratio to convert the float value by multiplying the value with the calculated ratio.

The PathNode's scaling requires a bit more code:

private fun PathNode.scaleTo(size: Size): PathNode {
    val originalWidth = 278f
    val originalHeight = 207f

    return when (this) {
        is PathNode.CurveTo ->
            this.copy(
                x1 = x1.scaleToSize(originalWidth, size.width),
                x2 = x2.scaleToSize(originalWidth, size.width),
                x3 = x3.scaleToSize(originalWidth, size.width),
                y1 = y1.scaleToSize(originalHeight, size.height),
                y2 = y2.scaleToSize(originalHeight, size.height),
                y3 = y3.scaleToSize(originalHeight, size.height),
            )
        is PathNode.MoveTo ->
            this.copy(
                x = x.scaleToSize(originalWidth, size.width),
                y = y.scaleToSize(originalHeight, size.height),
            )
        else -> this
    }
}
Enter fullscreen mode Exit fullscreen mode

We're scaling the values from the original size to the Canvas size for each type of PathNode. In the when-clause, we're handling only CurveTo and MoveTo, because those are the only commands our path strings contain. If there were others, they should be handled here too.

Scaling of each parameter utilizes the scaleToSize we defined previously. The parameters we're passing to the function depend on if the parameter is on the x or y-axis - if it's on the x, we pass in width (as that's the x-axis), and if it is on the y-axis, then we pass in height.

Now that we have the helper functions let's change the mapping of path strings a bit:

paths.map { (pathString, color) ->
    val parsedPath =
        PathParser()
          .parsePathString(pathString)
          .toNodes()
          .map { it.scaleTo(size) }
          .toPath()

    drawPath(
        path = parsedPath,
        color = color,
    )
}
Enter fullscreen mode Exit fullscreen mode

Here, we first parse the path, then convert the PathParser it returns with toNodes to a list of PathNodes. We then map through each PathNode, and scale it to size. Finally, we turn the scaled PathNode list into Path, which we can then draw.

After these changes, the picture scales nicely:

Eighth hand-drawn hearts in yellow, white, purple, and black, which now scale the whole image's area.

Wrapping up

In this blog post, we've looked into how to turn SVG into Paths that can be used in Compose's canvas. This approach works for both native Android development, and Compose Multiplatform projects.

You can find the complete code from this Github gist.

Links in the Blog Post

Top comments (0)