Custom Transitions in React Navigation

Note: This is part 4 in a series on React Navigation. Check out my posts on Getting Up and Running, Styling Navigators, an introduction to Stack, Tab, and Drawer Navigators, and Connecting React Navigation to Redux.

React Navigation makes it relatively simple to style navigators, but what if we want to change how they behave? What if I don’t like the default behavior where screens slide in from the right and I want them to slide in from the bottom? What if I want them to fade in instead?

Well, we’re in luck. We can customize how screens animate in and out of view.

In this post, we’ll add custom transitions to a StackNavigator. We’ll start by looking at the way navigation animations are configured, then we’ll make a goofy toy example with some over-the-top transition animations. After we’re comfortable with the basics, we’ll tackle a more practical example and learn how to customize transitions based on our navigation state, or even on the fly.

Quick note before we begin: I’ll use the words ‘route’, ‘screen’, and ‘scene’ interchangeably (although they technically differ in subtle ways). When we navigate, routes/screens/scenes are pushed onto a ‘navigation stack’ and given an index that begins at 0 and increments with each new screen. I’ll refer to the index we’re navigating to as the toIndex and index we started on as the fromIndex. In the following example, I’ll start at the initial screen ( index: 0 )and navigate forward twice:

My stack would then look like:

routes: [
{ index: 0, ...route0Data },
{ index: 1, ...route1Data },
{ index: 2, ...route2Data } // <-- currently viewing
]

App Setup

First, let’s set up a simple app that has a single component called ‘GoScreen’ with two buttons. The “Go Forward!” button navigates to another instance of the same screen, and we’ll increment a navigation param to keep track of how many screens deep we are . The “Reset!” button resets the stack using a NavigationAction. I’ll cycle through different background colors so the screens don’t all look identical. Here’s how that looks:

We’ll set our screens in the StackNavigator config, and set an initialRouteName. We’ll also create a third parameter called transitionConfig that we’ll use to customize our navigation transitions.

const App = StackNavigator({
Go: { screen: GoScreen },
}, {
initialRouteName: 'Go',
transitionConfig,
})

Let’s create that transitionConfig now.


Setting up transitionConfig

Here’s a simple implementation of transitionConfig that slides in new screens from the right, much like the default animation:

Let’s step through it

transitionConfig is a function that returns an object with two parameters: transitionSpec and screenInterpolator. We’ll configure animation timing properties like duration and easing in the transitionSpec, and we’ll configure our layout transformations in screenInterpolator.

There’s nothing too special about transitionSpec. In fact, it looks a lot like a standard React Native Animated example. We set the duration of our transition and the easing profile, configure it to be a timing-based animation rather than a spring, and to use the native driver for performance.

The screenInterpolator is where the magic happens. screenInterpolator is a function that React Navigation calls with an argument we’ll call sceneProps. screenInterpolator(sceneProps) is called for each screen in the stack and the return value is used to configure its transition.

So, if we want the screens to slide up instead of from the side, we’ll return a translateY transformation. If we want screens to fade in and out, we’ll return an opacity transformation.

sceneProps is an object that contains information about the transition as well as an animated value called position. Let’s run our app and log out sceneProps to get a better feel for what’s going on. The first thing we’ll notice when we press “Go Forward!” from our initial screen is that screenInterpolator gets called four times:

Why four? Well, we have two screens in our stack: the screen we started on and the screen we’re navigating to. It turns out the screenInterpolator is called for each scene both at the beginning and at the end of the transition.

(I’m not exactly sure why screenInterpolator gets called again after the transition completes but I think it’s due to a re-render within React Navigation due to the change in navigation props)

Let’s take a look at the value of the sceneProps object the very first time screenInterpolator(sceneProps) is called:

Oof, that’s a lot of data. Luckily, we don’t care about most of it. For now, let’s take note of a few important values contained within sceneProps:

  • index:tells us the route index where we’re navigating to, in this case we’re going from index 0 to 1, so index: 1
  • position: is a shared animated value that will animate from fromIndex to toIndex over the course of the entire transition. In this case position will range from 0 to 1 and sweep through all decimal values in between. The animation will take 750 milliseconds to complete, which we set in transitionSpec.
  • scene: an object containing data about one scene on the stack. Since we’re looking at the first call to screenInterpolator, scene represents the initial route in our stack, which is the route we’re navigating from. Immediately after this, screenInterpolator(sceneProps) will be called again and scene will be the route we’re navigating to. scene also contains a route object that contains any navigation params.
  • scenes: the state of the route stack at the time the transition was triggered.

To get a feel for what’s going on under the hood, imagine looping through the route stack, setting up each screen by calling screenInterpolator() on it, then kicking off the transition animation. It might look something like this:

(Here’s the actual code if you’re interested)

Let’s take a moment to prove to ourselves that scene is being set the way we think it is. Here are the same console.logs posted above, but I’ve popped open the first two:

As expected, the first time screenInterpolator(sceneProps)is called, sceneProps.scene is our initial route ( index: 0 ). The second time it is called, sceneProps.scene is the route we are navigating to ( index: 1 ).

Creating custom transition animations

