DEV Community

Cover image for Be Mine and Add Interaction with Compose and Canvas
Eevis
Eevis

Posted on • Originally published at eevis.codes

Be Mine and Add Interaction with Compose and Canvas

Valentine's Day is approaching, and while I prefer the Finnish version ("Friend's Day"), I was inspired to do some creative coding in the holiday theme.

The Hollywood-centric film industry (in addition to globalization in general) has carried those heart-shaped candies to the North as well. While browsing some images for inspiration, I decided to use them for the next piece I'm writing.

And that's not all! I also wanted to continue my series of Canvas-related blog posts, so this one walks through another concept with Canvas: How to detect pointer input gestures and add some interaction.

So, what are we building today? Here's a video showing some heart-shaped candies with messages and how they scale bigger when I'm touching them:

Let's get coding!

Drawing the Hearts

This project builds on my previous blog post, Using SVGs on Canvas with Compose Multiplatform, which explained how to draw from SVG's path strings to Compose Canvas. I prepared an SVG image on Figma and then extracted the path strings to draw the hearts.

We will utilize some functions from the previous blog post: Float.scaleToSize and PathNode.scaleTo. I won't explain them in this blog post; just mention them. If you want a refresher on how they work, head to the blog post describing them.

Alright, let's start by drawing one heart on Canvas.

Drawing One Heart

We want to extract the logic for drawing the heart to a function. Let's call it drawCandyHeart, and pass the top-left offset, the size of the heart, and the color we're drawing the heart with to the function:

private fun DrawScope.drawCandyHeart(
    topLeft: Offset,
    heartSize: Size,
    color: Color,
) {

}
Enter fullscreen mode Exit fullscreen mode

I've defined the path strings in a separate list, but I will leave them out from this blog post for brevity. They're included in the final code, which you can find at the end of the blog post.

We can get the path strings with a function pathString(color: Color): Pair<String, Color>, which returns the two path strings with the color with different opacity values to get the look we aim for.

So, using the function to get the path strings with color, let's first parse the strings into Paths:

HeartCandy.Heart
    .pathStrings(color)
    .map { (pathString, color) ->
        val path =
            PathParser()
                .parsePathString(pathString)
                .toNodes()
                .map { path -> path.scaleTo(heartSize.height) }
                .toPath()
                .apply {
                    translate(topLeft)
                }
...
}
Enter fullscreen mode Exit fullscreen mode

Here, we go through each path string and color pair, and using PathParser, we parse the path strings into Paths. Then, we convert the Path into Nodes, so we can scale them to the correct size. Once that's done, we convert them back to Paths, and then move them to correct position.

After that, we can use the parsed path to draw the candy:

drawPath(
    path = path,
    color = color,
)
drawPath(
    path = path,
    color = color,
    style = Stroke(width = 2f),
)
Enter fullscreen mode Exit fullscreen mode

We draw the path twice to get the fill and then to draw the stroke around the shapes. After these steps, our heart looks like this:

A pink heart candy shaped heart on a peach background.

The next step is to add some text to the candy.

Adding the Text

To draw the text on Canvas, we need the text as TextLayoutResult, so let's add a new parameter to drawHeartCandy called text, which is of type TextLayoutResult:

private fun DrawScope.drawCandyHeart(
    ...
    text: TextLayoutResult,
) { ... }
Enter fullscreen mode Exit fullscreen mode

This way, we can handle all text-related changes in the parent component without passing a TextMeasurer to the drawing component, as the drawText method needs the text as TextLayoutResult.

We pass in the text we want to display, which we also measure with textMeasurer:

val textMeasurer = rememberTextMeasurer()
...
drawCandyHeart(
   ...
    text = textMeasurer.measure(
        text = "Enby\nLove".uppercase(),
        style = TextStyle.Default.copy(
            fontFamily = RighteousFontFamily,
            textAlign = TextAlign.Center,
            fontSize = 10.sp,
        ),
    )
)
Enter fullscreen mode Exit fullscreen mode

We also style the text here by turning it all uppercase and defining its style. If you're wondering about the font family (Righteous), that's imported to the code as described in Not a Phase - Text with Compose and Canvas.

The next step is to draw the text. Our code looks like this:

rotate(
    degrees = -7f,
    pivot = Offset(
        x = topLeft.x + heartSize.width * 0.5f,
        y = topLeft.y + heartSize.height * 0.5f,
    ),
) {
    drawText(
        textLayoutResult = text,
        topLeft = calculateTextTopLeft(
            topLeft = topLeft,
            text = text,
            heartWidth = heartSize.width,
            padding = 5.dp.toPx()
        ),
        color = color,
    )
}
Enter fullscreen mode Exit fullscreen mode

What's worth noting here is that we need to rotate the text slightly to align with the heart correctly. Also, we calculate the top left coordinates for the text with the following function:

private fun calculateTextTopLeft(
    topLeft: Offset,
    text: TextLayoutResult,
    heartWidth: Float,
    padding: Float
) = Offset(
    y = topLeft.y + (text.size.height * (1f / text.lineCount) + padding),
    x = topLeft.x + heartWidth * 0.5f - text.size.width * 0.3f,
)
Enter fullscreen mode Exit fullscreen mode

After these changes, the heart looks like this:

A pink heart candy shaped heart on peach background with text 'enby love'.

