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:

fun onNextEpisodeToWatchItemClick(view: View, nextEpisodeToWatch: Episode) {
    // Tell the ViewModel to include the season’s episodes in the
    // RecyclerView data set. This will trigger a database fetch, and update
    // the view state
    viewModel.expandSeason(nextEpisodeToWatch.seasonId)

    // Scroll the RecyclerView so that the episode is displayed
    recyclerView.scrollToItemId(nextEpisodeToWatch.id)

    // Expand the item like before
    recyclerView.expandItem(nextEpisodeToWatch.id)
}

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)

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:

fun onNextEpisodeToWatchItemClick(view: View, nextEpisodeToWatch: Episode) {
    // Tell the ViewModel to include the season’s episodes in the
    // RecyclerView data set. This will trigger a database fetch, and update
    // the view state
    viewModel.expandSeason(nextEpisodeToWatch.seasonId)

    // Scroll the RecyclerView so that the episode is displayed
    recyclerView.scrollToItemId(nextEpisodeToWatch.id)

    // Expand the item like before
    recyclerView.expandItem(nextEpisodeToWatch.id)
}

In reality though, it would need to look more like this:

fun onNextEpisodeToWatchItemClick(view: View, nextEpisodeToWatch: Episode) {
    // Tell the ViewModel to include the season’s episodes in the
    // RecyclerView data set. This will trigger a database fetch
    viewModel.expandSeason(nextEpisodeToWatch.seasonId)

    // TODO wait for new state dispatch from the ViewModel
    // TODO wait for RecyclerView adapter to diff new data set
    // TODO wait for RecyclerView to layout any new items

    // Scroll the RecyclerView so that the episode is displayed
    recyclerView.scrollToItemId(nextEpisodeToWatch.id)

    // TODO wait for RecyclerView scroller to finish

    // Expand the item like before
    recyclerView.expandItem(nextEpisodeToWatch.id)
}

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:

fun expandEpisodeItem(itemId: Long) {
    recyclerView.expandItem(itemId)
}

fun scrollToEpisodeItem(position: Int) {
   recyclerView.smoothScrollToPosition(position)
  
   // Add a scroll listener, and wait for the RV to be become idle
   recyclerView.addOnScrollListener(object : OnScrollListener() {
        override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
            if (newState == RecyclerView.SCROLL_STATE_IDLE) {
                expandEpisodeItem(episode.id)
            }
        }
    })
}

fun waitForEpisodeItemInAdapter() {
    // We need to wait for the adapter to contain the item id
    val position = adapter.findItemIdPosition(itemId)
    if (position != RecyclerView.NO_POSITION) {
        // The item ID is in the adapter, now we can scroll to it
        scrollToEpisodeItem(itemId))
    } else {
        // Otherwise we wait for new items to be added to the adapter and try again
       adapter.registerAdapterDataObserver(object : AdapterDataObserver() {
            override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
                waitForEpisodeItemInAdapter()
            }
        })
    }
}

// And tell the ViewModel to give us the expanded season data set
viewModel.expandSeason(nextEpisodeToWatch.seasonId)
// Now need to wait for the new data
waitForEpisodeItemInAdapter()

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:

viewLifecycleOwner.lifecycleScope.launch {    
    // await until the adapter contains the episode item ID
    adapter.awaitItemIdExists(episode.id)
    // Find the position of the season item
    val seasonItemPosition = adapter.findItemIdPosition(episode.seasonId)

    // Scroll the RecyclerView so that the season item is at the
    // top of the viewport
    recyclerView.smoothScrollToPosition(seasonItemPosition)
    // ...and await that scroll to finish
    recyclerView.awaitScrollEnd()

    // Finally, expand the episode item to show the episode details
    recyclerView.expandItem(episode.id)
}

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.

Our awaitTransitionComplete() function is then defined as:

