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 index0
to1
, soindex: 1
position:
is a shared animated value that will animate fromfromIndex
totoIndex
over the course of the entire transition. In this caseposition
will range from0
to1
and sweep through all decimal values in between. The animation will take 750 milliseconds to complete, which we set intransitionSpec
.scene:
an object containing data about one scene on the stack. Since we’re looking at the first call toscreenInterpolator
,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 andscene
will be the route we’re navigating to.scene
also contains aroute
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:
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:
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
:
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: