Animations with Lookahead in Jetpack Compose

Animations with Lookahead in Jetpack Compose

The LookaheadScope (replaced by the previous LookaheadLayout) is a new experimental API in Jetpack Compose that allows for predictive animations based on the future state of the layout by pre-calculating size and position. It supports a lookahead pass of measure and layout before the actual measure/layout. Thus the latter can make use of the values pre-calculated during the previous to update the node on every frame.

LookaheadLayout was first introduced in Version 1.3.0-alpha01 (see commit). Since then the API has gone through many changes. Check for some of the important changes listed at the end of this article.

As mentioned by Jaewoong Eum in this tweet: “This allows us to look ahead and calculate a new layout while allowing the actual measurement & placement of every frame to be different than the pre-calculation.” Also, Doris Liu in this tweet shared an amazing example of lookahead with movable content, which made it possible to move UI between a single-column and a double-column layout without losing their animation states.

In this article, I will try to explain the various functions and interfaces that I have explored so far in this API and then analyze an official example for animating four Box composables provided in the document.

How does it work?

Quick Overview

  1. Lookahead pass: All layouts will first determine their target or destination layout. This allows a pre-calculation of the layout when it changes and use this information to measure and place layouts during each frame of the animation (next point).

  2. Approach pass: Then they will run the measurement and placement approach or logic to reach the destination gradually.

  3. The LookaheadScope interface creates a scope for the above two steps.

  4. The measurement and placement approach in the approach pass is defined by the Modifier.approachLayout or by the ApproachLayoutModifierNode interface (creating custom approach logic in an explicit Modifier Node).

Let's understand the APIs

LookaheadScope

There is a LookaheadScope interface and a LookaheadScope composable. The interface is a receiver scope for all child layouts within the composable. It provides access to the lookaheadScopeCoordinates from any child's PlacementScope. This allows any child to convert LayoutCoordinates to the LayoutCoordinates in lookahead coordinate space using the toLookaheadCoordinates() function. This is particularly useful for animations where we want to calculate positions based on the future state of the layout.

ApproachMeasureScope

A scope that provides access to the lookahead results. The ApproachLayoutModifierNode can leverage these results to define how measurements and placements approach their destination.

approachLayout Modifier and ApproachLayoutModifierNode

The approachLayout modifier is instrumental in managing the approach pass (it replaces the now-deprecated Modifier.intermediateLayout). It operates within the LookaheadScope to establish the methodology for measuring and positioning a composable's content throughout an animation. This modifier constructs an "approach layout" that facilitates a gradual transition towards the target layout defined during the lookahead pass.

Internally, the approachLayout modifier utilizes the ApproachLayoutModifierNode API, a new Modifier Node tailored for the destination layout as determined in the lookahead phase. This Node relies on input from the users to confirm the completion of measurement and placement processes. Such confirmations enable the system to bypass the approach phase once concluded, thereby enhancing layout performance by avoiding unnecessary recalculations.

DeferredTargetAnimation

The DeferredTargetAnimation is a new experimental API for creating those animations whose target is unknown at instantiation. It can be used for size or position animations where the target size or position stays unknown until the later measure and placement phase. It has an updateTarget function, which sets up an animation or updates an already running animation. It returns the current value of the animation, after launching the animation in the given coroutineScope. It also has an isIdle Boolean property which returns true when the animation has finished running and reached its destination, or when the animation has not been set up.

Some important concepts here:

  1. lookaheadSize: The destination/target size of the layout obtained in the ApproachMeasureScope. This is the size of the ApproachLayoutModifierNode measured during the lookahead pass.

  2. localLookaheadPositionOf: It calculates the local position in the Lookahead coordinate space. It is used to obtain the target position during placement by using LookaheadScope.localLookaheadPositionOf and the lookahead LayoutCoordinates in the ApproachMeasureScope.

  3. By knowing the target size and position, animations or other layout adjustments can be defined in the ApproachLayoutModifierNode to morph the layout gradually in both size and position to arrive at its precalculated bounds.

  4. isMeasurementApproachComplete: A block that indicates whether the measurement has reached the destination size. It is invoked after the destination has been determined by the lookahead pass, before approachMeasure is invoked. It receives lookahead size in the argument to decide whether the destination size has been reached.

  5. isPlacementApproachComplete: A block that indicates whether the position has approached the destination defined by the lookahead. Based on this, the system can decide whether additional approach placements are necessary. It is invoked after the destination position has been determined by the lookahead pass, and before the placement phase in approachMeasure.

  6. Once both of the above two blocks return true, the system may skip approach pass until additional approach passes are necessary as indicated by them. If the above two approach callbacks are incomplete for a long time, the system might skip approach pass whenever possible. Hence it’s important to be accurate about these two callbacks.

