Animation

Animatable2: Part 2

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 AnimatableWrapperBase constructor(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.

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.