Jetpack Compose: Reducing recomposition on Bubbles Animations

Rogelio Robledo
Trade Republic Engineering
6 min readMay 6, 2024

--

At Trade Republic, our journey with Jetpack Compose began in August 2021 with 1.0.0-rc02. Since then, we have built two design systems with it. One of them has gracefully retired, but the other, it's Compose all the way.

You might have seen the cool Bubble Chart on the Cash Screen of our app. It’s not just there to look pretty — it actually helps you understand your monthly transactions in a clear and easy way. This blog post dives into the challenges we faced when building the animations for this Bubble Chart.

⚠️ Before we start, this blog post assumes you already have some knowledge on Jetpack Compose, modifiers and how composables work.

Requirements

Our goal was to create a customisable Bubble Chart capable of supporting infinite number of bubbles, and designed for intuitive interaction. To meet these requirements, we needed to place a strong emphasis on ensuring smooth and efficient animations.

Each bubble within the chart comes alive through user interaction, reflecting its underlying data.

  • Tree-Structured Categories: Bubbles represent categories, which can further be broken down into subcategories, forming a hierarchical tree structure. For the sake of this blog post, we will refer to the category bubbles as “parent bubbles” and the subcategory bubbles as “child bubbles”.
  • Interactive Animations: Clicking a parent bubble triggers the creation of child bubbles as a visual expansion of its content. Simultaneously, unclicked bubbles dynamically reposition themselves within the chart to maintain a clear presentation.

This approach provides a way for users to visualize their categorized Monthly transactions.

Breaking down the animation

When a user clicks on a parent bubble, three animations occur simultaneously:

  1. Child Bubbles Appearing: New child bubbles emerge (in green) and move to their designated positions. This involves animation of alpha, size, and color.

2. Parent Bubble Disappearing: The parent bubble fades out (in green) as the child bubbles appear. This is a simple alpha animation with a Snap Animation Spec.

3. Other Bubbles Resizing and Shifting: The remaining bubbles (purple and blue) that weren’t clicked need to adjust their position to accommodate the expanding child bubbles. This is achieved through a translation animation.

Challenge: Scaling for Infinite Bubbles

Imagine a chart bursting with information — 20 parent bubbles, each housing a multitude of subcategories. Clicking any parent unleashes a cascade of, say, 30 child bubbles, bringing the total to 50! The challenge lies in ensuring a smooth and visually pleasing animation for all these bubbles, even with an infinite number of potential categories. Each bubble needs to seamlessly transition in alpha (opacity), size, color, and position to create a clear and engaging experience.

Building the Basic Animation

Let’s start with a simple animation focusing only on the translation, specifically the movement in the X and Y directions. We’ll use the Animatable class to manage the bubble's position over time.

Here’s a glimpse of the setup:

val xOffset = remember { Animatable(bubbleLayout.x, Dp.VectorConverter) }
val yOffset = remember { Animatable(bubbleLayout.y, Dp.VectorConverter) }

We have defined two states: hide for visibility and bubbleLayout for the bubble position. These states will define on how the composable animation will be produced:

LaunchedEffect(hide, bubbleLayout) {
if (hide) {
val xAnim = async { xOffset.animateTo(bubbleLayout.x, tweenAnimationSpec()) }
val yAnim = async { yOffset.animateTo(bubbleLayout.y, tweenAnimationSpec()) }
awaitAll(yAnim, xAnim)
} else {
...
}
}

Finally, we apply the animated offsets to our Bubble composable to achieve the desired translation effect:

Bubble(
modifier = Modifier
.offset(x = xOffset.value, y = yOffset.value)
...
) {
content()
}

The Recomposition Problem

Running this code initially revealed a high number of recompositions. Recompositions occur whenever the composable function needs to be rebuilt due to changes in its state or properties. Excessive recompositions can negatively impact performance, especially for animations.

In this case, the recomposition count are only for the translation animation, if we would add the other animations like color and alpha, the recompositions count would increase way higher.

How did we optimize it?

To address this issue, we leveraged a Jetpack Compose best practice: Deferring reads as long as possible. Deferring state reads ensures that Compose re-runs the minimum possible code on recomposition.

The initial approach using Modifier.offset(x: Dp, y: Dp) caused a slight stutter during animations. This is because Compose recomposed all the bubbles whenever the animation state changed.

To fix this, we switched to the lambda version of the Modifier.offset function in the Bubble composable.

Here’s the magic: the lambda defers reading the animation state until the Layout phase, skipping the Composition phase. This allows Compose to skip unnecessary recompositions, resulting in smoother animations!

When you are passing frequently changing State variables into modifiers, you should use the lambda versions of the modifiers whenever possible.

Here’s the updated code:

Bubble(
modifier = Modifier.offset {
IntOffset(
x = xOffset.value.roundToPx(),
y = yOffset.value.roundToPx(),
)
},
...
) {
content()
}

As you can see, our recomposition count has decreased significantly:

I recommend to read these best practices in Developer Android and this article by Ben Trengrove. Also, to keep in mind this statement that helped us understand where we should skip recompositions:

You should be suspicious if you are causing recomposition just to re-layout or redraw a Composable.

Optimising Color and Alpha Animations

We applied similar optimisation techniques for color and alpha animations. These properties are typically read during the draw phase, allowing Compose to skip unnecessary recompositions.

Here are some examples:

Color Animation:

Modifier
.clip(CircleShape)
.drawBehind {
drawRect(color.value)
}

In this case, the color.value is read only during the draw phase because the drawBehind modifier executes instructions specifically for drawing on the canvas. Since the color doesn't affect the layout of the composable, Compose can avoid recompositions for layout updates.

Alpha Animation:

Modifier
.graphicsLayer {
alpha = alphaOffset.value
}

The graphicsLayer modifier allows you to customize visual effects on a composable. Setting the alpha property within the lambda ensures it's read during the draw phase, as it directly affects how the composable is drawn on the screen. This again helps minimize unnecessary recompositions.

Remember, the key takeaway here is that by using these techniques, we can ensure that color and alpha animations are only updated when necessary, leading to smoother and more efficient performance.

Conclusion

We built a Bubble Chart that can handle tons of bubbles, scroll with ease, and keep the animations smooth! The key was optimizing for performance.

We did this by deferring reads as long as possible. This allows Compose to skip the Composition phase and go straight to the Layout or Draw phase.

We should prefer using the lambda versions of the modifiers whenever it is possible, for example, prefer Modifier.offset lambda function instead of Modifier.offset(x: Dp, y: Dp).

Further Reading

--

--