Implement in code

We have learned a lot of concepts. But we do not know how to use them yet. Let’s write some code with these APIs.

Let’s start with an example where we want to animate four colored Boxes smoothly from a Row view to a Column view and vice-versa, on the click of the container. This example has been taken from the official docs here for LookAheadScope. We want to create something like this:

Now, we can follow two ways to use Lookahead APIs:

  1. Write a custom implementation of the ApproachLayoutModifierNode

  2. Use the approachLayout Modifier (internally uses ApproachLayoutModifierNode)

Both are ideally doing the same thing: defining an approach or logic to reach the destination. I will show both ways separately to obtain the desired animation shown above.

1. Writing a custom implementation of the ApproachLayoutModifierNode

Step 1:Create movable contents and switch layouts

First, we will create movable contents for the four colored Boxes. For this, we will use the movableContentOf API. This enables us to move around content without the need for recomposition. It works by receiving a composable lambda function that it will remember and move to wherever it is invoked. Then using a Boolean mutable state we will toggle them between a Row and a Column on the click of the parent Box container.

@Composable
fun LookAheadWithSimpleMovableContent() {
    val colors = listOf(
        Color(0xffff6f69),
        Color(0xffffcc5c),
        Color(0xff264653),
        Color(0xFF679138),
    )
    var isInColumn by remember { mutableStateOf(true) }
    val items = remember {
        movableContentOf {
            colors.forEach { color ->
                Box(
                    Modifier
                        .padding(8.dp)
                        .size(80.dp)
                        .background(color, RoundedCornerShape(10))
                )
            }
        }
    }

    Box(
        modifier = Modifier.fillMaxSize().clickable { isInColumn = !isInColumn },
        contentAlignment = Alignment.Center
    ) {
        if (isInColumn) {
            Column { items() }
        } else {
            Row { items() }
        }
    }
}

This will create a behavior like this. The layouts are switching but there is no animation as we have not added any.

Step 2: Use theLookaheadScopeAPI

Next, we will wrap our content with the LookaheadScope composable. This will provide us with the scope to “look ahead” so that we can determine the destination of our Box layouts through a lookahead pass.

So, our code will now look like this:

@Composable
fun LookAheadWithSimpleMovableContent() {
    // ...
    var isInColumn by remember { mutableStateOf(true) }
    LookaheadScope { // Wrapped with LookaheadScope
        // ...
        val items = remember {
            movableContentOf {
                // ...
            }
        }

        Box(
            modifier = Modifier.fillMaxSize().clickable { isInColumn = !isInColumn },
            contentAlignment = Alignment.Center
        ) {
            // Remaining code
        }
    }
}

Step 3: A custom implementation of ApproachLayoutModifierNode

Now the most crucial part. We will use the LookaheadScope from the above step to create a custom implementation of the ApproachLayoutModifierNode.

For this, we have to override 3 functions:

  1. isMeasurementApproachComplete

  2. isPlacementApproachComplete

  3. approachMeasure

First, we have to create an offset animation of the type DeferredTargetAnimation, the target of which will be known during placement.

private val offsetAnimation: DeferredTargetAnimation<IntOffset, AnimationVector2D> = 
    DeferredTargetAnimation(IntOffset.VectorConverter)

Then override isMeasurementApproachComplete. As we are only animating the placement here, we can consider the measurement approach complete. So we can simply return a true here.

override fun isMeasurementApproachComplete(lookaheadSize: IntSize): Boolean {
    return true
}

Now we’ll override the isPlacementApproachComplete.

override fun Placeable.PlacementScope.isPlacementApproachComplete(
    lookaheadCoordinates: LayoutCoordinates
): Boolean {
    val target: IntOffset = with(lookaheadScope) {
        lookaheadScopeCoordinates.localLookaheadPositionOf(lookaheadCoordinates).round()
    }
    offsetAnimation.updateTarget(target, coroutineScope)
    return offsetAnimation.isIdle
}

Let’s try to understand what is happening in the above code. Our main objective of this function is to return true when the offset animation is complete, and false otherwise. First, we are acquiring the target position of the layout using the localLookaheadPositionOf with the layout’s coordinates. We have discussed previously that localLookaheadPositionOf calculates the local position of the layout in the Lookahead coordinate space. Then we will call updateTarget on the offsetAnimation that we created at the beginning with this acquired target position. This function will set up the animation or will update the already running animation based on the target. Finally, we will return offsetAnimation.isIdle which will return true when the animation has finished running.

