Every developer have to follow prototypes coming from design work. The thing is not always said is that sometimes the designer perceive something that it's hard for developers. I have a similar comparison that I like to do: Imagine an architect that architects a house in some weird or hard way, pretty, but hard. The engineer needs to overcome the issues, and, if possible delivery the architect solution. Getting back to ours, we are the engineer (metaphoric and specifically saying) that needs to deliver the awkward, pretty and hard structure.
For me, it was on my way working on a fintech, the need of a curved tab navigation arrived, and me, as a developer that have never done it, struggled. So today, I'll try, hope with success, explain in details how I could achieve the desired behavior with performance, low and clean code.
Introduction
The first step of the way was understanding the ways to do it, my finds were divided in:
- Creating a curved bottom navigation using Views only.
- Using SVG forms and trying to shape them the way we need (fyi, we can create awesome forms by shaping them).
Moving on, I decided to go with the most beautiful, performant and cleaner solution I encounter, which was using SVGs and shaping them accordingly. So let's code!!
Setting up Project and Installing dependencies
- Let's start our react-native app, I'm going to use a bare
react-native-cli
installation, but you can go withexpo
too: First and formals we need to create a react native app:
yarn react-native init curved-bottom-tab-rn
- Moving on we need to install the needed
react-navigation
dependencies for a bottom tab use. At this tutrial, I'm usingv5
ofreact-navigation
which it's completely your choice.
yarn add @react-navigation/native
yarn add react-native-reanimated react-native-gesture-handler react-native-screens react-native-safe-area-context @react-native-community/masked-view
yarn add @react-navigation/stack
yarn add @react-navigation/bottom-tabs
PS: You should follow react-navigation documentation for better documentation on installing all dependencies and settings. All of them are made for this project and can be encountered in the repository at the final of this post.
Using React Navigation
Here, for test purposes, I'm going to create different Stacks, which are going to be used as an entry point to different Tabs. So with no more delay, let's create our first file navigation.{ts,js}
inside our src/*
folder.
In my case here's the example below:
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { First, Second, Third } from './screens'
const Stack = createStackNavigator();
const Tab = createBottomTabNavigator();
export function FirstStack() {
return (
<Stack.Navigator>
<Stack.Screen name="First" component={First} />
</Stack.Navigator>
);
}
export function SecondStack() {
return (
<Stack.Navigator>
<Stack.Screen name="Second" component={Secondary} />
</Stack.Navigator>
);
}
export function ThirdStack() {
return (
<Stack.Navigator>
<Stack.Screen name="Third" component={Third} />
</Stack.Navigator>
);
}
export default function Navigation() {
return (
<NavigationContainer>
<Tab.Navigator>
<Tab.Screen name="First" component={FirstStack} />
<Tab.Screen name="Secondary" component={SecondaryStack}/>
<Tab.Screen name="Third" component={ThirdStack} />
</Tab.Navigator>
</NavigationContainer>
);
}
For more information on this piece of code, here are the things we're doing and the result of it below:
- Adding
Screens
: we added three different screens (First, Second and Third) which just exists for test purposes, and are just flex components with different background colors. - Creating
Stacks
, which, as the name suggests, it's a stack of screens. - Creating a
TabNavigator
: theTabNavigator
is basically a Tab that keeps at the bottom of the screen, in which, in our case, will be always visible for all screens no matter the current Stack.
Building a Custom TabBar
For customizing a TabBar, react-navigation
give us a full documentation that can be really self explainer, so I'll leave you guys with that here.
Following, let's create a file for this custom tabBar, I'm going to name it MyTabBar
, inside components
folder.
import { View, Text, TouchableOpacity } from 'react-native';
function MyTabBar({ state, descriptors, navigation }) {
const focusedOptions = descriptors[state.routes[state.index].key].options;
if (focusedOptions.tabBarVisible === false) {
return null;
}
return (
<View style={{ flexDirection: 'row' }}>
{state.routes.map((route, index) => {
const { options } = descriptors[route.key];
const label =
options.tabBarLabel !== undefined
? options.tabBarLabel
: options.title !== undefined
? options.title
: route.name;
const isFocused = state.index === index;
const onPress = () => {
const event = navigation.emit({
type: 'tabPress',
target: route.key,
canPreventDefault: true,
});
if (!isFocused && !event.defaultPrevented) {
navigation.navigate(route.name);
}
};
const onLongPress = () => {
navigation.emit({
type: 'tabLongPress',
target: route.key,
});
};
return (
<TouchableOpacity
accessibilityRole="button"
accessibilityState={isFocused ? { selected: true } : {}}
accessibilityLabel={options.tabBarAccessibilityLabel}
testID={options.tabBarTestID}
onPress={onPress}
onLongPress={onLongPress}
style={{ flex: 1 }}
>
<Text style={{ color: isFocused ? '#673ab7' : '#222' }}>
{label}
</Text>
</TouchableOpacity>
);
})}
</View>
);
}
- Now, let's get to work. In our
src/navigation.{ts,js}
file, we have in the TabNavigator a prop namedtabBar
that returns a function with props, those props are what we need for our custom tab navigator, so we can receive those props and return our custom component. So let's import our componentMyTabBar
and in ournavigation.{ts,js}
file we're going to use the propertytabBar
mentioned prior:
import { MyTabBar } from './components'
export default function Navigation() {
return (
<NavigationContainer>
<Tab.Navigator tabBar={props => <MyTabBar {...props} />}>
<Tab.Screen name="First" component={FirstStack} />
<Tab.Screen name="Secondary" component={SecondaryStack}/>
<Tab.Screen name="Third" component={ThirdStack} />
</Tab.Navigator>
</NavigationContainer>
);
}
- Now, you can realize that all three tabs has the same shape, but we want the middle one with a different display. For that we're going to create a
TabBarButton.{tsx,jsx}
component, which will be used only for the middle tab.
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
export default function TabBarButton() {
return (
<View style={styles.container}>
<Text style={styles.text}>+</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
position: 'absolute',
width: 55,
height: 55,
borderRadius: 999, // full radius
bottom: 10,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'purple',
},
text: {
fontSize: 40,
color: 'white',
},
});
- Let's add the
TabBarButton
to ourTabNavigator
, using a prop calledoptions.tabBarButton
which receives an Component instead of the current tabBarButton component:
export default function Navigation() {
return (
<NavigationContainer>
<Tab.Navigator>
<Tab.Screen name="First" component={FirstStack} />
<Tab.Screen name="Secondary" component={SecondaryStack} options={{ tabBarButton: () => <TabBarButton /> }} />
<Tab.Screen name="Third" component={ThirdStack} />
</Tab.Navigator>
</NavigationContainer>
);
}
- Things are not working correctly just yet, as we created a custom tabBar, the option
tabBarButton
is not being accessed. For accessing it, we can add a condition like the following:
import { View, Text, TouchableOpacity } from 'react-native';
function MyTabBar({ state, descriptors, navigation }) {
const focusedOptions = descriptors[state.routes[state.index].key].options;
if (focusedOptions.tabBarVisible === false) {
return null;
}
return (
<View style={{ flexDirection: 'row' }}>
{state.routes.map((route, index) => {
const { options } = descriptors[route.key];
const label =
options.tabBarLabel !== undefined
? options.tabBarLabel
: options.title !== undefined
? options.title
: route.name;
const isFocused = state.index === index;
const onPress = () => {
const event = navigation.emit({
type: 'tabPress',
target: route.key,
canPreventDefault: true,
});
if (!isFocused && !event.defaultPrevented) {
navigation.navigate(route.name);
}
};
const onLongPress = () => {
navigation.emit({
type: 'tabLongPress',
target: route.key,
});
};
return (
<TouchableOpacity
accessibilityRole="button"
accessibilityState={isFocused ? {selected: true} : {}}
accessibilityLabel={options.tabBarAccessibilityLabel}
testID={options.tabBarTestID}
onPress={onPress}
onLongPress={onLongPress}
style={{flex: 1, justifyContent: 'center', alignItems: 'center'}}>
{options.tabBarButton ? (
<TabBarButton />
) : (
<Text style={{color: isFocused ? '#673ab7' : '#222'}}>
{label}
</Text>
)}
</TouchableOpacity>
);
})}
</View>
);
}
Let's look what we have at this moment:
Using D3-Shape to Shape our TabBar
- Mostly important, and probably why you're here to be honest, we're going to shape our TabBar. For that we're going to use d3-shape and react-native-svg, so let's add them:
yarn add d3-shape react-native-svg
If using IOS, run:
cd ios/ && pod install && cd ..
- Now, we're going to build a component that will be called
TabShape.{tsx,jsx}
which will be responsible for shaping our tabBar:
import React from 'react';
import {Dimensions} from 'react-native';
import {curveBasis, line} from 'd3-shape';
import Svg, {Path} from 'react-native-svg';
const TAB_HEIGHT = 80; // This fixed height can be as you wish.
const { width } = Dimensions.get('window');
const lineGenerator = line();
const rect = lineGenerator([
[0, 0],
[width / 2, 0],
[width, 0],
[width, TAB_HEIGHT],
[0, TAB_HEIGHT],
[0, 0],
]);
const center = lineGenerator.curve(curveBasis)([
[(width / 5) * 2, 0],
[(width / 5) * 2 + 20, TAB_HEIGHT * 0.5],
[(width / 5) * 3 - 20, TAB_HEIGHT * 0.5],
[(width / 5) * 3, 0],
]);
const d = `${center} ${rect}`;
export default function TabShape() {
return (
<Svg width={width} height={TAB_HEIGHT}>
<Path fill="white" {...{d}} />
</Svg>
);
}
Okay, let's run into this code so you can understand it:
Svg
andPath
fromreact-native-svg
are responsible for creating an SVG through something, which in this case are shapes generated from d3-shape.- The
line
function is responsible for generating lines based on giving coordinates. - The
rect
constant uses thelineGenerator
to build a rectangle, as the following:
const rect = lineGenerator([
[0, 0],
[width / 2, 0],
[width, 0],
[width, TAB_HEIGHT],
[0, TAB_HEIGHT],
[0, 0],
]);
Translates to that in the reality:
[0,0] ─────────────── [width / 2, 0] ───────────────────── [width, 0]
│ │ │
│ │ │
│ │ │
[0,TAB_HEIGHT] ────────────────────────────────────── [width, TAB_HEIGHT]
- Last and most importantly, the
curve
, which follows the exact same idea of therect
, but by curving it:
const center = lineGenerator.curve(curveBasis)([
[(width / 5) * 2, 0],
[(width / 5) * 2 + 20, HEIGHT_SIZE * 0.5],
[(width / 5) * 3 - 20, HEIGHT_SIZE * 0.5],
[(width / 5) * 3, 0],
]);
- Check the whole explanation with this excalindraw Draw:
- Now that we have our TabShape ready we need to turn our
TabBar
into an absolute component, so we can add a shape to it:
import { Dimensions } from 'react-native'
import TabShape from './TabShape'
const TAB_HEIGHT = 80;
const { width } = Dimensions.get('window')
const styles = StyleSheet.create({
myTabBarContainer: {
position: 'absolute',
height: TAB_HEIGHT,
width,
shadowOffset: {
width: 0,
height: 0,
},
shadowOpacity: 0.2,
elevation: 5,
}
})
const MyTabBar = ({...}) => {
return (
<View style={styles.myTabBarContainer}>
<TabShape />
<View style={StyleSheet.absoluteFill}>
<View style={{ flexDirection: 'row', flex: 1, alignItems: 'center', justifyContent: 'center' }}>
{state.routes.map((route, index) => {
const {options} = descriptors[route.key];
const label =
options.tabBarLabel !== undefined
? options.tabBarLabel
: options.title !== undefined
? options.title
: route.name;
const isFocused = state.index === index;
const onPress = () => {
const event = navigation.emit({
type: 'tabPress',
target: route.key,
canPreventDefault: true,
});
if (!isFocused && !event.defaultPrevented) {
navigation.navigate(route.name);
}
};
const onLongPress = () => {
navigation.emit({
type: 'tabLongPress',
target: route.key,
});
};
return (
<TouchableOpacity
key={index}
accessibilityRole="button"
accessibilityState={isFocused ? {selected: true} : {}}
accessibilityLabel={options.tabBarAccessibilityLabel}
testID={options.tabBarTestID}
onPress={onPress}
onLongPress={onLongPress}
style={styles.button}>
{options.tabBarButton ? (
<TabBarButton />
) : (
<Text style={isFocused ? styles.label : styles.inactiveLabel}>
{label}
</Text>
)}
</TouchableOpacity>
);
})}
</View>
</View>
</View>
);
}
Results
Finally, we have our goal done!!! Take a look at that:
Conclusion
It was definitely a challenge that we've faced, but we made it! Thanks to you, as I'm seeing you reached the end of it, hope you understood the whole tutorial without problems. Let me know if you have doubts or encountered issues. Stay tuned!
You can check the repository example for the full implementation.