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
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).
Approach pass: Then they will run the measurement and placement approach or logic to reach the destination gradually.
The LookaheadScope interface creates a scope for the above two steps.
The measurement and placement approach in the approach pass is defined by the
Modifier.approachLayout
or by theApproachLayoutModifierNode
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:
lookaheadSize
: The destination/target size of the layout obtained in theApproachMeasureScope
. This is the size of theApproachLayoutModifierNode
measured during the lookahead pass.localLookaheadPositionOf
: It calculates the local position in the Lookahead coordinate space. It is used to obtain the target position during placement by usingLookaheadScope.localLookaheadPositionOf
and the lookaheadLayoutCoordinates
in theApproachMeasureScope
.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.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, beforeapproachMeasure
is invoked. It receives lookahead size in the argument to decide whether the destination size has been reached.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 inapproachMeasure
.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:
Write a custom implementation of the
ApproachLayoutModifierNode
Use the
approachLayout
Modifier (internally usesApproachLayoutModifierNode
)
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 theLookaheadScope
API
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:
isMeasurementApproachComplete
isPlacementApproachComplete
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
andlocalPositionOf
are used to get the converted offset relative to a specific coordinate. The only difference is that unlikelocalPositionOf
,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
In Version 1.7.0-alpha03, the
ApproachLayoutModifierNode
API has been added to support creating custom approach logic in an explicit Modifier Node.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.In Version 1.6.0-alpha02,
LookaheadLayout
andLookaheadLayoutScope
have been finally removed and replaced byLookaheadScope
APIs.In Version 1.6.0-alpha01, support for lookahead has been added to
LazyList
.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.In Version 1.5.0-alpha01,
LookaheadLayout
has been replaced byLookaheadScope
, which is no longer a Layout.In Version 1.3.0-alpha01,
LookaheadLayout
was first introduced. See the commit here.
Additional resources
Learn more about LookAheadLayout from these resources:
Official Docs:
https://developer.android.com/reference/kotlin/androidx/compose/ui/layout/LookaheadScopeIntroducing LookaheadLayout byJorge Castillo**:
**https://substack.com/inbox/post/64358322This 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