Await next frame in Jetpack Compose
Learn why view invalidation per se is not a thing in Jetpack Compose.
🤷 Use case
In Android, when we are drawing something to Canvas
we usually enforce View
invalidation via mechanisms like View#invalidate()
. That is to enforce the system to perform a new drawing pass on the View
according to new imposed requirements, or to a new state. Then we can rely on elapsed time to calculate how to render the current animation tick.
Here is a library I wrote ages ago that made use of that concept.
Ultimately, we can understand any animation as a succession of frames where each one renders according to a state for the current snapshot in time. In Compose these animations are also possible, but they rely on the Kotlin suspend system.
✅ Jetpack Compose solution
I’ll show the solution originally shared by @adamwp first then we’ll discuss a bit about the implementation.
@Composable
fun animationTimeMillis(): State<Long> {
val millisState = state { 0L }
val lifecycleOwner = LifecycleOwnerAmbient.current
launchInComposition {
val startTime = withFrameMillis { it }
lifecycleOwner.whenStarted {
while (true) {
withFrameMillis { frameTime ->
millisState.value = frameTime - startTime
}
}
}
}
return millisState
}
This snippet uses the latest developer preview available for Jetpack Compose, which can be found here. It’s 0.1.0-dev15
as of today.
Focusing on the proposed solution, we could highlight the following ideas:
- It’s a composable function that returns an immutable
State<Long>
we can observe from any composable, so it recomposes every time the state gets updated. This state will reflect the elapsed animation time every frame. - The state is a
Long
value reflecting an elapsed amount of time in milliseconds. It is initialized as0L
, and grows linearly from there. launchInComposition
block launches asuspend
side effect right when the composition is called, and that also gets cancelled as soon as this composable leaves the composition to avoid leaks. Per the official docs:
The lambda “will run in the
apply
scope of the composition’sRecomposer
, which is usually your UI’s main thread”.
“Recomposition does not cause this block to be called again. To do that, there are some launchInComposition overloads that accept keys as additional arguments. Whenever those keys change, previous recomposition gets cancelled and the block runs again”.
So we are scoping our task to the composition.
- First thing we do inside the block is to record the starting time when the animation starts. That is used to calculate the elapsed time every frame. We use
withFrameMillis { it }
which basically supends until a new frame is requested. You can find more details here.
Compose also provides a withFrameNanos variant that spits nanoseconds instead. These times rely on a
MonotonicFrameClock
.
- We want to start our animation as soon as the enclosing
LifecycleOwner
gets started, not before. At that point we start an infinite loop that willsuspend
to wait for the next frame every time, and update the state with the currently elapsed time. This will represent our “draw invalidation”, since observing composables will recompose each time. - We can use
LifecycleOwnerAmbient.current
to retrieve the current enclosingLifecycleOwner
. RememberAmbients
are a mechanism by Compose that rely on Providers to be implicitly “injected” down the tree. They are usually used as a means to provide access to things required by many levels down the tree, so they don’t need to be manually passed. You can find a detailed explanation of this here. - Last thing we need to do is observe the returned state from any other
@Composable
, and be are ready to go 🥳
😯 Example
ComposeFillableLoaders sample can work as a good code sample that makes use of this idea to create a complex animation based on Paths
drawn to the Canvas
. The library adds a new variant to the animation state, to indicate which animation phase it is in at the current time snapshot.
This post was written using Android Studio 4.2 Canary 4. Remember you need the latest canary to run the latest version of Jetpack Compose.
Thanks @adamwp for sharing the original idea.
You might be interested in other posts I wrote about Jetpack Compose, like:
I share thoughts and ideas on Twitter quite regularly. You can also find me on Instagram. See you there!
Stay tunned for more Jetpack Compose posts 👋