Correctly determining whether to use Animatable2
or Animatable2Compat
can be more complex than it may appear. Previously we looked at some of the complexities of using Animatable2
or Animatable2Compat
to register for animation callbacks. This is because Animatable2
only appeared in API 23 and later AnimatedVectorDrawableCompat
caters for earlier versions. Also, the Android X library will determine at runtime whether to use either AnimatedVectorDrawable
or AnimatedVCectorDrawableCompat
. We came up with a solution, but it had issues. In this concluding article we’ll look at a much better solution.
The pattern that we’re going to use is to create our own compatibility wrapper. This may at first appear to be quite verbose and complex. However, encapsulating the logic in this way will make it easy to consume throughout our codebase.
This wrapper will mimic the interfaces provided by the frameworkAnimatable2
and Animatable2.AnimationCallback
; and the Android X equivalents: Animatable2Compat
and Animatable2Compat.AnimationCallback
:
interface AnimatableWrapper : Animatable { fun clearAnimationCallbacks() fun registerAnimationCallback(callback: AnimationCallback) fun unregisterAnimationCallback(callback: AnimationCallback): Boolean interface AnimationCallback { fun onAnimationEnd(drawable: Drawable) fun onAnimationStart(drawable: Drawable) } open class AnimationCallbackAdapter : AnimationCallback { override fun onAnimationEnd(drawable: Drawable) { /* NO-OP */ } override fun onAnimationStart(drawable: Drawable) { /* NO-OP */ } } . . . }
As well as the AnimationCallback
interface, I have also included AnimationCallbackAdapter
which has no-op implementations of both methods. I a class implements AnimationCallback
it must override both methods. But any class extending AnimationCallbackAdapter
needs only override the methods that it needs.
The Base Implementation
Next we have a generic base implementation of AnimatableWrapper
:
private open class AnimatableWrapperBaseconstructor(animatable: Animatable) : Animatable by animatable, AnimatableWrapper { protected val registeredCallbacks = mutableMapOf () override fun clearAnimationCallbacks() { /* NO-OP */ } override fun registerAnimationCallback(callback: AnimatableWrapper.AnimationCallback) { /* NO-OP */ } override fun unregisterAnimationCallback( callback: AnimatableWrapper.AnimationCallback ): Boolean = false }
This is a private class (as are all of the following classes. The reason for this is that consumers of AnimatableWrapper
will be completely agnostic of the underlying implementations. The AnimatableWrapper
interfaces are the only visible components. We’ll look at how this is achieved in due course.
One thing worthy of note is that both Animatable2
and Animatable2Compat
extend Animatable
. Therefore we need to do the same. AnimatableWrapperBase
takes an Animatable
in its constructor and delegates the Animatable
behaviour to it. Kotlin delegation saves us a lot of boilerplate code here.
Another thing worth noting is the we hold a map of AnimatableWrapper.AnimationCallback
to the generic type T
, This will be used by the concrete implementations to map the AnimatableWrapper.AnimationCallback
methods to the correct ones.
Also worthy of mention is that AnimatableWrapperBase
also has no-op implementations of each of the methods in AnimatableWrapper
. We’ll use this as a fallback later on.
This may seem a little confusing, but please bear with me it will become much clearer when we look at one of the concrete implementations.
Animatable2Wrapper
The first of the concrete implementations we’ll look at is the wrapper for the framework Animatable2
:
@TargetApi(Build.VERSION_CODES.M) private class Animatable2Wrapper(private val animatable2: Animatable2) : AnimatableWrapperBase(animatable2) { override fun registerAnimationCallback(callback: AnimatableWrapper.AnimationCallback) { Animatable2Callback(callback).also { innerCallback -> animatable2.registerAnimationCallback(innerCallback) registeredCallbacks += callback to innerCallback } } override fun unregisterAnimationCallback( callback: AnimatableWrapper.AnimationCallback ): Boolean { return registeredCallbacks.remove(callback)?.let { innerCallback -> animatable2.unregisterAnimationCallback(innerCallback) } ?: false } override fun clearAnimationCallbacks() { animatable2.clearAnimationCallbacks() registeredCallbacks.clear() } }
This takes an Animatable2
instance as its constructor argument. This gets passed down to the base implementation which performs the Animatable
delegation. The generic parameter used is Animatable2.AnimationCallback
and so the registeredCallbacks
map that we saw in the base class will hold a mapping of <AnimatableWrapper.AnimationCallback,Animatable2.AnimationCallback>
.
The registerAnimationCallback()
method takes an argument of an AnimatableWrapper.AnimationCallback
. It constructs an Animatable2Callback
instance passing in the argument to the constructor. We’ll discuss Animatable2Callback
in a moment. It then registers this newly created callback with the Animatable2
instance that we received in the constructor, and then adds a mapping of the argument AnimatableWrapper.AnimationCallback
to the Animatable2Callback
to the registeredCallbacks
map.
The unregisterAnimationCallback()
method is where the purpose of the registeredCallbacks
map becomes apparent. Once again this method receives an argument which is an AnimatableWrapper.AnimationCallback
. We use this to retrieve (and remove) a previously added Animatable2Callback
. If one was retrieved, then we use it as an argument to unregisterAnimationCallback()
of the Animatable2
.
These two methods are marshalling between the AnimatableWrapper
methods (which we defined), to the equivalents in Animatable2
. A consumer requires knowledge of AnimatableWrapper
and does not know that it’s actually using Animatable2
under the covers.
The clearAnimationCallbacks()
method clears the registeredCallbacks
map, and calls clearAnimationCallbacks()
on the Animatable2
.
Animatable2Callbacks
The Animatable2Callbacks
class implements Animatable2.AnimationCallback
, and this was registered with Aimatable2
. It takes a constructor argument of an AnimatableWrapper.AnimationCallback
instance. It receives the callbacks from the Animatable2.AnimationCallback
and passes these on to the AnimatableWrapper.AnimationCallback
:
@TargetApi(Build.VERSION_CODES.M) private class Animatable2Callback( private val animatable2Callback: AnimatableWrapper.AnimationCallback ) : Animatable2.AnimationCallback() { override fun onAnimationStart(drawable: Drawable) { super.onAnimationStart(drawable) animatable2Callback.onAnimationStart(drawable) } override fun onAnimationEnd(drawable: Drawable) { super.onAnimationEnd(drawable) animatable2Callback.onAnimationEnd(drawable) } }
This is a little tricky to follow because it is callbacks within callbacks. But what happens here is that the client creates an implementation of AnimatableWrapper.AnimationCallback
and registers this with AnimatableWrapper
. Internally this gets mapped to the Animatable2
and Animatable2Wrapper
and Animatable2Callbacks
perform this specifically for Animatable2
.
AnimatableCompatWrapper
AnimatableCompatWrapper
and AnimatableCompatCallbacks
do much the same thing, only they do it for Animatable2Compat
and Animatable2Compat.AnimationCallbacks
instead:
private class Animatable2CompatWrapper(private val animatable2: Animatable2Compat) : AnimatableWrapperBase(animatable2) { override fun registerAnimationCallback(callback: AnimatableWrapper.AnimationCallback) { Animatable2CompatCallback(callback).also { innerCallback -> animatable2.registerAnimationCallback(innerCallback) registeredCallbacks += callback to innerCallback } } override fun unregisterAnimationCallback( callback: AnimatableWrapper.AnimationCallback ): Boolean { return registeredCallbacks.remove(callback)?.let { innerCallback -> animatable2.unregisterAnimationCallback(innerCallback) } ?: false } override fun clearAnimationCallbacks() { animatable2.clearAnimationCallbacks() registeredCallbacks.clear() } } private class Animatable2CompatCallback( private val animatable2Callback: AnimatableWrapper.AnimationCallback ) : Animatable2Compat.AnimationCallback() { override fun onAnimationStart(drawable: Drawable) { super.onAnimationStart(drawable) animatable2Callback.onAnimationStart(drawable) } override fun onAnimationEnd(drawable: Drawable) { super.onAnimationEnd(drawable) animatable2Callback.onAnimationEnd(drawable) } }
The logic is all the same, only the underlying types have changed.
Putting It All Together
We have the components in place, and we now need to logic to create the correct instances for us. This is done within a single public companion object function in the AnimatableWrapper
interface:
interface AnimatableWrapper : Animatable { . . . companion object { fun wrap(animatable: Animatable): AnimatableWrapper = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { wrapM(animatable) } else { wrapLegacy(animatable) } @TargetApi(Build.VERSION_CODES.M) private fun wrapM(animatable: Animatable): AnimatableWrapper = when (animatable) { is Animatable2 -> Animatable2Wrapper(animatable) is Animatable2Compat -> Animatable2CompatWrapper(animatable) else -> AnimatableWrapperBase(animatable) } private fun wrapLegacy(animatable: Animatable): AnimatableWrapper = when (animatable) { is Animatable2Compat -> Animatable2CompatWrapper(animatable) else -> AnimatableWrapperBase (animatable) } } }
It calls either wrapM()
or wrapLegacy()
depending on the API level. These function select the correct AnimatableWrapper
implementation based on whether the animatable implements Aimatable2
or Animatable2Compat
. If it implements neither then the it returns no-op version.
The final little piece of the puzzle an extension function for Drawable
:
fun Drawable.toAnimatableWrapper(): AnimatableWrapper? = (this as? Animatable)?.let { AnimatableWrapper.wrap(it) }
This checks whether the drawable is Animatable
and wraps it if it is.
Simplifying The Activity
With this in place we can now use it within our activity:
class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding private var animatable: AnimatableWrapper? = null private val callback = object : AnimatableWrapper.AnimationCallbackAdapter() { override fun onAnimationEnd(drawable: Drawable) { println("Animation ended") } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) animatable = binding.animation.drawable.toAnimatableWrapper()?.apply { registerAnimationCallback(callback) binding.animation.setOnClickListener { start() } } } override fun onDestroy() { animatable?.unregisterAnimationCallback(callback) super.onDestroy() } }
This is much cleaner than before. It is much easier to understand this code and the duplication is now within AnimatableWrapper
which is a closed environment. We can consume AnimatableWrapper
throughout our codebase without having to worry so the duplication happens once and once only. That makes it much easier to maintain. We have also removed all API checks and instance checks from our Activity class.
Conclusion
Correctly determining whether to use Animatable2
or Animatable2Compat
can be more complex than it may appear. Creating our own compat wrapper to hide these complexities is a reusable way can make life much easier and keep our codebase terser and easier to maintainable. Hopefully the wrapper that we’ve created here will help to do that and offers some tips for applying the same kind of pattern to other problems.
The source code for this article is available here.
© 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.