Adding animations to your React Native app - Part 2: Transition animations

wernancheta

Wern Ancheta

Posted on July 13, 2019

Adding animations to your React Native app - Part 2: Transition animations

Welcome to part two of a three-part series on adding animations to your React Native app. In this part, you’re going to learn how to add transition animations. Specifically, we’re going to:

  • Animate the header when the user scrolls on a list.
  • Customize the page transition animation that’s set by React Navigation.
  • Use LayoutAnimation to animate the components that are affected by a state change.

Prerequisites

To follow this tutorial, you need to know the basics of React and React Native.

Going through part one of this series is helpful but not required. Though this tutorial assumes that you know how to implement basic animations on React Native. You should already know how to implement scale, rotation, and sequence animations. We will be applying those same concepts when implementing transition animations.

We’ll also be using version two of React Navigation. Knowledge of the Stack Navigator is helpful but not required.

What you’ll be building

Here’s what you’ll be building:

React Native Animations Part 2 Final Output (image credit: pokeapi.co, pokemondb.net, and pixabay.com)

You can find the full source code for this tutorial on its GitHub repo. We will be making changes to the final source code from the first part. If at any time you feel confused as to what specific changes you need to make, be sure to check the commit history of the branch containing the final source code for this part of the series (part2).

Setting up the project

To follow along, you first need to clone the repo:

git clone https://github.com/anchetaWern/RNRealworldAnimations.git
Enter fullscreen mode Exit fullscreen mode

After that, switch to the part1 branch and install the dependencies:

cd RNRealworldAnimations
git checkout part1
npm install
Enter fullscreen mode Exit fullscreen mode

Next, setup the android and ios folders:

react-native upgrade
Enter fullscreen mode Exit fullscreen mode

Link the native dependencies. In this case, it’s only the React Native Vector Icons:

react-native link
Enter fullscreen mode Exit fullscreen mode

Once that’s done, you should be able to run the app on your device or emulator:

react-native run-android
react-native run-ios
Enter fullscreen mode Exit fullscreen mode

The part1 branch contains the final output of the first part of this series. We will add new animations on top of the ones we’ve already implemented on the first part. The final source code for this part is available at the part2 branch.

Header scroll animation

First, let’s look at how we can implement header animations while the user is scrolling through a list:

Header animation on scroll

To implement the animation, we need to make the following changes:

  1. Update the App.js file to add an animated value that will be interpolated to animate the header. This animated value will correlate directly to the current scroll position. Thus allowing us to perform different kinds of animations depending on the current scroll position.
  2. Create an AnimatedHeader component which implements the animations.

Refactor the code

Before we proceed, let’s first refactor the code. In the App.js file, move the getRandomInt function to a separate file (src/lib/random.js):

// src/lib/random.js
const getRandomInt = (max, min) => {
  return Math.floor(Math.random() * (max - min) + min);
};

export { getRandomInt };
Enter fullscreen mode Exit fullscreen mode

Then import it into the App.js file:

import { getRandomInt } from "./src/lib/random";
Enter fullscreen mode Exit fullscreen mode

Add the animated value

Now we can start with the implementation of the animated header.

Create a src/settings/layout.js file. This is where we keep the layout settings for the animated header:

  • HEADER_MAX_HEIGHT - the maximum height of the animated header. This will be the header’s default height when the user still hasn’t scrolled through the list.
  • HEADER_MIN_HEIGHT - the minimum height of the header. This will slightly differ based on the platform the app is currently running on because the height from the top of the screen down to the actual real estate where the app is rendered will be different between the two platforms.
  • HEADER_SCROLL_DISTANCE - the height that the scroll needs to be scrolled in order for the HEADER_MIN_HEIGHT to be applied.
// src/settings/layout.js
import { Platform } from "react-native";
const HEADER_MAX_HEIGHT = 250;
const HEADER_MIN_HEIGHT = Platform.OS === "ios" ? 40 : 53;
const HEADER_SCROLL_DISTANCE = HEADER_MAX_HEIGHT - HEADER_MIN_HEIGHT;

export { HEADER_MAX_HEIGHT, HEADER_MIN_HEIGHT, HEADER_SCROLL_DISTANCE };
Enter fullscreen mode Exit fullscreen mode

Next, open the App.js file and import the modules that we need:

import { View, ScrollView, Platform, Animated } from "react-native";
import { HEADER_MAX_HEIGHT } from "./src/settings/layout";
Enter fullscreen mode Exit fullscreen mode

In the constructor, initialize the animated value that we will be using. By default, this should have a value of 0 because we haven’t really scrolled yet when the page loads. But iOS is different because of the content inset. We have to use the negative equivalent of the maximum height of the animated header (-HEADER_MAX_HEIGHT) as the initial value. If we just specified 0, the output will look like this:

