Suspending over Views — Example

Suspending over Views — Example

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: Suspending over Views

Let’s take what we learnt in the previous post and apply it to a real-world app use case.

The problem

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:

Episode expanding (20% speed)
Episode expanding (20% speed)

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 were 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() function for this item. Unfortunately this led to a broken animation when clicked.

Wrong item expanding (20% speed)
Wrong item expanding (20% speed)

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:

  1. The ID we use in the click listener is taken directly from the Episode class. This ID maps to the individual episode item within the season list.

  2. 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 RecyclerView.

Because of these issues, the library falls back to expanding the first item.

Ideal solution

So what is the intended behavior? Ideally we’d have something like this (slowed down:

The ideal result (20% speed)
The ideal result (20% speed)

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:

Closely coupled

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

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…

MotionLayout.awaitTransitionComplete()

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.

We’re using a subclass of MotionLayout which adds support for multiple listeners: MultiListenerMotionLayout.

Our awaitTransitionComplete() function is then defined as:

Adapter.awaitItemIdExists()

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
recyclerView.scrollToItemId(nextEpisodeToWatch.id)

The function is implemented using RecyclerView’s AdapterDataObserver, which is called whenever the adapter’s data set changes:

RecyclerView.awaitScrollEnd()

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:

Final result

In the end, the sequence of operations looks like this:

Solution, broken down into steps (20% speed)
Solution, broken down into steps (20% speed)

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 🔨.