React Native collapsible navbar
I think a lot of you liked the article I wrote about the ScrollView animated header so here is one more about another popular UI pattern that can be pretty hard to implement.
This is inspired heavily by a screen I had to make when working on the Th3rdwave app. Here is the final result.
Please note that to keep the code samples brief I’ll omit some parts and mark it with `[…]`, you can check the source for the missing parts.
First let’s create the view hierarchy that we will need. Basically this is just a ListView with some padding equals to the navbar height and the navbar is displayed on top of it using absolute positioning. This allows translating the navbar using transforms when scrolling the ListView.
Adding some Animated values
Next let’s create some Animated values. We will create 3 different Animated values:
- scrollAnim, this is simply the current scroll y position of the ListView
- offsetAnim, this will be used to move the navbar programmatically when needed. This will be useful when we want to fully reveal or hide the navbar at the end of a scroll gesture. We don’t want to animate the scrollAnim directly since as soon as scrolling happens it’s value will be reset to the scroll position and cause some weird behavior.
- clampedScroll, this is the value used to animate the navbar. We also save it in the state to avoid recreating it on each render. It is created by adding together scrollAnim and offsetAnim and then using Animated.diffClamp. I’ll explain what diffClamp does in details in the next section. It also does an interpolation on scrollAnim to avoid issues with the bounce effect on iOS.
This was added to animated recently to be able to represent the animation we want to do here. Basically we can’t simply use Animated.interpolate with extrapolate: clamp since we want the navbar to start showing up again as soon as we start scrolling back up. This is where diffClamp is useful, what it does is calculate the difference between the current value and the last and then clamp that value. For example for Animated.diffClamp(input, 0, 10) here is a table of the input and output values.
Attaching the Animated values
Now we need to attach these values to the views and add some interpolations to map them to the opacity of the title text and the translation of the navbar view. I won’t explain these in details so if you are not familiar with Animated and useNativeDriver I suggest checking my first article and my blog post on native animations.
At this point our example is pretty functional but one detail is missing. When you stop scrolling and the navbar is half collapsed it would be nice to animate it back to either it’s fully displayed or hidden state. This is where we will use the offsetAnim value we created earlier.
Now let’s add some code to detect when scrolling is over. Sadly I don’t know of a solution that works properly with both momentum and normal scroll. Here is the order events are called for a normal scroll:
And a momentum scroll:
What we do is start a short timer in onScrollEndDrag and clear it in onMomentumScrollBegin. In the case of a normal scroll, our method that handles animating the navbar will get called after a small delay by the timer and in the case of a momentum scroll, the timer will get cleared and our method will get called in onMomentumScrollEnd.
The final animation
The last thing we need is to know whether we should hide the navbar or show it. For that we need to know the value of the different Animated values used. To do that we can add listeners to the values and save it in an instance variable to access it in the _onMomentumScrollEnd method. One caveat here is that the value returned from Animated.diffClamp doesn’t support adding listeners to it (there is a PR to support that, but it isn’t merged yet) so we have to do the same calculations that diffClamp does manually.
Now that we have synchronous access to these values we can implement the logic to show or hide the navbar.
Here we want to animate the offset. If we add to its current value it will cause the navbar to hide (like if the user scrolled down) and if we subtract from it, it will cause the navbar to show. What we do is simply check the clamped scroll value and check if it has passed the half of the navbar. We also want to make sure we don’t hide the navbar if we haven’t scrolled enough yet or it will show a blank space where the navbar is supposed to be.
Well that was something…
Implementing these advanced UI behaviors is definitely tricky, either natively or with React Native (with the exception of Android that has developed a nice library to deal with this). Ideally we’d have some abstractions to avoid having to implementing this when developing an app.
See the final code here