iOS animated header issue

Here’s the code:

// App.js
constructor(props) {
  super(props);
  this.pokemon_stats = [];

  // add this:
  this.nativeScrollY = new Animated.Value(
    Platform.OS === "ios" ? -HEADER_MAX_HEIGHT : 0
  );
}
// next: add code for render method
Enter fullscreen mode Exit fullscreen mode

Next, inside the render method, we need to bring back the scroll value to 0. We can do that by using the Animated.add method. This allows us to add a new value to an existing animated value, in this case, it’s this.nativeScrollY. The second argument is the value you want to add.

Earlier, we’ve set the default animated value for iOS to the negative equivalent of the header’s maximum height (-HEADER_MAX_HEIGHT). So we need to add the equivalent positive value to bring it back to 0. I know it’s a bit hard to wrap your head around the idea, so consider it as a little hack to deal with the contentInset for the scrollbars in iOS. If you don’t apply it, you’ll get an issue similar to the demo earlier. Here’s the code:

// App.js
render() {
  let nativeScrollY = Animated.add(
    this.nativeScrollY,
    Platform.OS === "ios" ? HEADER_MAX_HEIGHT : 0
  );

  // next: add code for rendering the CardList
}
Enter fullscreen mode Exit fullscreen mode

Next, pass an onScroll prop to the CardList component. The value for this prop will be passed directly to the onScroll prop of the ScrollView component in the src/components/CardList.js file later on. What this does is bind the animated value to the ScrollView's current scroll position. We do that by using Animated.event and passing in the mapping to the native event. In this case, the nativeEvent.contentOffset.y is mapped to this.nativeScrollY:

// App.js
{this.nativeScrollY && (
  <CardList
    ...previously added props here...
    onScroll={Animated.event(
      [{ nativeEvent: { contentOffset: { y: this.nativeScrollY } } }],
      {
        useNativeDriver: true
      }
    )}
  />
)}
Enter fullscreen mode Exit fullscreen mode

Next, open the src/components/CardList.js file and import the modules that we’ll need:

import { View, FlatList, Animated, Platform } from "react-native";
import { HEADER_MAX_HEIGHT } from "../settings/layout";
Enter fullscreen mode Exit fullscreen mode

Extract the the onScroll prop that we passed earlier:

// src/components/CardList.js
const CardList = ({
  // ..previously extracted props
  onScroll // add this
}) => {
  // next: update render code
};
Enter fullscreen mode Exit fullscreen mode

Next, replace ScrollView with Animated.ScrollView and supply the onScroll prop. We also need to supply a few other props:

  • scrollEventThrottle - this controls how often the scroll event will be fired while scrolling. Information regarding the scroll is sent over the bridge so this needs to be kept as low as possible so the app’s performance will not be impacted. The lower value means that it will be sent less often.
  • contentInset and contentOffset - the offset applied for the animated header. These settings only apply to iOS. This allows us to specify the height the header is going to be. That way, the header won’t overlap the list below it. If these two aren’t applied, here’s how it will look like:

iOS content inset and offset issue

See how it jumped to its final height the moment it was scrolled?

Here’s the code:

// src/components/CardList.js
return (
  <Animated.ScrollView
    style={styles.scroll}
    scrollEventThrottle={1}
    onScroll={onScroll}
    contentInset={{
      top: HEADER_MAX_HEIGHT
    }}
    contentOffset={{
      y: -HEADER_MAX_HEIGHT
    }}
  >
    ..previously added scrollview contents
  </Animated.ScrollView>
);
Enter fullscreen mode Exit fullscreen mode

Lastly, add the styles. The important thing to remember here is that a flex property should be applied to the animated ScrollView, and the paddingTop applied to Android. This is how we deal with positioning the CardList right below the header so they don’t overlap on Android. For iOS, it’s already been taken care with the content inset so we simply set the paddingTop to 0:

// src/components/CardList.js
const styles = {
  scroll: {
    flex: 1
  },
  scroll_container: {
    alignItems: "center",
    paddingTop: Platform.OS == "android" ? HEADER_MAX_HEIGHT : 0
  }
};
Enter fullscreen mode Exit fullscreen mode

Animate the header

Now we’re ready to add the code for animating the header. First import the modules that we need:

// src/components/AnimatedHeader.js
import React from "react";
import { View, Text, Animated, Platform } from "react-native";
import { HEADER_MAX_HEIGHT, HEADER_SCROLL_DISTANCE } from "../settings/layout";
Enter fullscreen mode Exit fullscreen mode

Here’s the component:

const AnimatedHeader = ({ title, nativeScrollY }) => {
  if (nativeScrollY) {
    // next: add the animation code
  }
};
Enter fullscreen mode Exit fullscreen mode

