Skip to main content
Suspending over Views

Suspending over Views

·1924 words·10 mins
Suspending over Views - This article is part of a series.
Part 1: This Article

Kotlin Coroutines allow us to model asynchronous problems like synchronous code. That’s great, but most usage seems to concentrate on I/O tasks and concurrent operations. Coroutines are great at modelling problems which work across threads, but can also model asynchronous problems on the same thread.

There’s one place which I think really benefits from this, and that’s working with the Android view system.

Android views 💘 callbacks
#

The Android view system loves callbacks; like really loves callbacks. To give you an idea, there are currently 80+ callbacks in view and widgets classes in the Android framework, and then another 200+ in Jetpack (includes non-UI libraries, but you get the idea).

Commonly used examples include:

Then there are the APIs which accept a Runnable to perform an async action, such as View.post() or View.postDelayed(), etc.

There are so many callbacks because user interface programming on Android is inherently asynchronous. Everything from measure & layout, drawing, to inset dispatch are all performed asynchronously. Generally, something (usually a view) requests a traversal from the system, and then some time later the system dispatches the call, which then triggers any listeners.

KTX extension functions
#

For a lot of the APIs we’ve mentioned above, the team has added extension functions in Jetpack to improve the developer ergonomics. One of my favorites is View.doOnPreDraw(), which greatly simplifies waiting for the next draw to happen. There are many others which I use every day: View.doOnLayout() and Animator.doOnEnd() to name two.

But these extension functions only go so far: they make a old-school callback API into a Kotlin-friendly lambda-based API. They’re nicer to use but we’re still dealing with callbacks in a different form, which makes performing complex UI operations more difficult. Since we’re talking about asynchronous operations, could we could benefit from coroutines here? 🤔

Coroutines to the rescue
#

This blog post assumes a working level of coroutines knowledge. If something sounds alien to you below, we published a blog post series earlier this year to help you recap: Coroutines on Android (part I): Getting the background

Suspending functions are one of the basic units of coroutines, allowing us to write code in non-blocking way. This is important when we’re dealing with Android UI, since we never want to block the main thread, which can result in performance problems like jank.

suspendCancellableCoroutine
#

In the Kotlin coroutines library there are a number of coroutine builder functions which enable wrapping callback based APIs with suspending functions. The primary API is suspendCoroutine(), with a cancellable version called suspendCancellableCoroutine().

We recommend to always use suspendCancellableCoroutine() since it allows us to handle cancellation in both directions:

#1: The coroutine can be cancelled while the async operation is pending. Depending on the scope the coroutine is running in, the coroutine might be cancelled if the view is removed from the view hierarchy. Example: fragment is popped off the stack. Handling this direction allows us to cancel any async operations, and clean up any ongoing resources.

#2: The async UI operation is cancelled (or throws an error) while the coroutine is suspended. Not all operations have a cancelled or error state but for those that do, like Animator below, we should propagate those states to the coroutine, allowing the caller of the method to handle the error.

Wait for a view to be laid out
#

Let’s take a look at an example which wraps up the task of waiting for the next layout pass on a view (e.g. you’ve changed the text of a TextView and need to wait for a layout pass to know it’s new size):

suspend fun View.awaitNextLayout() = suspendCancellableCoroutine<Unit> { cont ->
    // This lambda is invoked immediately, allowing us to create
    // a callback/listener

    val listener = object : View.OnLayoutChangeListener {
        override fun onLayoutChange(...) {
            // The next layout has happened!
            // First remove the listener to not leak the coroutine
            view.removeOnLayoutChangeListener(this)
            // Finally resume the continuation, and
            // wake the coroutine up
            cont.resume(Unit)
        }
    }
    // If the coroutine is cancelled, remove the listener
    cont.invokeOnCancellation { removeOnLayoutChangeListener(listener) }
    // And finally add the listener to view
    addOnLayoutChangeListener(listener)

    // The coroutine will now be suspended. It will only be resumed
    // when calling cont.resume() in the listener above
}

