I’ve covered React Native’s Animated
API quite extensively in previous posts by exploring interpolations, gestures, transforms, and others. Used correctly, most animations can achieve native speed (60fps) with the Animated
API.
However there are certain cases where the API is inflexible and/or performs poorly. One of these cases is animating multiple views at once. In my experience, once you get above 4–5 views performance starts drastically dipping and the code gets more confusing. For instance, take the Facebook ‘chat head’ concept:
To implement this with Animated
we have to keep track of an array of Animated.Value
’s. This gets complicated quick.
Then we have to implement a staggering effect. Having to loop through your Animated.Value
array, setting a timeout, and then calling Animated.event
on each. If we want a realistic effects we have to animate each value accelerating from rest, not simply starting to move after a certain timeout. Quite messy.
In this post, I’ll walk through how to make a Facebook chat head concept in React Native without the use of the Animated
API and with the help of a different framework. This is the end product:
Introducing react-motion
For our example we will be using a library called react-motion
that is used in the React web community for complex animations. It provides a lot of powerful pre-built components that allow you to create beautiful animations with very minimal amounts of code. In fact, this example was written in only 53 lines:
In it’s vanilla state, react-motion
isn’t using any fancy performance hacks and most definitely isn’t optimized for mobile. This library was originally developed for web. All it’s doing is changing component state, resulting in a re-render, and then doing this over and over again.
Why not use this for simpler animations? The Animated
API offers better performance for most cases. In the domain of more complex animations, it’s probably better to look for other solutions.
Getting Started with React Motion
React Motion works through a collection of components that describe the type of animation you want:
<Motion/>
— The most basic component<StaggeredMotion/>
—<Motion/>
, for multiple elements with a staggered effect. This is what we’re using in our example.<TransitionMotion/>
—For entry/exit animations.
Another key to understanding react-motion
is the spring
function.
The documentation describes it as:
it specifies the how to animate to the destination value, e.g.
spring(10, {stiffness: 120, damping: 17})
means "animate to value 10, with a spring of stiffness 120 and damping 17".
So we provide the return value of spring
to one of the three components described above. This will tell react-motion
how you want your animation to work.
For example:
<Motion defaultStyle={{x: 0}} style={{x: spring(100, {stiffness: 120, damping: 17})}}>
This tells react-motion
two things:
- We have some value that starts off at 0 (indicated by
defaultStyle
). This could be a position attribute (top
,left
,marginLeft
, etc.), opacity, anything you want. - We want this value to spring to 100 with a stiffness of 120 and damping of 17.
We’vedescribed how we want the animation to work but we haven’t said what we want to animate. To accomplish this, we need to create a child component to the Motion
that takes the style
object and maps it to a presentational component. An example might be:
{(style) =>
<View style={{width: 100, height: 100, opacity: style.x}}/>
}
We now put them together. Keep this structure in mind as you read more complex react-motion
code.
<Motion defaultStyle={{x: 0}} style={{x: spring(100, {stiffness: 120, damping: 17})}}>
{(style) =>
<View style={{width: 100, height: 100, opacity: style.x}}/>
}
</Motion>
This tells react-motion
:
I want to animate the opacity of a View from 0 to 100 with the following easing curve: a spring with a stiffness of 120 and damping 17.
Quite a lot with just a few lines of code! This might show you the power of React Motion.
Note: The key name x
in the style prop is an arbitrary choice. I could’ve written:
<Motion defaultStyle={{giraffe: 0}} style={{giraffe: spring(100, {stiffness: 120, damping: 17})}}>
{(style) =>
<View style={{width: 100, height: 100, opacity: style.giraffe}}/>
}
</Motion>
Use the key name that indicates most clearly what you’re animating.
StaggeredMotion
<StaggeredMotion/>
works slightly differently than <Motion/>
in that everything is now multiple instead of singular. Instead of providing a defaultStyle we provide defaultStyles and instead of providing style we provide styles.
Considering we want to animate multiple things this makes sense. One other caveat: the styles
prop is now a function of previous styles. Sounds confusing, but once we apply it, it all makes sense.
Putting It All Together
Since we are responding to user touches here we must bring in our old friend the PanResponder
:
panResponder = PanResponder.create({
onMoveShouldSetPanResponderCapture: () => true,
onPanResponderMove: (event) => {
this.setState({x: event.nativeEvent.pageX, y: event.nativeEvent.pageY})
},
})
This says that once the user moves their finger we want to update the component state with the new coordinates of their finger.
Now, we are faced with two questions: How should the animation work?, and what should it be animating?.
How?
We answer this with just six lines:
styles={(prevStyles) => prevStyles.map((a, i) => {
return i === 0 ? this.state : {
x: spring(prevStyles[i - 1].x, presets.gentle),
y: spring(prevStyles[i - 1].y, presets.gentle),
}
})}
I mentioned previously that our styles prop will be a function of previous styles. This is what I meant. So we have an array of values that describes the ‘animation state’ of each chat bubble. This styles
prop gives us the chance to customize their behavior. To make sense of all this we observe there are two rules to this animation:
- The first bubble (or the bubble on top) directly follows the user’s finger. Thus its position should be where the user’s finger is. Recall that with the
PanResponder
we created before our component’s state is now keeping track of exactly where the user’s finger is, so we check this. Ifi === 0
, we are describing the style for the first bubble — so we simply return the current location of the user’s finger. - Bubble
i
is playing ‘catch-up’ with bubblei-1
. The second bubble wants to be where the first bubble just was, the third bubble wants to be where the second just was, and so on. This is why we have prevStyles!prevStyles
tells us where each bubble just was! So now all we have to do is tell each bubble to spring to where the bubble above it was. Thus, we have:{ x: spring(prevStyles[i — 1].x, presets.gentle), y: spring(prevStyles[i — 1].y, presets.gentle) }
. Thepresets.gentle
is merely a prepackaged configuration object (behind the scenes,presets.gentle={stiffness: 120, damping: 14}
).
What?
So now, we have described how the animation should work, along with the ‘staggering’ effect. But we haven’t actually put the animated styles to use yet. We do this with the below:
{styles =>
<View>
{styles.slice().reverse().map(({x, y}, i) => {
const index = styles.length - i - 1
return <View key={index}
style={{
width: 70,
borderRadius: 35,
height: 70,
position: 'absolute',
left: x + 3 * index,
top: y + 3 * index,
backgroundColor: colors[index],
}}/>
}
)}
</View>
}
Most of this code is self-explanatory but there are a few oddities you may be confused by.
Why are we calling .reverse()
? This has to do with ReactNative’s problems dealing with zIndex
. It seems that with an absolutely positioned View
, ReactNative completely ignores zIndex
. However the last child is always rendered first. For example:
<Parent>
<A style={{position:"absolute"}}>
<B style={{position:"absolute"}}>
<C style={{position:"absolute"}}>
</Parent>
You will see C
on top, then B
, then A
. If you want A
to be on top, you will have to reverse the order. So we simply reverse the order of the styles to get our first element to render last, putting it on top.
If this sounds counterintuitive, try it out for yourself! However, we still want to know the ‘actual’ index of each style object, thus we find it by taking styles.length — i — 1
. I hope this clears up the somewhat odd code structure. In an ideal world, this is what the code would actually look like:
{styles.map(({x, y}, i) => {
return <View key={i}
style={{
width: 70,
borderRadius: 35,
height: 70,
position: 'absolute',
left: x + 3 * i,
top: y + 3 * i,
backgroundColor: colors[i],
zIndex: styles.length - i
}}/>
}
)}
Unfortunately, React Native seems to have an unavoidable bug with zIndex that makes this impossible.
The second question: why are is left
not simply equal to x
and top
simply equal to y
? Well, if we left it like this, the bubbles would be stacked directly on top of each other, giving no indication that there are actually multiple bubbles. To offset this, we add an offset to every bubble. Each bubble is 3 pixels to the right and 3 pixels down from the bubble preceding it. This creates a more natural stacking effect.