Flying Hearts Animation in Jetpack Compose: Quick and Easy Edition

I find that the best way to learn anything is by replicating existing features. The flying hearts animation became quite popular when Instagram introduced a live-video feature. And I thought this could be a really good feature to practice animation in Jetpack Compose.

At first, it may seem like a complex animation, but if we look closer, for version 1 only two properties are used: visibility and the position of each heart. In this tutorial, I’ll walk you through the implementation details of it, using the version 1.1.0-beta01 of Jetpack Compose.

1. Drawing the heart shape

In Jetpack Compose, there are multiple ways to apply a custom shape to a component. In the previous article , I wrote about leveraging the Shape API. In this one, I’ll use Canvas API to have precise control over what’s drawn on the screen.

Note: Canvas API of Jetpack Compose still hits the view-based Canvas API under the hood. However, it provides a more developer-friendly interface and takes care of managing states, performance, etc.

The new Canvas API exposes DrawScope , a scoped drawing environment that provides sizing information and different transformations. The biggest benefit of using DrawScope in the new API is there is no longer a need for maintaining the underlying state of the Canvas . This means that creating and updating the objects happens in one place.

 2fun Heart() {
 3    Canvas(modifier = Modifier
 4        .fillMaxSize(),
 5        onDraw = {
 6            val path = Path().apply {
 7                heartPath(Size(120f, 120f))
 8            }
10            drawPath(
11                path = path,
12                color = Color.Red,
13            )
14        }
15    )

The heart path extension is available here .

2. Introducing the state

For this animation, we have two states: Show and Hide .

1enum class HeartState {
2    Show,
3    Hide

When the heart is shown, it will appear and move in the upward direction. When it’s in the hidden state, the heart disappears at the reached position.

The transition between states in the flying hearts is not fully user-controlled, the state change from Show to Hide happens automatically within a certain duration. To make that transition we’ll use LaunchedEffect to swap the states:

1    LaunchedEffect(key1 = state, block = {
2        state.value = when (state.value) {
3            HeartState.Show -> HeartState.Hide
4            HeartState.Hide -> HeartState.Show
5        }
6    })

3. Adding Visibility animation

Now with the state in place, we can start adding animations. The first property to animate is opacity, aka alpha in view-based and compose-based UI components. There are multiple ways to animate the appearance and disappearance of the element in Compose:

a) wrapping the element in high-level animation API AnimatedVisibility;

b) leveraging Modifier function alpha to set the visibility of the element;

c) using alpha parameter in drawPath() function to set the visibility of the Path.

I went with the AnimatedVisibility API.

When using AnimatedVisibility the entire content of the element will have the enter and exit animations. Under the hood, AnimatedVisibility creates a custom layout for the element and its content. Once the exit animation is completed, AnimatedVisibility removes the element and its content from the UI tree and then finally disposes.

1    AnimatedVisibility(
2        visible = state.value == HeartState.Show,
3        enter = fadeIn(animationSpec = tween(durationMillis = 250)),
4        exit = fadeOut(animationSpec = tween(durationMillis = 700)),
5    ) {
6        Text(text = "This is a Text1")
7        Text(text = "This is a Text2")
8    }

In the example above, both Texts will fade in at the same time and with the same easing.

In AnimatedVisibility only parameters visible and content are required. The parameters enter, exit, modifier, and label have default values. By default, the content appears by fading in and expanding, and it disappears by fading out and shrinking.

4. Adding Position animation

Now the next property to animate is the position of each heart. With each frame, the heart moves up. Let’s refresh our knowledge of the canvas coordinates system: the top start corner of the screen has (0,0) coordinates, while the bottom end has (width, height) coordinates. Canvas Coordinates in Android Development *The image is taken from @hi_man_shoe ’s article “Understanding Canvas API in Android” .

Now for the movement of the heart, we need two coordinates: start (x,y) and end (x1, y1). Because the heart goes up, the start coordinate will be in the bottom half of the screen, while the end coordinate in the top half.

