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 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:
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.
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
Episode
class. 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 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:
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:
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 🔨.