Animations are an important part of the user experience, especially for mobile apps. Animations provide users with a clear feedback when they’re interacting with the UI elements in the app. Animations bring the app to life through the use of movement.
Whenever the app needs to perform a long operation, animations can be used to entertain the user. At the same time, it can also be used to inform the user of the operation’s status. Most importantly, animations can be used to teach the user how to interact with the app. This is through the use of meaningful transitions when hiding or showing UI elements.
In this series, we’ll be taking a look at how we can implement animations in a React Native app. In each part, you will learn the following:
- How to implement basic animations such as scale, spring, and transform.
- How to implement transition animations when users navigate from one page to another.
- How to implement gesture animations when users interact with UI elements.
Prerequisites
In order to follow this tutorial, you must have basic knowledge of React and React Native. This tutorial assumes that you have a working knowledge of the following concepts:
- State
- Refs and Props
- Component lifecycle methods
- Class-based and functional components
Aside from that, you should also be familiar with some ES6 features. Things like object destructuring and spread operator.
I also assume that your computer is set up for React Native development. If you’re using Expo, you can also follow along as this tutorial doesn’t require the use of any native modules. But I’ll let you handle the modifications required to get the app running.
What you’ll be building
Throughout the whole series, we’re going to build just a single app. We will be adding the animations mentioned above as we go along.
On the first part of the series, here’s what the final output is going to look like:
The app is going to be a Pokemon gallery app where users can view the details of a Pokemon. The app will have minimal functionality to keep the focus on animation.
Setting up the project
In order for us to get to the animation part as soon as possible, I’ve already set up a React Native project which has all the screens and components that the app requires. You can clone it using the command below:
git clone https://github.com/anchetaWern/RNRealworldAnimations.git
The repo defaults to the master
branch which contains the final output for the whole series. Switch to the starter
branch so you can follow along with this part of the series:
cd RNRealworldAnimations
git checkout starter
After switching, install all the required packages:
npm install
Next, execute the following in order to add the android
and ios
directories:
react-native upgrade
The project uses React Native Vector Icons, and it needs to be linked to the native project, so execute the following as well:
react-native link
Once it’s done installing, you should be able to run the app on your emulator or device:
react-native run-android
react-native run-ios
Scale animation
The first type of animation we’re going to implement is a scale animation and it looks like this:
What we want to do is to scale up the size of the card as the user is pressing the card. We then scale it back down once they release it. The component that we will be working on is the Card
component (src/components/Card.js
).
Animations in React Native can be implemented through the following steps:
- Import the animation modules.
- Declare an animated value.
- Specify how the animated value will change over time.
- Set the animated style and render an animated version of the component.
- Start the animation.
The first step is to import the animation modules. React Native already provides modules that allow us to add animations to our apps, go ahead and import them:
// src/components/Card.js
import {
// previously imported modules
Animated, // provides methods for animating components
Easing // for implementing easing functions
} from "react-native";
Next, declare an animated value. In a functional component like the Card
component, this can be done right before you return the component to be rendered:
const Card = ({
// props destructuring
}) => {
let scaleValue = new Animated.Value(0); // declare an animated value
In the above code, we’re initializing an animated value to zero. Most of the time, this is what you’ll want to do. This is because every component always starts out as static, they only move once the user interacts with it or it becomes visible in the foreground. The updating of this animated value is what allows us to move components as you’ll see later on. It has the same idea as the state though animated values are specifically used for animations.
The next step is to specify how the animated value will change over time. All animated values come with an interpolate
method which allows us to update its value once the animation is started. This method accepts an object containing an inputRange
and outputRange
properties. Each one has an array value which maps to one another:
const cardScale = scaleValue.interpolate({
inputRange: [0, 0.5, 1],
outputRange: [1, 1.1, 1.2]
});
When I said “maps to one another”, I mean this:
- 0 → 1
- 0.5 → 1.1
- 1 → 1.2
We’ve already initialized the animated value to 0
. This is one of the values we’ve specified in the inputRange
, and the corresponding outputRange
value for that is 1
. The outputRange
in this case refers to the scale factor of the component. 1
means it’s the same as its original size because any integer that you multiply by 1 will always be equal to itself.
Next, is 0.5
is to 1.1
. This means that when the animation gets halfway through its peak, we want to scale the component so it’s 10% bigger than its original size. And by the time the animation reaches its peak, we want it to be 20% bigger. Note that we only need to specify a sample for the input and output ranges. The animation library will automatically figure out anything in between the numbers you specified. This ensures that the animation is as smooth as possible.
The next step to implement the animation is to set the animated style and render an animated version of the component. All you have to do is to replace <View style={styles.card}>
with <Animated.View style={transformStyle}>
. So you’re essentially wrapping the entire contents of the Card
component with Animated.View
. Here’s what that looks like in code:
let transformStyle = { ...styles.card, transform: [{ scale: cardScale }] };
return (
<TouchableWithoutFeedback>
<Animated.View style={transformStyle}>
<Image source={item.pic} style={styles.thumbnail} />
<Text style={styles.name}>{item.name}</Text>
<View style={styles.icons}>
<IconButton
icon="search"
onPress={() => {
viewAction(item.name, item.full_pic);
}}
data={item}
/>
<IconButton icon="bookmark" onPress={bookmarkAction} data={item} />
<IconButton icon="share" onPress={shareAction} data={item} />
</View>
</Animated.View>
</TouchableWithoutFeedback>
);
On the first line of the code above, we’re creating a new style
object composed of the card’s default style (styles.card
) and the transform styles. If you’re familiar with CSS animations, this should make sense to you. But if not, the code above is using a CSS scale
transform declaration to scale the size of the card based on the current value of the animated value.
When you render an animated component, you should use the animated version. React Native comes with three primitive components: View
, Text
, Image
and ScrollView
. To use the animated versions of these, all you have to do is prefix them with Animated
, so View
becomes Animated.View
and so on.
The last step to implement the animation is to actually start it. In the starter code, the Card
component is using the onPress
event handler. And yes, we can actually start the animation from there:
<TouchableWithoutFeedback
onPress={() => {
scaleValue.setValue(0);
Animated.timing(scaleValue, {
toValue: 1,
duration: 250,
easing: Easing.linear,
useNativeDriver: true
}).start();
cardAction();
}}
/>
The only problem is that the code above doesn’t really take into consideration when the user is holding down the component. If you try to run it, the component will simply go back to its original size even while the user is still holding it down.
Thankfully, the TouchableWithoutFeedback
component already comes with an onPressIn
and onPressOut
event handlers. This allows us to capture when the user is holding down the button or when they already released it:
<TouchableWithoutFeedback
onPressIn={() => {
scaleValue.setValue(0);
Animated.timing(scaleValue, {
toValue: 1,
duration: 250,
easing: Easing.linear,
useNativeDriver: true
}).start();
cardAction();
}}
onPressOut={() => {
Animated.timing(scaleValue, {
toValue: 0,
duration: 100,
easing: Easing.linear,
useNativeDriver: true
}).start();
}}
>
Breaking down the code above, first, we go through the body of the onPressIn
method. First, we set the animated value to 0
. This effectively resets the animation everytime the user initiates it:
scaleValue.setValue(0);
Next, we start the animation by using the timing
animation. This allows us to update the animated value over a specific period of time. This method accepts two arguments: an animated value, and an object containing the settings to use for the animation.
In the code below, we’re telling it to update the animated value to 1
over the course of 250
milliseconds and it will use the linear
easing function:
Animated.timing(scaleValue, {
toValue: 1, // update the animated value to
duration: 250, // how long the animation will take in milliseconds
easing: Easing.linear, // easing function to use (https://facebook.github.io/react-native/docs/easing.html)
useNativeDriver: true // delegate all the animation related work to the native layer
}).start(); // start the animation
When the user releases the component, all we have to do is bring back the animated value to its initial value over a specific period of time. This effectively reverses the animation by scaling back the size of the component:
onPressOut={() => {
Animated.timing(scaleValue, {
toValue: 0, // reset the animated value to 0
duration: 100, // animate over 100ms
easing: Easing.linear,
useNativeDriver: true
}).start();
}}
Note that when you’re working with animations that are triggered by user controls, you want to use React Native components that don’t already have built-in animation behavior. In the case of a button, React Native also has Button
, TouchableOpacity
and TouchableNativeFeedback
components. These are all pretty much the same, but they all have built-in animation behavior when a user interacts with them. That’s why we used TouchableWithoutFeedback
to have full control over the animation without the need to override built-in behavior.
Rotate animation
The next type of animation is a rotate animation and it looks like this:
Here we want to rotate or spin the buttons whenever the user presses on it. It has the same mechanics as the scale animation of the Card
component earlier, the only difference is that we’re rotating the component instead of scaling it.
This time, we’ll be working with the IconButton
component (src/components/IconButton.js
). IconButton
is a class-based component, and the best place to declare an animated value is inside the constructor
:
constructor(props) {
super(props);
this.rotateValue = new Animated.Value(0); // declare animated value
}
Inside the render
method, we specify how the animated value will change. Since we’re doing a rotate animation, the outputRange
is different. This time, we’re using a string value which specifies the degrees the animated value will rotate:
render() {
const { icon, onPress, data } = this.props;
let rotation = this.rotateValue.interpolate({
inputRange: [0, 1],
outputRange: ["0deg", "360deg"] // degree of rotation
});
// next: add transformStyle
}
Next, declare the styles for rotating the component:
let transformStyle = { transform: [{ rotate: rotation }] };
Render the component:
<TouchableWithoutFeedback
onPressIn={() => {
Animated.timing(this.rotateValue, {
toValue: 1,
duration: 700,
easing: Easing.linear
}).start();
onPress(data);
}}
onPressOut={() => {
Animated.timing(this.rotateValue, {
toValue: 0,
duration: 350,
easing: Easing.linear
}).start();
}}
>
<Animated.View style={transformStyle}>
<Icon name={icon} style={styles.icon} size={icon_size} color={icon_color} />
</Animated.View>
</TouchableWithoutFeedback>
As you can see from the code above, the code for starting and stopping the animation is pretty much the same as the scaling animation. We animate towards the desired value when the user interacts on it, then we reverse it when the user releases.
Another thing to notice is that we’re not animating the component itself, but its child (Animated.View
). Note that the component that’s being animated doesn’t always need to be a child of the component being interacted upon (TouchableWithoutFeedback
). It can be anywhere else in the render tree. As long as the component you want to animate is currently being rendered (it doesn’t have to be visible), you can animate it.
Spring animation
The next type of animation is a spring animation and it looks like this:
This time, we’ll be working with the AnimatedModal
component (src/components/AnimatedModal.js
). Currently, it’s not “Animated” yet so let’s go ahead and do that.
Unlike the two previous components we’ve worked with so far, this one relies on the state. If you open the App.js
file, the modal is opened when isModalVisible
is set to true
:
viewAction = (pokemon, image) => {
// ...
this.setState({
// ...
isModalVisible: true
});
};
Then inside the render
method of AnimatedModal
, by default, we set its bottom
value to be equal to the negative value of the screen’s height. This effectively hides the component from view. When isModalVisible
is set to true
the bottom
value is updated to 0
. This returns the component back to its original position:
import {
/* previously imported modules here */
Animated, // add this
Easing // add this
} from "react-native";
const { height, width } = Dimensions.get("window");
type Props = {};
export default class AnimatedModal extends Component<Props> {
render() {
const { title, image, children, onClose } = this.props;
let bottomStyle = this.props.visible ? { bottom: 0 } : { bottom: -height }; // show or hide the component from view
}
}
This works, but there’s really no animation taking place when the component is hidden or shown from view. Let’s go ahead and fix this:
constructor(props) {
super(props);
this.yTranslate = new Animated.Value(0); // declare animated value for controlling the vertical position of the modal
}
Inside the render
method, specify how the bottom
value will change once the animation is started. In this case, we want the animated value (yTranslate
) to be 0
at the beginning of the animation. And it will become a specific height once it finishes:
render() {
const { title, image, children, onClose } = this.props;
let negativeHeight = -height + 20;
let modalMoveY = this.yTranslate.interpolate({
inputRange: [0, 1],
outputRange: [0, negativeHeight]
});
let translateStyle = { transform: [{ translateY: modalMoveY }] }; // translateY is the transform for moving objects vertically
// next: render the component
}
Breaking down the code above, first, we need to determine the final bottom
value once the animation finishes. Here, we’re getting the negative equivalent of the screen’s height and adding 20
:
let negativeHeight = -height + 20;
But why? If you scroll down to the styles declaration of the component, you’ll find that the bottom
value is set to the negative equivalent of the screen’s height:
const styles = {
container: {
position: "absolute",
height: height,
width: width,
bottom: -height, // look here
backgroundColor: "#fff"
}
// ... other styles
};
So translating the Y
position (vertical position) of the component to -height
means that it will cancel out the bottom
value declared in the styles. Remember that when two negative values are subtracted, they cancel each other out because the subtrahend (number on the rightmost portion of the equation) is converted to a positive number and the operation becomes addition:
translateY = -1 - -1;
translateY = -1 + 1;
translateY = 0;
This effectively brings the component back to its original position. The 20
that we’re adding is the height of the uppermost portion of the screen (where the battery, time, and signal is indicated). We don’t really want the modal to replace those.
Next, apply the translateStyle
to the component:
return (
<Animated.View style={[styles.container, translateStyle]}>
<Header title={title}>
<TouchableOpacity onPress={onClose}>
<Text style={styles.closeText}>Close</Text>
</TouchableOpacity>
</Header>
<View style={styles.modalContent}>{children}</View>
</Animated.View>
);
On the modal’s header, we have a TouchableOpacity
which allows us to close the modal. This executes the onClose
method passed as a prop from the App.js
file:
<AnimatedModal
title={"View Pokemon"}
visible={this.state.isModalVisible}
onClose={() => {
this.setState({
isModalVisible: false
});
}}
>
...
</AnimatedModal>
Having done all the necessary setup, how do we actually start the animation? We know that the animation should be started when the value of isModalVisible
is updated. But how do we know when the state is actually updated? Well, we can use the componentDidUpdate
lifecycle method to listen for when the component is updated. This function is called every time the state is updated because we’re using a prop which relies on the state’s value (visible
).
Here’s the code:
componentDidUpdate(prevProps, prevState) {
if (this.props.visible) {
// animate the showing of the modal
this.yTranslate.setValue(0); // reset the animated value
Animated.spring(this.yTranslate, {
toValue: 1,
friction: 6
}).start();
} else {
// animate the hiding of the modal
Animated.timing(this.yTranslate, {
toValue: 0,
duration: 200,
easing: Easing.linear
}).start();
}
}
There’s nothing really new with the code above, aside from the fact that we’re starting the animation from a lifecycle method instead of from an event handler. Another is that we’re using a spring animation. This is similar to the timing
animation, though it differs in the options that you pass to it.
The spring animation only requires you to pass toValue
for the options, but here we’re passing the friction
as well. This allows us to specify how much friction we apply in the spring. The higher friction
means less spring or bounciness. The value we specified is close to the default value which is 7
. This adds just a little bit of spring to the upward motion as the modal becomes visible:
Animated.spring(this.yTranslate, {
toValue: 1,
friction: 6 // how much friction to apply to the spring
}).start();
You might be wondering why we created our own modal component instead of React Native’s Modal component. That’s because it already comes with animation capabilities. But the downside is that it’s hard to customize the animation because the default behavior gets in the way.
Width animation
The next type of animation is width animation and it looks like this:
In the above demo, we’re animating the width of the bar which represents the individual stats of the Pokemon.
This time, open the src/components/AnimatedBar.js
file. If you run the app right now, the bars should just be static. What we’ll do is animate it from a width of 0
to its proper width.
Start by declaring the animated value:
constructor(props) {
super(props);
this.width = new Animated.Value(0);
}
Then in the render
method, we set the component’s width
to be equal to the animated value:
render() {
let barWidth = {
width: this.width
};
return <Animated.View style={[styles.bar, barWidth]} />;
}
Yeah, that’s right. In the code above, we don’t actually need to specify how the animated value will change over time. Using the interpolate
method is optional if all we need to animate is the component’s width
, height
, margin
, or padding
. You can still use it if you want to have fine control over how the animated value will change. But since the width
is a very linear property, we don’t really need to do that. Plus the animation looks better if the width changes in uniform fashion over time.
Next, create a function which will start the animation. So that the width for each bar doesn’t get animated at the same time, we’re adding a delay
that is based on the current index. The first stat (HP) will have an index
of 0
so there’s no delay. The second one will have a 150-millisecond delay, the third one will be twice as that and so on. This allows us to achieve the cascading effect that you saw on the demo earlier:
animateBar = () => {
const { value, index } = this.props;
this.width.setValue(0); // initialize the animated value
Animated.timing(this.width, {
toValue: value,
delay: index * 150 // how long to wait before actually starting the animation
}).start();
};
We start the animation when the component is mounted and when its props are updated:
componentDidMount() {
this.animateBar();
}
componentDidUpdate() {
this.animateBar();
}
You might be asking why we need to start the animation on both componentDidMount
and componentDidUpdate
. Well, the answer is that the component is only mounted once when the app is started. This is because its parent (AnimatedModal
) is always rendered. It’s just hidden from view because of the negative bottom
value. On the first time the AnimatedModal
is opened, the componentDidMount
function in the AnimatedBar
is executed. But for the succeeding views, only the componentDidUpdate
function is executed.
Also, note that we’re updating the value of the stats every time the modal is opened. This allows us to still animate the width
even if the user viewed a specific Pokemon twice in a row:
// pre-written code on the App.js file
viewAction = (pokemon, image) => {
this.pokemon_stats = [];
pokemon_stats.forEach(item => {
this.pokemon_stats.push({
label: item,
value: getRandomInt(25, 150)
});
});
};
Next, right below the mainContainer
on the BigCard
component, make sure that the renderDataRows
function is being called. This function renders the DataRow
component which in turn renders the AnimatedBar
:
// src/components/BigCard.js
render() {
/* previously written animation code here */
return (
<View style={styles.container}>
<View style={styles.mainContainer}>
...current contents here
</View>
</View>
{data && (
<View style={styles.dataContainer}>{this.renderDataRows(data)}</View>
)}
}
The last step is to make sure the AnimatedBar
is rendered inside the DataRow
component. The code for that is already pre-written, so all you have to do is make sure <AnimatedBar>
is rendered somewhere inside the src/components/DataRow.js
file.
Sequence animation
The last type of animation that we’re going to look at is the sequence animation and it looks like this:
There’s a lot happening in the demo above, so let’s break it down. A sequence animation is basically a collection of different animations that are executed one after the other. In the demo above, we’re performing the following animations in sequence:
- Opacity animation - changes the Pokemon’s big image from an opacity of zero to one.
- Translate animation - changes the position of the title. Starting from the top up to its proper position below the big Pokemon image.
- Scale animation - scales the size of the Pokemon’s name.
You already know how to implement the last two animations so I’m not going to explain the code for implementing those in detail. Also, note that this last animation is a bit over the top and you don’t really want to be doing this in a real-world app.
With that out of the way, let’s begin. Open the src/components/BigCard.js
file and declare the three animated values that we will be working with:
import {
/* previously imported modules */ Animated,
Easing
} from "react-native";
export default class BigCard extends Component<Props> {
// add the code below
constructor(props) {
super(props);
this.imageOpacityValue = new Animated.Value(0);
this.titleTranslateYValue = new Animated.Value(0);
this.titleScaleValue = new Animated.Value(0);
}
}
Inside the render
method, we specify how those three values will change. Interpolating the opacity is pretty much the same as how you would interpolate the scale or the vertical position of a component. The only difference is the style. In the code below, we’re setting the opacity
to that interpolated value. This is very similar to how we animated the width
for the Pokemon status bars earlier:
render() {
const { image, title, data } = this.props;
// interpolate the images opacity
const imageOpacity = this.imageOpacityValue.interpolate({
inputRange: [0, 0.25, 0.5, 0.75, 1],
outputRange: [0, 0.25, 0.5, 0.75, 1]
});
// construct the image style
const imageOpacityStyle = {
opacity: imageOpacity
};
// interpolate the vertical position of the title
const titleMoveY = this.titleTranslateYValue.interpolate({
inputRange: [0, 1],
outputRange: [0, 280]
});
// interpolate the scale of the title
const titleScale = this.titleScaleValue.interpolate({
inputRange: [0, 0.5, 1],
outputRange: [0.25, 0.5, 1]
});
// construct the styles for the title
const titleTransformStyle = {
transform: [{ translateY: titleMoveY }, { scale: titleScale }]
};
// next: render the component
}
Next, we render the component and assign the interpolated styles:
return (
<View style={styles.container}>
<View style={styles.mainContainer}>
<Animated.Image
source={image}
style={[styles.image, imageOpacityStyle]}
resizeMode={"contain"}
/>
<Animated.View style={[styles.titleContainer, titleTransformStyle]}>
<Text style={styles.title}>{title}</Text>
</Animated.View>
</View>
...previous code here
</View>
);
Note that the titleContainer
has a top
value of -100
. This is the starting position of the title. If you have noticed in the demo earlier, this results in the title being directly below the current local time:
const styles = {
// .. other styles
titleContainer: {
position: "absolute",
top: -100
}
// .. other styles
};
We start the animation when the component is updated. If you open the App.js
file, you’ll see that the BigCard
component relies on the current value of the state for three of its props. This allows us to use componentDidUpdate
to listen for changes in the state:
<BigCard
title={this.state.pokemon}
image={this.state.image}
data={this.state.stats}
/>
Going back to the BigCard
component, here’s the code for componentDidUpdate
:
componentDidUpdate() {
// reset the animated values
this.imageOpacityValue.setValue(0);
this.titleTranslateYValue.setValue(0);
this.titleScaleValue.setValue(0);
// start the sequence
Animated.sequence([
Animated.timing(this.imageOpacityValue, {
toValue: 1,
duration: 1000,
easing: Easing.linear
}),
Animated.timing(this.titleTranslateYValue, {
toValue: 1,
duration: 300,
easing: Easing.linear
}),
Animated.timing(this.titleScaleValue, {
toValue: 1,
duration: 300,
easing: Easing.linear
})
]).start();
}
Breaking down the code above, first, we reset the individual animated values, then we start the sequence animation. The sequence
method accepts an array of animations. In this case, we’re only using timing animations, but you can actually mix in spring and decay. Another difference is that we only start the sequence animation itself, not the individual animations inside it.
Of course, you can use the same method that we did earlier with the width animations wherein we added a delay
setting. The sequence
method doesn’t really allow you to specify the delay between each animation. If you want that, then there’s the stagger method.
Conclusion
That’s it! In the first part of this series, you’ve learned how to implement basic animations using React Native’s Animated
API. As you have seen, implementing animations in React Native is very code-heavy. This is because it doesn’t come with components that allow you to easily implement even the most basic types of animations. But that’s the price we have to pay for having finer control over how the animations will proceed.
We haven’t actually covered all the animation functions that the Animated library provides. Specifically, we haven’t covered:
- Animated.decay
- Animated.parallel
- Animated.stagger
Be sure to check out the docs if you want to learn about those.
You can check the final source code for this part of the series on its own branch (part1
).
Stay tuned for the next part where we will look at how to implement page transition animations.
Originally published on the Pusher blog
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.