Let’s assume, for the start coordinate in X-axis, the animation will start at width/2 , and in Y-axis it’ll start at height - (bottomMargin + buttonHeight).

For the end coordinate, let’s calculate random values, using Random generator class:

1val yRandom = Random.nextInt(0, height / 2)
2val xRandom = Random.nextInt(0, width))

yRandom has a minimum value of zero, the top point of the screen, and a maximum value of height / 2 , the vertical center of the screen. xRandom has a minimum value of zero, the left point of the screen, and a maximum value of width , the right point of the screen.

As for animation, we can use low-level animation API animate*AsState function, specifically the animateDpAsState() :

1    val offsetYAnimation: Dp by animateDpAsState(
2        targetValue = when (state.value) {
3            HeartState.Show -> height.dp
4            else -> yRandom
5        },
6        animationSpec = tween(1000)
7    )
1    val offsetXAnimation: Dp by animateDpAsState(
2        targetValue = when (state.value) {
3            HeartState.Show -> (width / 2).dp
4            else -> xRandom.dp
5        },
6        animationSpec = tween(1000)
7    )

In animate*Float/Color/Dp/Size/Offset/Rect/Int/IntSize/IntOffset*AsState functions, only targetValue parameter is required, the rest have the default values.

animationSpec stores:

  • the data type to be animated;

  • the animation configuration, that will be used once the data has been converted to AnimationVector .

In the example above, I used tween spec with default FastOutSlowInEasing.

It’s worth noting that animate*AsState() is perfect for animating a single value. To animate multiple values it’s better to use updateTransition() that will return the instance of Transition. However, in this example, using transition gave me janky and earthquake-shaky animation.

With the declared animation properties for the position, we can pass them to:

  • the translate() function of the Path ;

  • the transformation function translate() of the DrawScope ;

  • modifier function offset() of the canvas.

I chose the latter one:

1    Canvas(
2        modifier = modifier
3            .offset(y = offsetYAnimation, x = offsetXAnimation)
4    )

5. Making it multiple

To make hearts appear on each click, we introduce the counter:

1    val heartCount = remember { mutableStateOf(0) }

On each button click, we’ll increase the counter:

1    Button(
2        onClick = {
3            heartCount.value++
4        }
5    )

Then wrap the canvas into Kotlin’s repeat inline function:

1    repeat(heartCount.value) {
2        Heart()
3    }

6. Additional animations

The animation function animate*Float/Color/Dp/Size/Offset/Rect/Int/IntSize/IntOffset*AsState provides us out-of-the-box support for the extensive list of data types. We can introduce additional animation values and pass them to:

  • DrawScope ’s transformation functions like translate along the X and Y axes, scale in both vertical and horizontal directions, rotate to the current transform, etc.

  • DrawScope ’s regular functions like drawPath(), drawLine() , drawArc() , drawPoints() , etc where we can leverage color, alpha , sweepAngle and many other parameters.

  • Modifier ’s functions like background() , size() , border() , etc.

7. Clean up

After making the animations work, it’s time to modify the code and introduce parameters to the Heart() composable function.

  • The first parameter is the Modifier to the Canvas component.

  • The second one is the horizontal padding for the left and right sides of the X-axis animation to narrow the horizontal axis for the shape swaying.

  • The third one is the bottom margin of the screen to determine the beginning of Y-axis animation.

Additionally, we can introduce the duration of the animations and other parameters.

Full Code can be found here .

8. What’s next?

After making the animation work, my view-based brain couldn’t stop but wonder about the performance of adding and removing the layout for every single heart. So I decided to optimize the code, and draw as many paths as the button clicked using the repeat function or simple for-loop inside onDraw() function of Canvas, but after days of trying to make it work, I figured it was time to take a nice break and write this article.

If you know the solution, please share it with me in the comments or on Twitter. Thank you so much!

❤️ Was this post helpful?

If you liked the article, give a shoutout to @aida_isay on Instagram, Threads, or Twitter and help share this article.

Thank you for your support 🙌