At this point, we can now animate based on the current value of nativeScrollY. This is the current scroll position that we will pass later on from the App.js file. As you have seen in the demo earlier, we’re actually animating multiple components at the same time:

  • Header - animate the Y position.
  • Background image - animate the Y position and the opacity.
  • Title - animate the scale and the Y position.

From the breakdown above, you can see that we need to animate all the components involved. There’s no such thing as inheritance when it comes to animation. Animating the header’s container won’t actually animate its children the same way. So you have to apply the individual animations that you want to use for each component.

Here’s the code for animating the header’s Y position. From the code below, you can see that we’re relying on the HEADER_SCROLL_DISTANCE for the input and output ranges. This value is the maximum height the scrollbar needs to be scrolled in order for the header to animate to its final position. So we set it as the final value for the inputRange, while the final value for the outputRange will just be its inverse. Why? Because we’re animating the Y position, applying a negative value means the component will be moved to the top:

// src/components/AnimatedHeader.js
const headerTranslate = nativeScrollY.interpolate({
  inputRange: [0, HEADER_SCROLL_DISTANCE],
  outputRange: [0, -HEADER_SCROLL_DISTANCE],
  extrapolate: "clamp" // so it wont go over the output range
});
Enter fullscreen mode Exit fullscreen mode

Here’s the code for animating the background image:

// src/components/AnimatedHeader.js
// for animating the opacity
const BGImageOpacity = nativeScrollY.interpolate({
  inputRange: [0, HEADER_SCROLL_DISTANCE / 2, HEADER_SCROLL_DISTANCE],
  outputRange: [1, 0.3, 0],
  extrapolate: "clamp"
});

// for animating the Y position
const BGImageTranslate = nativeScrollY.interpolate({
  inputRange: [0, HEADER_SCROLL_DISTANCE],
  outputRange: [0, 100],
  extrapolate: "clamp"
});
Enter fullscreen mode Exit fullscreen mode

Here’s the code for animating the title:

// for animating the scale
const titleScale = nativeScrollY.interpolate({
  inputRange: [0, HEADER_SCROLL_DISTANCE / 2, HEADER_SCROLL_DISTANCE],
  outputRange: [1, 0.8, 0.7],
  extrapolate: "clamp"
});

// for animating the Y position
const titleTranslateY = nativeScrollY.interpolate({
  inputRange: [0, HEADER_SCROLL_DISTANCE / 2, HEADER_SCROLL_DISTANCE],
  outputRange: [25, 35, 15],
  extrapolate: "clamp"
});
Enter fullscreen mode Exit fullscreen mode

And here are the animated styles:

const headerStyles = { transform: [{ translateY: headerTranslate }] };

const headerBarStyles = {
  transform: [{ scale: titleScale }, { translateY: titleTranslateY }]
};

const BGImageStyles = {
  opacity: BGImageOpacity,
  transform: [{ translateY: BGImageTranslate }]
};
Enter fullscreen mode Exit fullscreen mode

Next, apply the animated styles to each of the components we want to target:

return (
  <View style={styles.header_container}>
    <Animated.View pointerEvents="none" style={[styles.header, headerStyles]}>
      <Animated.Image
        style={[styles.header_bg, BGImageStyles]}
        resizeMode={"cover"}
        source={require("../img/team-instinct.jpg")}
      />
    </Animated.View>

    <Animated.View style={[styles.header_bar, headerBarStyles]}>
      <Text style={styles.header_text}>{title}</Text>
    </Animated.View>
  </View>
);
// next: add code for rendering default component if nativeScrollY isn't present
Enter fullscreen mode Exit fullscreen mode

Next, we need to render a default component while nativeScrollY isn’t available:

if (nativeScrollY) {
  // ...previously added code here
}

// add this:
return (
  <View style={styles.header}>
    <View>
      <Text style={styles.header_text}>{title}</Text>
    </View>
  </View>
);
Enter fullscreen mode Exit fullscreen mode

Next, add the styles:

const styles = {
  header_container: {
    ...Platform.select({
      ios: {
        zIndex: 1 // only applied to iOS, for some reason the cards is laid on top of the header when scrolling
      }
    })
  },
  header: {
    position: "absolute",
    top: 0, // so it's at the very top of its container
    left: 0, // for 100% width
    right: 0, // for 100% width
    backgroundColor: "#B4A608",
    overflow: "hidden", // for containing the background image because this container is absolutely positioned
    height: HEADER_MAX_HEIGHT, // needed for absolutely positioned elements
    zIndex: 1 // so the header will be laid on top of the list
  },
  header_bar: {
    backgroundColor: "transparent",
    height: 32,
    alignItems: "center",
    justifyContent: "center",
    position: "absolute",
    top: 0,
    left: 0,
    right: 0,
    zIndex: 1
  },
  header_bg: {
    position: "absolute",
    top: 0,
    left: 0,
    right: 0,
    width: null, // important so we can apply resizeMode=cover
    height: HEADER_MAX_HEIGHT
  },
  header_text: {
    color: "#FFF",
    fontSize: 25,
    fontWeight: "bold"
  }
};