This function only supports cancellation in one direction, from the coroutine to the operation (#1), since layout does not have an error state we can observe.

We can then use it like so:

viewLifecycleOwner.lifecycleScope.launch {
    // Make the view invisible and set some new text
    titleView.isInvisible = true
    titleView.text = "Hi everyone!"

    // Wait for the next layout pass to know
    // the height of the view
    titleView.awaitNextLayout()

    // Layout has happened!
    // We can now make the view visible, translate it up, and then animate it
    // back down
    titleView.isVisible = true
    titleView.translationY = -titleView.height.toFloat()
    titleView.animate().translationY(0f)
}

We’ve just built an await function for a View’s layout. The same recipe can be applied to many commonly used callbacks, such as doOnPreDraw() to know when a draw pass is about to happen, postOnAnimation() to know when the next animation frame is, and so on.

Scope
#

You’ll notice in the example above that we’re using a lifecycleScope to launch our coroutine. What is that?

The scope which we use to run any coroutines is especially important when we’re touching the UI, to avoid accidentally leaking memory. Luckily there are a number of Lifecycles available which are appropriately scoped for our views. We can then use the lifecycleScope extension property to obtain a CoroutineScope which is scoped to that lifecycle.

LifecycleScope is available in the AndroidX lifecycle-runtime-ktx library. You can find more information here.

A commonly used lifecycle owner is Fragment’s viewLifecycleOwner, which is active for as long as the fragment’s view is attached. Once the fragment’s view is removed, the attached lifecycleScope is automatically cancelled. And because we’re adding cancellation support to our suspending functions, everything will be automatically cleaned-up if this happened.

Waiting for an Animator to finish
#

Let’s look at another example, this time awaiting an Animator to finish:

suspend fun Animator.awaitEnd() = suspendCancellableCoroutine<Unit> { cont ->
    // Add an invokeOnCancellation listener. If the coroutine is
    // cancelled, cancel the animation too that will notify
    // listener's onAnimationCancel() function
    cont.invokeOnCancellation { cancel() }

    addListener(object : AnimatorListenerAdapter() {
        private var endedSuccessfully = true

        override fun onAnimationCancel(animation: Animator) {
            // Animator has been cancelled, so flip the success flag
            endedSuccessfully = false
        }

        override fun onAnimationEnd(animation: Animator) {
            // Make sure we remove the listener so we don't keep
            // leak the coroutine continuation
            animation.removeListener(this)

            if (cont.isActive) {
                // If the coroutine is still active...
                if (endedSuccessfully) {
                    // ...and the Animator ended successfully, resume the coroutine
                    cont.resume(Unit)
                } else {
                    // ...and the Animator was cancelled, cancel the coroutine too
                    cont.cancel()
                }
            }
        }
    })
}

This function supports cancellation in both directions, as both the Animator and the coroutine can be separately cancelled.

#1: The coroutine is cancelled while the animator is running. We can use the invokeOnCancellation callback to know when the coroutine has been cancelled, enabling us to cancel the animator too.

#2: The animator is cancelled while the coroutine is suspended. We can use the onAnimationCancel() callback to know when the animator is cancelled, allowing us to call cancel() on the continuation, to cancel the suspended coroutine.

We have just learnt the basics of wrapping up callback API into a suspending await function. 🏅

Orchestrating the band
#

At this point you might be thinking “great, but what does this give me?" In isolation these functions don’t do a lot, but when you start combining them together they become really powerful.

Here’s an example which uses Animator.awaitEnd() to run 3 animators in sequence:

viewLifecycleOwner.lifecycleScope.launch {
    ObjectAnimator.ofFloat(imageView, View.ALPHA, 0f, 1f).run {
        start()
        awaitEnd()
    }

    ObjectAnimator.ofFloat(imageView, View.TRANSLATION_Y, 0f, 100f).run {
        start()
        awaitEnd()
    }

    ObjectAnimator.ofFloat(imageView, View.TRANSLATION_X, -100f, 0f).run {
        start()
        awaitEnd()
    }
}

For this particular example, you could instead put them all into a AnimatorSet, and get the same effect.

But this technique works for different types of async operations; here using a ValueAnimator, a RecyclerView smooth scroll, and an Animator:

viewLifecycleOwner.lifecycleScope.launch {
    // #1: ValueAnimator
    imageView.animate().run {
        alpha(0f)
        start()
        awaitEnd()
    }

    // #2: RecyclerView smooth scroll
    recyclerView.run {
        smoothScrollToPosition(10)
        // We haven't mentioned this function, but it's similar to
        // the others, and awaits the current scroll to end.
        // The code is available in the gist at the end of this post
        awaitScrollEnd()
    }

    // #3: ObjectAnimator
    ObjectAnimator.ofFloat(textView, View.TRANSLATION_X, -100f, 0f).run {
        start()
        awaitEnd()
    }
}

Try doing this with an AnimatorSet 🤯. To achieve this without coroutines would mean adding listeners to each operation, which would start the next operation, and so on. Yuck.

By modeling different asynchronous operations as suspend functions, we gain the ability to orchestrate them expressively and concisely.

We can go even further though…

What if we want the ValueAnimator and the smooth scroll to start at the same time, then start the ObjectAnimator after both have finished? Since we’re using coroutines, we can run them concurrently using async():

viewLifecycleOwner.lifecycleScope.launch {
    val anim1 = async {
        imageView.animate().run {
            alpha(0f)
            start()
            awaitEnd()
        }
    }

    val scroll = async {
        recyclerView.run {
            smoothScrollToPosition(10)
            awaitScrollEnd()
        }
    }

    // Wait for both of the first two operations to finish
    anim1.await()
    scroll.await()

    // anim1 and scroll have now both finished, start the ObjectAnimator 
    ObjectAnimator.ofFloat(textView, View.TRANSLATION_X, -100f, 0f).run {
        start()
        awaitEnd()
    }
}

But what if you then want the scroll to start with a delay? (similar to Animator.startDelay). Well coroutines has you covered there too, we can use the delay() function:

viewLifecycleOwner.lifecycleScope.launch {
    val anim1 = async {
        // ...
    }

    val scroll = async {
        // We want the scroll to start 200ms after anim1
        delay(200)

        recyclerView.run {
            smoothScrollToPosition(10)
            awaitScrollEnd()
        }
    }

    // …
}

What if we then want a transition to repeat? We can wrap the whole thing with the repeat() function (or use a for loop). Here’s an example of a view fading in and out, 3 times:

viewLifecycleOwner.lifecycleScope.launch {
    repeat(3) {
        ObjectAnimator.ofFloat(textView, View.ALPHA, 0f, 1f, 0f).run {
            start()
            awaitEnd()
        }
    }
}

You can even achieve neat things with the repetition count. Say you want the fade-in-fade-out to get progressively slower on each repetition:

viewLifecycleOwner.lifecycleScope.launch {
    repeat(3) { repetition ->
        ObjectAnimator.ofFloat(textView, View.ALPHA, 0f, 1f, 0f).run {
            // 1st run will last 150ms, 2nd: 300ms, 3rd: 450ms
            duration = (repetition + 1) * 150L
            start()
            awaitEnd()
        }
    }
}

In my mind, this is where the power of using coroutines with the Android view system really comes into its own. We can create a complex asynchronous transition, combining different animation types, without having to resort to chaining different types of callback together.

By using the same coroutine primitives which we use on the data layers of our apps, we also make UI programming more accessible. An await function is much more readable to someone new to the code base than a number of seemingly disconnected callbacks.

post.resume()
#

Hopefully this post has gotten you thinking about what other APIs could benefit from coroutines!

The follow-up post to this, which demonstrates how coroutines can be used to orchestrate a complex transition, and includes implementations for some common views, can be found here:

Suspending over Views — Example
·1926 words·10 mins
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.
Suspending over Views - This article is part of a series.
Part 1: This Article