Showing posts with label React Native. Show all posts
Showing posts with label React Native. Show all posts

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;

Tuesday, March 27, 2018

Running Expo offline

Offline Expo:

I wanted to continue working on my expo project on my laptop while no wifi was present.   It was very hard to find information on how to do get it running, or if it was even possible.

Finally found the 2 magic commands!

This command will start the offline server.   No login required.
exp start --offline

This command will start the app in a running android emulator.
exp android --offline


To make this even easier (and so I didn't have to remember the commands) I just added them to my scripts in package.json

"scripts": {
"server": "exp start --offline",
"emulator": "exp android --offline"
}


Then I just run on one terminal:
npm run emulator


And in another terminal:
npm run server


Documentation:
To get the full offline experience, I also downloaded react-native, react-native-elements and expo from github so I can access the docs offline!   The gh-pages branch in react-native and react-native-elements contain the doc files, though the links won't work, so you have to browse to them manually.   Still better than no docs at all!

Thursday, March 22, 2018

Expo Icon Fonts with React Native and React Native Elements!

I was trying to use the Avatar and Icon objects from react-native-elements I kept getting the following error:
fontFamily 'MaterialIcons' is not a system font and has not been loaded through Expo.Font.loadAsync.

- If you intended to use a system font, make sure you typed the name correctly and that it is supported by your device operating system.

- If this is a custom font, be sure to load it with Expo.Font.loadAsync.

It was driving me nuts.   Googling for the answer just brought up snippets of information about what to do.   So finally I pieced together the parts to get it working.

You have to load the fonts before they are used.   It seems that if you ever blow away your node_modules and then do npm install again, you lose the built in loading.  So you have to do it manually.  Here is how!

I made sure the @expo/vector icons are loaded:

npm install --save @expo/vector-icons


Then I changed the App to load them directly:

import React from 'react';
import { View } from 'react-native';
import { Avatar } from 'react-native-elements';
import { AppLoading, Font } from 'expo';

import FontAwesome  
from './node_modules/@expo/vector-icons/fonts/FontAwesome.ttf';
import MaterialIcons  
from './node_modules/@expo/vector-icons/fonts/MaterialIcons.ttf';
export default class App extends React.Component {
state = {
fontLoaded: false
};

async componentWillMount() {
try {
await Font.loadAsync({
FontAwesome,
MaterialIcons
});
this.setState({ fontLoaded: true });
} catch (error) {
console.log('error loading icon fonts', error);
}
}
render() {
if (!this.state.fontLoaded) {
return <AppLoading />;
}

return (
<View>
<Text>My App</Text>
<Avatar
small
rounded
icon={{ name: 'add' }}
/>
</View>
);
}
}

So now the fonts load before the app is shown.   While they are loading, the AppLoading continues to render the loading screen before showing any of the app.   The fonts get loaded, then the state is set so the AppLoading component no longer renders and it continues to your app.

But why throw all that into the main App.js?    It's messy.   So I made an AppFontLoader utility that looks like this:


import React from 'react';
import { AppLoading, Font } from 'expo';

import FontAwesome 
from '../../node_modules/@expo/vector-icons/fonts/FontAwesome.ttf';
import MaterialIcons  
from '../../node_modules/@expo/vector-icons/fonts/MaterialIcons.ttf';

class AppFontLoader extends React.Component {
state = {
fontLoaded: false
};

async componentWillMount() {
try {
await Font.loadAsync({
FontAwesome,
MaterialIcons
});
this.setState({ fontLoaded: true });
} catch (error) {
console.log('error loading icon fonts', error);
}
}

render() {
if (!this.state.fontLoaded) {
return <AppLoading />;
}

return this.props.children;
}
}

export { AppFontLoader };

Now the App.js gets simplified!

import React from 'react';
import { View } from 'react-native';
import { Avatar } from 'react-native-elements';
import { AppFontLoader } from './src/utils';
export default class App extends React.Component { render() {
return ( 
<AppFontLoader>
 <View>
<Text>My App</Text>
<Avatar
small
rounded
icon={{ name: 'add' }}
/>
</View>
</AppFontLoader>
);
}
}

I hope this helps you.   It shouldn't take 4 hours to figure this out!