Now, we’ll override the approachMeasure.

override fun ApproachMeasureScope.approachMeasure(
    measurable: Measurable,
    constraints: Constraints
): MeasureResult {
    val placeable = measurable.measure(constraints)
    return layout(placeable.width, placeable.height) {
        val coordinates = coordinates
        if (coordinates != null) {
            val target = with(lookaheadScope) {
                lookaheadScopeCoordinates.localLookaheadPositionOf(coordinates).round()
            }
            val animatedOffset = offsetAnimation.updateTarget(target, coroutineScope)
            val placementOffset = with(lookaheadScope) {
                lookaheadScopeCoordinates.localPositionOf(coordinates, Offset.Zero).round()
            }
            val (x, y) = animatedOffset - placementOffset
            placeable.place(x, y)
        } else {
            placeable.place(0, 0)
        }
    }
}

Let’s try to understand what is happening in the above code. The function approachMeasure accepts a Measurable and Constraints and returns a MeasureResult. So we are first measuring our measurable (which is our layout) using the constraints. This generates a Placeable. By definition, we know that a Placeable corresponds to a child layout that can be positioned by its parent layout. So we need to position or place this Placeable. Now, similar to the previous step, we are calculating the target offset within the lookaheadScope, using the availableLayoutCoordinates. Then using the updateTarget function and the target offset, we are starting an offset animation. This gives us the animated offset or position. Then, we are calculating the current offset within the given LookaheadScope using the localPositionOf function. We then calculate the delta between the animated position and the current position in lookaheadScope. Finally, we put the child layout in the animated position using the place function.

Both localLookaheadPositionOf and localPositionOf are used to get the converted offset relative to a specific coordinate. The only difference is that unlike localPositionOf, localLookaheadPositionOf uses the lookahead position for coordinate calculation.

Till here we are done with the custom implementation of the ApproachLayoutModifierNode and overridden all three functions.

Step 4: Create a custom node element

Now we have to create a custom node element for the AnimatedPlacementModifierNode created above by implementing the ModifierNodeElement abstract class. It takes a LookaheadScope instance in the constructor and creates/updates our custom ApproachLayoutModifierNode.

data class AnimatePlacementNodeElement(val lookaheadScope: LookaheadScope) :
    ModifierNodeElement<AnimatedPlacementModifierNode>() {

    override fun update(node: AnimatedPlacementModifierNode) {
        node.lookaheadScope = lookaheadScope
    }

    override fun create(): AnimatedPlacementModifierNode {
        return AnimatedPlacementModifierNode(lookaheadScope)
    }
}

Step 5: Create a Modifier to use the custom node element

We will create a Modifier that will internally use the custom node element AnimatePlacementNodeElement created above. This Modifier will be used with the colored Boxes that we want to animate.

fun Modifier.animatePlacementInScope(lookaheadScope: LookaheadScope): Modifier {
    return this.then(AnimatePlacementNodeElement(lookaheadScope))
}

Step 6: Use the above-createdModifier

Going back to the initial code we wrote, we will use this Modifier with the movable content Boxes. And we will pass the current LookaheadScope instance using this as we already wrapped it inside the LookaheadScope composable.

@Composable
fun LookAheadWithSimpleMovableContent() {
    // ...
    var isInColumn by remember { mutableStateOf(true) }
    LookaheadScope {
        val items = remember {
            movableContentOf {
                colors.forEach { color ->
                    Box(
                        Modifier
                            .padding(8.dp)
                            .size(80.dp)
                            .animatePlacementInScope(this) // Add Modifier here
                            .background(color, RoundedCornerShape(10))
                    )
                }
            }
        }
      // Remaining code
    }
}

Our implementation of Lookahead APIs with movable content is completed and now we will see the desired behavior. A smooth animation of the four Boxes between Row to Column layouts.

See the complete code for this example here:https://github.com/pushpalroy/ComposeLayoutPlayground/…/LookAheadWithCustomApproachLayoutModifierNode.kt

2. Using the approachLayout Modifier

This approach will be very similar to the previous way, with some differences.

Step 1: Create movable contents with LookaheadScope receiver

In the former approach, we used the movableContentOf API. In this approach, we will use the movableContentWithReceiverOf API. This is also used to create movable content but with a receiver context, which in our case will be the LookaheadScope. So our previous code to initialize the items of colored Boxes will become:

val items = remember {
    movableContentWithReceiverOf<LookaheadScope> {
        colors.forEach { color ->
            Box(
                Modifier
                    .padding(8.dp)
                    .size(80.dp)
                    .background(color, RoundedCornerShape(10))
            )
        }
    }
}

