How I created Burst’s mind bending animations with React Native
OK maybe not mind bending, but I’ll show you how I made something cool.
--
Animation in React Native is a double edged sword. Out of the box, LayoutAnimation is like a magic shortcut that brings your UI to life. However, once you want to dig deeper, the Animated API can get complicated fast. Especially when you want to bring interactive animations into the mix.
In this guide, I’ll walk you through a cut-down version of the animations I created for Burst to give you a real-world look at the Animated API in action.
Note that because overflow:visible
doesn’t work with React Native on Android, the end result only really works for iOS. I’ll update this guide when that’s resolved.
Let’s break it down
The final animations in Burst are a bit too much to cover in one article, so for simplicity’s sake we’ll be going through a cut-down version. So what’s the difference? Here’s a handy visual to compare the two:
So what are we making?
There are three things we’ll be animating:
- The overlay opacity to isolate each post when swiped
- The zIndex of the active post, so that it raises up above the others
- The y-position of the active post’s comment to move it to the top of the screen
What are we not making?
This is the stuff left out to be your homework or maybe a part 2 of this guide:
- Adjusting for scrolling the main content area
- Elements animated by multiple posts, like the header
- Dynamic post & comment heights
Let’s get stuck in!
Download the starter project from github if you want to follow along. I’ve skipped ahead and set us up with the basic layout and styling. If you want to really skip ahead, I also have the finished version on GitHub.
I’ve also set up some fake posts to make things a bit more realistic. Thanks to Lorem Picsum for the images! I’ve set them up to change every time you refresh, just to be super crazy 🙃
Fire up the project and you’ll see we have a list of posts. Each post scrolls horizontally to reveal a single comment.
In the code, there are only two files we need to look at — App.js, and Post.js.
App.js
App.js holds our post data and sets up some key variables for each Post component. Things to note here are that we’re passing a fixed height to each post, along with its index. We’ll use those later to calculate the final position of the comment once it’s swiped over.
Post title cards can be any height in the production version of Burst, so there’s normally an extra step where each post is measured it’s animated.
Post.js
Post.js has a few things going on:
- The container View sets the height, and will be used to hold an extra element we’ll add in a minute.
- The ScrollView holds both the post title and comment. Since the transition we’re building is based on the user swiping, we’ll use the value of this component to drive our animations.
- Our two child views are the post title card and comment card, respectively. I’ve set the size of the image to take up most of the title card, just leaving some room for the text.
Setting the stage
Alright, first up we need to start getting values from each Post’s ScrollView. To do this, we’ll use the Animated API.
We’ll start by changing our ScrollView
component into an Animated.Scrollview
and give it some extra properties. It should look like this:
<Animated.ScrollView
horizontal
style={styles.scrollContainer}
contentContainerStyle={styles.contentContainer}
showsHorizontalScrollIndicator={false}
snapToInterval={SCROLL_INTERVAL}
decelerationRate="fast"
scrollEventThrottle={1}
onScroll={this.onScroll}> ...</Animated.ScrollView>
We’ve added two new properties here:
scrollEventThrottle={1}
— This controls how often we’ll get updates to the scroll value. It’s a time interval in milliseconds, so lower numbers mean more updates. We’ll use1
because we want it to be as responsive as possible.onScroll={this.onScroll}
— We need to hook up our scroll events to something, and this is where we do it.this.onScroll
is a function we’ll set up next.
Now let’s create that onScroll function in Post.js. Add this to the top of the component:
scrollValue = new Animated.Value(0)
onScroll = Animated.event(
[{ nativeEvent: { contentOffset: { x: this.scrollValue } } }],
{ useNativeDriver: true }
)
We’re doing two things here. First up we create a new Animated.Value
that we’ll use to hold our ScrollView’s x-position. We’ll use it later to turn the scroll value into positions for each of our animated elements.
Then we create the onScroll
function referenced earlier. There’s a crazy chain of stuff going on in there, but all you need to know is that it passes the x-position of our ScrollView to our new scrollValue
variable.
Now can we animate something?
We can! So let’s go.
The first thing we’ll animate will be the overlay that sits under each Post and fades into view when you swipe over. This’ll make things a bit clearer for when we start animating the comments.
<Animated.View style={styles.overlay} pointerEvents="none" />
This is our overlay component. Add it to Post.js just before the ScrollView. I’ve already included its styling in the starter project, but for reference this is what it looks like:
overlay: {
opacity: 0,
backgroundColor: '#fff',
position: 'absolute',
width: DEVICE_WIDTH,
height: 99999,
top: -99999/2,
left: 0,
},
To avoid having to worry about its position on screen, we’re giving it a huge height and positioning it absolutely at about halfway. It starts completely transparent, but we’ll override that with an animation function:
fadeIn = () => {
const FULL_OPACITY = 1;
return {
opacity: this.scrollValue.interpolate({
inputRange: [
0,
SCROLL_INTERVAL,
],
outputRange: [0, FULL_OPACITY],
extrapolate: 'clamp'
})
};
}
This function will return a new opacity for our overlay. For the opacity’s value, we’re using this.scrollView.interpolate
. This is a function that lets us take the input from our ScrollView’s x-position and turn it into any value we want. The inputRange
and outputRange
are where the magic happens, and they work just like you’d imagine. Our Post starts with a scroll value of 0, and finishes at SCROLL_INTERVAL
. In this case, that’s mapped to values between 0 and FULL_OPACITY
. This is a really powerful way to take the scroll position of one component, and apply it to something else with entirely different effects.
Have a play around with SCROLL_INTERVAL
and FULL_OPACITY
to get a better feel for how it fits together.
extrapolate: ‘clamp'
is telling our function not to worry about any values outside of our input range. We don’t want or need our opacity to change outside of our output range, so this will stop that from happening. If you were animating an endlessly moving element, you might want to remove this so that you can define a few small values and let React Native fill in the blanks for you.
The last thing to do is to hook up this function with our overlay component:
<Animated.View style={[styles.overlay, this.fadeIn()]} pointerEvents="none" />
All we need to do is add the fadeIn
function to the overlay component. Because we now have multiple styling objects, remember to change the style property to use an array.
Stacking it
Run the project and flick through the posts. The overlay animates! Kind of… The overlay only seems to be covering some of the Posts.
That’s because of React Native’s stacking order. Basically, the order in which things are rendered determines how things are stacked on top of each other. Because Post #1 is rendered before Post #2, its overlay is rendered underneath. But not to fear, we can fix it.
Animating the stacking order
To dynamically change the stacking order, we’ll animate each Post’s zIndex
in much the same way we did for the overlay’s opacity.
elevate = () => {
return {
zIndex: this.scrollValue.interpolate({
inputRange: [0, 1],
outputRange: [1, 2],
extrapolate: 'clamp'
})
};
}
Here, we’ve created a new styling function that returns a new zIndex
. Just like with our overlay opacity, we’re using the interpolate function to tweak the scrollValue to suit our needs. Our input range of [0, 1]
means that our new zIndex will kick in as soon as the Post starts to scroll. The output range of [1, 2]
gives every Post a zIndex
of 1 while it’s inactive, and 2 as soon as it’s scrolled. That should do the trick!
Run the project again and we’ll see each post isolated as it scrolls. Lookin’ good!
Let’s talk performance
This is a pretty simple animation so far, but when things get more complex you’ll want to think about optimising for super smooth animations.
By default, React Native runs animations through javascript. That means they won’t be as performant as native animations. The good news is that you can tell React to use the native driver, which is basically like flipping a switch from janky to super smooth animations.
If it’s that simple why wouldn’t you always want to turn it on? Well, there’s a downside. Using the native driver limits the properties we can animate. With the native driver, we’re only able to animate non-layout properties like transform and opacity. It’s a bit of a tradeoff of performance vs functionality, but I think it’s worth making an effort to get it working if you can. You might not notice the difference if you have a shiny new phone, but the difference is night and day for older devices.
Enough talk, let’s flip the switch! All we need to do is add one line to our onScroll
function:
onScroll = Animated.event(
[{ nativeEvent: { contentOffset: { x: this.scrollValue } } }],
{ useNativeDriver: true }
)
Like magic! Now if you run the project you should notice that things are a bit smoother.
Oh no.
🚨 Filthy hack alert 🚨
This is where we get a little sneaky. React Native has a whitelist of accepted properties that can be animated with the native driver, and unfortunately zIndex isn’t one of them. Thankfully zIndex does actually work with the native driver, so we can just add it to the whitelist.
Inside /node_modules/react-native/Libraries/Animated/src/
you’ll find NativeAnimatedHelper.js
. Find the STYLES_WHITELIST
constant and update it to include zIndex:
const STYLES_WHITELIST = {
zIndex: true,
opacity: true,
transform: true,
/* ios styles */
shadowOpacity: true,
shadowRadius: true,
/* legacy android transform properties */
scaleX: true,
scaleY: true,
translateX: true,
translateY: true,
};
Alright, you can wipe off your brow and close that file. Now if you re-run the project everything should be fine and dandy. Now we’ve got smooth, native animations up and running!
The final piece
This is where we’ll transition each comment to the top of the screen when they’re swiped over. This is a little different from the others because each Post will need to have a slightly different destination point. We’ll use each Post’s index and height values to calculate where it needs to be.
Add a new styling function that looks like this:
verticallyAlignComment = () => {
const ACTIVE_POSITION = -this.props.index * (this.props.height + CARD_GUTTER_VERTICAL)return {
transform: [{
translateY: this.scrollValue.interpolate({
inputRange: [
0,
SCROLL_INTERVAL,
],
outputRange: [0, ACTIVE_POSITION],
extrapolate: 'clamp'
}),
}],
};
}
This is similar to our previous two, except for a few key variables. We’re using the static post height multiplied by its index to figure out its y-position. That alone would give us the comment’s current position, so we’re actually making the index negative so that the final value is also negative.
This gets tricker with dynamic Post heights, since you’d need to measure each one ahead of time. We’re cheating a little here for simplicity. This is actually how things worked in an early version of Burst, but it didn’t take long for me to realise that it kind of sucked having all the posts the same size.
The final result
Looking pretty good if I do say so myself. If you got stuck at any point, remember that I have the finished version on Github.
If you want to try the real thing, you can check out Burst here.
Good luck!