There is an occasional series on Styling Android which covers techniques for creating Animated Icons as AnimatedVectorDrawable
and AnimatedStateListDrawable
. These are quite easy to use when using them inside a standard ImageView
, but there can be other scenarios where they can be a little trickier to get working, and one of those is in the BottomNavigationView
control from Material Components. In this article we’ll look at some tricks for getting them to play nicely.
There are two distinct types of animated icons that we’ve covered in the Animated Icons series: Those that animate based on changing view state (such as the strikethru animation on the left), and those that are ongoing repeating animations such as the LoadingV2 animation. The first are achieved by using an AnimatedStateListDrawable
, and the second are implemented using AnimatedVectorDrawable
and need to be started and stopped manually (as seen here).
This is easy enough to implement when using these Drawable
s within standard ImageView
, but they do not work quite as expected when using them within BottomNavigationView
. I am indebted to my colleague at Babylon Health Maxime Mazzone from whom I learned of this issue – you have him to thank for the investigations which prompted this article.
The problem is not with the view state-based animations – using AnimatedStateListDrawable
for the icon drawable in MenuItem
s will animate according to the view states triggered by user interaction with the BottomNavigationView
:
In this case we use the exact same strikethru drawable from this article. The problem arises when we want to use an on-going repeating icon such as the loadingv2 drawable from this article:
We’ve already explained how using an AnimatedVectorDrawable
requires us to manually start and stop the animation, so it would seem that all we need to do is 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) // Don't try this at home - it doesn't work! binding.bottomNavBar.menu.startAnimations() } private fun Menu.startAnimations() { (0 until size()).map { get(it).icon } .filterIsInstance() .forEach { it.start() } } }
What this is doing is going through each of the menu items, and for any where the icon
drawable implements the Animatable2
interface, we call start()
to kick off the animation. This code works as it should, and finds an AnimatedVectorDrawable
(which implements Animatable2
) for the relevant MenuItem
, and start()
is called on that drawable.
Internally Drawable
has a Callback
to the parent view which the Drawable
can call to invalidate itself, and this mechanism is used by the start()
method of AnimatedVectorDrawable
to invalidate the parent View
to trigger a re-draw during which the animation will be started. For some reason – I’m not sure whether it is by design because it interferes with the operation of BottomNavigationView
– Bottom=NavigationView
does not appear to register a Callback
with the Drawable
so the redraw never gets triggered, and therefore the animation does not start.
There are a few ways to work around this depending on the exact use-case we’re aiming for. The first is where we just want the animation to run continuously. The trick is to hook the animation starting in to the view state by wrapping the AnimatedVectorDrawable
inside an AnimatedStateListDrawable
:
This uses the AnimatedVectorDrawable
that we used earlier, but adds a transition between states to start it. As the animator it uses internally is a repeating one, then it starts a repeating animation. The actual view state transition is when the MenuItem
itself, or the parent BottomNavigationView
receives focus. This happens automatically in my layout as the BottomNavigationView
automatically receives input focus, but if this doesn’t work in more complex layouts you may need to do something funky once the layout has been inflated to force focus to the BottomNavigationView
in order to trigger the animation, then restore it back to whatever is supposed to have initial focus shortly after.
We can now use this AnimatedStateListDrawable
as the icon within the menu
:
This now has an ongoing animation which continues even through selection state transitions:
The next use-case we’ll look at is where we only want the animation to run when the menu item is active and stop when it is inactive. Once again, we need to do this using an AnimatedStateListDrawable
but in this case we’ll transition between static and animating states depending on checked state:
In checked state we’ll use the AnimatedVectorDrawable
that we saw earlier, and in unchecked state we’ll use the static VectorDrawable
that is used within the animated version. This now behaves like this:
The static version of the graphic has a single box showing – this is so that we can see that there is no animation taking place – in a real scenario you would want something a little more meaningful. Note how the animation is interrupted immediately when the MenuItem
loses checked state.
The final use-case we’ll consider is where we want to change the animation state depending on some external state. For example we may want to animate the icon when something else is loading, and stop the animation once the loading is completing. For this example we’ll add a switch to the main layout, and the changing state of this switch will control when the icon for a specific MenuItem
is animated or static.
We now add an OnCheckChangedListener
to this switch which changes the drawable used for a specific MenuItem
:
class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) binding.toggle.setOnCheckedChangeListener { _, isChecked -> binding.bottomNavBar.menu.findItem(R.id.menu_loadingv2_external).icon = if (isChecked) { resources.getDrawable(R.drawable.loadingv2_ongoing_selector, theme) } else { resources.getDrawable(R.drawable.loadingv2_vector, theme) } } } }
The important thing to note is that for the checked state we use R.drawable.loadingv2_ongoing_selector
which is the AnimdatedStateListDrawable
that we used earlier for the ongoing animation – this will get started automatically. For the non checked state we use the static VectorDrawable
the same as the previous use-case:
When the switch is on, the MenuItem
icon has a repeating animation which behaves independently of the checked state of the MenuItem
itself. If you wanted something that only animates when both the switch is on, and the MenuItem
has checked state, then you would use R.drawable.loadingv2_stateful_selector
instead of R.drawable.loadingv2_ongoing_selector
.
Of course, this external control technique isn’t just for repeating animations – it is possible to trigger simple state transitions by using different AnimatedVectorDrawable
s inside the AnimatedStateListDrawable
that we use in the OnCheckChangedListener
.
While there are some problems if you try and use AnimatedVectorDrawable
directly within BottomNavigationView
these can easily be overcome by wrapping this inside an AnimatedStateListDrawable
and we can get some pretty fine-grained control over how and when our animation run with some creative application of this.
The source code for this article is available here.
© 2019, Mark Allison. All rights reserved.
Copyright © 2019 Styling Android. All Rights Reserved.
Information about how to reuse or republish this work may be available at http://blog.stylingandroid.com/license-information.