Suspending over views — example
A worked example from the Tivi app
This blog post is the second of two which explores how coroutines enables you to write complex asynchronous UI operations in a much simpler way. The first post goes through the theory, while this post demonstrates how they fix a problem.
If you want to recap the first post, you can find it here:
Let’s take what we learnt in the previous post and apply it to a real-world app use case.
Here we have the TV show details UI from the Tivi sample app. As well as information about the show, it lists the show’s seasons and episodes. When the user clicks one of the episodes, the episode’s details are displayed using an animation which expands the clicked item:
The app uses the InboxRecyclerView library to handle the expanding animation above:
InboxRecyclerView works by us providing the item ID of view to expand. It then finds the matching view from the
RecyclerView items, and performs the animation on it.
Now let’s look at the issue we’re trying to fix. Near the top of the same UI is a different item, which shows the user their next episode to watch. It uses the same view type as the individual episode item shown above, but has a different item ID.
To aid development, I was lazy and used the same
onEpisodeItemClicked() for this item. Unfortunately this led to a broken animation when clicked.
Instead of expanding the clicked item, the library expands a seemingly random item at the top. This is not the effect we want, and is caused by some underlying issues:
- The ID we use in the click listener is taken directly from the
Episodeclass. This ID maps to the individual episode item within the season list.
- The episode item may not be attached to the
RecyclerView. The user would need to have expanded the season and scrolled so that the item is in the viewport, for the view to exist in the
Because of these issues, the library falls back to expanding the first item.
So what is the intended behavior? Ideally we’d have something like this (slowed down:
In pseudo-code it might look a bit like this:
In reality though, it would need to look more like this:
As you can see, there’s a lot of waiting around for asynchronous things to happen! ⏳
The pseudo code here doesn’t look too complex, but when you start to implement this we quickly descend into callback hell. Here’s an attempt at writing a skeleton solution using chained callbacks:
This code is not particularly good and probably doesn’t work, but hopefully illustrates how callbacks can make UI programming really complex. Generally, this code has a few issues:
Since we have to write our transition using callbacks, each ‘animation’ has to be aware of what next to call: Callback #1 calls Animation 2, Callback #2 calls Animation #3, and so. These animations have no relation to each other, but we’ve been forced to couple them together.
Hard to maintain/update
Two months after writing this, your motion designer asks you to add in fade transition in the middle. You’ll need to trace through the transition, going through each callback to find the correct callback in which to trigger your new animation. Then you’ll need to test it…
Testing animations is hard anyway, but relying on this hot mess of callbacks makes it every more difficult. Your test needs to about all of the different animation types, to callbacks itself to assert that something ran. We don’t really touch upon testing in this article, but it’s something which coroutines makes much easier.
Coroutines to the rescue 🦸
In the first post we learnt how to wrap a callback API into a suspending function. Let’s use that knowledge to turn our ugly callback code into this:
How much more readable is that?! 💘
The new await suspending functions hide all of the complexity, resulting in a sequential list of function calls. Let’s dig into the details…
There are currently no MotionLayout ktx extensions available, and
MotionLayout is also currently missing the ability to have more than one listener added at a time (feature request). This means that the implementation of the
awaitTransitionComplete() function is a bit more involved than some of the other functions.
awaitTransitionComplete() function is then defined as:
This function is probably quite niche, but it’s also really useful. In the TV show example from above, it actually handles a few different async states:
// Make sure that the season is expanded, with the episode attached
viewModel.expandSeason(nextEpisodeToWatch.seasonId)// 1. Wait for new data dispatch
// 2. Wait for RecyclerView adapter to diff new data set// Scroll the RecyclerView so that the episode is displayed
The function is implemented using RecyclerView’s AdapterDataObserver, which is called whenever the adapter’s data set changes:
The final function to highlight is the
RecyclerView.awaitScrollEnd() function, which waits for any scrolling to finish:
Hopefully by now this code is looking pretty mundane. The tricky bit with this function is the need to use
awaitAnimationFrame() before performing the fail-fast check. As mentioned in the comments, this is because a
SmoothScroller actually starts on the next animation frame, so we need to wait for that to happen before checking the scrolling state.
awaitAnimationFrame() is a wrapper around
postOnAnimation(), which allows us to wait for the next animation time step, which typically happens on the next display render. It is implemented like the
doOnNextLayout() example from the first post:
In the end, the sequence of operations looks like this:
Break the callback-chains ⛓️
Moving to coroutines results in our code being able to break away from huge chains of callbacks, which are hard to maintain and test.
The recipe of wrapping up a callback/listener/observer API into a suspending function is largely the same for all APIs. Hopefully the functions we’ve shown in this post are now looking quite repetitive. So go forth and free your UI code of callback chains 🔨.