Tuesday, July 28, 2020

React Router Native AnimatedSwitch

I wanted to automate the animations in my react native apps. Since I was using React Router I read up on their blurb on animating transitions. I came up with an AnimatedSwitch functional component for animating route changes.

So you use the component something like this:

<AnimatedSwitch {...this.props} exact>
<Route exact path="/" component={Home} />
<Route path="/customer" component={CustomerPage} />
<Route path="/about" component={AboutPage} />
</AnimatedSwitch>


If going to a new route, by default it will slide in from the right.   If you are going back or popping a route or even replacing a route, by default it will slide in from the left.

You can also change to a fade animation by changing the animationType to ANIMATE_FADE instead of the default of ANIMATE_SLIDE.   

You can also make a custom animationType by matching the shape of the 2 predefined animations.  For that object:
  • prev is the animation for the outgoing route
  • new is the animation for the incoming route
  • backPrevious is the animation for the outgoing route when going back or popping off the history
  • backNew is the animation for the incoming route when going back or popping off the history

In the animationSetup variable you can pass in a custom animation object to override the values used for the animation, such as type (decay, timing, spring) and then the setup variables for that type of animation directly matching the Animated API in React Native.

I hope you find it useful.   With a bit of work, it could also be modified to work with the web version of react router by switching out the Animated.Views with divs and css animations.    I haven't done that as we use NextJS for our web apps.

This is the usePrevious hook used to keep track of location and children of the previous render.   It needs to be available to AnimatedSwitch (you may need to adjust the import if your file structure doesn't match mine).   

import {useEffect, useRef} from 'react';

/**
* Any easy way to store the last value of a variable
* (maybe one set from useState).
*
* This can replace prevProps from componentDidUpdate.
*
* @param value The value to store.
*/
function usePrevious(value) {
const ref = useRef();

// Update the value in ref AFTER the render
// ready for the next call
useEffect(() => {
ref.current = value;
});

// This returns the value before the useEffect hook
// fires, so it is still the previous value
return ref.current;
}

export default usePrevious;

This is the AnimatedSwitch component.

import React, {useState, useEffect} from 'react';
import {Animated, Dimensions, View} from 'react-native';
import {
Switch,
matchPath,
useLocation,
useHistory,
Route,
} from 'react-router-native';

import usePrevious from '../../hooks/usePrevious';

const {width} = Dimensions.get('window');

export const DEFAULT_ANIMATION_SETUP = {
type: 'timing',
init: {
fromValue: 0,
toValue: 1,
duration: 250,
useNativeDriver: true,
},
};

export const ANIMATE_SLIDE = {
previous: anim => ({
position: 'absolute',
transform: [
{
translateX: anim.interpolate({
inputRange: [0, 1],
outputRange: [0, -width],
}),
},
],
}),
new: anim => ({
position: 'absolute',
transform: [
{
translateX: anim.interpolate({
inputRange: [0, 1],
outputRange: [width, 0],
}),
},
],
}),
backPrevious: anim => ({
position: 'absolute',
transform: [
{
translateX: anim.interpolate({
inputRange: [0, 1],
outputRange: [0, width],
}),
},
],
}),
backNew: anim => ({
position: 'absolute',
transform: [
{
translateX: anim.interpolate({
inputRange: [0, 1],
outputRange: [-width, 0],
}),
},
],
}),
};

export const ANIMATE_FADE = {
previous: anim => ({
opacity: anim.interpolate({
inputRange: [0, 0.75],
outputRange: [1, 0],
}),
}),
new: anim => ({
opacity: anim.interpolate({
inputRange: [0, 0.75],
outputRange: [0, 1],
}),
}),
backPrevious: anim => ({
opacity: anim.interpolate({
inputRange: [0, 0.75],
outputRange: [1, 0],
}),
}),
backNew: anim => ({
opacity: anim.interpolate({
inputRange: [0, 0.75],
outputRange: [0, 1],
}),
}),
};

const getPreviousRoute = (exact, prevPath, routes) => {
if (!routes) {
return null;
}
return routes.find(route => {
const match = matchPath(prevPath.pathname, route.props);

if (exact) {
return match != null && match.isExact;
} else {
return match != null;
}
});
};

const renderPreviousRoute = previousRoute => {
if (previousRoute) {
return previousRoute.props.component ? (
React.createElement(previousRoute.props.component)
) : (
<Route render={previousRoute.props.render} />
);
} else {
return null;
}
};

function AnimatedSwitch({
children,
exact,
animationType = ANIMATE_SLIDE,
animationSetup = DEFAULT_ANIMATION_SETUP,
}) {
const [animating, setAnimating] = useState(false);
const [anim] = useState(new Animated.Value(0));
const [previousRoute, setPreviousRoute] = useState();

const location = useLocation();
const history = useHistory();

// we're going to save the previous matching route so we can render
// it when it doesn't actually match the location anymore
const previousLocation = usePrevious(location);
const previousChildren = usePrevious(children);

// now save the animation type for both previous and new views
const [newAnimationStyle, setNewAnimationStyle] = useState();
const [prevAnimationStyle, setPrevAnimationStyle] = useState();

const finishAnimating = () => {
setAnimating(false);
setPreviousRoute();
};

const needsAnimation = previousLocation !== location;

useEffect(() => {
const newPreviousRoute = getPreviousRoute(
exact,
previousLocation,
previousChildren,
);

if (needsAnimation && newPreviousRoute) {
// we were rendering, but now we're heading back up to the parent,
// so we need to save the newPreviousRoute so we can render it
// while the animation is playing

if (history.action === 'POP' || history.action === 'REPLACE') {
setNewAnimationStyle(animationType.backNew(anim));
setPrevAnimationStyle(animationType.backPrevious(anim));
} else {
setNewAnimationStyle(animationType.new(anim));
setPrevAnimationStyle(animationType.previous(anim));
}

setPreviousRoute(newPreviousRoute);

setAnimating(true);
}
}, [
location,
needsAnimation,
animationType,
previousChildren,
previousLocation,
history.action,
anim,
exact,
]);

useEffect(() => {
if (animating) {
switch (animationSetup.type) {
case 'decay':
Animated.decay(anim, animationSetup.init).start(finishAnimating);
break;
case 'timing':
Animated.timing(anim, animationSetup.init).start(finishAnimating);
break;
case 'spring':
default:
Animated.spring(anim, animationSetup.init).start(finishAnimating);
}
}
}, [animating, anim, animationSetup]);

// Need to render the previous route for the time between not animating and animating
if (needsAnimation && !animating) {
const tempPreviousRoute = getPreviousRoute(
exact,
previousLocation,
previousChildren,
);

if (tempPreviousRoute) {
const prevRouteComp = renderPreviousRoute(tempPreviousRoute);
return prevRouteComp;
} else {
return null;
}
}

if (animating) {
// Animate both the previous route and the new route at the same time,
// then change them to the new route based on the animation type, so either slide or fade or spring
const prevRouteComp = renderPreviousRoute(previousRoute);
return (
<View>
<Animated.View key="newView" style={newAnimationStyle}>
<Switch>{children}</Switch>
</Animated.View>

<Animated.View key="prevView" style={prevAnimationStyle}>
<Switch>{prevRouteComp}</Switch>
</Animated.View>
</View>
);
} else {
// Just animate the actual route from the location
return (
<View>
<Animated.View key="newView">
<Switch>{children}</Switch>
</Animated.View>
</View>
);
}
}

export default AnimatedSwitch;