DEV Community

loading...
Cover image for React Native Custom BottomBar Navigation with BottomSheet

React Native Custom BottomBar Navigation with BottomSheet

Jeff Edmondson
Just a software engineer trying to find his way through life & code. https://www.jeffedmondson.dev/
・7 min read

React Native Custom Navigation Bottom Bar he

Goal

  • Create a custom bottom bar with react navigation and a bottom sheet action button.
  • If you are using expo the code will be the same. The installation of the libraries may be different.

Libraries Used:

Install the libraries

  • React Native Navigation
yarn add @react-navigation/native
# Dependencies 
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/bottom-tabs
yarn add @react-navigation/stack
yarn add react-native-safe-area-contex
yarn add react-native-screens
cd ios
pod install
Enter fullscreen mode Exit fullscreen mode
  • ‼️ Import react-native-gesture-handler a the top of the app.tsx file ‼️
import 'react-native-gesture-handler';
Enter fullscreen mode Exit fullscreen mode
  • BottomSheet (Your can use whatever bottom sheet library you want. They should all work the same). Make sure to install all dependencies as well!
yarn add @gorhom/bottom-sheet@^2
# Dependencies (Should already be installed from React Native Naviagation Library) 
yarn add react-native-reanimated@^1 react-native-gesture-handler
cd ios
pod install
# There are more steps required for android to set up React Native Gesture Handler (Updating MainActivity.java)
# https://docs.swmansion.com/react-native-gesture-handler/docs/#installation
Enter fullscreen mode Exit fullscreen mode
  • React Native Portal
yarn add @gorhom/portal
Enter fullscreen mode Exit fullscreen mode
  • React Native IonIcons (Optional)
yarn add react-native-ionicons@^4.x
Enter fullscreen mode Exit fullscreen mode

Project Structure

Project Structure

  • navigation directory - This will hold all of our code that has to do with anything navigation.
  • screens directory - Holds all of the screens that our application will use.
  • components directory - Holds shared components that can be re-used a crossed different screens & components.

Setting Up the Navigation

  • First things first, let's create an index.tsx file. This will be the entry point of the navigation code.
  • We first need to setup a NavigationContainer that will be wrapped around our RootNavigator component.
  • createStackNavigator() - allows you to transition between screens. Screens are added and removed from the stack similar to as it is done on a normal web browser.
  • After we create our Stack we then define the screens that we want to be within the stack. For our purposes we want root & NotFound. This components are stacks themselves. Our root component will be our BottomTabNavigator (bottom tab bar)

navigation/index.tsx

import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
import * as React from 'react';

import { RootStackParamList } from '../types';
import BottomTabNavigator from './BottomTabNavigator';
import NotFoundScreen from '../screens/NotFoundScreen';

export default function Navigation() {
   return (
     <NavigationContainer>
       <RootNavigator />
     </NavigationContainer>
   );
}

const Stack = createStackNavigator<RootStackParamList>();

function RootNavigator() {
   return (
     <Stack.Navigator screenOptions={{ headerShown: false }}>
       <Stack.Screen name="Root" component={BottomTabNavigator} />
       <Stack.Screen name="NotFound" component={NotFoundScreen} options={{ title: 'Oops!' }} />
     </Stack.Navigator>
   );
}
Enter fullscreen mode Exit fullscreen mode

Bottom Tab Navigator

  • First we need to create the bottomTabNavigator: createBottomTabNavigator. This is the skeleton of the bottom bar. It allows us to navigate to different routes that we will define.

