Animated and React Native ScrollViews

Page after page, you keep scrolling. Whether it’s on your computer or your phone, a lot is usually happening while scrolling.

In React Native, it is important to know how you can add side effects when the user scrolls down. In today’s chapter, let’s learn about animated events with scroll views.

This was first published as a video for the How To Animated series. If you learn better through audio/image, I highly recommend it.

This article is part of a series. If you are unfamiliar with the basics of animations (Animated.Value, Animated.View, interpolate), the first chapter should help you to understand what’s next.

onScroll

Here’s the idea: we have a long scroll view component, and whenever we scroll down to a certain point, a header should appear at the top of the screen. When we go up again, it should disappear.

To get started, I have created both the scroll view and the header but for now, there is no animation.

import React, { useState } from 'react';
import { ScrollView, View } from 'react-native';
export default () => {
const [headerShown, setHeaderShown] = useState(false);

return (
<>
<View
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 80,
backgroundColor: 'tomato',
transform: [
{ translateX: headerShown ? 0 : - 100 },
],
}}
/>

<ScrollView
onScroll={(event) => {
const scrolling = event.nativeEvent.contentOffset.y;

if (scrolling > 100) {
setHeaderShown(true);
} else {
setHeaderShown(false);
}
}}
// onScroll will be fired every 16ms
scrollEventThrottle={16}
style={{ flex: 1 }}
>
<View style={{ flex: 1, height: 1000 }} />
</ScrollView>
</>
);
}

The way it works is that using the onScroll callback on ScrollView, we can check whenever the vertical scroll position is higher than let’s say 100 pixels, and if so, we show the header by changing the state headerShown, otherwise we put it back to a hidden position.

By the way, if you look at onScroll, you can see we’re using contentOffset.y to get the vertical scrolling, but this event contains a few other indicators in case you want to do something different such as knowing the whole layout or content size.

To animate it, there are actually not so many changes to do.

First, we import Animated and we need to create an animated value that will hold the translation value for the header. Just to be clear, this value will hold the vertical position so in this case, 0 or -100 and we’re going to animate this so it smoothly appears on the screen.

By default, it’s hidden so we’ll set it to -100, and it should be set as the translateY style property on the header, which will become an Animated.View also:

import React, { useRef, useState } from 'react';
import { Animated, ScrollView, View } from 'react-native';
export default () => {
const [headerShown, setHeaderShown] = useState(false);
const translation = useRef(new Animated.Value(-100)).current;

return (
<>
<Animated.View
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 80,
backgroundColor: 'tomato',
transform: [
{ translateX: translation },
],
}}
/>

Alright, now all that is missing is to animate the translation value based on state changes: when headerShown becomes true, the translation should animate to 0, and when it’s false, to -100. Let’s start the animation in the useEffect hook:

import React, { useEffect, useRef, useState } from 'react';
import { Animated, ScrollView, View } from 'react-native';
export default () => {
const [headerShown, setHeaderShown] = useState(false);
const translation = useRef(new Animated.Value(-100)).current;

useEffect(() => {
Animated.timing(translation, {
toValue: headerShown ? 0 : -100,
duration: 250,
useNativeDriver: true,
}).start();
}, [headerShown]);


return (

Now the problem with this approach is once again performance-related.

In fact, with Animated, there is quite often a way to animate views without using state at all. Not using state means things could happen on the UI thread instead of the JavaScript one which is a boost for UI changes.

Let’s see how we could do this in a more “Animated way”.

Animated.event

The way we could think of this problem is the following: if we can store the vertical scroll value as an animated value, then we could interpolate this value to either -100 when we’re below 100 pixels, or 0 otherwise to show the header.

The major difference is that we’ll keep track of the scrolling value at any time, and based on this, we’ll decide whether the header should be shown.

“But talk is cheap, let’s see the code”, right? To begin with, we’ll create another animated value that will store the scrolling value:

const scrolling = useRef(new Animated.Value(0)).current;
const translation = useRef(new Animated.Value(-100)).current;

The idea now is that when we scroll, this value should be updated. To do so, there is a very handy helper called Animated.event which maps an animated value to an event value:

onScroll={Animated.event(
[{
nativeEvent: {
contentOffset: {
y: scrolling,
},
},
}]
)}

Ok so I agree, the API for this looks odd the first time, but you can see it as a deconstruction of the event object usually passed through the onScroll callback. Here we say, map the scrolling animated value to the nativeEvent.contentOffset.y event value.

So whenever this value changes, Animated will reflect this change on the animated value too. If I wanted to map scrolling to the horizontal offset, I would need to change y to x:

nativeEvent: {
contentOffset: {
x: scrolling,
},
},

Again, I think it helps to think of it as a deconstruction that we usually do in JavaScript. All it does is mapping a sub value from the event to the animated value, that’s it.

By the way, if you’re curious, under the hood Animated calls setValue on the animated value whenever the event is fired. But instead of you doing it, Animated handles it for you:

onScroll={(event) => {
// Done in an optimised way, on the UI thread
scrolling.setValue(event.nativeEvent.contentOffset.y);
}}

Anyways, one last thing missing here is the second parameter which holds the optional configuration for the event. We need to mark it as using the native driver, to get the performance boost I mentioned earlier:

onScroll={Animated.event(
[{
nativeEvent: {
contentOffset: {
y: scrolling,
},
},
}],
{ useNativeDriver: true },

)}

And also, because onScroll is using an Animated event, the ScrollView component needs to be turned into an animated one as well. So we’ll replace ScrollView with an Animated.ScrollView:

<Animated.ScrollView
...
</Animated.ScrollView>

Now since we won’t be using the state to update our header position, we can get rid of the side-effect and the state itself:

export default () => {
const scrolling = useRef(new Animated.Value(0)).current;
const translation = useRef(new Animated.Value(-100)).current;

return (

The only bit we’re left with is our vertical translation for the header. As I said, the idea is to interpolate our scrolling value so that when we’re past 100 pixels down, the header should be visible and hidden otherwise.

To do so, we can say when scrolling is between 100 and 130 pixels, the header should appear accordingly. Remember to clamp on both sides, since we want to keep it at -100 before 100 pixels, and at 0 after 130:

const translation = scrolling.interpolate({
inputRange: [100, 130],
outputRange: [-100, 0],
extrapolate: 'clamp',
})
;

See? It works! Now, I have to say there is indeed one difference with our first version which is that the scrolling is simply interpolated in this case. In the previous example, we had a clear animation playing when the header visibility was set to change.

What you should remember here is that instead of the usual callback with onScroll and a component state, we simply used animated values, with the scrolling one being mapped to the current scrolling using Animated.event.

You could also for example interpolate the scrolling to a background color, going from a light one to darker tones when scrolling. It’s all up to you!

Okay now that you know about scrolling animated events, let’s wrap this up.

Recap

If there are three things to remember today, here they are:

  • onScroll gives us information on the current scrolling position and other indicators (contentOffset, contentInset, contentSize…).
  • To change an animated value based on a scrolling event, we use Animated.event with onScroll.
  • It requires a mapping of the event with the corresponding animated values, that will be changed on the UI thread whenever the event is fired.

Alright, that’s it for now. In the next chapter, we will look at a big topic, gestures following what we’ve learned today.

New chapters will be released on a weekly-basis. Remember to follow me if you don’t want to miss any.

I often find myself reading too many articles on the internet

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store