Animation

Animatable2: Part 1

Sometimes we need to receive callback when an AnimatedVectorDrawable starts and ends. The Animatable2 interface makes this possible. It allows an Animatable2.AnimationCallback implementation to be registered which receives relevant callbacks as the animation is played. Things aren’t always as straightforward as they might initially appear. Animatable2 first appeared in API 23, but API 21 was when AnimatedVectorDrawable made its debut. To bridge this shortfall, AnimatedVectorDrawableCompat comes with its own implementation Animatable2Compat. So if we use this when using the Android X version of AnimatedVectorDrawable, then things should be good, right? Unfortunately not because Animatable2 and Animatable2Compat are incompatible and supporting both adds complexity to our code. In this article we’ll explore this complexity, and introduce a pattern to overcome these issues.

To allow us to explore the complexity we’ll use a part of the animation used in this article. Specifically we’ll use the AnimatedVectorDrawable in pause_to_play.xml which transitions from the pause symbol to the play symbol. We’ll place this inside an AppCompatImageView and add on OnClickListener to start the animation whenever the user taps on the image. I won’t give an explanation of this code but it’s available to view in the source below.

The Problem

The problems start when we want to add an AnimationCallback. The issue is that the Android X support library decides whether to use the native framework implementation of AnimatedVectorDrawable or its own AnimatedVectorDrawableCompat. It makes this decision at runtime and will vary depending on the OS level of the device. We do not know at compile time which implementation will be used.

This is a problem because AnimatedVectorDrawable uses Animatable2 and AnimatedVectorDrawableCompat uses its own Animatable2Compat. Both of these interfaces declare similar methods, but they are incompatible. Animatable2Compat cannot have a dependency upon Animatable2 because Animatable2 is only available on API 23 and later. Animatable2 cannot have a dependency upon Animatable2Compat because Android X libraries are not available to the Android framework.

The Simple Solution

The usual way of implementing this would be something like this :

val drawable = binding.animation.drawable
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && drawable is Animatable2) {
    drawable.registerAnimationCallback(object : Animatable2.AnimationCallback() {
        override fun onAnimationEnd(drawable: Drawable?) {
            println("Animation ended")
        }

        override fun onAnimationStart(drawable: Drawable?) {
            println("Animation started")
        }
    })
}

This checks whether a drawable implements Animatable2 (after an API level check) and registers the AnimationCallback if it does.

This works perfectly if I run it on my Pixel 3a XL running Android 11 developer preview. However if I run this on my Nexus 5 running 6.0.1 nothing happens. Although this is API 23 and Animatable2 is supported, the the Android X library choses AnimatedVectorDrawableCompat instead of AnimatedVectorDrawable because of other compatibility issues. The check fails because AnimatedVectorDrawableCompat implements Animatable2Compat and not Animatable2.

The solution is to do something like this:

val drawable = binding.animation.drawable
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && drawable is Animatable2) {
    drawable.registerAnimationCallback(object : Animatable2.AnimationCallback() {
        override fun onAnimationEnd(drawable: Drawable?) {
            println("Animation ended")
        }

        override fun onAnimationStart(drawable: Drawable?) {
            println("Animation started")
        }
    }) 
} else if (drawable is Animatable2Compat) {
    drawable.registerAnimationCallback(object : Animatable2Compat.AnimationCallback() {
        override fun onAnimationEnd(drawable: Drawable?) {
            println("Animation ended")
        }

        override fun onAnimationStart(drawable: Drawable?) {
            println("Animation started")
        }
    })
}

This now works correctly on both devices.

The Problems With This

This solution is far from ideal. It has doubled in size and is mainly duplication. This will be harder to maintain because of the duplication. We must remember to update both cases if we change anything.

There are also less obvious issues. This example code shows how we can register for different AnimationCallback types. But we should be good citizens and clean up properly by deregistering them at some point. To do that we either need to call clearAnimationCallbacks() on either Animatable2 or Animatable2Compat or we need to call unregisterAnimationCallback(). This latter option becomes problematic because we must hold a reference to the AnimationCallback that we need to unregister later on. If we are targeting an API version prior to API 23 declaring a field to hold this will not be straightforward. We will not be able to instantiate an Animatable2.AnimationCallback instance outside of an API level check. So to properly unregister our callbacks we’ll need to do something like this:

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        val drawable = binding.animation.drawable
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && drawable is Animatable2) {
            animatable2Callback = object : Animatable2.AnimationCallback() {
                override fun onAnimationEnd(drawable: Drawable?) {
                    println("Animation ended")
                }

                override fun onAnimationStart(drawable: Drawable?) {
                    println("Animation started")
                }
            }.also {
                drawable.registerAnimationCallback(it)
            }
            binding.animation.setOnClickListener {
                drawable.start()
            }
        } else if (drawable is Animatable2Compat) {
            drawable.registerAnimationCallback(animatable2CompatCallback)
            binding.animation.setOnClickListener {
                drawable.start()
            }
        }
    }

    private var animatable2Callback: Animatable2.AnimationCallback? = null
    private var animatable2CompatCallback = object : Animatable2Compat.AnimationCallback() {
        override fun onAnimationEnd(drawable: Drawable?) {
            println("Animation ended")
        }

        override fun onAnimationStart(drawable: Drawable?) {
            println("Animation started")
        }
    }

    override fun onDestroy() {
        val drawable = binding.animation.drawable
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && drawable is Animatable2) {
            animatable2Callback?.also {
                drawable.unregisterAnimationCallback(it)
            }
        } else if (drawable is Animatable2Compat) {
            drawable.unregisterAnimationCallback(animatable2CompatCallback)
        }
        super.onDestroy()
    }
}

Fortunately we are able to have a nullable field for animatable2Callback provided we only create an instance within an API level check.

This Activity really does very little, but requires a lot of boilerplate to do it. That’s a pretty big smell that something is wrong. If we need to do this throughout our codebase we are going to get a lot of bloat.

Conclusion

Hopefully the complexities introduced because Animatable2 and Animatable2Compat are incompatible should now be fairly clear. While we have a solution, it is verbose and will be difficult to maintain. In the concluding article in this series we’ll look at a way that we can improve on this.

I haven’t published the source to accompany this article because it is far from ideal. Publishing it without the explanation text in this article may result in folks using it without understanding the issues. Instead I’ll leave the publication of the code until the next article which will have a much cleaner implementation.

© 2020, Mark Allison. All rights reserved.

Copyright © 2020 Styling Android. All Rights Reserved.
Information about how to reuse or republish this work may be available at http://blog.stylingandroid.com/license-information.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.