Now that we know the index we’re navigating to, we have a reference to our position animated value, and we know how to access each individual scene being passed through the screenInterpolator, we can build our first custom animation. We’ll keep it simple for now and replace the slide-in-from-the-right behavior with a fade-in from opacity: 0 to opacity: 1:

Now is a good time to take a closer look at the position.interpolate() interpolation, which will drive all of our animations. Remember, position is an animated value that will range from the fromIndex to toIndex over the duration of our transition (750ms in our case). This gets slightly math-y, and if you’re like me you’ll have to reread it a few times before it sinks in, but it’ll pay off!

Interpolation

If you’re not familiar with the concept of interpolation, check out the React Native docs, which can explain it better than I can.

When we configure an interpolation, we define how we want an inputRange to map to an outputRange. Consider the following interpolation:

animatedValue.interpolate({
inputRange: [0, 1],
outputRange: [0, 100]
})

The inputRange refers to the value of animatedValue, which we expect to swing between 0–1 (notated as [0, 1]) over the course of its animation. This range gets mapped to our outputRange, which we’ve set to the range 0–100. For example, when our animation is halfway through, animatedValue has value0.5 and the output will be 50, or halfway through the outputRange we’ve specified.

If animatedValue goes below 0 or above 1 the interpolation will continue to output using whatever mapping was applied at 0 or 1 (i.e. an animatedValue of -1 would output -100).

We can add more ‘anchor points’ to the arrays to further customize the mapping:

animatedValue.interpolate({
inputRange: [0, 0.5, 1],
outputRange: [0, 10, 100]
})

The above snippet will output the range from 0 — 10 as the animatedValue moves from 0 to 0.5, then as animatedValue increases from 0.5 — 1, the output will be values from 10 — 100.

Here’s what that looks like plotted on a line chart:

We can fine-tune our animations by creating custom interpolations.

Interpolating the value of ‘position’

Ok, now back to our app. Let’s take a look at the position interpolation, which we’re using to set each screen’s opacity:

const opacity = position.interpolate({        
inputRange: [thisSceneIndex - 1, thisSceneIndex],
outputRange: [0, 1],
})

We’re saying “as the value of position increases from thisSceneIndex — 1 to thisSceneIndex, I want this scene’s opacity value to ramp up from 0 to 1”.

This was a big aha moment for me so let’s make sure it sinks in: we’re configuring each screen to animate from some ‘disabled’ state (i.e. opacity: 0) to some ‘active’ state ( opacity: 1 ) as the animated position value approaches and then equals that screen’s own index.

Here’s how a screen with index: 2 would be handled by our sceneInterpolator:

Whenever position is less than or equal to scene.index — 1 its opacity is at opacity: 0.0. However, as position increases beyond 1.0 and approaches 2.0, our opacity value ramps up as defined in our interpolation. It reaches its full value of opacity: 1.0 when position === index (i.e. position: 2.0).

Here’s what our opacity-based screenInterpolator looks like in our app:

Note that screenInterpolator doesn’t apply to the header

An over-the-top example

Let’s make a screenInterpolator where the first four screens slide in from the bottom, then any other screens added will start 4x larger than normal and scale down while fading in.

We’ll add some randomness too. We’ll tweak our ‘GoScreen’ component so that 25% of the time a plain navigation param is set to true. When plain: true, we’ll simply slide that screen in from the right. Got all that? Here we go!

First, we’ll add the code to our component that randomly sets a plain: true param whenever Math.random() is more than .75 (i.e. ~25% of the time):

Then we’ll set our screenInterpolator to output different transformations based on our navigation state and params:

The screens that slide in from the right will be different each time since they are based on random numbers:

Great! We’ve now seen that we can customize screens based on their index on the stack and based on custom navigation params we pass to them.

I once used screenInterpolator to implement the old-film-cliche ‘spinning newspaper’ effect on each screen, which I’ll let you figure out for yourself.


A more practical example

Now that we’ve made a ridiculous app, let’s make something we might actually use. We’ll add new screens using the standard ‘slide in from the right’ animation and remove single screens by sliding back out to the right. However, if we go back multiple screens at once, I want the current screen to drop downward to reveal the target screen.

We’ll start by rolling our own ‘slide in from right’ translation:

Next, we’ll check whether we’re navigating back more than one screen and slide out from bottom if so:

It works, but I don’t like what happens when multiple screens are removed at once — when we reset the navigation stack, we see every intermediate screen fly out of view like a deck of cards blowing away in the wind:

Lets fix that. We’ll hide all screens that are not the screen we started on and are not the screen we’re navigating to by setting the opacity to 0.

And that should do it! Here’s our final screenInterpolator:

Navigating forward/back by single screens animates from right like usual, but going back multiple screens drops current screen downward to reveal screen navigated to.

View the complete project on GitHub

Final thoughts

Custom navigation animations are not the easiest concept to wrap one’s head around, but understanding them opens you up to crafting apps with new and creative navigation animations.

We covered a lot of ground. We learned how the screenInterpolator configures each screen on the stack, we dug into interpolation and the relationship between position and scene.index and we saw that we can dynamically set animations based on scene.index, or even via custom navigation params.

I want to see what you build with React Navigation! Share your project in the comments!

Check out my other posts about React Navigation: