· 7 min read Posted by Gustavo Fão Valvassori
Jetpack Compose Animations Beyond the State Change
Jetpack Compose hit 1.0 a few weeks ago, and it came with a wonderful and robust animations API. With this new API, you will have total control over animations when the state changes. But when it comes to more complex scenarios, you may face some non-obvious paths. This article will explore some of these scenarios and help you understand how you can achieve your goal.
I’m about to discuss the problems I found when trying to implement the AVLoadingIndicatorView library in Compose. And to guide you through this journey, I’ll use the following loading indicators as examples.
Also, all the code we discuss in this article is available in this repository: faogustavo/loading-indicator-demo
BallScaleIndicator
Let’s start with some simple animation. The animation consists in reducing alpha while increasing the scale from a circle. We can do that with just one value that will move from 0 to 1. So the scale will be the current value, and the alpha will be the complementary value (1 - currentValue
). As this is a loading animation, we will have to configure it to repeat forever.
TL;DR; we would have to do something like this:
@Composable
fun BallScaleIndicator() {
val animationProgress by animateFloatAsState(
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 800)
)
)
Ball(
modifier = Modifier
.scale(animationProgress)
.alpha(1 - animationProgress),
)
}
But when you run your app, this is the result:
Nothing happens. Why? In the first lines from this article, we said that Compose has an awesome API to animate state changes, but this is not some state change. We would have to add one variable to control the target state and change it to start the animation. Also, when the view is rendered, we would have to change the value to start the animation.
@Composable
fun BallScaleIndicator() {
// Create one target value state that will
// change to start the animation
var targetValue by remember { mutableStateOf(0f) }
// Update the attribute on the animation
val animationProgress by animateFloatAsState(
targetValue = targetValue,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 800)
)
)
// Use the SideEffect helper to run something
// when this block runs
SideEffect { targetValue = 1f }
Ball(
modifier = Modifier
.scale(animationProgress)
.alpha(1 - animationProgress),
)
}
And now, everything works 🎉
But we have another problem now. The SideEffect
will run every time the view is recomposed. So as the animation changes the state value on each iteration, we will run this effect many times. It makes the animation work, but it may not scale nicely and cause some UI issues.
Compose also provides a way to animate values using transitions. One type of transition is the InfiniteTransition. It provides you a syntax with the initial and final values as parameters and it’s automatically started when created (no need for SideEffects). To use it, you need to create an instance of it using the rememberInfiniteTransition
method and call the animateFloat
function to have an animated state.
After a small refactor, this is the result.
@Composable
fun BallScaleIndicator() {
// Creates the infinite transition
val infiniteTransition = rememberInfiniteTransition()
// Animate from 0f to 1f
val animationProgress by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 800)
)
)
Ball(
modifier = Modifier
.scale(animationProgress)
.alpha(1 - animationProgress),
)
}
And the result animation is the same, but without using side effects on this function.
BallPulseSyncIndicator
Alright, we got the first one. Let’s try something more complex. This one consists of three balls jumping in synchrony. The easiest way to achieve this is to delay the start of each animation. We can have an animation time of 600ms and start it with a delay of 70ms for each ball.
On a quick search into the compose animation API, we find that the tween
animation has a property delayMillis
that we can use to implement this behavior. And to animate the values, we can keep the InfiniteTransition. So let’s start working with it.
@Composable
fun BallPulseSyncIndicator() {
val infiniteTransition = rememberInfiniteTransition()
val animationValues = (1..3).map { index ->
infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 12f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 300,
delayMillis = 70 * index,
),
repeatMode = RepeatMode.Reverse,
)
)
}
Row {
animationValues.forEach { animatedValue ->
Ball(
modifier = Modifier
.padding(horizontal = 4.dp)
.offset(y = animatedValue.value.dp),
)
}
}
}
Sound good, right? But when we see the animation, we will notice something weird.
You can see that, after some running time, the animation loses synchrony and starts behaving weirdly. The reason for that is the property that we used to delay the animation. It applies the delay to each iteration, not just the first one.
The solution to this is to use the Coroutines Animation API. It’s provided by the Compose animations and has a method called animate
. It has a syntax pretty similar to the animateFloat
from transition. With that in mind, we can use the delay
function from Coroutines before starting the animation. This will guarantee the correct behavior.
@Composable
fun BallPulseSyncIndicator() {
val animationValues = (1..3).map { index ->
var animatedValue by remember { mutableStateOf(0f) }
LaunchedEffect(key1 = Unit) {
// Delaying using Coroutines
delay(70L * index)
animate(
initialValue = 0f,
targetValue = 12f,
animationSpec = infiniteRepeatable(
// Remove delay property
animation = tween(durationMillis = 300),
repeatMode = RepeatMode.Reverse,
)
) { value, _ -> animatedValue = value }
}
animatedValue
}
Row {
animationValues.forEach { animatedValue ->
Ball(
modifier = Modifier
.padding(horizontal = 4.dp)
.offset(y = animatedValue.dp),
)
}
}
}
Now, the animation will keep synchronized, even after some time.
TriangleSkewSpinIndicator
Alright, let’s go to the next one. This triangle indicator has two animations (rotation on the X-axis and Y-axis), but you need to wait for the previous one to execute to start the next one. So if we put that in a timeline, we will have something like this:
The easiest way to evaluate something like this is to handle the animation as one thing with groups. Each group will be formed by the value and the next item in the list and take the same amount to evaluate.
Rewriting the image in Kotlin, we would have something like this:
@Composable
fun animateValues(
values: List<Float>,
animationSpec: AnimationSpec<Float> = spring(),
): State<Float> {
// 1. Create the groups zipping with next entry
val groups by rememberUpdatedState(newValue = values.zipWithNext())
// 2. Start the state with the first value
val state = remember { mutableStateOf(values.first()) }
LaunchedEffect(key1 = groups) {
val (_, setValue) = state
// Start the animation from 0 to groups quantity
animate(
initialValue = 0f,
targetValue = groups.size.toFloat(),
animationSpec = animationSpec,
) { frame, _ ->
// Get which group is being evaluated
val integerPart = frame.toInt()
val (initialValue, finalValue) = groups[frame.toInt()]
// Get the current "position" from the group animation
val decimalPart = frame - integerPart
// Calculate the progress between the initial and final value
setValue(
initialValue + (finalValue - initialValue) * decimalPart
)
}
}
return state
}
With this one implemented, the animation process will be pretty simple. Just create two variables to hold the X and Y rotation, and update the view using the .graphicsLayer
modifier.
@Composable
fun TriangleSkewSpinIndicator() {
val animationSpec = infiniteRepeatable<Float>(
animation = tween(
durationMillis = 2500,
easing = LinearEasing,
)
)
val xRotation by animateValues(
values = listOf(0f, 180f, 180f, 0f, 0f),
animationSpec = animationSpec
)
val yRotation by animateValues(
values = listOf(0f, 0f, 180f, 180f, 0f),
animationSpec = animationSpec
)
Triangle(
modifier = Modifier.graphicsLayer(
rotationX = xRotation,
rotationY = yRotation,
)
)
}
And this is the result:
Final Thoughts
The Jetpack Compose comes with an incredible animation API. It provides you many ways to implement all kinds of animations you may need. But, the current API is a bit different from the imperative version, and some paths will not be that obvious.
To help you have a smooth transition to compose, Touchlab has started a project to help you with all these non-obvious paths. For more details about that, check this repository touchlab-lab/compose-animations.
Thanks for reading! Let me know in the comments if you have questions. Also, you can reach out to me at @faogustavo on Twitter, the Kotlin Slack, or AndroidDevBr Slack. And if you find all this interesting, maybe you’d like to work with or work at Touchlab.