export default AnimatedHeader;
Enter fullscreen mode Exit fullscreen mode

Lastly, update App.js file to use the AnimatedHeader component instead of the Header component. Don’t forget to pass the nativeScrollY prop:

// App.js
import AnimatedHeader from "./src/components/AnimatedHeader";
export default class App extends Component<Props> {
  render() {
    return (
      <View style={styles.container}>
        <AnimatedHeader title={"Poke-Gallery"} nativeScrollY={nativeScrollY} />
        ..previously added code here
      </View>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Once that’s done, you should be able to scroll the list and the header will be animated according to its current position.

Page transition animations

The next animation we’re going to implement is page transition animation, and it looks like this:

Custom page transition animation: spring animation

In case you didn’t notice, we’re performing a bit of a bouncing animation and an opacity animation as we navigate to the next page. We’re also doing the same as we navigate back to the previous page, but it becomes subtle because of we’re also animating the opacity back to zero.

For this animation, we’re going to use the React Navigation library. This library is like the de-facto standard for implementing navigation in React applications. So we’re going to take advantage of it instead of implementing our own navigation. This library already comes with default animations, but we’re going to customize it instead.

To implement custom page transition animations, we need to take care of the following first:

  1. Install the dependencies.
  2. Set up the main screen of the app.
  3. Create a new “Share” screen. This is where we’ll be navigating to so we can apply some animations.
  4. Set up the navigator for navigating between the Main and Share screen.
  5. Create a custom transition that will be applied when navigating between the two screens.

We will be moving around some code in this section. If at any time you feel unsure on what needs to be changed, you can check out the commits on the GitHub repo. I tried to be descriptive with the commit messages as much as possible, so you should be able to find the exact changes.

Install the dependencies

We will be needing the React Navigation library to implement navigation within the app. You can install it with the following command. It should already be installed if you’ve switched to the part1 branch and installed the dependencies:

npm install --save react-navigation
Enter fullscreen mode Exit fullscreen mode

Set up the main screen

First, create a screens folder inside the src directory. This is where we’ll be putting all the screens used in the app.

Next, create a Main.js file and Share.js file inside the screens folder you just created.

Open the App.js file and copy all its contents over to the Main.js file. This will now serve as the default screen of the app.

In the Main.js file, update the paths to the files that we’re importing. Simply do a multi-search and replace on your text editor, seach for ./src/ and replace it with ../ and that should do the job for you. You don’t even have to mind the code below once you’ve done that:

// src/screens/Main.js
import pokemon from "../data/pokemon";
import pokemon_stats from "../data/pokemon-stats";

import AnimatedHeader from "../components/AnimatedHeader";
import CardList from "../components/CardList";
import AnimatedModal from "../components/AnimatedModal";

Next, rename the component from `App` to `Main`:

// src/screens/Main.js
export default class Main extends Component<Props> {
  // next: add navigation options
}
Enter fullscreen mode Exit fullscreen mode

Next, add the navigation options to be used by React Navigation:

static navigationOptions = ({ navigation }) => {
  return {
    headerTitle: "", // empty because we're using the label inside the AnimatedHeader
    headerStyle: {
      elevation: 0, // only applied to Android to remove the shadow in the header
      shadowOpacity: 0, // for removing the shadow in the header
      backgroundColor: "#B4A608"
    },
    headerTitleStyle: {
      color: "#FFF"
    }
  };
};
Enter fullscreen mode Exit fullscreen mode

As for the App.js file, remove all the code from it for now.

Create a Share Screen

Next, let’s create the Share screen. I’ll just breeze through the code explanation for this screen because we’re only creating it so we could navigate to another screen. We won’t really be adding any animation code to this screen.

Here’s the code:

// src/screens/Share.js
import React, { Component } from "react";
import { View } from "react-native";

import IconLabel from "../components/IconLabel";

type Props = {};
export default class Share extends Component<Props> {
  static navigationOptions = ({ navigation }) => {
    return {
      headerTitle: "Share",
      headerStyle: {
        backgroundColor: "#B4A608"
      },
      headerTitleStyle: {
        color: "#FFF"
      }
    };
  };

  render() {
    return (
      <View style={styles.container}>
        <IconLabel
          icon="facebook-f"
          label="Share to Facebook"
          bgColor="#4267b2"
        />

        <IconLabel
          icon="google-plus"
          label="Share to Google+"
          bgColor="#db4437"
        />

        <IconLabel icon="twitter" label="Share to Twitter" bgColor="#1B95E0" />

        <IconLabel
          icon="linkedin"
          label="Share to LinkedIn"
          bgColor="#0077B5"
        />
      </View>
    );
  }
}

const styles = {
  container: {
    flex: 1,
    padding: 20
  }
};
Enter fullscreen mode Exit fullscreen mode

In the code above, we’re using an IconLabel component to render a button with an icon and label on it.

Here’s the code for the IconLabel component:

// src/components/IconLabel.js
import React from "react";
import { Text, TouchableOpacity } from "react-native";
import Icon from "react-native-vector-icons/FontAwesome";

const IconLabel = ({ icon, label, bgColor }) => {
  let backgroundColor = { backgroundColor: bgColor };
  return (
    <TouchableOpacity
      onPress={this.share}
      style={[styles.shareButton, backgroundColor]}
    >
      <Icon name={icon} style={styles.icon} size={30} color="#fff" />
      <Text style={styles.label}>{label}</Text>
    </TouchableOpacity>
  );
};

const styles = {
  shareButton: {
    padding: 10,
    marginBottom: 10,
    flexDirection: "row",
    justifyContent: "space-between"
  },
  icon: {
    flex: 2
  },
  label: {
    flex: 8,
    marginTop: 5,
    color: "#fff",
    fontSize: 16,
    fontWeight: "bold"
  }
};

export default IconLabel;
Enter fullscreen mode Exit fullscreen mode

Once that’s done, you can now update the Main.js file so it navigates to the Share screen:

// src/screens/Main.js
shareAction = (pokemon, image) => {
  this.props.navigation.navigate("Share"); // add this inside the existing shareAction function
};
Enter fullscreen mode Exit fullscreen mode

Set up the navigator

Now we’re ready to set up the navigator. There are two parts to this. First, we update the App.js file to render the Root component. This component is where the navigator will be declared:

// App.js
import React, { Component } from "react";
import { View, YellowBox } from "react-native";

import Root from "./Root";

YellowBox.ignoreWarnings([
  "Warning: isMounted(...) is deprecated",
  "Module RCTImageLoader"
]);

type Props = {};
export default class App extends Component<Props> {
  render() {
    return (
      <View style={styles.container}>
        <Root />
      </View>
    );
  }
}

const styles = {
  container: {
    flex: 1,
    backgroundColor: "#fff"
  }
};
Enter fullscreen mode Exit fullscreen mode

Note that I have ignored the isMounted(...) is deprecated and Module RCTImageLoader warning. Those issues only seem to come up when React Navigation is installed. I haven’t found any solution for this so let’s ignore the warning for now.

Next, let’s proceed with the Root.js file. Start by importing the modules and screens that we will be working with:

// Root.js
import React from "react";
import { Animated, Easing } from "react-native";
import { createStackNavigator } from "react-navigation";

import MainScreen from "./src/screens/Main";
import ShareScreen from "./src/screens/Share";
Enter fullscreen mode Exit fullscreen mode

Next, create a Stack Navigator for the two screens. The transitionConfig is an option that we can pass to the Stack Navigator. This is where we will be declaring the animation that we want to perform when navigating between the two screens:

const RootStack = createStackNavigator(
  {
    Main: {
      screen: MainScreen
    },
    Share: {
      screen: ShareScreen
    }
  },
  {
    initialRouteName: "Main", // set the default page
    transitionConfig // the animation
  }
);
Enter fullscreen mode Exit fullscreen mode

The transitionConfig is an object which contains the animation configuration. If you look closely, you’ll notice that it’s using the same options as the ones you use for the Animated API. The only difference is the timing property. This is the type of animation to use. We’ve previously used Animated.timing and Animated.spring, you can also use those.

Don’t forget to supply additional options for each type of animation if you want to customize it. For example, Animated.spring can have a property called friction:

// Root.js
const transitionConfig = () => {
  return {
    transitionSpec: {
      duration: 400, // how long the transition will take
      easing: Easing.bounce, // easing function to use (https://facebook.github.io/react-native/docs/easing.html)
      timing: Animated.timing, // the type of animation to use (timing, spring, decay)
      useNativeDriver: true // delegate all the animation related work to the native layer
    },
    screenInterpolator: sceneProps => {
      // next: add code for customizing the transition animation
    }
  };
};
Enter fullscreen mode Exit fullscreen mode

Below the general animation setting is the screenInterpolator function. This is where you will specify the actual animations that you’d like to perform while the app navigates from one screen to the next.

Start by extracting all the data we need from the sceneProps:

  • layout - contains information about the screen layout. Things like the height and width of the screen. In this case, we’re only using it to determine the initial width (initWidth) of the current screen.
  • position - the position of the current screen. This is the animated value that we can interpolate in order to perform animations.
  • scene - contains information about the screen we’re navigating to. In this case, we’ll only be using it to get the index of the next screen. Remember, we’re using a Stack Navigator, so screens are just stacked on top of each other. This means the next screen will have a higher index than the current screen. Unless we’re navigating backward.
// Root.js
const { layout, position, scene } = sceneProps;
const thisSceneIndex = scene.index; // the index of the current screen
const width = layout.initWidth; // the width of the current screen
Enter fullscreen mode Exit fullscreen mode

At this point, we can now perform the animations. By default, the Stack Navigator uses transforms to translate the X position of the screen. If you’re navigating to a new screen, the new screen slides from right to left until it replaces the current screen. When navigating backward, the reverse is performed (the current screen slides left to right until it disappears from view. This reveals the entirety of the previous screen when it ends). The code below does the same thing, but we’re also animating the opacity. Aside from that, the transition will have a bit of bounce to it, since we applied Easing.bounce as the easing function. This will gradually fade into the next screen when moving forward, and fade the current screen out when going back:

// Root.js
const translateX = position.interpolate({
  inputRange: [thisSceneIndex - 1, thisSceneIndex],
  outputRange: [width, 0],
  extrapolate: "clamp" // clamp so it doesn't go beyond the outputRange. Without this, you'll see a few black portions in the screen while navigating
});

const opacity = position.interpolate({
  inputRange: [thisSceneIndex - 1, thisSceneIndex - 0.5, thisSceneIndex],
  outputRange: [0, 0.2, 1],
  extrapolate: "clamp"
});
return { opacity, transform: [{ translateX }] }; // return the animated styles to be applied to the current view upon navigation
Enter fullscreen mode Exit fullscreen mode

From the code above, you can see that we’re actually using the same Animated API that we’ve previously used. This means that you can actually apply the rotation, scale, spring, and all the other animations we’ve previously implemented. Your imagination is the limit! Don’t go overboard though, transition animations should be fast and simple to avoid annoying the user.

Note that we don’t have to specify how the position will be interpolated when going back to a previous page. React Navigation also takes care of this.

Lastly, don’t forget to export the RootStack:

export default RootStack;
Enter fullscreen mode Exit fullscreen mode

Adjust the header title

With the addition of the header added by React Navigation, we have to adjust the header title accordingly. Open the src/components/AnimatedHeader.js file and replace the following line:

const titleTranslateY = nativeScrollY.interpolate({
  /*previously added inputRange and extrapolate value here*/
  outputRange: [25, 35, 15] // existing outputRange
});
Enter fullscreen mode Exit fullscreen mode

With this line:

const titleTranslateY = nativeScrollY.interpolate({
  outputRange: [0, -10, -8] // only replace existing outputRange with this line, other lines inside this are still intact
});
Enter fullscreen mode Exit fullscreen mode

Because there is now a header above the animated header, we no longer have to add a Y position. We can even add a negative Y position and it won’t go over the status bar.

Solving the header issue

At this point, you should now be able to try the transition out in your device or emulator. But if you try the animated modal that we’ve implemented on the first part, you’ll see this:

Issue with header z-index

The issue above is that the parent of the AnimatedModal component has a lower z-index value than the header. That’s fine because we want the header to be on top of everything else. That is, except for modal windows. This is one of the benefits of using React Native’s Modal component because it is laid on top of everything else. But we opted out of it because we wanted full control of the animations.

To solve the issue, we’ll create a new screen which shows the same contents as the AnimatedModal. And then we’ll tell React Navigation to treat the new screen as a modal, not a screen.

The first step is to copy the contents of the src/components/AnimatedModal.js file into a new file called src/screens/Details.js. The following are the changes that need to be made:

  • Remove all the code that animates the Animated Modal. The animations will now be implemented using React Navigation so we no longer have to implement it on our own.
  • Update the code so it no longer uses the Header component. The new Details page will be headerless.
  • Props should be coming from the navigation instead of supplied directly to the component.
  • Add a button for closing the modal. This will simply navigate the user backward, but it will use the same animation as a modal. The screen can also be flicked downwards just like a modal does.

Once those changes are made, the code should now look like this:

// src/screens/Details.js
import React, { Component } from "react";
import { View, Text, TouchableOpacity } from "react-native";

import BigCard from "../components/BigCard";

type Props = {};
export default class Details extends Component<Props> {
  render() {
    const { navigation } = this.props;
    const title = navigation.getParam("title");
    const image = navigation.getParam("image");
    const data = navigation.getParam("data");

    return (
      <View style={styles.container}>
        <View style={styles.modalContent}>
          <TouchableOpacity
            style={styles.closeButton}
            onPress={() => {
              navigation.goBack();
            }}
          >
            <Text style={styles.closeText}>Close</Text>
          </TouchableOpacity>
          <BigCard title={title} image={image} data={data} />
        </View>
      </View>
    );
  }
}

const styles = {
  container: {
    flex: 1,
    backgroundColor: "#fff"
  },
  modalContent: {
    flex: 1,
    alignItems: "stretch",
    paddingTop: 30
  },
  closeButton: {
    alignSelf: "flex-end"
  },
  closeText: {
    color: "#333",
    paddingRight: 10
  }
};
Enter fullscreen mode Exit fullscreen mode

You also need to update src/components/BigCard.js so it only initiates the animation on componentDidMount instead of componentDidUpdate. This is because it no longer resides inside the AnimatedModal component which is simply hidden from view. To do that, simply replace componentDidUpdate with componentDidMount. All the code inside of it is still intact.

Next, update the src/screens/Main.js file so it no longer uses the AnimatedModal component when the View button is clicked. It should navigate to the Details screen instead:

viewAction = (pokemon, image) => {
  this.props.navigation.navigate("Details", {
    title: pokemon,
    image: image,
    data: this.getPokemonStats()
  });
};
// next: add function declaration for getPokemonStats()
Enter fullscreen mode Exit fullscreen mode

At this point, you can delete the src/components/AnimatedModal.js file as well.

Here’s the code for the getPokemonStats function:

// src/screens/Main.js
// add right after viewAction
getPokemonStats = () => {
  let pokemon_stats_data = [];
  pokemon_stats.forEach(item => {
    pokemon_stats_data.push({
      label: item,
      value: getRandomInt(25, 150)
    });
  });

  return pokemon_stats_data;
};
Enter fullscreen mode Exit fullscreen mode

Next, import the DetailsScreen from the Root.js file:

import DetailsScreen from "./src/screens/Details";
Enter fullscreen mode Exit fullscreen mode

Still on the Root.js file, we need to tell React Navigation that the Details screen should be treated as a modal. The following are the steps for doing so.

Create a new Stack Navigator. This contains the screens of the app. These are the same as what we have earlier, so all you have to do is copy the existing RootStack and paste the new one on top of the existing one then rename it to MainStack. Once that’s done, your code should look like this:

// Root.js
const MainStack = createStackNavigator(
  {
    Main: {
      screen: MainScreen
    },
    Share: {
      screen: ShareScreen
    }
  },
  {
    initialRouteName: "Main",
    transitionConfig
  }
);

const RootStack = createStackNavigator({...}); //next: add config options in place of "..."
Enter fullscreen mode Exit fullscreen mode

The RootStack will now have the MainStack and the DetailsScreen as its screens. We’re also supplying an object specifying that the mode should be modal, and if it is, then there will be no header displayed. This setup works because we’ve wrapped our screens (Main and Details) inside a Stack Navigator. Thus, only the screens which are used as is will be considered as a modal:

const RootStack = createStackNavigator(
  {
    Main: {
      screen: MainStack // MainStack was previously MainScreen
    },
    Details: {
      // existing ShareScreen is remove and replaced with this one
      screen: DetailsScreen
    }
  },
  // replace initialRouteName and transitionConfig with below code:
  {
    mode: "modal",
    headerMode: "none" // don't display a header if modal
  }
);
Enter fullscreen mode Exit fullscreen mode

The last step is to clean up the remaining AnimatedModal code on src/screens/Main.js. Make sure to remove this specific code from that file:

<AnimatedModal
  title={"View Pokemon"}
  visible={this.state.isModalVisible}
  onClose={() => {
    this.setState({
      isModalVisible: false
    });
  }}
>
  <BigCard
    title={this.state.pokemon}
    image={this.state.image}
    data={this.state.stats}
  />
</AnimatedModal>
Enter fullscreen mode Exit fullscreen mode

Once you’ve made the necessary changes, it will now look like this:

React Navigation modal

LayoutAnimation

The last animation that we’re going to implement is LayoutAnimation, and it looks like this:

LayoutAnimation spring scaleXY

React Native’s LayoutAnimation is only useful for simple animations. These animations are automatically applied to the components whose state was recently updated.

In the demo above, we’re shuffling the array of Pokemon data when the button in the header is clicked. The state is then updated with the newly ordered data. This results in the spring animation that you see above.

LayoutAnimation can be implemented in three steps:

  1. Import the LayoutAnimation module.
  2. Specify the animation you want to perform when the state is updated.
  3. Call LayoutAnimation.configureNext right before the state is updated.

Before we proceed, note that LayoutAnimation isn’t enabled on Android by default. This is because it’s still considered as an experimental feature in Android. For iOS, it should work by default.

Start by opening the src/screens/Main.js file and import the following:

import { /* existing modules here */ Platform, UIManager } from "react-native";
Enter fullscreen mode Exit fullscreen mode

Then check if the platform is Android then enable LayoutAnimation:

if (Platform.OS === "android") {
  UIManager.setLayoutAnimationEnabledExperimental(true);
}
Enter fullscreen mode Exit fullscreen mode

Now that that’s taken care of, let’s get into it.

First, import the LayoutAnimation module:

// src/screens/Main.js
import { /* existing modules here */ LayoutAnimation } from "react-native";
Enter fullscreen mode Exit fullscreen mode

Next, specify the type of animation. This requires the type and property to be supplied:

  • type - the type of animation you want to perform. Currently, these includes the following preset values: easeIn, easeInEaseOut, easeOut, linear, spring.
  • property - the property you want to animate. Currently, only scaleXY and opacity are available. scaleXY animates the width and height of the component, while opacity animates the opacity.
  • springDamping - how much resistance you want to apply to the spring. This should be a value that’s less than or equal to one. The lesser the number, the more spring will be applied to the component.
// src/screens/Main.js
const springAnimationProperties = {
  type: LayoutAnimation.Types.spring,
  property: LayoutAnimation.Properties.scaleXY,
  springDamping: 0.3
};
Enter fullscreen mode Exit fullscreen mode

Next, construct the actual animation config. Specify the animations to be performed when a new component is rendered (create), when an existing component is updated (update), and when a component is deleted (delete). In this case, we’re specifying the same animation for all three. But you can also omit update and delete since we’ll only be using create as you’ll see later:

const animationConfig = {
  duration: 500, // how long the animation will take
  create: springAnimationProperties,
  update: springAnimationProperties,
  delete: springAnimationProperties
};
Enter fullscreen mode Exit fullscreen mode

Next, add an onPress prop to AnimatedHeader. The function you pass to it will be executed when the user clicks on the header button which we’ll be adding shortly:

<AnimatedHeader
  title={"Poke-Gallery"}
  nativeScrollY={nativeScrollY}
  onPress={this.shuffleData}
/>
Enter fullscreen mode Exit fullscreen mode

After that, update the AnimatedHeader so it wraps the header text with TouchableOpacity. We then pass the onPress prop to it so it gets executed when the a user clicks on it. Don’t forget to destructure TouchableOpacity out of react-native first, as well as destructure onPress from the props passed to AnimatedHeader before doing this:

// src/components/AnimatedHeader.js
<Animated.View style={[styles.header_bar, headerBarStyles]}>
  <TouchableOpacity onPress={onPress}>
    <Text style={styles.header_text}>{title}</Text>
  </TouchableOpacity>
</Animated.View>
Enter fullscreen mode Exit fullscreen mode

Going back to the Main screen, below is the shuffleData function. This is where the final piece of LayoutAnimation happens. Right before you update the state with the shuffled data, call LayoutAnimation.configureNext() and supply the animationConfig from earlier:

// src/screens/Main.js
shuffleData = () => {
  LayoutAnimation.configureNext(animationConfig); // configure next LayoutAnimation
  let newArray = shuffleArray(this.state.pokemon); // randomly order the items in the array
  this.setState({
    pokemon: newArray
  });
};
Enter fullscreen mode Exit fullscreen mode

Don’t forget to set the default pokemon array in the state:

export default class Main extends Component<Props> {
  state = {
    pokemon: pokemon
  };
}
Enter fullscreen mode Exit fullscreen mode

And then set it as the data source for the CardList component:

// this is inside the render method of src/screens/Main.js
<CardList
  data={this.state.pokemon}
  ...previously added props
/>
Enter fullscreen mode Exit fullscreen mode

The shuffleArray function is declared in the src/lib/random.js file:

const shuffleArray = arr => {
  return arr
    .map(a => [Math.random(), a])
    .sort((a, b) => a[0] - b[0])
    .map(a => a[1]);
};
export { /* existing exports here */ shuffleArray };
Enter fullscreen mode Exit fullscreen mode

Don’t forget to import it on the src/screens/Main.js file:

import { /* existing imports here */ shuffleArray } from "../lib/random";
Enter fullscreen mode Exit fullscreen mode

Once that’s done, the spring animation is performed when you click on the header text.

Conclusion

That’s it! In this tutorial, you’ve learned how to add transition animations to your React Native app. As you have seen, the React Navigation library made it easy for us to implement custom page transition animations. You’ve also seen that when it comes to animations, there are a few changes for each platform that we need to deal with before we can implement the same animations.

The full source code for this tutorial is available on this GitHub repo. Be sure to switch to the part2 branch if you only want the final output for this part of the series.

Originally published on the Pusher blog

💖 💪 🙅 🚩
wernancheta
Wern Ancheta

Posted on July 13, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related