React Native Parallax Scroll with Tabs

August 12, 2017 0 Comments

React Native Parallax Scroll with Tabs

 

 

The React Native Animated API makes it really simple to create complex, yet smooth animations. For one of my most recent projects, I had to build a parallax scrollview with tabs. The final result looks something like this:

Before your read on, make sure you thoroughly understand the react native Animated API; you will have a hard time customizing this solution to meet your needs if you do not fully understand how the animations work. There is a great rundown of the Animated API here.

The animated parts of this component do not depend on any external packages — I’ve used two packages simply to streamline the UI code needed for this demo. These packages are:

  • Native base, for the tabs
  • React native linear gradient, to improve the legibility of the header when it’s positioned in front of the image

Essentially, everything but the header is put into a scrollview. In order to keep elements ‘fixed’ (i.e. the tab bar), I use the transform property with a translateY that is set to the scrollview’s current scroll position. Since animating transform properties is supported natively, all animations run at 60fps.

The final code looks something like this:

There are several animations in play here; I’ve broken them down into 5 major pieces:

  1. Animate the image to emulate a parallax effect.
  2. Animate the size and opacity as scroll changes to achieve a smoother UI experience.
  3. Animate the position of the tabs so that they stop scrolling up after they reach the top of the page.
  4. Animate the background color of the tabs and header as the scroll changes.
  5. Animate the text color of the header and tabs as the scroll changes.

To begin animating these components, we need to listen to scroll events on the scrollview — we can do this by adding the onScroll prop to the scrollview:

<Animated.ScrollView onScroll={Animated.event([{
nativeEvent: {
contentOffset: {
y: this.nScroll
}
}
}], {useNativeDriver: true})} scrollEventThrottle={5}>

The first step should be to animate the image — after all, it serves as the ‘background’, and so its behaviour is the key to the parallax effect.

I’ve arbitrarily decided that the parallax effect should be at 35%:

The background (the image) should scroll at 35% the speed of the foreground (the tabs and their corresponding views)

Thus, the following transform should be added:

transform: [{translateY: Animated.multiply(this.nScroll, 0.65)}

Why 0.65 and not 0.35? The translateY specifies distance from the top of the container. If set the distance from the top as 65% of the current scroll, the image will move up the screen at a rate of 1–0.65=35% the rate of scroll. It may take you a while to wrap your head around this, so play around with the scroll multiplier (try changing 0.65 to 1 and see what happens).

When the user scrolls up, the image should increase in size to cover up any white space that would’ve shown otherwise. This is only applicable to iOS, since on android the user cannot scroll above the top of the scrollview, but it’s a nice touch to add.

We can achieve this effect by interpolating the scroll with the following configuration:

const imageScale = this.nScroll.interpolate({
inputRange: [-25, 0],
outputRange: [1.1, 1],
extrapolateRight: "clamp"
});

It is necessary to specify that the extrapolateRight method is “clamp”, so that when the user scrolls down, the image does not shrink.

A nice final touch would be to have the image fade out as the user scrolls down. However, it should fade out to the THEMECOLOR. To implement this, I simply wrapped the image with an Animated.View as follows:

<Animated.View style={{
transform: [{translateY: Animated.multiply(this.nScroll, 0.65)}, {scale: this.imgScale}],
backgroundColor: THEME
COLOR
}}>
<Animated.Image
source={{uri: SOMEIMGURL}}
style={{height: IMAGEHEIGHT, width: "100%", opacity: this.imgOpacity}}>
{/*gradient*/}
</Animated.Image>
</Animated.View>

Finally, we can define imgOpacity as follows:

imgOpacity = this.nScroll.interpolate({
inputRange: [0, SCROLL
HEIGHT],
outputRange: [1, 0],
});

From here, the base of the parallax effect has been achieved. However, the component is far from finished. We need the tabs to stay fixed on the top of the page so that the user can continue to navigate between tabs even after they scroll far down.

We can easily define an interpolation configuration that will allow the tabs to scroll naturally until they hit the top of the screen, at which point they will stop moving:

tabY = this.nScroll.interpolate({
inputRange: [0, SCROLLHEIGHT, SCROLLHEIGHT + 1],
outputRange: [0, 0, 1]
});

With this, we are saying that once the tabs have hit the top of the screen (when scroll=SCROLLHEIGHT=IMAGEHEIGHT-HEADERHEIGHT), the tab’s y position should begin increasing at the same rate as the scroll, so in effect, the tabs will remain stationary on the screen.

Native base exposes a renderTabBar prop which allows you to define a custom component to use in place of the default tabs. We can simply provide this prop as:

renderTabBar={(props) => <Animated.View
style={{transform: [{translateY: this.tabY}], zIndex: 1, width: "100%", backgroundColor: "white"}}

Before you begin reading, note that I have defined a constant THEMECOLOR. You may or may not choose to use this setup — perhaps you want to use different colors for different animations. It’s up to you!

You may have noticed that I am using this.nScroll to track scroll, which may have struck you as an odd choice for a name. However, it makes sense when you begin to consider animating colors. I am actually using two variables to track scroll progress: one that uses the native driver, and one that doesn’t. Why use a second variable for non-native scroll? After all, its performance is much worse than the native. However, animating colors is not yet supported by the native driver, the two variables each play a role in animated values: one for animating supported properties, such as transform, and one for unsupported properties such as color.

I ‘linked’ the two animated values togethers with this short piece of code.

this.nScroll.addListener(Animated.event([{value: this.scroll}], {useNativeDriver: false}));

You can read more about how this works on this short article I wrote.

With this ‘non-native’ scroll established, we can begin to define color animations.

Animating the actual color of the tabs is a slight challenge, since the UI code for the tabs is located in the Native Base module. Fortunately, they expose a prop called renderTab that allows you to customize how tabs are rendered:

renderTab={(name, page, active, onPress, onLayout) => (
<TouchableOpacity key={page}
onPress={() => onPress(page)}
onLayout={onLayout}
activeOpacity={0.4}>
<Animated.View
style={{
flex: 1,
height: 100,
backgroundColor: this.tabBg
}}>
<TabHeading scrollable
style
={{
backgroundColor: "transparent",
width: SCREENWIDTH / 2
}}
active={active}>
<Animated.Text style={{
fontWeight: active ? "bold" : "normal",
color: this.textColor,
fontSize: 14
}}>
{name}
</Animated.Text>
</TabHeading>
</Animated.View>
</TouchableOpacity>
)}

And we can now define this.textColor and this.tabBg :

textColor = this.scroll.interpolate({
inputRange: [0, SCROLL
HEIGHT / 5, SCROLLHEIGHT],
outputRange: [THEME
COLOR, FADEDTHEMECOLOR, "white"],
extrapolate: "clamp"
});
tabBg = this.scroll.interpolate({
inputRange: [0, SCROLLHEIGHT],
outputRange: ["white", THEME
COLOR],
extrapolate: "clamp"
});

This should be far simpler than animating tab color; after all, the UI code for the header is in our own code.

const headerColor = this.scroll.interpolate({
inputRange: [0, IMAGEHEIGHT - HEADERHEIGHT],
outputRange: ["rgba(255,255,255,0)", "rgba(255,255,255,1)"]
});

It is also possible to do this with opacities (animating opacity of a background, white View from 0 to 1), but this produces much messier UI code.

With this, we can wrap the Native base header component with an Animated.View:

<Animated.View
style={{
position: "absolute",
width: "100%",
height: HEADER_HEIGHT,
backgroundColor: headerColor,
zIndex: 1,
}}>
<Header hasTabs>
...
</Header>
</Animated.View>

The component is finished! Feel free to customize it to your own needs; I’ve published my final code here.


Tag cloud