React Navigation: An Overview of Transitioner and CardStack
This is a series of posts about how to create custom transition “views” using the Transitioner
in React Navigation (based on “NavigationExperiemental”):
- An overview of Transitioner and CardStack (this post)
- Simple transitions: cross fade and Android default
- Shared element transition 1/3: overview
- Shared element transition 2/3: bounding boxes
- Shared element transition 3/3: the animation
Update notes (2017-02-01): This post was written before the release of React Navigation which will replace NavigationExperimental soon. However, this post is still worth reading since React Native uses core classes in NavigationExperimental. Some minor changes:
NavigationCardStack
andNavigationTransitioner
are now simplyCardStack
andTransitioner
CardStack
now uses bottom-up transition on Android by default, very similar to the androidDefault animation shown below. That’s a great move.CardStack
now has anavigation
prop which includes thestate
and navigation functions.
NavigationExperimental provides great support for custom transition animations. Do you want to create transitions like the following? This series of posts is for you!
Before jumping into it though, let’s first study the built-in container NavigationCardStack
and see how its transition is implemented. If you are not familiar with NavigationCardStack
, see my previous post for a simple tutorial on how to use it.
NavigationCardStack
in a nutshell
With NavigationCardStack
, screens are arranged in a virtual stack of cards. When a new card is brought into the scene, it slides in from either the right or bottom edge of the screen. When it leaves the scene, it slides back to its origin.
How does it work? I dug into its source code and here’s what I’ve found in a nutshell:
- The
Animated
library is the backbone of transition animations. NavigationCardStack
is backed byNavigationTransitioner
.NavigationTransitioner
creates and passes along two generic values that describe the transition state:position
andprogress
.NavigationCardStack
converts these values to the actual visual style properties of the incoming and outgoing screens.
Let’s unfold the whole process one step at a time.
A premier on Animated
Animated
is a powerful animation library. As mentioned in the official document, it “focuses on declarative relationship between inputs and outputs”, like so:
const rotate = progress.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '360deg']
});
Here, progress
is an “Animated.Value
” that represents a generic, high-level state that changes over time. We can then map (interpolate) this value to an actual visual style property (such as rotate
).
Whenever the original value changes, the visual style is updated accordingly. You just need to “wire” the animated values together (defining the mapping), and the rest is taken care of automatically. All this happens without going through the standard, setState-diff-render cycle in React. This removes a lot of overhead to ensure the smoothness of our animations.
Animated
can be typically used this way:
- Create an
Animated.Value
and start the animation when appropriate. This code typically lives in upstream where we just care about a generic state and want to delegate the actual rendering to downstream.NavigationTransitioner
is a good example of this (we’ll come back to it later).
const progress = new Animated.Value(0);
Animated.timing({ progress, { toValue: 1 }}).start();
- Map the value created above to desired visual style properties, by calling
interpolate()
:
const rotate = progress.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '360deg']
});
- Use the visual style properties created above in a
Animated.View
(orAnimated.Text
,Animated.Image
etc.) to render the component:
const style = { transform: [{ rotate }]};
return <Animated.View style={ style }> ... </Animated.View>;
The code above will create a view that spins for 500 milliseconds (the default duration).
Of course, I’ve only scratched the surface of Animated
here. For more details, I’d recommend you to watch this talk to see what’s possible, read through the official document, and check out this tutorial.
CardStack
and Transitioner
Now that we know Animated
, it’s time to dig into the source code of NavigationCardStack
!
There will be more code than talking from now on. My goal is to provide a guided tour for the source code to make it easier to understand. Only relevant code is shown and the rest are “....
”. It’d perhaps be useful to open the full source on another screen when reading this post.
class NavigationCardStack extends React.Component<DefaultProps, Props, void> {
....
render(): React.Element<any> {
return (
<NavigationTransitioner
configureTransition={this._configureTransition}
navigationState={this.props.navigationState}
render={this._render}
style={this.props.style}
/>
);
}
}
So a NavigationCardStack
is bascially a NavigationTransitioner
. Let’s follow along:
class NavigationTransitioner extends React.Component<any, Props, State> {
....
constructor(props: Props, context: any) {
....
this.state = {
....
position: new Animated.Value(this.props.navigationState.index),
progress: new Animated.Value(1),
};
....
}
componentWillReceiveProps(nextProps: Props): void {
....
progress.setValue(0);
const animations = [
timing(
progress,
{
...transitionSpec,
toValue: 1,
},
),
];
if (nextProps.navigationState.index !== this.props.navigationState.index) {
animations.push(
timing(
position,
{
...transitionSpec,
toValue: nextProps.navigationState.index,
},
),
);
}
// update scenes and play the transition
this.setState(nextState, () => {
....
Animated.parallel(animations).start(this._onTransitionEnd);
});
}
....
}
We see that two Animated.Value
’s are created: position
and progress
. progress
simply goes from 0
to 1
whereas position
goes from the previous index to the next index of the navigation state. These two values are being animated in parallel. If the index does not change, then only progress
will be animated.
When it’s time to render, NavigationTransitioner
simply delegates to the render()
function in props:
render(): React.Element<any> {
return (
<View ...>
{this.props.render(this._transitionProps, this._prevTransitionProps)}
</View>
);
}
The two Animated.Value
’s, position
and progress
, are passed to render()
, as a part of the two parameters _transitionProps
and _prevTransitionProps
. Here’s how the transitionProps
are constructed:
...
this._transitionProps = buildTransitionProps(this.props, nextState);
...
function buildTransitionProps(
props: Props,
state: State,
): NavigationTransitionProps {
...
const {
position,
progress,
...
} = state;
return {
position,
progress,
...
};
}
Why do we pass position
and progress
to the props.render
method? Let the implementor of props.render
create the actual animations!
Interpolating position
Do you still remember how Animated
works? We’ll need an Animated.View
whose style
prop is an Animated.Value
.
It’s safe to guess that there must be something like position.interpolate()
in NavigationCardStack
when it renders the view. Let’s track it down:
class NavigationCardStack extends React.Component<DefaultProps, Props, void> {
...
_renderScene(props: NavigationSceneRendererProps): React.Element<any> {
const isVertical = this.props.direction === 'vertical';
const style = isVertical ?
NavigationCardStackStyleInterpolator.forVertical(props) :
NavigationCardStackStyleInterpolator.forHorizontal(props);
...
return (
<NavigationCard ...
style={[style, this.props.cardStyle]}
/>
);
}
}
class NavigationCard extends React.Component<any, Props, any> {
....
render(): React.Element<any> {
....
return (
<Animated.View
style={[styles.main, viewStyle]}>
{renderScene(props)}
</Animated.View>
);
}
}
Each scene is rendered as a NavigationCard
, which is eventually rendered as an Animated.View
. Bingo!
Who creates the style for the NavigationCard
? It’s NavigationCardStackStyleInterpolator.forVertical()
(or forHorizontal
)!
We can then find the secrete sauce that maps (interpolates) position
to the location, scale and opacity of the cards. Let’s take a quick glance:
// NavigationCardStackStyleInterpolator.js
function forHorizontal(props: NavigationSceneRendererProps): Object {
const {
position,
....
} = props;
....
const index = scene.index;
const inputRange = [index - 1, index, index + 0.99, index + 1];
const width = layout.initWidth;
const outputRange = I18nManager.isRTL ?
([-width, 0, 10, 10]: Array<number>) :
([width, 0, -10, -10]: Array<number>);
const opacity = position.interpolate({
inputRange,
outputRange: ([1, 1, 0.3, 0]: Array<number>),
});
const scale = position.interpolate({
inputRange,
outputRange: ([1, 1, 0.95, 0.95]: Array<number>),
});
const translateY = 0;
const translateX = position.interpolate({
inputRange,
outputRange,
});
return {
opacity,
transform: [
{ scale },
{ translateX },
{ translateY },
],
};
}
We can see that opacity
, scale
and translateX
are all interpolations of position
– they’ll change as position
changes. This effectively creates the sliding card animation that we’ve seen earlier.
You probably have questions about how the input/output ranges are set up in this function, but let’s leave them to the next post, where we’ll also start creating our own transitions soon!
Summary
Just to repeat a few key points on how the transition animation works in the built-in NavigationCardStack
:
- The
Animated
library is the backbone of transition animations. NavigationCardStack
is backed byNavigationTransitioner
.NavigationTransitioner
creates and passes along twoAnimated.Value
’s that describe the transition state:position
andprogress
.NavigationCardStack
converts these values totranslateX
,opacity
andscale
properties of the incoming and outgoing screens.
Feel free to ask me questions in the comments below, or suggest topics that you’d like me to write about in future posts. Have a great day!