Search code examples
androidkotlinandroid-animationextension-methodskotlin-coroutines

Replace animation callbacks with Kotlin cancellable coroutine extensions (Suspending over views)


So I read this great Medium post by Chris Banes here which explains how coroutines or specifically suspend functions can be used to coordinate animations without the "callback hell" that you can fall into when chaining animations together. I managed to get the onAnimationEnd listener extension working as per his example but I can't seem to do the same for the onAnimationStart listener, here is my awaitEnd method

suspend fun Animator.awaitEnd() = suspendCancellableCoroutine<Unit> { continuation ->

    continuation.invokeOnCancellation { cancel() }

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

        override fun onAnimationCancel(animation: Animator) {

            endedSuccessfully = false
        }

        override fun onAnimationEnd(animation: Animator) {

            animation.removeListener(this)

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

}

In the code below I'm using this in an async block and awaiting first the animation to finish and then the coroutine to finish

val anim = async {
    binding.splash.circleReveal(null, startAtX = x, startAtY = y).run {
        start()
        //...doStuff()
        awaitEnd()
    }
}
anim.await()

This works beautifully, adding logs to the correct functions shows me that everything is being called exactly as expected, now to add a started extension in the same way...

suspend fun Animator.started() = suspendCancellableCoroutine<Unit> { continuation ->

    continuation.invokeOnCancellation { cancel() }

    this.addListener(object : AnimatorListenerAdapter() {

        private var endedSuccessfully = true

        override fun onAnimationCancel(animation: Animator?) {
            endedSuccessfully = false
        }

        override fun onAnimationStart(animation: Animator?) {
            Log.d("DETAIL", "Animator.started() onAnimationStart")

            animation?.removeListener(this)

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

and call it from the same async block after the start method (this may have something to do with things)

val anim = async {
    binding.splash.circleReveal(null, startAtX = x, startAtY = y).run {
        start()
        started()
        //...doStuff()
        awaitEnd()
    }
}
anim.await()

now what happens is the coroutine for started gets suspended but never resumes, if I add some logging statements I can see that it calls start(), it then calls started() but then doesn't go any further, obviously I've tried changing the order of operations to no avail can anyone see what I'm doing wrong here?

many thanks

EDIT

I also tried this for adding a sharedElementEnter transition but again it just will not work for me,

suspend fun TransitionSet.awaitTransitionEnd() = suspendCancellableCoroutine<Unit> { continuation ->

    val listener = object : TransitionListenerAdapter() {
        private var endedSuccessfully = true

        override fun onTransitionCancel(transition: Transition) {
            super.onTransitionCancel(transition)
            endedSuccessfully = false
        }

        override fun onTransitionEnd(transition: Transition) {
            super.onTransitionEnd(transition)
            Log.d("DETAIL","enterTransition onTransitionEnd")
            transition.removeListener(this)

            if (continuation.isActive) {
                // If the coroutine is still active...
                if (endedSuccessfully) {
                    // ...and the Animator ended successfully, resume the coroutine
                    continuation.resume(Unit)
                } else {
                    // ...and the Animator was cancelled, cancel the coroutine too
                    continuation.cancel()
                }
            }
        }
    }
    continuation.invokeOnCancellation { removeListener(listener) }
    this.addListener(listener)

}

and again trying to use the await method

viewLifecycleOwner.lifecycleScope.launch {
    val sharedElementEnterTransitionAsync = async {
        sharedElementEnterTransition = TransitionInflater.from(context)
            .inflateTransition(R.transition.shared_element_transition)
        (sharedElementEnterTransition as TransitionSet).awaitTransitionEnd()
    }
    sharedElementEnterTransitionAsync.await()
}

Solution

  • You are correct - coroutine is never resumed.

    Why?

    By the time you call started() method animation has already started. It means onAnimationStart defined in a listener within started() will not be called (as it has been already called) placing the underlying coroutine in a forever waiting state.

    The same will happen if you call started() before start(): underlying coroutine will be waiting for onAnimationStart to be called but it will never happen because start() method call is blocked by the coroutine created by started() method.

    That is almost a dead-lock.

    Solution 1

    Call start() before started() returns:

    suspend fun Animator.started() = suspendCancellableCoroutine<Unit> { continuation ->
    
        continuation.invokeOnCancellation { cancel() }
    
        this.addListener(object : AnimatorListenerAdapter() {
            ...
        })
    
        // Before the coroutine is even returned we should start the animation
        start()
    }
    

    Solution 2

    Pass in a function (that optionally takes a CancellableContinuation<Unit> argument):

    suspend fun Animator.started(executeBeforeReturn: (CancellableContinuation<Unit>) -> Unit) = suspendCancellableCoroutine<Unit> { continuation ->
    
        continuation.invokeOnCancellation { cancel() }
    
        this.addListener(object : AnimatorListenerAdapter() {
            ...
        })
        executeBeforeReturn(continuation)
    }
    

    It will allow you to:

    • use the coroutine (e.g. cancel) before you even start the animation;
    • avoid the lock.

    Example:

    val anim = async { 
        ObjectAnimator.ofFloat(view, View.ALPHA, 0f, 1f).run { 
            started { continuation ->
                if (anything) {
                    continuation.cancel()
                }
                start()
            }
            awaitEnd()
        }
    }
    anim.await()