Animated sliding tab bar in React Native
Oksana Ivanchenko
Posted on January 26, 2020
Today I'll show you how to do this custom tab bar with sliding animation in React Native.
I came across this amazing tutorial written by Mateo Hrastnik that helps us achieve exactly what we need.
Let's Create A Custom Animated Tab Bar With React Native
Mateo Hrastnik ・ Jan 6 '19
Problem: In this tutorial, the author uses a library called react-native-pose to animate the tab bar. On 15th January 2020 the creators of this library announced that it will no longer be maintained and that it is now deprecated. We need to find another way to animate the tab bar. It turned out that it is simple to do with the native Animated API. That's what I'll show you in this tutorial. Also, I'll show you how to manage the orientation change. If you are too lazy to read it all, you can jump directly into the GitHub repository.
Dependencies needed
We will be using react-navigation. We will also install react-navigation-tabs to create the bottom tab bar.
yarn add react-navigation react-navigation-tabs
For this tutorial, I will also be using FontAwesome for the icons. If you want to do the same, you need to install all the needed dependencies.
yarn add react-native-svg @fortawesome/fontawesome-svg-core @fortawesome/free-solid-svg-icons @fortawesome/react-native-fontawesome
My folder configuration:
>src
>components
>icon.js
>tabbar.js
>router.js
>screens
>home.js
>planning.js
>search.js
>settings.js
App.js
Creating a custom bottom tab bar
I will create a router in src/components/router.js.
/* src/components/router.js */
import React from 'react';
import {createAppContainer} from 'react-navigation';
import {createBottomTabNavigator} from 'react-navigation-tabs';
import Home from '../screens/home';
import Settings from '../screens/settings';
import Search from '../screens/search';
import Planning from '../screens/planning';
import Icon from './icon';
import TabBar from './tabbar';
const Router = createBottomTabNavigator(
{
Home: {
screen: Home,
navigationOptions: {
tabBarIcon: ({tintColor}) => <Icon name="home" color={tintColor} />,
},
},
Planning: {
screen: Planning,
navigationOptions: {
tabBarIcon: ({tintColor}) => <Icon name="planning" color={tintColor} />,
},
},
Search: {
screen: Search,
navigationOptions: {
tabBarIcon: ({tintColor}) => <Icon name="search" color={tintColor} />,
},
},
Settings: {
screen: Settings,
navigationOptions: {
tabBarIcon: ({tintColor}) => <Icon name="settings" color={tintColor} />,
},
},
},
{
tabBarComponent: TabBar,
tabBarOptions: {
activeTintColor: '#2FC7FF',
inactiveTintColor: '#C5C5C5',
},
},
);
export default createAppContainer(Router);
Basically in this code, we are defining our routes and telling that we will be using a custom TabBar component for our tab bar.
In src/components/icon.js:
/* src/components/icon.js */
import React from 'react';
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome';
import {
faHome,
faCog,
faSearch,
faClock,
} from '@fortawesome/free-solid-svg-icons';
const icons = {
home: faHome,
search: faSearch,
planning: faClock,
settings: faCog,
};
const Icon = ({name, color}) => {
return (
<FontAwesomeIcon icon={icons[name]} style={{color: color}} size={20} />
);
};
export default Icon;
Depending on the name of the route that we passed in props, the icon will be different for each tab.
Now we need to import our router in App.js.
/* App.js */
import React from 'react';
import Router from './src/components/router';
const App = () => {
return (
<Router />
);
};
export default App;
In src/components/tabbar.js:
/* src/components/tabbar.js */
import React from 'react';
import {View, TouchableOpacity, StyleSheet, SafeAreaView, Dimensions} from 'react-native';
const S = StyleSheet.create({
container: {
flexDirection: 'row',
height: 54,
borderTopWidth: 1,
borderTopColor: '#E8E8E8',
},
tabButton: {flex: 1, justifyContent: 'center', alignItems: 'center'},
activeTab: {
height: '100%',
alignItems: 'center',
justifyContent: 'center',
},
activeTabInner: {
width: 48,
height: 48,
backgroundColor: '#E1F5FE',
borderRadius: 24,
},
});
const TabBar = props => {
const {
renderIcon,
activeTintColor,
inactiveTintColor,
onTabPress,
onTabLongPress,
getAccessibilityLabel,
navigation,
} = props;
const {routes, index: activeRouteIndex} = navigation.state;
const totalWidth = Dimensions.get("window").width;
const tabWidth = totalWidth / routes.length;
return (
<SafeAreaView>
<View style={S.container}>
<View>
<View style={StyleSheet.absoluteFillObject}>
<View
style={[S.activeTab, { width: tabWidth }]}>
<View style={S.activeTabInner} />
</View>
</View>
</View>
{routes.map((route, routeIndex) => {
const isRouteActive = routeIndex === activeRouteIndex;
const tintColor = isRouteActive ? activeTintColor : inactiveTintColor;
return (
<TouchableOpacity
key={routeIndex}
style={S.tabButton}
onPress={() => {
onTabPress({route});
}}
onLongPress={() => {
onTabLongPress({route});
}}
accessibilityLabel={getAccessibilityLabel({route})}>
{renderIcon({route, focused: isRouteActive, tintColor})}
</TouchableOpacity>
);
})}
</View>
</SafeAreaView>
);
};
export default TabBar;
Here is our custom tab bar. For now, it looks like this:
Now we need to animate our activeTab.
Animating active tab
To animate the circle that indicates the selected tab, we will be using the Animated library which is included in react-native. Our circle should move horizontally from one active tab to another, so we will be using the translateX transform value.
In src/components/tabbar.js:
...
/* src/components/tabbar.js */
import {useState} from 'react';
import {Animated} from 'react-native';
const TabBar = props => {
...
const [translateValue] = useState(new Animated.Value(0));
// When the user opens the application, it's the first tab that is open.
// The initial value of translateX is 0.
const onTabBarPress = (route, routeIndex) => {
onTabPress(route); // function that will change the route;
Animated.spring(translateValue, {
toValue: routeIndex * tabWidth,
// The translateX value should change depending on the chosen route
velocity: 10,
useNativeDriver: true,
}).start(); // the animation that animates the active tab circle
};
return (
<SafeAreaView>
<View style={S.container}>
<View>
<View style={StyleSheet.absoluteFillObject}>
<Animated.View
style={[
S.activeTab,
{
width: tabWidth,
transform: [{translateX: translateValue}],
},
]}>
<View style={S.activeTabInner} />
</Animated.View>
{/* the container that we animate */}
</View>
</View>
{routes.map((route, routeIndex) => {
const isRouteActive = routeIndex === activeRouteIndex;
const tintColor = isRouteActive ? activeTintColor : inactiveTintColor;
return (
<TouchableOpacity
key={routeIndex}
style={S.tabButton}
onPress={() => {
onTabBarPress({route}, routeIndex);
}}
onLongPress={() => {
onTabLongPress({route});
}}
accessibilityLabel={getAccessibilityLabel({route})}>
{renderIcon({route, focused: isRouteActive, tintColor})}
</TouchableOpacity>
{/* the onPress function changed. We will now use the onTabPress function that we created.
We will send the route that was selected and its index */}
);
})}
</View>
</SafeAreaView>
);
};
export default TabBar;
That's it. Now we have a desirable animation. But if we change the orientation of our phone, it will be broken as the width of our tab won't be changed dynamically.
Managing the orientation change
To do this we will create a High Order Component. As it's written in the documentation, HOC is a function that takes a component and returns a new component.
In src/components we are creating with-dimensions.js:
/* src/components/with-dimensions.js */
import React, {useEffect, useState} from 'react';
import {Dimensions} from 'react-native';
const withDimensions = BaseComponent => props => {
const [dimensions, setDimensions] = useState({
width: Dimensions.get('window').width,
});
//setting the initial width;
const handleOrientationChange = ({window}) => {
const {width} = window;
setDimensions({width});
};
useEffect(() => {
Dimensions.addEventListener('change', handleOrientationChange);
//when the component is mounted and the dimensions change, we will go to the handleOrientationChange function;
return () =>
Dimensions.removeEventListener('change', handleOrientationChange);
// when the component is unmounted, we will remove the event listener;
}, []);
return (
<BaseComponent
dimensions={{width: dimensions.width}}
{...props}
/>
);
};
export default withDimensions;
Now we need to wrap our Tabbar component in the withDimensions component.
/*src/components/tabbar.js*/
...
import {useEffect} from 'react';
import withDimensions from './with-dimensions';
...
const TabBar = props => {
const {
renderIcon,
activeTintColor,
inactiveTintColor,
onTabPress,
onTabLongPress,
getAccessibilityLabel,
navigation,
dimensions,
} = props;
// adding dimensions in props
//const totalWidth = Dimensions.get("window").width;
const tabWidth = dimensions.width / routes.length;
useEffect(() => {
translateValue.setValue(activeRouteIndex * tabWidth);
}, [tabWidth]);
// whenever the tabWidth changes, we also change the translateX value
...
return (
...
);
};
export default withDimensions(TabBar);
Now it works perfectly on all the Android phones and iPhones without a notch. But if you have an iPhone 10+, you will have a bug in the horizontal mode.
Fixing SafeAreaView bug for the iPhone 10+
The problem is that { Dimensions } from 'react-native'* sends us the total width of the device and not the width of the SafeAreaView. To fix it we will be using react-native-safe-area-view. It will help us know the padding of the SafeAreaView so we can include it in the width calculation.
yarn add react-native-safe-area-view react-native-safe-area-context
To use it we will need to wrap our App component in a SafeAreaProvider provided by this library.
/* App.js */
import React from 'react';
import {SafeAreaProvider} from 'react-native-safe-area-context';
import Router from './src/components/router';
const App = () => {
return (
<SafeAreaProvider>
<Router />
</SafeAreaProvider>
);
};
export default App;
Now in src/components/withDimensions.js:
/*src/components/withDimensions.js*/
...
import {SafeAreaConsumer} from 'react-native-safe-area-context';
...
return (
<SafeAreaConsumer>
{insets => (
<BaseComponent
dimensions={{width: dimensions.width - insets.left - insets.right}}
{...props}
/>
)}
</SafeAreaConsumer>
);
{/* when we are calculating the width, we substract the padding depending on the iPhone model */}
Also, don't forget to change the SafeAreaView importation. Now we will import it from 'react-native-safe-area-view'.
/*src/components/tabbar.js*/
import SafeAreaView from 'react-native-safe-area-view';
Posted on January 26, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.