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.