Like many things material design Google introduced Bottom navigation bars on Android via the design library but failed to provide one key component — a scroll behavior for them. This is frustrating because the official material design specs specify that the bottom bar can hide on scroll. For my app, Curated I needed this behavior.
While there are good third party libraries which will provide a custom Bottom bar for you that does this, I decided to find a way to add this (seemingly simple) requirement to the normal BottomNavigationView. I was inspired by the implementation of the Google+ app. The way it handles it is by basically treating the Bottom Bar as an AppBarLayout but at the bottom — you can notice that they both scroll away at the same speed and in the same way. I wanted to emulate that behavior.
A custom behavior — surprisingly easy
I knew the implementation would involve writing a custom CoordinatorLayout Behavior which is what threw me off initially. Anytime I’ve delved into the example code of custom behaviors it seemed way too complicated. It turns out, though, that in this case it was not that hard at all:
And the result? Voilà:
The first important part of the code is the
onStartNestedScroll method where we instruct CoordinatorLayout that we care about vertical scroll events. Then we handle
onNestedPreScroll. This is the same method that the AppBarLayout behavior overrides to hide itself away. “PreScroll” means that we get the scroll event before the RecyclerView (the actual scrolling container in this case) receives it. The only part of it we care about is the
dy which is the scroll change delta. Then all we have to do is set the
translationY property of our view.
max(0f, min(child.height.toFloat(), child.translationY + dy)) is a clamping function. It clamps the translationY value between two bounds — 0 and the view height — because we don’t want to push the bottom bar further down than its own height and we don’t want to push it up further than its starting position.
Attaching the behaviour to your view
If you’ve never used a custom CoordinatorLayout behavior the way you attach it to your view is like this — just put in
layout_behavourthe path to your own class:
What about Snackbars?
What we have is great but it has a flaw — it doesn’t respect Snackbars appearing in the CoordinatorLayout. If one was to appear it’ll be behind the BottomNavigationView which is wrong. The material specs are clear on what should happen:
When a snackbar appears it needs to be docked above the bottom bar and move with it as it moves. Well as it turns out doing that isn’t too complicated too (notice a pattern here?):
Hey presto — we have Snackbar support:
The way this works is that we’re tapping into the
layoutDependsOn method which fires off when a new layout event happens — as a result of a Snackbar being added, for example. Since we know the Snackbar is being added to a CoordinatorLayout we can make use of that and change its layout parameters. Making use of the very handy
gravity properties we instruct the Snackbar to be anchored to the top edge of the bottom bar (remember,
child in that code is the BottomNavigationView the behavior is attached to) and it should appear with gravity top, meaning above the bar.
What about Floating Action Buttons (FAB)?
Another element to handle with this BottomNavigationView implementation is Floating Action Buttons.
First off, to make the FAB move with the BottomNavigationView we can do what we do with the Snackbar — just anchor it to it!
If you look at the code you’ll see it’s the same thing we do with the Snackbar in
BottomNavigationBehaviour but just declared in XML. Our FAB now moves correctly:
But right now if we show our snackbar, it’ll appear over the FAB, which isn’t nice. What we want is for the FAB to move up when a Snackbar appears and move back down when it disappears, as it does by default in normal layouts.
We can do that with another simple custom CoordinatorLayout Behavior:
We have our desired result:
BottomNavigationFABBehavior isn’t very complicated but lets break down what it does.
First off, similar to the other Behavior this one declares that it depends on a Snackbar. This allows it to get a callback in
onDependentViewChanged when the Snackbar appears and moves. Now, in order to get a smooth movement transition the FAB needs to move up together with the Snackbar. The way Snackbars are animated in is by translating them up by their entire height. This means that at any one time the difference between the snackbar’s
translationY property and its height is the amount of it that is showing from the bottom of the screen. This amount is how much we have to translate the FAB by so that it’s not covered by the Snackbar.
newTranslation so that we can return a correct response to
onDependentViewChanged which expects Behaviors to return true or false depending on whether Behavior changed the child view’s size or position. Not doing this doesn’t have any visible negative consequences but I think it’s better to be correct 👍
The final thing to notice here is the overriden
onDependentViewRemoved method — this is used so that if the snackbar is manually dismissed (via swipe) the FAB can return to it’s old position.
What about snap behaviour?
By popular demand I’ve decided to include how to extend the
BottomNavigationBehavior to handle snapping. By snapping I mean a behavior similar to what you get when you add
app:layout_scrollFlags=”snap” to your AppBarLayout views — for example to your Toolbar. It makes it so the Toolbar snaps to either be hidden or visible when the user stops scrolling.
Since we want our BottomNavigationView to handle snapping like the AppBar does, it’s worth to look at the AppBarLayout source code to see how it handles snapping. Instead of showing it, I’ll summarize it:
- It waits for the
onStopNestedScrollevent. When it happens it checks if any of it’s children have a snap behavior attached.
- If so, it checks if the child should be snapped to full visibility or to a hidden state. It does this by checking how much of the child is currently showing.
- It then animates the child’s offset to that new state.
The actual implementation is obviously slightly more complex but those 3 steps are the essence of it.
Ok, lets follow these steps and edit our code to support snapping:
Oh, snap, we have it:
Looks great but we’ve changed quite a bit haven’t we? Lets take it one change at a time to see how we got it working. The first thing you’ll notice is that we keep a track of
onStartNestedScroll in order to make sure we can indeed run our snap behavior in
onStopNestedScroll. This code is borrowed directly from the AppBarLayout source.
onStopNestedScroll we first check whether we support snapping and can run our logic. If we can than the first thing we do is check whether we should hide or show the view. We do this really easily by just checking whether the current translationY is greater or less than the half height of the view.
Onwards to our
animateBarVisibility method. Again, this is inspired by the AppBarLayout source. We first instantiate our
offsetAnimator if we’ve not already. It’s a really simple ValueAnimator object that will animate the translationY property of our view. The duration of 150 milliseconds and the DecelarateInterpolator are the same as the ones that AppBarLayout uses. This keeps our snap behaviour in sync with the AppBar.
You can also see that in a couple of places we call
cancel on the animator — this is so that if a new scroll event happens while the animation is running we stop it and let the user control the visibility again.
Not a whole lot of code for what is a very nice scroll interaction with Snackbar, FAB and snapping support. Yes, I know that the more advanced usages are a bit more than 10 lines of code but they’re still pretty simple, right?. I don’t plan on making this into a library since it’s so simple but I’ve uploaded the whole code in this Github gist if you want to make use of it. There is also a Java version of it (courtesy of user bubbleguum).
Edited 23/11/2018 to add documentation on how to handle snapping.