Drawing Them All

Okay, we have one heart. But the final goal is to have 16 of them. I've defined the color and text pairs outside the component as the type of List<Pair<Color, String>>.

To create the 4 x 4 layout for the hearts, we're going to first chunk the list of items into four sublists and then map through each item in the row:

HeartCandy.hearts
    .chunked(4)
    .mapIndexed { row, colors ->
        colors.mapIndexed { index, (color, text) ->
            ...
        }
    }
Enter fullscreen mode Exit fullscreen mode

Then, extending and generalizing the code we wrote for the one heart, inside the colors.mapIndexed we call:

drawCandyHeart(
    topLeft = Offset(
        x = index * (heartSize.width + horizontalPadding),
        y = row * (heartSize.height + verticalPadding),
    ),
    heartSize = HeartCandy.Heart.size,
    color = color,
    text = textMeasurer.measure(
        text = text.uppercase(),
        style = HeartCandy.Heart.textStyle,
    )
)
Enter fullscreen mode Exit fullscreen mode

We use the index on the column and row to add enough offset to each heart to position them correctly. We also add horizontal and vertical padding, which is calculated based on the remaining space on the screen.

We pass in the color and use the text from the item instead of the hard-coded text we had for the one heart. Finally, the text style has been extracted to an object to save space, compared to the code for drawing one heart.

After these changes, our piece of art looks like this:

16 hearts with different colors and texts lick me, queer joy, xo xo, we're here, queer cutie, enby love, ace pal, bi bestie, love is love, love me, u belong, they them, queer & here, U R valid, eat the rich, and be mine.

Adding Interaction

Finally, we're at the point where we're adding some interaction! First, we need to store one variable and add a modifier in the parent component to enable dragging gestures:

@Composable
fun HeartCandy() {
    var dragPosition by remember { mutableStateOf(Offset.Zero) }
    ... 

    Canvas(
        modifier =  Modifier
            ...
            .pointerInput(Unit) {
                detectDragGestures(
                    onDragEnd = {
                        dragPosition = Offset.Zero
                    },
                ) { change, _ ->
                    dragPosition = dragPosition.copy(
                        x = change.position.x,
                        y = change.position.y
                    )
                }
            },
        ) { ... }
   }
Enter fullscreen mode Exit fullscreen mode

We define a variable to store the dragging position, add the pointerInput-modifier, and then call the detectDragGestures inside the modifier. Then, when a drag gesture is detected, we set the changed position to the dragPosition-variable. When the drag gesture ends, we set the position to default value, so, in this case, Offset.Zero.

We now know where the user's pointer input is moving. Next, we'll want to use that information and transform a heart when the pointer input touches it. First, let's pass the drag position to the drawCandyHeart-method:

private fun DrawScope.drawCandyHeart(
    dragPosition: Offset,
) { ... }
Enter fullscreen mode Exit fullscreen mode

Next, we want to know if the drag spot is within the drawn paths for the hearts. Path provides an excellent method: getBounds, which returns the path's bounds as a Rect. Then, we can check if this Rect contains the current drag position.

There is just one thing: We have two different paths for the heart, so we want to check if the drag spot is within either of them to do transformations. We'll need to refactor our way of drawing a bit by dividing the path parsing from the path drawing:

val paths = HeartCandy.Heart
    .pathStrings(color)
    .map { (pathString, pathColor) ->
         val path = // Parse the path
         Pair(path, pathColor)
    }

val pathBounds = paths.map { it.first.getBounds() }
val isSelected = pathBounds.any { it.contains(dragPosition) }

paths
    .map { (path, color) ->
        // Draw the paths
    }
Enter fullscreen mode Exit fullscreen mode

We first draw the paths as we did before, and from the map, we return the data as Pair<Path, String>. Then, we map through the paths to get the bounds for each path and store the result in pathBounds. After that, we map through it, checking if any of them contain the current drag position.

Now that we know if a heart is currently selected, we can do some transformations. Let's do two things: Scale the heart slightly bigger and change the text color.

First, let's define the variables:

val scaleAmount = if (isSelected) 1.05f else 1f
val textColor = if (isSelected) HeartCandy.highlightColor else color
Enter fullscreen mode Exit fullscreen mode

Then, we want to wrap everything we're drawing with the scale transform function and pass in the scale amount:

scale(scaleAmount) {
    paths.map {...}
}
Enter fullscreen mode Exit fullscreen mode

And finally, change the text color to the newly defined variable:

drawText(
    ...
    color = textColor,
)
Enter fullscreen mode Exit fullscreen mode

And that's it. With these code changes, we've added some interaction to our canvas.

Wrapping Up

In this blog post, we've looked into how to add interaction on Compose Canvas. We drew some Valentine's day themed hearts with a queer twist and added an ability to select each of them with pointer input. The complete code is available in this code snippet.

While this project was fun to create, my inner accessibility specialist is reminding me about a few things: First of all, the text in the candies doesn't have enough contrast between the text and the background to be legible. The other thing is that right now, all the interactions are available only for pointer input, leaving out anyone using assistive technologies that don't support the pointer input.

I might write a blog post about improving these aspects later.

Have you built something exciting and interactive with Compose and Canvas? Or do you have future plans?

Links in the Blog Post

Top comments (0)