Jetpack Compose value-based animation could be tricky!

Andrey
5 min readAug 18, 2024

Jetpack Compose provides a tremendous amount of built-in animations that helps you make your app smooth and pretty. But sometimes misunderstanding of how things work could lead to unexpected behaviour.

Today we are gonna look on a simple api of value-based animation and how it could go wrong.

Phase 1. Learn about value-based animation.

From official documentation:

The animate*AsState functions are the simplest animation APIs in Compose for animating a single value. You only provide the target value (or end value), and the API starts animation from the current value to the specified value.

Well, that sounds very easy isn`t it? Now lets take a look at the example (also from the official doc):

var moved by remember { mutableStateOf(false) }
val pxToMove = with(LocalDensity.current) {
100.dp.toPx().roundToInt()
}
val offset by animateIntOffsetAsState(
targetValue = if (moved) {
IntOffset(pxToMove, pxToMove)
} else {
IntOffset.Zero
},
label = "offset"
)
Box(
modifier = Modifier
.offset { // delegate to layout phase
offset
}
.background(Color.Blue)
.size(100.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
moved = !moved
}
)

The code above animates offset of the box from 0 to 100 dp when you click on it and after you click it for the second time — changes the offset back from 100 to 0 dp.

Great! Just with a few lines of code you can do this type of animation.

Phase 2. Create a more complex animation.

Now, let`s imagine that we need to animate horizontal offset of our object when user drag it and return object to initial offset when we are no longer receiving drag events.

Let`s start with detecting horizontal drag gesture.
To do this we can use pointerInput and call detectHorizontalDragGestures inside pointerInput block and apply it to Box modifier like that:

Box(
modifier = Modifier
.pointerInput(Unit) { // receive pointer input events
detectHorizontalDragGestures( // receive horizontal gesture
onDragStart = { },
onDragEnd = { },
onDragCancel = { },
onHorizontalDrag = { change, dragAmount ->
// receive horizontal offset delta
}
)
}
)

Okay, it wasn`t that hard.
Now, we need to figure out how we could animate changing of Box offset.
Hmm.. IntOffset contains values and that values changed.
So..maybe..we could use value-based animation api?

Let`s try.

var offsetState by remember {
mutableStateOf(IntOffset.Zero)
}
val offsetAnimation by animateIntOffsetAsState(
targetValue = offsetState,
label = "offset",
)

Box(
modifier = Modifier
.offset {
offsetAnimation
}
.background(Color.Red)
.size(100.dp)
.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragStart = { },
onDragEnd = { // return to initial state
offsetState = IntOffset.Zero
},
onDragCancel = { // return to initial state
offsetState = IntOffset.Zero
},
onHorizontalDrag = { change, dragAmount ->
// update offset value
offsetState = offsetState.copy(
x = offsetState.x + dragAmount.roundToInt()
)
}
)
}
)

It is almost like in example from documentation:
1. create animate*AsState object that holds target value
2. apply it to Modifier.offset { }
3. profit
The only difference is that animate*AsState object now holds mutable variable that changed every time we receive horizontal drag gesture event. So what, right?

Now let`s take a look at our app.

Great, it worked!

Now we can check that everything is fine in layout inspector and move on to the next tasks.

What is dis?

Well, it happens that now our function recomposes on every offset state changing even though we moved Box offset change to the layout phase.
Why? Let`s look closely:

var offsetState by remember { // changed on every drag event
mutableStateOf(IntOffset.Zero)
}
val offsetAnimation by animateIntOffsetAsState(
targetValue = offsetState, // read value when it change (cause recomposition)
label = "offset",
)

So it turns out that animate*AsState is not for every value-base cases.
And that`s how we easily made a mistake because of misunderstating of conception. And it moves us to the last phase of this article.

Phase 3. Suspend animations.

To use suspended animation, we will create Animatable object.

From docs:

Animatable is a value holder that automatically animates its value when the value is changed via animateTo

val offsetAnimation = remember { // init object with initial value
Animatable(IntOffset.Zero, IntOffset.VectorConverter)
}

We can see that animatable does not read value (because we don`t use offsetState no more).

But how could i update it`s value?
Use snapTo function, it is sets the current value to the target value, without any animation.
What if i want to run animation too?
Use animateTo function, this function updates target value and run animation (remember there is no reads of value in composition phase, so it takes 0 recompositions). Also you can provide custom animation specs.

Let`s update our previous code with new Animatable object:

val coroutineScope = rememberCoroutineScope() // to launch suspended operation
val offsetAnimation = remember {
Animatable(IntOffset.Zero, IntOffset.VectorConverter)
}

fun animateToInitialPosition() {
coroutineScope.launch {
offsetAnimation.animateTo(
targetValue = IntOffset.Zero,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow,
)
)
}
}

Box(
modifier = Modifier
.offset {
offsetAnimation.value
}
.background(Color.Green)
.size(100.dp)
.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragStart = { },
onDragEnd = { // return to initial position with animation
animateToInitialPosition()
},
onDragCancel = { // return to initial position with animation
animateToInitialPosition()
},
onHorizontalDrag = { change, dragAmount ->
// update to new value without animation
coroutineScope.launch {
offsetAnimation.snapTo(
targetValue = offsetAnimation.value.copy(
offsetAnimation.value.x + dragAmount.roundToInt()
),
)
}
}
)
}
)

As you can see, now Animatable holds offset value and update it through snapTo and animateTo functions.
We can even customize animation spec on diffirent animateTo calls.

Conclusion

Jetpack Compose makes it incredibly easy to add animations to your apps. With just a few lines of code, you can create animations that make your app more interactive and visually appealing. But always check if you applying it correctly.

Links

Repository with full code
My linkedin profile

--

--

Andrey

🚀 Software engineer with 5+ years of experience in backend, web, android