Context

In Part-II we explored the low-level Animatable API, which leverages Animation API and coroutines to unlock the possibility of composing complex animations running parallelly or sequentially.

While this API offers fine control over the animation, most of the time you want simple animations. Things like scaling, rotating or changing alpha, based on some state change within compose. Sometimes you need to run multiple animations together based on state change.

animate*AsState and Transition APIs built on top of Animatable and Animation, respectively, are the two high-level APIs that allow you to implement such animations. In this part we will see the implementation details of these two APIs.

If you haven’t read the first two parts I would suggest reading them before continuing here.

  1. Part-I covering low level Animation API.
  2. Part-II covering Animatable API.

animate*AsState

This is just a composable wrapping Animatable and exposing its state. Since this is the composable function it can observe state change. Based on state value, it internally triggers the animation towards the new target. So essentially, the input for this API is state, and the output is the state object that your composable observes and recomposes.

Let’s understand this using an example.

// Compose Context  
val isVisible by remember { mutableStateOf(false) }

val alphaState: State<Float> = animateFloatAsState(
    targetValue = if (isVisible) 1f else 0f, 
    label = "alphaAnimation"
)

Image(
    modifier = Modifier.graphicsLayer { alpha = alphaState.value },
    painter = painterResource(id = R.drawable.image),
    contentDescription = "Image"
)

Here we are, toggling the visibility of image based on state change. Notice isVisible above is the state value observed by animateFloatAsState. If it is true, then the target value of animation is 1 else 0. animateFloatAsState composable returns another state object of float. This state object is then applied to the alpha of the image.

The above example animates the value of type float, but there are composables to animate Int (animateIntAsState), Offset (animateOffsetAsState), Rect (animateRectAsState), DP(animateDpAsState) etc. In fact, you can animate your custom object type if you provide TwoWayConverter(Refer to Part-I if you want to know more about TwoWayConverter) to the generic animateValueAsState function. All the built-in composables, animateIntAsState, animateFloatAsState, animateRectAsState, animateDpAsState, etc, internally call animateValueAsState only by passing TwoWayConverter of respective type.

Hopefully, by now, you have a better understanding of the API and curious to know its implementation. Since everything boils down to animateValueAsState, let’s zoom into the function.

@Composable
public fun <T, V : AnimationVector> animateValueAsState(
    targetValue: T,
    typeConverter: TwoWayConverter<T, V>,
    animationSpec: AnimationSpec<T> = remember { spring() },
    visibilityThreshold: T? = null,
    label: String = "ValueAnimation",
    finishedListener: ((T) -> Unit)? = null
): State<T> {

    .
    .
    val animatable = remember { Animatable(targetValue, typeConverter, visibilityThreshold, label) }
    .
    .
    .
    val channel = remember { Channel<T>(Channel.CONFLATED) }
    SideEffect { channel.trySend(targetValue) }
    LaunchedEffect(channel) {
        for (target in channel) {
            val newTarget = channel.tryReceive().getOrNull() ?: target
            launch {
                if (newTarget != animatable.targetValue) {
                    animatable.animateTo(newTarget, animSpec)
                    listener?.invoke(animatable.value)
                }
            }
        }
    }
    return toolingOverride.value ?: animatable.asState()
}

This is the trim-down version of full implementation.

This composable remembers Animatable with an initial target. Remember I said wrapping Animatable ? This is exactly what I meant.

Internally, it uses channel to dispatch target change. The SideEffect above gets executed every time there is a change in the target. Within this block, we are dispatching new target values to the channel.

This channel is then observed within LaunchedEffect, and if the new target is different from the existing target, it starts the animation.

Finally, outside the LaunchedEffect at the end, the function returns state from the Animatable. This state object is updated on each frame by Animatable resulting in Animation.

toolingOverride above is for overriding the actual animation state with the default tooling state. This will be used while composable is in preview mode.

That concludes animate*AsState API. A simple wrapper over Animatable.

Transition

Transition API triggers multiple animations on state change. For each animation, it maintains a state object tracking its current value, whether the animation is finished, etc. This state object holds the reference of low-level Animation API and delegates animation to it. So essentially, Transition API is a wrapper over Animation API.

Before we dive into the implementation details, Let’s see how you can consume this API.

// Compose Context
var isVisible by remember { mutableStateOf(false) }

val transition = updateTransition(target = isVisible)

val alpha by transition.animateFloat(
    label = "Alpha"
) { isVisible ->
    if (isVisible) 1f else 0f
}

val scale by transition.animateFloat(
    label = "Scale"
) { isVisible ->
    if (isVisible) 1f else 0f
}

We have isVisibile mutable state that is being passed to updateTransition composable. This composable internally creates, remembers, and returns an instance of Transition.

With this Transition instance, you can create child animations. In the above example, two animations are created. The first animation is for animating alpha, and the second animates the scale. Notice the trailing lambda of animateFloat function. It exposes the isVisibile state value that is observed by transition. Based on this state value we decide the target for child animation.

Finally, you can apply this alpha and scale to animate composable.

Image(
    modifier = Modifier.graphicsLayer { 
	    alpha = alpha 
	    scale = scale
	},
    painter = painterResource(id = R.drawable.image),
    contentDescription = "Image"
)

Now that we are clear on how we can consume Transition API, let’s zoom into the implementation 🧐

It starts with updateTransition composable.

@Composable
public fun <T> updateTransition(targetState: T, label: String? = null): Transition<T> {
    val transition = remember { Transition(targetState, label = label) }
    transition.animateTo(targetState)
    DisposableEffect(transition) {
        onDispose {
            transition.onDisposed()
        }
    }
    return transition
}

Nothing complicated here. It just creates an instance of Transition and remembers it. Since this is composable, every time there is a change in the target state that is being passed to this function, it recomposes and calls animateTo on Transition passing a new target state. Transition then triggers all child animations towards their new respective targets.

It also takes care of disposing transition when the composition exits. Finally, it returns the created instance of transition.

The above composable is merely a thin wrapper delegating target change to Transition. Let’s zoom into the Transition implementation.

public class Transition<S> {
    .
    .
    .
    public val animations: List<TransitionAnimationState<*, *>>
        get() = _animations
    .
    .
    .
    internal fun animateTo(targetState: S) {
        .
        .
        .    
        while (isActive) {
            withFrameNanos {
                if (!isSeeking) { frameTimeNanos ->
                    onFrame(frameTimeNanos / AnimationDebugDurationScale, durationScale)
                }
            }
        }    
        .
        .
        .
    }
}

This is the ultra trim-down version of the actual implementation. There is a lot going on in this class and I encourage you to explore it.

Two things I want to focus on here are a list of TransitionAnimationState and animateTo function.

The list of TransitionAnimationState above holds the state of each child animation. This list is populated when you create the child animation on Transition. Remember this?

val alpha by transition.animateFloat(
    label = "Alpha"
) { isVisible ->
    if (isVisible) 1f else 0f
}

This was one of the child animations we created earlier in the example. When you call animate* (animateInt, animateFloat, animateIntOffset etc) on transition, it internally creates TransitionAnimationState, passing the initial target value and adds it to the list.

The animateTo function is the entry point to Transition API, which will be called by updateTransition composable function on state change. This function starts listening to new frames from the choreographer. In Part-II of this series, we saw the implementation details of frame listening. I am not covering it again as it is same here as well.

The trailing lambda of withFrameNanos will get called for each frame. This lambda gets the frame time in nanoseconds. Later, this frame time is used to calculate the playtime of animation within onFrame function. Again, the calculation of play time is very similar to what we saw in Part-II. I would leave this to you as an exercise to explore onFrame function.

The calculated playtime of the animation is then fed to each child animation represented by TransitionAnimationState.

 _animations.fastForEach {
    .
    .
    it.onPlayTimeChanged(scaledPlayTimeNanos, scaleToEnd)
    .
    .
}

Now that playtime is available and fed to each child animation, let’s see the implementation details of TransitionAnimationState to understand how animation value is calculated for a frame.

public inner class TransitionAnimationState<T, V : AnimationVector>) : State<T> {
    .
    .
    .
    public var animation: TargetBasedAnimation<T, V> by
            mutableStateOf(
                TargetBasedAnimation(
                    animationSpec,
                    typeConverter,
                    initialValue,
                    targetValue,
                    initialVelocityVector
                )
            )
            private set
    .
    .
    .
    override var value: T by mutableStateOf(initialValue)
    internal set
    .
    .
    .
    internal fun onPlayTimeChanged(playTimeNanos: Long, scaleToEnd: Boolean) {
            val playTime = if (scaleToEnd) animation.durationNanos else playTimeNanos
            value = animation.getValueFromNanos(playTime)
            velocityVector = animation.getVelocityVectorFromNanos(playTime)
            if (animation.isFinishedFromNanos(playTime)) {
                isFinished = true
            }
        }
}

Again, this is the ultra trim-down version of the actual implementation.

Let’s break down a simplified version of the TransitionAnimationState class and understand how it works.

This class represents the internal state of child animation. It implements State<T>, making it observable in Compose. Whenever its value changes, the UI will recompose accordingly. This is what you get back when you create child animation on transition.

val alpha by transition.animateFloat(
    label = "Alpha"
) { isVisible ->
    if (isVisible) 1f else 0f
}

The animateFloat above returns State<Float>, which is nothing but TransitionAnimationState

The animation playtime we calculated is fed to onPlayTimeChanged, which internally feeds this playtime to Animation to calculate the animation value for the frame. This animation value is then applied to the state, resulting in recomposition of your Composable.

To understand how Animation calculates value based on playtime please head to Part-I of this series, where I detailed this.

Summary

This concludes the implementation details of two high-level Animation APIs, which animate things on state change. While these APIs look different from others, they leverage low-level Animation APIs to hide boilerplate code that you need to write otherwise. Let’s summarise them.

animate*AsState

  1. This is a composable wrapping Animatable and delegates animation to it.
  2. Internally it uses channel to publish all state change and observe this channel within LauchedEffect to forward state changes to Animatable

Transition

  1. updateTransition composable is the entry point that internally creates a Transition object.
  2. Transition react to state changes, start listening for frame changes, and forward them to all child animations
  3. Child animations are wrapped in TransitionAnimationState object, which reacts to frame change from Transition and internally delegates animation to Animation API.
  4. The updated animation values are then applied to TransitionAnimationState which causes recomposition

Parting Thoughts

I know this is a bit heavy to digest. I would recommend reading this along with the full implementation open on cs.android.com.

If you are still here, you are brave! Pet yourself on the back. Honestly, I am unsure if I simplified this enough to sink this in. Transition animation was by far difficult to explain as it involves multiple animations and additional state objects.

With this part, I am concluding this series on the internals of Compose animation API. I hope this series was insightful. If you have any suggestions or questions, please drop them in the comment below. Until then, stay curious!