Bottom Bar

  • Once we have an instance of createBottomTabNavigator we can render the component

     <BottomTab.Navigator
          initialRouteName="Home"   // What tab do we want to default to
          tabBarOptions={{          // This gives us the ability to add addtional
            showLabel: false,       // options when we create the bottom tab
            style: {.               // most importantly the style component
              position: 'absolute',
              bottom: 25, 
              left: 20,
              right: 20,
              backgroundColor: '#ffffff',
              borderRadius: 15,
              ...style.shadow,
              paddingBottom: 5
            }
          }}      
          >
          ...
        </BottomTab.Navigator>
    
  • Now that we have the tab bar we will want to fill it up with some screens. In order to do that we can add the screens within the Bottom.Navigator component. For the sake of this blog post we will just have 2 screens. Home & About.

  • Each screen needs to have a name and a component . These components themselves are going to be stackNavigators. This will allow us to navigate to different pages within the currently selected tab.

  • We can also set specific options for each screen. Here we are calling a method in order to render an IonIcon

<BottomTab.Screen
     name="Home"
   component={HomeScreenNavigator}
   options={{
      tabBarIcon: ({ color }) => <TabBarIcon name="home" color={color} />,
   }}
 />

<BottomTab.Screen
   name="About"
   component={ReminderScreenNavigator}
   options={{
      tabBarIcon: ({ color }) => <TabBarIcon name="alarm" color={color} />,
   }}
 />
Enter fullscreen mode Exit fullscreen mode
const HomeScreenStack = createStackNavigator<HomeScreenParamList>();
function HomeScreenNavigator() {
  return (
    <HomeScreenStack.Navigator>
      <HomeScreenStack.Screen
        name="Home"
        component={HomeScreen}
        options={{ headerShown: true }}
      />
    </HomeScreenStack.Navigator>
  );
}

const AboutScreenStack = createStackNavigator<AboutScreenParamList>();
function ReminderScreenNavigator() {
  return (
    <AboutScreenStack.Navigator>
      <AboutScreenStack.Screen
        name="About"
        component={AboutScreen}
        options={{ headerTitle: 'About' }}
      />
    </AboutScreenStack.Navigator>
  );
}
Enter fullscreen mode Exit fullscreen mode

Wrapping Up Boilerplate Navigation Code

  • Once we have all of the above, it is time to wrap our entry component.
  • Within App.tsx we want to import our Navigation component that we defined earlier.
  • We also want to wrap Navigation within SafeAreaProvider
  • Your App.tsx file should look like the following

import 'react-native-gesture-handler';
import React from 'react';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import Navigation from './navigation';

const App = () => {
   return (
     <SafeAreaProvider>
              <Navigation />
     </SafeAreaProvider>
   );
 };

 export default App;
Enter fullscreen mode Exit fullscreen mode
  • We should then be left with the following.

Basic Auth

  • Everything we have done up to this point is standard react native navigation code. The real challenge comes when we want to add a custom button in the BottomTab to open a bottom sheet.

Setting up the Bottom Sheet

  • In order to add another "tab" or "button" to our BottomTab navigator we must declare a new entry within it.
  • As we learned earlier each BottomTab.Screen entry is required to have a component. In our case we can create a null component since we want to open a bottom sheet component instead of navigating to a different page.
const AddScreenComponent = () => {
  return null;
}
Enter fullscreen mode Exit fullscreen mode
  • And then finally we need to add this to our BottomTab navigator. Instead of rendering a tabBarIcon we are going to use the tabBarButton option and then a function to render a custom BottomSheet component (AddBottomSheet) that we will define shortly.
...
<BottomTab.Screen
      name="Add"
    component={AddScreenComponent}
    options={{
       tabBarButton: () => <AddBottomSheet />,
    }}
/>
...
Enter fullscreen mode Exit fullscreen mode
  • And that's it for the BottomTabBar.tsx component!

Full BottomTabBar.tsx code

navigation/BottomTabBar.tsx

import Icon from 'react-native-ionicons';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { createStackNavigator } from '@react-navigation/stack';
import { StyleSheet } from 'react-native';
import * as React from 'react';
import HomeScreen from '../screens/HomeScreen';
import AboutScreen from '../screens/AboutScreen';
import AddBottomSheet from '../components/AddBottomSheet';
import { 
  BottomTabParamList, 
  HomeScreenParamList, 
  AboutScreenParamList
} from '../types';