/**
 * Wait for the transition to complete so that the given [transitionId] is fully displayed.
 * 
 * @param transitionId The transition set to await the completion of
 * @param timeout Timeout for the transition to take place. Defaults to 5 seconds.
 */
suspend fun MotionLayout.awaitTransitionComplete(transitionId: Int, timeout: Long = 5000L) {
    // If we're already at the specified state, return now
    if (currentState == transitionId) return

    var listener: MotionLayout.TransitionListener? = null

    try {
        withTimeout(timeout) {
            suspendCancellableCoroutine<Unit> { continuation ->
                val l = object : TransitionAdapter() {
                    override fun onTransitionCompleted(motionLayout: MotionLayout, currentId: Int) {
                        if (currentId == transitionId) {
                            removeTransitionListener(this)
                            continuation.resume(Unit)
                        }
                    }
                }
                // If the coroutine is cancelled, remove the listener
                continuation.invokeOnCancellation {
                    removeTransitionListener(l)
                }
                // And finally add the listener
                addTransitionListener(l)
                listener = l
            }
        }
    } catch (tex: TimeoutCancellationException) {
        // Transition didn't happen in time. Remove our listener and throw a cancellation
        // exception to let the coroutine know 
        listener?.let(::removeTransitionListener)
        throw CancellationException("Transition to state with id: $transitionId did not" +
                " complete in timeout.", tex)
    }
}

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:

/**
 * Await an item in the data set with the given [itemId], and return its adapter position.
 */
suspend fun <VH : RecyclerView.ViewHolder> RecyclerView.Adapter<VH>.awaitItemIdExists(itemId: Long): Int {
    val currentPos = findItemIdPosition(itemId)
    // If the item is already in the data set, return the position now
    if (currentPos >= 0) return currentPos

    // Otherwise we register a data set observer and wait for the item ID to be added
    return suspendCancellableCoroutine { continuation ->
        val observer = object : RecyclerView.AdapterDataObserver() {
            override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
                (positionStart until positionStart + itemCount).forEach { position ->
                    // Iterate through the new items and check if any have our itemId
                    if (getItemId(position) == itemId) {
                        // Remove this observer so we don't leak the coroutine
                        unregisterAdapterDataObserver(this)
                        // And resume the coroutine
                        continuation.resume(position)
                    }
                }
            }
        }
        // If the coroutine is cancelled, remove the observer
        continuation.invokeOnCancellation {
            unregisterAdapterDataObserver(observer)
        }
        // And finally register the observer
        registerAdapterDataObserver(observer)
    }
}

RecyclerView.awaitScrollEnd()

The final function to highlight is the RecyclerView.awaitScrollEnd() function, which waits for any scrolling to finish:

suspend fun RecyclerView.awaitScrollEnd() {
    // If a smooth scroll has just been started, it won't actually start until the next
    // animation frame, so we'll await that first
    awaitAnimationFrame()
    // Now we can check if we're actually idle. If so, return now
    if (scrollState == RecyclerView.SCROLL_STATE_IDLE) return

    suspendCancellableCoroutine<Unit> { continuation ->
        continuation.invokeOnCancellation {
            // If the coroutine is cancelled, remove the scroll listener
            recyclerView.removeOnScrollListener(this)
            // We could also stop the scroll here if desired
        }

        addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
                if (newState == RecyclerView.SCROLL_STATE_IDLE) {
                    // Make sure we remove the listener so we don't leak the
                    // coroutine continuation
                    recyclerView.removeOnScrollListener(this)
                    // Finally, resume the coroutine
                    continuation.resume(Unit)
                }
            }
        })
    }
}

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:

suspend fun View.awaitAnimationFrame() = suspendCancellableCoroutine<Unit> { continuation ->
    val runnable = Runnable {
        continuation.resume(Unit)
    }
    // If the coroutine is cancelled, remove the callback
    continuation.invokeOnCancellation { removeCallbacks(runnable) }
    // And finally post the runnable
    postOnAnimation(runnable)
}

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