Curved Bottom Tab in React Native using SVG and D3-Shape

Creating a curved bottom tab with a really cool deep effect.

April 24, 2021

React

React-native

Javascript

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

  1. Let's start our react-native app, I'm going to use a bare react-native-cli installation, but you can go with expo too: First and formals we need to create a react native app:
yarn react-native init curved-bottom-tab-rn
  1. Moving on we need to install the needed react-navigation dependencies for a bottom tab use. At this tutrial, I'm using v5 of react-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: the TabNavigator 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.

Screen Shot 2021-04-24 at 17.10.23

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>
  );
}
  1. Now, let's get to work. In our src/navigation.{ts,js} file, we have in the TabNavigator a prop named tabBar 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 component MyTabBar and in our navigation.{ts,js} file we're going to use the property tabBar 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>
  );
}
  1. 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',
  },
});
  1. Let's add the TabBarButton to our TabNavigator, using a prop called options.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>
    );
  }
  1. 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: Screen Shot 2021-04-24 at 17.32.31

Using D3-Shape to Shape our TabBar

  1. 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 ..
  1. 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 and Path from react-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 the lineGenerator 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 the rect, 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:

CURVED_TAB_EXPLANATION CURVED_TAB_EXPLANATION

  • 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: Screen Shot 2021-04-24 at 17.49.31

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.