By
— 14 min read

React Navigation: Cross Fade and Android Default Transitions

This is a series of posts about how to create custom transition “views” using the Transitioner in React Navigation (based on “NavigationExperiemental”):


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 and NavigationTransitioner are now simply CardStack and Transitioner
  • 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 a navigation prop which includes the state and navigation functions.

In the previous post, we covered how the transition animations in NavigationCardStack work: the NavigationTransitioner creates two AnimatedValues, position and progress, which are then passed to CardStack and “interpolated” into style properties such as scaleX, translateX and opacity.

In this post, we’ll take a closer look at how the inputRange and outputRange are set up in CardStack when interpolating the AnimatedValues. We’ll also apply what we learn to create a couple of simple transitions:

scene.index and inputRange

The default “card stack” transition looks like this:

Before diving into the code of CardStack, let’s imagine how we’d implement this ourselves. We’ll use opacity as an example.

If we observe closely, we’ll notice that when the photo detail screen moves in, the photo grid screen becomes dimmer and dimmer until totally invisible, whereas the photo detail screen remains opaque.

cardStack seq

Remember the AnimateValue position? It represents the changing index of the scene during the transition. When it transits from the index of photo grid to the index of photo detail, we shall change the opacity of the grid from 1 to 0, and vice versa. Therefore:

// photo grid
const opacityForGrid = position.interpolate({
  inputRange: [indexOfGrid, indexOfDetail],
  outputRange: [1, 0],
});

// photo detail
const opacityForDetail = position.interpolate({
  inputRange: [indexOfGrid, indexOfDetail],
  outputRange: [1, 1],
});

The question is how to figure out indexOfGrid and indexOfDetail. Let’s keep going.

If we check out NavigationCardStack, we’ll see that _renderScene() is called with every scene available:

const scenes = props.scenes.map(
 scene => this._renderScene({
   ...props,
   scene,
 })
);

This means that in _renderScene() we know the index of the current scene being rendered:

_renderScene(props: NavigationSceneTransitionProps) {
  const scene = props.scene;
  const index = scene.index;
  ....
}

But how do we know whether this index is indexOfGrid or indexOfDetail? There is a small trick here. We know that indexOfDetail is always indexOfGrid + 1, so:

  • if index === indexOfGrid, then indexOfDetail = index + 1
  • if index === indexOfDetail, then indexOfGrid = index - 1

Using this, we can merge our previous opacityForGrid and opacityForDetail into a single opacity which can be used to render all the scenes, no matter if it’s the photo grid or detail. Isn’t it beautiful?

opacity code

As a recap, here’s the complete code:

// NavigationCardStack.js

class NavigationCardStack extends React.Component<DefaultProps, Props, void> {
  ....
  _render(props: NavigationTransitionProps): React.Element<any> {
    ....
    const scenes = props.scenes.map(
     scene => this._renderScene({
       ...props,
       scene,
     })
    );

    return (
        <View .... >
          {scenes}
        </View>
    );
  }
  _renderScene(props: NavigationSceneTransitionProps) {
    ....
    const style = NavigationCardStackStyleInterpolator.forHorizontal(props);
    ....
  }
}

// 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 opacity = position.interpolate({
    inputRange,
    outputRange: ([1, 1, 0.3, 0]: Array<number>),
  });
  ....
  return {
    opacity,
    ....
  };
}

I know. What is that “index + 0.99” doing there? Let me explain.

The “0.99-cliff” trick

opacity code

The point for the 0.99 is that it’s very close to 1. Using them as the input range, we are able to create a “cliff” in the output range. In the example above, when the position changes from index to index + 0.99, the output opacity gradually changes from 1 to 0.3, and then at index + 1, it suddenly sinks to 0.

opacity code

The cliff, in the card stack transition in particular, is an important performance tuning. Because composing translucent scenes is expensive, we should always try to minimize the number of translucent layers. Here when the photo grid scene is completely covered, we want to set its opacity to 0 to avoid unnecessary composition.

In comparison, the code below suffers from composition overhead because it leaves the opacity of photo grid non-zero even when it’s off screen:

const opacity = position.interpolate({
  inputRange:  [index - 1, index, index + 1],
  outputRange: [1,         1,     0.3      ],
});

Let’s recap what we’ve learned about the NavigationCardStack so far:

  • inputRange: [index - 1, index, index + 1]: with this single input range, we are able to express concisely both photo grid and photo detail scenes. If it’s rendering the photo grid scene, the range [index, index + 1] is effective; if it’s rendering the photo detail scene, the range [index - 1, index] is effective.
  • 0.99-cliff: useful to create output range that “suddenly” changes at some point. Question: what if we want to create a cliff just to the left of index? Shall we use index - 0.99? Or something else?

Our very own transitions!

Once we’ve learned about the above, it’s almost trivial to create the transitions I listed at the beginning of this post. I’ll just list the interpolation code below for your viewing pleasure. You can also check out the complete code on github (this and this).

const inputRange = [index-1, index, index+1];
  const opacity = position.interpolate({
      inputRange,
      outputRange: [ 0, 1, 0],
  });
const opacity = position.interpolate({
      inputRange: [index-1, index, index+0.999, index+1],
      outputRange: [ 0, 1, 1, 0],
  });

  const translateY = position.interpolate({
      inputRange: [index-1, index, index+1],
      outputRange: [150, 0, 0],
  })

Summary

Now that we’ve learned about how to concisely declare input and output ranges for all scenes and the “0.99” trick to create cliffs in the output range. We also created a couple of custom transitions without even breaking a sweat.

In the next post, we’ll start working on something a bit more challenging, the shared element transition! 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!

Special thanks to Eric Vlad Vicenti who reviewed this post and provided invaluable feedback!