Compose Animations beyond the state change

faogustavo

Gustavo Fão Valvassori

Posted on August 24, 2021

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.

Kapture 2021-08-18 at 18.02.33

Also, all the code we discuss in this article is available in this repository:

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),
    )
}
Enter fullscreen mode Exit fullscreen mode

But when you run your app, this is the result:

Loading indicator not working

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),
    )
}
Enter fullscreen mode Exit fullscreen mode

And now, everything works 🎉

Loading indicator with side effect

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),
    )
}
Enter fullscreen mode Exit fullscreen mode

And the result animation is the same, but without using side effects on this function.

Loading indicator with infinite transition

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),
            )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Sound good, right? But when we see the animation, we will notice something weird.

Loading indicator losing synchrony

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),
            )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, the animation will keep synchronized, even after some time.

Loading indicator with delay to start

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:

Untitled

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.

Untitled (1)

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
}
Enter fullscreen mode Exit fullscreen mode

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,
        )
    )
}
Enter fullscreen mode Exit fullscreen mode

And this is the result:

Triangle indicator example

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.

GitHub logo touchlab / compose-animations

Group of libraries to help you build better animations with Compose Multiplatform

Compose Animations

Group of libraries to help you build better animations with Compose Multiplatform

See docs at Multiplatform Compose Animations

License

Copyright 2024 Touchlab, Inc.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

   http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

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.

💖 💪 🙅 🚩
faogustavo
Gustavo Fão Valvassori

Posted on August 24, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related