Today we’re going to take a loot at movableContentOf
and how to use it to achieve something similar to shared transitions in Jetpack Compose.
In the GIF below you can see the images moving in the composable.

To start wrap your code using LookaheadScope
, this has to be in the tree for the animation to work.
LookaheadScope {
// your code goes here
}
After that we create the composable that will be moved. It has to be inside the LookaheadScope
scope so you can use a custom Modifier
.
@Composable
fun LookaheadScope.SharedAvatar(
...
) {
Box(
modifier = Modifier
.size(40.dp)
.then(AnimatePlacementNodeElement(this))
) {
...
}
}
The new thing here is AnimatePlacementNodeElement
, this is a custom modifier you need.
You can copy paste the code below to get it.
@OptIn(ExperimentalAnimatableApi::class)
class AnimatedPlacementModifierNode(
var lookaheadScope: LookaheadScope
) : ApproachLayoutModifierNode, Node() {
private val offsetAnim = DeferredTargetAnimation(IntOffset.VectorConverter)
override fun isMeasurementApproachInProgress(lookaheadSize: IntSize) = false
override fun Placeable.PlacementScope.isPlacementApproachInProgress(
lookaheadCoordinates: LayoutCoordinates
): Boolean {
val target = with(lookaheadScope) {
lookaheadScopeCoordinates.localLookaheadPositionOf(lookaheadCoordinates).round()
}
offsetAnim.updateTarget(target, coroutineScope)
return !offsetAnim.isIdle
}
override fun ApproachMeasureScope.approachMeasure(
measurable: Measurable,
constraints: Constraints
): MeasureResult {
val placeable = measurable.measure(constraints)
return layout(placeable.width, placeable.height) {
val coords = coordinates
if (coords != null) {
val target = with(lookaheadScope) {
lookaheadScopeCoordinates.localLookaheadPositionOf(coords).round()
}
val animated = offsetAnim.updateTarget(target, coroutineScope)
val current = with(lookaheadScope) {
lookaheadScopeCoordinates.localPositionOf(coords, Offset.Zero).round()
}
val (dx, dy) = animated - current
placeable.place(dx, dy)
} else {
placeable.place(0, 0)
}
}
}
}
data class AnimatePlacementNodeElement(val lookaheadScope: LookaheadScope) :
ModifierNodeElement<AnimatedPlacementModifierNode>() {
override fun create() = AnimatedPlacementModifierNode(lookaheadScope)
override fun update(node: AnimatedPlacementModifierNode) {
node.lookaheadScope = lookaheadScope
}
}
If you want the animation to be slower you can change DeferredTargetAnimation
.
private val offsetAnim = DeferredTargetAnimation(
IntOffset.VectorConverter,
spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessLow // Lower stiffness = slower animation
)
)
private val offsetAnim = DeferredTargetAnimation(
IntOffset.VectorConverter,
tween(durationMillis = 600) // Increase for longer animations
)
Now let’s define our composables and use movableContentOf
so they can be moved.
val avatarSlots = remember(state.avatars) {
state.avatars.associateBy(
{ it.authorId },
{ author ->
movableContentOf<LookaheadScope> { scope ->
scope.SharedAvatar(...)
}
}
)
}
Make sure you pass your composables around using @Composable () -> Unit
, you need that for it to work correctly.
@Composable
private fun CreatingPost(
avatarSlots: @Composable (String) -> Unit,
) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
avatars.forEach { avatar ->
avatarSlots(avatar.authorId)
}
}
}
Now the the same where the composable will end up.
@Composable
private fun Reply(
avatar: @Composable () -> Unit,
) {
...
}
Now, just added a condition that show one or the other and LookaheadScope
will do the job of moving them.
LookaheadScope {
Column {
if (condition) {
CreatingPost(
avatarSlots = { id -> avatarSlots[id]?.invoke(this@LookaheadScope) }
)
} else {
Reply(
avatar = {
avatarSlots[item.authorId]?.invoke(this@LookaheadScope)
},
)
}
}
}
That’s it, you got a shared element transition working in Jetpack Compose.
The app shown here is Lume, it’s a side project I’m working on. You can download it on Play Store.
I hope you enjoyed this article, feel free to contact me if you need anything. See you next time.
Photo by Ahmad Odeh on Unsplash