The remaining code for switching the layout between Row and Column will remain the same as the former code.

Step 2: Create a Modifier using the approachLayout Modifier

This step is very similar to how we created the custom implementation of ApproachLayoutModifierNode before. The approachLayout Modifier also provides us the three lambdas to invoke: isMeasurementApproachComplete, isPlacementApproachComplete and approachMeasure. This is exactly the same as how we have overridden the three functions previously. Also, we are going to write the same login inside each of these lambdas for our use case.

Note how we marked the entire Modifier function with context (LookaheadScope) at the top. This means our animateBounds Modifier will only work in the context of a LookaheadScope. This also helps to access the lookaheadScopeCoordinates for calculating the localPosition in the Lookahead coordinate space.

context (LookaheadScope)
@OptIn(ExperimentalAnimatableApi::class, ExperimentalComposeUiApi::class)
fun Modifier.animateBounds(): Modifier = composed {
    val offsetAnim = remember { DeferredTargetAnimation(IntOffset.VectorConverter) }
    val scope = rememberCoroutineScope()
    this.approachLayout(
        isMeasurementApproachComplete = {
            true
        },
        isPlacementApproachComplete = {
            val target = lookaheadScopeCoordinates.localLookaheadPositionOf(it)
            offsetAnim.updateTarget(target.round(), scope)
            offsetAnim.isIdle
        }
    ) { measurable, constraints ->
        measurable.measure(constraints)
            .run {
                layout(width, height) {
                    coordinates?.let {
                        val target = lookaheadScopeCoordinates.localLookaheadPositionOf(it).round()
                        val animOffset = offsetAnim.updateTarget(target, scope)
                        val current = lookaheadScopeCoordinates.localPositionOf(it, Offset.Zero).round()
                        val (x, y) = animOffset - current
                        place(x, y)
                    } ?: place(0, 0)
                }
            }
    }
}

Note: If we see the internals of the approachLayout Modifier, we will find that it is in fact creating a ApproachLayoutElement data class which is an implementation of the ModifierNodeElement abstract class (Step 4 of our former way). And a custom implementation of ApproachLayoutModifierNode is there, called the ApproachLayoutModifierNodeImpl (Step 3 of our former way). So basically both the ways we just covered do the same thing.

Step 3: Use the above-created Modifier in our layout

Now the obvious step, we will use the animateBounds modifier in our layout. This is similar to Step 6 of our former way. As we used movableContentWithReceiverOf<LookaheadScope> the animateBounds() will execute in the context of LookaheadScope, which we need.

// Remaining Code
// ..
val items = remember {
    movableContentWithReceiverOf<LookaheadScope> {
        colors.forEach { color ->
            Box(
                Modifier
                    .padding(8.dp)
                    .size(80.dp)
                    .animateBounds() // Use modifier here
                    .background(color, RoundedCornerShape(10))
            )
        }
    }
}
// ..
// Remaining Code

All the other codes will remain the same. If we run the above code it will result in the same animation we obtained earlier.

See the complete code for this example here:

https://github.com/pushpalroy/ComposeLayoutPlayground/…/LookAheadWithApproachLayoutModifier.kt

It’s obvious that the second approach, that is using the approachLayout Modifier is an easier way and requires writing less code for our use case.

Customize animations

In the isPlacementApproachComplete lambda, we are updating the offset animation using the updateTarget function:

offsetAnim.updateTarget(target.round(), scope)

The updateTarget has a default animationSpec of spring(), which we can customize. For example, we can pass a tween() like this:

offsetAnim.updateTarget(target.round(), scope, tween(durationMillis = 800))

This will create an animation like this:

Animate size along with placement

If we remember, in the previous animation we have just animated the placement of the Boxes. As we only animated the placement here and not the size, we considered the measurement was approach complete. So we returned a true inside the isMeasurementApproachComplete lambda.

What if we want to animate the size as well? Let’s do that.

Let’s modify the last code and switch the size of each Box between 80.dp and 20.dp based on whether the layout is a Row or Column.

// ..
var isInColumn by remember { mutableStateOf(true) }
val items = remember {
    movableContentWithReceiverOf<LookaheadScope> {
        colors.forEach { color ->
            Box(
                Modifier
                    .padding(8.dp)
                    .size(if (isInColumn) 80.dp else 20.dp)
                    .animateBounds()
                    .background(color, RoundedCornerShape(10))
            )
        }
    }
}
// Remaining Code

So now based on the isInColumn flag, we are changing the size. That means if the layout is a Column 80.dp will be used else 20.dp will be used.

