Chaining Animations With Jetpack Compose

Discover how to run sequential and parallel animations

When it comes to animations individually, Google provides thorough running documentation. When it comes to chaining several animations — whether they are sequential or parallel — I’ve struggled to find the right resource.

Until I bumped into this video that summarizes exactly what I was looking for:

This video explains how to leverage coroutines to chain your animations. That was my missing piece to accomplish a shuffle animation. I would animate two cards that would translate back and forth while they would scale down until they both reach a middle point.

Look at the final effect by yourself:

Let’s have a look at the different animations being applied to achieve this shuffle effect.

If you look closely at the animation, we can split it into a set of animations. There are actually 3 sets themselves containing sometimes a set of animations:

  1. The opening: a slight scale down from 1 to 0.9%
  2. The core: three parallel animations with the same duration where the scale down slowly continues until reaching 0.8%. Similarly, the opacity diminishes from 1 to 0.4%. Finally, we repeat five times the same back and forth translations at a faster pace.
  3. The closing: we hide one card while the other gets its opacity reset to 1. The decided card’s offset is set to 70% of the height between the two cards. Finally, after a small delay, the card animates back to the top of the screen while the scale goes back to normal.

All those animations run both sequentially and simultaneously. They can be represented as follow:

Now that we’ve illustrated how the animation should behave, let’s see how we can leverage coroutines to wrap them all.

Jetpack Compose uses coroutines to let you chain several animations. Whether they are sequential or simultaneous, you can create as many coroutines to create the desired effect.

We’ll start by building the skeleton of our shuffle animation. Since it includes both sequential and parallel animations, we’ll need to create several coroutines as well as some coroutineScope for the simultenaous aspect.

The skeleton should look like this:

This skeleton bootstraps our animation by splitting them into subsets of coroutines.

As for the animations themselves, Jetpack Compose exposes an animate suspend method callable from a coroutine context.

suspend fun animate(
initialValue: Float,
targetValue: Float,
initialVelocity: Float = 0f,
animationSpec: AnimationSpec<Float> = spring(),
block: (value: Float, velocity: Float) -> Unit
)

As an example, let’s start with the enter animation. We would like to scale down our cards from 1 to 0.9.

We can start by saving a scale ratio and updating its value within the animate method.

var scale by remember { mutableStateOf(1f) }
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = Unit) {
scope.launch {
animate(1f, 0.9f) { value: Float, _: Float ->
scale = value
}
}
}

Then we can apply this value to both our card composables.

PlayerCard(
modifier = Modifier
.scale(scale),
// Other parameters
)

The animate function will interpolate the scale value with a default spring effect. You can change this effect by overriding the animationSpec property.

This part of the animation deserves its own section. As a disclaimer, I want to emphasize I’m not advocating how I’ve handled this transition. The reason lies in its lack of scalability as much as a hunch there must be a better way out there. I encourage you to share your alternatives to further improve the overall quality of this animation.

Having said that, my approach works. I start by retrieving the position I want to translate to called finalOffset. In my case, since the cards swap, this value equals the card’s height and the padding between them.

I remember this value from the second card composable using the onGloballyPositioned method.

val density = LocalDensity.current
val padding = 30.dp
var finalOffset by remember { mutableStateOf(0f) }PlayerCard(
modifier = Modifier
.padding(top = padding * 2)
.onGloballyPositioned {
finalOffset = with(density) {
it.size.height + padding.toPx() * 2
}
}
// Other properties
)

We’ll need to store the interpolated offset while running the animation. Finally, the translation results in the final offset interpolated with the animated offset. We negate this value for the second card to translate in the opposite direction.

var offset by remember { mutableStateOf(0f) }
val translation = finalOffset * offset
PlayerCard(
modifier = Modifier
.graphicsLayer(
translationY = translation
),
// Other parameters
)
PlayerCard(
modifier = Modifier
.graphicsLayer(
translationY = -translation
),
// Other parameters
)

As for the offset computation, we’ll use the same mechanism as we did for scale. We need to apply two sequential animations where the offset goes from 0 to 1 then 1 to 0 to mimic a back and forth. To wrap this up, we repeat this cycle as many times as you want (five in this example).

launch {
repeat(5) {
animate(0f, 1f) { value: Float, _: Float ->
offset = value
}
animate(1f, 0f) { value: Float, _: Float ->
offset = value
}
}
}

For the remaining animations, we will apply the same logic but on different properties. Let’s start by storing them all:

var scale by remember { mutableStateOf(1f) }
var firstCardAlpha by remember { mutableStateOf(1f) }
var secondCardAlpha by remember { mutableStateOf(1f) }
var offset by remember { mutableStateOf(0f) }

There is one important piece to mention though. Because we’re simultaneously manipulating different properties of our composable, we can’t rely anymore on the direct Modifier properties such as scale, alpha and offset. You’ll need to use the Modifier‘s graphicsLayer property.

PlayerCard(
modifier = Modifier
.graphicsLayer(
alpha = firstCardAlpha,
scaleX = scale,
scaleY = scale,
translationY = translation
),
// Other parameters
)
PlayerCard(
modifier = Modifier
.graphicsLayer(
alpha = secondCardAlpha,
scaleX = scale,
scaleY = scale,
translationY = -translation
),
// Other parameters
)

The complete animation block should tend towards this:

You’ll probably end up with something slightly different as it should match your use case — not to mention there is some business logic behind I’ve hidden as it was not relevant for the article.

Chaining animations result in leveraging coroutines and changing composable intrinsic properties. Don’t hesitate to build amazing user experiences with intricate animations that make immediate sense to your users. With this shuffle animation, the user understands the randomness lurking behind.

I dearly hope this article will ease your approach towards chained animations with Jetpack Compose. Happy coding!

Leave a Comment