const BottomTab = createBottomTabNavigator<BottomTabParamList>();

export default function BottomTabNavigator() {
  return (
    <BottomTab.Navigator
      initialRouteName="Home"
      tabBarOptions={{ 
        showLabel: false,
        style: {
          position: 'absolute',
          bottom: 25, 
          left: 20,
          right: 20,
          backgroundColor: '#ffffff',
          borderRadius: 15,
          ...style.shadow,
          paddingBottom: 5
        }
      }}      
      >
      <BottomTab.Screen
        name="Home"
        component={HomeScreenNavigator}
        options={{
          tabBarIcon: ({ color }) => <TabBarIcon name="home" color={color} />,
        }}
      />
      <BottomTab.Screen
        name="Add"
        component={AddScreenComponent}
        options={{
          tabBarButton: () => <AddBottomSheet />,
        }}
      /> 
      <BottomTab.Screen
        name="About"
        component={ReminderScreenNavigator}
        options={{
          tabBarIcon: ({ color }) => <TabBarIcon name="alarm" color={color} />,
        }}
      />
    </BottomTab.Navigator>
  );
}

function TabBarIcon(props: { name: React.ComponentProps<typeof Icon>['name']; color: string }) {
  return <Icon size={30} style={{ marginBottom: -3 }} {...props} />;
}

const HomeScreenStack = createStackNavigator<HomeScreenParamList>();
function HomeScreenNavigator() {
  return (
    <HomeScreenStack.Navigator>
      <HomeScreenStack.Screen
        name="Home"
        component={HomeScreen}
        options={{ headerShown: true }}
      />
    </HomeScreenStack.Navigator>
  );
}

const AboutScreenStack = createStackNavigator<AboutScreenParamList>();
function ReminderScreenNavigator() {
  return (
    <AboutScreenStack.Navigator>
      <AboutScreenStack.Screen
        name="About"
        component={AboutScreen}
        options={{ headerTitle: 'About' }}
      />
    </AboutScreenStack.Navigator>
  );
}

const AddScreenComponent = () => {
  return null;
}

const style = StyleSheet.create({
  shadow: {
    shadowColor: '#7F5DF0',
    shadowOffset: {
      width: 0, 
      height: 10
    },
    shadowOpacity: 0.25,
    shadowRadius: 3.5,
    elevation: 5,
  }
});
Enter fullscreen mode Exit fullscreen mode

Creating the BottomSheet Component AddBottomSheet.tsx

  • This component will be displayed on the bottom bar so therefore we want it to look like a button when the BottomSheet is not presented.
<TouchableWithoutFeedback onPress={onAddButtonPress}>
    <Icon size={65} name='add-circle' color={'#00a16e'} />          
</TouchableWithoutFeedback>

const onAddButtonPress = () => {
    console.log('button pressed');
}
Enter fullscreen mode Exit fullscreen mode
  • Now it is time to add the BottomSheet code.
import BottomSheet from '@gorhom/bottom-sheet';
import * as React from 'react';
import { StyleSheet, View, Text, TouchableWithoutFeedback, } from 'react-native';
import Icon from 'react-native-ionicons';
import { Portal, PortalHost } from '@gorhom/portal';

const AddBottomSheet = () => {
    // Creates a reference to the DOM element that we can interact with
    const bottomSheetRef = React.useRef<BottomSheet>(null);

    // Setting the points to which we want the bottom sheet to be set to
    // Using '-30' here so that it is not seen when it is not presented
    const snapPoints = React.useMemo(() => [-30, '75%'], []);

    // Callback function that gets called when the bottom sheet changes
    const handleSheetChanges = React.useCallback((index: number) => {
        console.log('handleSheetChanges', index);
    }, []);

    // Expands the bottom sheet when our button is pressed
    const onAddButtonPress = () => {
        bottomSheetRef?.current?.expand();
    }

   return ( 
    <>
         <TouchableWithoutFeedback onPress={onAddButtonPress}>
             <Icon size={65} name='add-circle' color={'#00a16e'} />          
         </TouchableWithoutFeedback>
                    <BottomSheet
                        ref={bottomSheetRef}
                        index={-1} // Hide the bottom sheet when we first load our component 
                        snapPoints={snapPoints}
                        onChange={handleSheetChanges}
                    >
                    <View style={styles.contentContainer}>
                        <Text style={styles.bottomSheetTitle}>Add Customer</Text>
                    </View>
                </BottomSheet>
      </>
   )
}

export default AddBottomSheet;

const styles = StyleSheet.create({
    container: {
        flex: 1,
        padding: 24,
        backgroundColor: 'grey',
     },
     contentContainer: {
        flex: 1,
        paddingLeft: 50
     },
     bottomSheetTitle: {
         fontSize: 24,
         fontWeight: '500'
     }
});
Enter fullscreen mode Exit fullscreen mode
  • When we run our project now we get some unintentional behavior. When we click our button the bottom sheet does appear, however it is limited to the context of the bottom bar. This is obviously not what we want. Without portal

React Native Portal

  • We can utilize react-native-portal to fix this issue
  • Portals exist within normal react. Portals are a way to render children into a DOM node exist outside of the parent component.
  • In our case we want our BottomSheet (Child Component) to be rendered outside of the BottomTabBar (Parent Component)
  • In order to accomplish this we first need to set up a PortalProvider within our App.tsx file. This tells our BottomSheet that we want it rendered at this level, outside of our navigation code.
import 'react-native-gesture-handler';
import React from 'react';
import { SafeAreaProvider } from 'react-native-safe-area-context';

import Navigation from './navigation';
import { PortalProvider } from '@gorhom/portal';

 const App = () => {

   return (
     <SafeAreaProvider>
          <PortalProvider>
              <Navigation />
          </PortalProvider>
     </SafeAreaProvider>
   );
 };

 export default App;
Enter fullscreen mode Exit fullscreen mode
  • Once we have done that we need to wrap our BottomSheet component inside of the Portal component and set a PortalHost
...
import { Portal, PortalHost } from '@gorhom/portal';
...
const AddBottomSheet = () => {

    ...
   return ( 
    <>
        ...
            <Portal>
                <BottomSheet
                    ref={bottomSheetRef}
                    index={-1}
                    snapPoints={snapPoints}
                    onChange={handleSheetChanges}
                    >
                    <View style={styles.contentContainer}>
                        <Text style={styles.bottomSheetTitle}>Add Customer</Text>
                    </View>
                </BottomSheet>
            </Portal>

            <PortalHost name="custom_host" /> // Name to be used as an id
      </>
   )
}

...
Enter fullscreen mode Exit fullscreen mode
  • After that everything should be working correctly. Our BottomSheet is now being rendered outside of the BottomTabBar

Conclusion

Discussion (4)

Collapse
koko_8350700d1d profile image
Koko Kevranian

I imported the git repository into a expo snack and it works but for some reason the bottom sheet is limited to the context of the bottom tab. How would I fix it to not be limited to the bottom tab on a snack?

Collapse
edmondso006 profile image
Jeff Edmondson Author

React Native Portal solves this. It allows for the bottomsheet element to be injected into a different level of the DOM. I will double check the git repo later!

Collapse
biljx profile image
BiLJX

I am getting this error, Reanimated 2 failed to create a worklet, maybe you forgot to add Reanimated's babel plugin?

Collapse
edmondso006 profile image
Jeff Edmondson Author

Hey,

Are you able to verify that you have done all of the installtion instructions for Reanimated and Gesture handler?
gorhom.github.io/react-native-bott...