Also, let’s increase the animation duration of the offset inside isPlacementApproachComplete so that we see clearly what is happening. Let's use a tween animation for 3000ms.

// ..
isPlacementApproachComplete = {
    val target = lookaheadScopeCoordinates.localLookaheadPositionOf(it)
    offsetAnim.updateTarget(target.round(), scope, tween(3000))
    offsetAnim.isIdle
}
// ..

Now without any further modification, if we run the code now, we will see this:

If we look closely, the size is changing but there is no animation for that. We can only see animation for placement. The size is just snapping from 80.dp to 20.dp quickly.

Let’s add animation for the size change:

Similar to the offsetAnim, we will create a DeferredTargetAnimation called the sizeAnim, which will track the animation for size. Then instead of returning true inside isMeasurementApproachComplete, now we will do updateTarget on sizeAnim and return if it’s idle:

// ..
this.approachLayout(
  isMeasurementApproachComplete = {
      sizeAnim.updateTarget(it, scope, tween(2000))
      sizeAnim.isIdle
  },
  isPlacementApproachComplete = {
    // Same as before
  }
// ..

The above code will return true inside isMeasurementApproachComplete when the size animation is complete. Note that we have used a tween animation of duration 2000ms for the size animation.

Now, inside the approachMeasure block, we will do sizeAnim.updateTarget with the lookaheadSize to get the animWidth and animHeight. This is the measured size of the layout while animating. We will then use this constraint to measure the measurable: Constraints.fixed(animWidth, animHeight).

this.approachLayout(
    isMeasurementApproachComplete = {
        // ..
    },
    isPlacementApproachComplete = {
        // ..
    }
) { measurable, _ ->
    val (animWidth, animHeight) = sizeAnim.updateTarget(lookaheadSize, scope)
    measurable.measure(Constraints.fixed(animWidth, animHeight))
        .run {
            layout(width, height) {
                coordinates?.let {
                    val target = lookaheadScopeCoordinates.localLookaheadPositionOf(it).round()
                    val animOffset = offsetAnim.updateTarget(target, scope)
                    val current = lookaheadScopeCoordinates.localPositionOf(it, Offset.Zero).round()
                    val (x, y) = animOffset - current
                    place(x, y)
                } ?: place(0, 0)
            }
        }
}

This will create an effect like this:

Now we can see that the Boxes' size and placement are getting animated smoothly.

See the complete code for this example here:

https://github.com/pushpalroy/ComposeLayoutPlayground/…/LookAheadWithApproachLayoutModifier2.kt


You can find all the examples along with some other examples of Lookahead in this repository:

Some important API changes

  1. In Version 1.7.0-alpha03, the ApproachLayoutModifierNode API has been added to support creating custom approach logic in an explicit Modifier Node.

  2. In Version 1.6.0-alpha03, the LookaheadScope composable and interfaces are made stable. Also, a new enter/exit transition to scale content to container size has been added. Read here.

  3. In Version 1.6.0-alpha02, LookaheadLayout and LookaheadLayoutScope have been finally removed and replaced by LookaheadScope APIs.

  4. In Version 1.6.0-alpha01, support for lookahead has been added to LazyList.

  5. In Version 1.5.0-alpha03, new behavior has been added due to which SubcomposeLayouts that don’t have conditional slots work nicely with lookahead animations.

  6. In Version 1.5.0-alpha01, LookaheadLayout has been replaced by LookaheadScope, which is no longer a Layout.

  7. In Version 1.3.0-alpha01, LookaheadLayout was first introduced. See the commit here.

Additional resources

Learn more about LookAheadLayout from these resources:

  1. Official Docs:
    https://developer.android.com/reference/kotlin/androidx/compose/ui/layout/LookaheadScope

  2. Introducing LookaheadLayout byJorge Castillo**:
    **https://substack.com/inbox/post/64358322

  3. This blog by Ji Sungbin:
    https://betterprogramming.pub/introducing-jetpack-composes-new-layout-lookaheadlayout-eb30406f715

Conclusion

Lookahead is a potent API within Jetpack Compose that, when utilized correctly, enables the creation of stunning and highly performant animations, including shared element transitions among others. This article aims to provide an initial exploration and shed light on various aspects of this new API, encouraging further experimentation.

Check out the follow-up article where I demonstrate how to create a container transform animation using Lookahead: Container Transform Animation with Lookahead in Jetpack Compose

Follow me: @pushpalroy

Did you find this article valuable?

Support Pushpal Roy by becoming a sponsor. Any amount is appreciated!