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,
) {
}
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 Path
s:
HeartCandy.Heart
.pathStrings(color)
.map { (pathString, color) ->
val path =
PathParser()
.parsePathString(pathString)
.toNodes()
.map { path -> path.scaleTo(heartSize.height) }
.toPath()
.apply {
translate(topLeft)
}
...
}
Here, we go through each path string and color pair, and using PathParser
, we parse the path strings into Path
s. Then, we convert the Path
into Node
s, so we can scale them to the correct size. Once that's done, we convert them back to Path
s, 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),
)
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:
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,
) { ... }
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,
),
)
)
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,
)
}
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,
)
After these changes, the heart looks like this:
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) ->
...
}
}
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,
)
)
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:
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
)
}
},
) { ... }
}
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,
) { ... }
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
}
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
Then, we want to wrap everything we're drawing with the scale transform function and pass in the scale amount:
scale(scaleAmount) {
paths.map {...}
}
And finally, change the text color to the newly defined variable:
drawText(
...
color = textColor,
)
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?
Top comments (0)