The Widlarz Group Blog

Creating custom animated bottom tab and drawer navigation

July 19, 2021

react-native-reanimated

@react-navigation/bottom-tabs

@react-navigation/native

react native

mobile

Introduction

This article is about my experiences with building a custom navigation using React-navigation/native. Link to navigation docs

I encountered two major problems during this assignment. The first issue was setting a custom button in the middle of bottom navigation, and the second was implementing the animation under the icons. In the case of the latter, we need to use a Reanimated library. Link to Reanimated docs

The whole app is prepared in Typescript which I highly encourage you to use. That said, you will find the most challenging typing at the end of this article, so those who use bare javascript can follow along easily.

Final recreated animation

As you can see we have four icons and one big button between them. This button is responsible for opening a modal. It will look like iOS modals. Under the icons there is a dot that indicates the currently opened screen. Dot movement is animated with the React Reanimated library, which my colleagues have described in other posts available on our blog.

My biggest challenge during this task was placing the circle button at the proper height. In react-native on the Android system, if part of the button is above the container, it’s not clickable. This feature does not allow me to use absolute positioning. That said, we can now start building it from scratch.

If you want to install the whole app with the necessary packages manually, you are free to do so, but it’s not necessary to follow this article. I’ve created a starter app where I’ve listed all the required dependencies. Link to starter app

Complete code from this article is available here. Complete App

If you decide to install everything by yourself, be sure to configure Reanimated properly. here

I will use @shopify/restyle to make styling easier but feel free to go with anything you want.

Content

Create bottom tab navigation

First of all, we have to make some dummy screens. These screens will contain only Welcome + screen name text to let us know if navigations are working properly. Next, we have to create our BottomTabNavigator file and put our screens there.

import React, {FC} from 'react';
import {Box, Text} from '../../utils/theme';
import {SafeAreaWrapper} from '../../components/SafeAreaWrapper';

export const Dashboard: FC = () => (
  <SafeAreaWrapper isDefaultBgColor>
    <Box margin="xl">
      <Text variant="title1">Welcome in dashboard</Text>
    </Box>
  </SafeAreaWrapper>
);

// Dashboard.tsx
import React from 'react';
import {StyleSheet} from 'react-native';
import {createBottomTabNavigator} from '@react-navigation/bottom-tabs';
import {SafeAreaView} from 'react-native-safe-area-context';

import {TabsUi} from './BottomNavComponents/TabsUi';
import {Dashboard} from '../screens/dashboard/Dashboard';
import {Calendar} from '../screens/calendar/Calendar';
import {Panel} from '../screens/panel/Panel';
import {Chat} from '../screens/chat/Chat';
import {BottomTabRoutes} from './types';

const Tab = createBottomTabNavigator<BottomTabRoutes>();

const EmptyComponent = () => null;
const tabs = [
  {name: 'Dashboard'},
  {name: 'Calendar'},
  {name: 'RequestModal'},
  {name: 'Panel'},
  {name: 'Chat'},
];
export const BottomTabNavigator = () => (
  <>
    <SafeAreaView style={styles.container}>
      <Tab.Navigator tabBar={props => <TabsUi {...{tabs, ...props}} />}>
        <Tab.Screen name="Dashboard" component={Dashboard} />
        <Tab.Screen name="Calendar" component={Calendar} />
        <Tab.Screen name="RequestModal" component={EmptyComponent} />
        <Tab.Screen name="Panel" component={Panel} />
        <Tab.Screen name="Chat" component={Chat} />
      </Tab.Navigator>
    </SafeAreaView>
  </>
);

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: 'white',
  },
});

// BottomTabNavigator.tsx

As you can see, we don’t use regular Bottom tab navigation, but we provide a custom component.

<Tab.Navigator tabBar={(props) => <TabsUi {...{ tabs, ...props }} />}>

The reason is that we need to do some tricks regarding our dot animation and circle button placement. Let’s create our TabsUi component. We have to pull the container 5px down. Without it, we will have a break between the safe area and our tabs container.

Our TabUi component receives **tabs** props, thanks to which we can calculate the width of a single bottom nav button.


```jsx

const tabWidth = useMemo(() => windowWidth / tabs.length, [tabs.length])

We need it inside our next two components, i.e. TabsHandler and AnimationDot. Pass the props destructured with spread operator (…) and build these components.

Here, an important part is ‘state’ props, which is accessible by every component wrapped by NavigationContainer, and gives us detailed info about our navigation state. The one we need in this case is an index of the active tab. That’s why we pass the activeTabIndex={state.index} further. 

Important

If you are using @react-navigation/native in version 6.x or higher, the “state” props is no longer available. Instead, you can use this workaround by using useRoute and useNavigationState.

import React, {FC, useMemo} from 'react';
import {Dimensions} from 'react-native';
import {useNavigationState, useRoute} from '@react-navigation/native';

import {Box} from '../../utils/theme';
import {NavigationDot} from './NavigationDot';
import {TabsHandler} from './TabsHandler';

type TabsUiProps = {
  tabs: {
    name: string;
  }[];
};

const {width: windowWidth} = Dimensions.get('window');

export const TabsUi: FC<TabsUiProps> = ({tabs}) => {
  const tabWidth = useMemo(() => windowWidth / tabs.length, [tabs.length]);
  const route = useRoute();
  const currentRouteIndex = useNavigationState(state => {
    const routes = state.routes.find(r => r.key === route.key).state.index;
    return routes;
  });

  return (
    <Box>
      <Box
        width={windowWidth}
        position="absolute"
        bottom={-5}
        backgroundColor="transparent">
        <Box flexDirection="column">
          <TabsHandler
            {...{tabs, tabWidth}}
            activeTabIndex={currentRouteIndex}
          />
          <NavigationDot width={tabWidth} activeTabIndex={currentRouteIndex} />
        </Box>
      </Box>
    </Box>
  );
};

//  TabsUi.tsx for @react-navigation/native v6 users 
import React, {FC, useMemo} from 'react';
import {Dimensions} from 'react-native';
import {NavigationState} from '@react-navigation/native';

import {Box} from '../../utils/theme';
import {TabsHandler} from './TabsHandler';

type TabsUiProps = {
  tabs: {
    name: string;
  }[];
  state: NavigationState;
};
const {width: windowWidth} = Dimensions.get('window');

export const TabsUi: FC<TabsUiProps> = ({tabs, state}) => {
  const tabWidth = useMemo(() => windowWidth / tabs.length, [tabs.length]);

  return (
    <Box>
      <Box
        width={windowWidth}
        position="absolute"
        bottom={-5}
        backgroundColor="transparent">
        <Box flexDirection="column">
          <TabsHandler {...{tabs, tabWidth}} activeTabIndex={state.index} />
        </Box>
      </Box>
    </Box>
  );
}

//  TabsUi.tsx

In TabsHandler.tsx we will map through our tabs variable, depending on the name we will display an icon or a circle button, and pass different onPress functions to them, but let’s start from the beginning.

import React, {FC} from 'react';
import {StyleSheet} from 'react-native';
import {useNavigation} from '@react-navigation/native';

import {AddButton} from '../../components/AddButton';
import {Box, theme} from '../../utils/theme';
import {ModalNavigationType} from '../types';
import {BorderlessButton} from 'react-native-gesture-handler';
import {getBottomTabIcon} from '../../utils/getBottomTabIcon';

type TabsHandlerProps = {
  tabs: {
    name: string;
  }[];
  tabWidth: number;
  activeTabIndex: number;
};

export const TabsHandler: FC<TabsHandlerProps> = ({
  tabs,
  tabWidth,
  activeTabIndex,
}) => {

  const navigation = useNavigation<ModalNavigationType<'DrawerNavigator'>>();

  return (
    <Box flexDirection="row">
      {tabs.map((tab: any, key: number) => {
        const onPress = () => {
          if (tab.name === 'RequestModal') {
            navigation.navigate('RequestVacation');
          } else {
            navigation.navigate(tab.name);
          }
        };
        if (tab.name === 'RequestModal') {
          return (
            <Box key="logo" width={tabWidth} backgroundColor="transparent">
              <AddButton onPress={onPress} />
            </Box>
          );
        }

// TabsHandler.tsx

First we will create a new component called TabsHandler, where we will map through the tabs array. Inside our map function, we define the onPress function. If the tab name is RequestModal, we will navigate to the RequestVacation component, otherwise, we will open the standard screen. Request vacation is a screen that looks like iOS modal. Next, we need to do some styling magic to place our button in the middle of the bottom bar.

return (
        <Box
            {...{key}}
            width={tabWidth}
            height={45}
            marginTop="lplus"
            alignItems="center"
            flexDirection="column"
            backgroundColor="white">
            <BorderlessButton onPress={onPress} style={styles.button}>
              {getBottomTabIcon(
                tab.name,
                tabs[activeTabIndex].name,
                theme.colors.black,
                theme.colors.bottomBarIcons,
              )}
            </BorderlessButton>
          </Box>
        );
      })}
    </Box>
  );
};

const styles = StyleSheet.create({
  button: {
    paddingTop: theme.spacing.xm,
    paddingBottom: theme.spacing.s,
    paddingHorizontal: theme.spacing.m,
  },
});


// TabsHandler.tsx

The above code contains the rest of our components. Again, we check if our tab.name is equal to RequestModal, if this returns true, we return our button which is just a normal react component. If it returns false, we return our icon inside a TouchableOpacity component. Icons are rendered through a function called getBottomTabIcon. This function accepts four props: the name of the tab that we are rendering, the currently active tab, the color value for an inactive icon, and the color value for an active icon.

import React from 'react';

import {CalendarIcon} from '../assets/icons/CalendarIcon';
import {HomeIcon} from '../assets/icons/HomeIcon';
import {MessageIcon} from '../assets/icons/MessageIcon';
import {PasteIcon} from '../assets/icons/PasteIcon';

export const getBottomTabIcon = (
  tab: string,
  routeName: string,
  fillActive: string,
  fillInactive: string,
) => {
  switch (tab) {
    case 'Dashboard': {
      return (
        <HomeIcon
          fill={routeName === 'Dashboard' ? fillActive : fillInactive}
        />
      );
    }
    case 'Calendar': {
      return (
        <CalendarIcon
          fill={routeName === 'Calendar' ? fillActive : fillInactive}
        />
      );
    }
    case 'Panel': {
      return (
        <PasteIcon fill={routeName === 'Panel' ? fillActive : fillInactive} />
      );
    }
    case 'Chat': {
      return (
        <MessageIcon fill={routeName === 'Chat' ? fillActive : fillInactive} />
      );
    }
    default:
      break;
  }
};

// getBottomTabIcon.tsx

The next step is to add our icons to the bottom tab bar. We will use the already installed packages: React-native-svg and React-native-svg-transformer.

import React, {FC} from 'react';
import Svg, {Path, G, Defs, Rect, ClipPath} from 'react-native-svg';

import {IconProps} from './PasteIcon';

export const HomeIcon: FC<IconProps> = ({fill}) => (
  <Svg width="23" height="23" viewBox="0 0 23 23" fill="none">
    <G clip-path="url(#clip0)">
      <Path
        fill-rule="evenodd"
        clip-rule="evenodd"
        d="M13.7219 0.877472L20.6137 6.91924C20.977 7.24425 21.2673 7.64267 21.4653 8.08818C21.6633 8.53368 21.7645 9.01613 21.7623 9.50365V19.5541C21.7623 20.468 21.3992 21.3445 20.753 21.9907C20.1068 22.637 19.2303 23 18.3164 23H4.5329C3.619 23 2.74252 22.637 2.0963 21.9907C1.45007 21.3445 1.08702 20.468 1.08702 19.5541V9.51514C1.08313 9.02569 1.18356 8.54103 1.38161 8.09342C1.57967 7.64581 1.87081 7.24554 2.23565 6.91924L9.1274 0.877472C9.75917 0.3124 10.577 0 11.4247 0C12.2723 0 13.0901 0.3124 13.7219 0.877472ZM19.1286 20.3663C19.344 20.1509 19.465 19.8588 19.465 19.5541V9.50365C19.4648 9.34056 19.4299 9.17939 19.3626 9.03085C19.2952 8.88232 19.197 8.74982 19.0745 8.64218L12.1827 2.6119C11.9731 2.42775 11.7037 2.32619 11.4247 2.32619C11.1456 2.32619 10.8762 2.42775 10.6666 2.6119L3.77481 8.64218C3.65228 8.74982 3.55409 8.88232 3.48675 9.03085C3.41941 9.17939 3.38448 9.34056 3.38427 9.50365V19.5541C3.38427 19.8588 3.50529 20.1509 3.7207 20.3663C3.90151 20.5471 4.13639 20.6614 4.38733 20.6935C4.68674 19.0548 5.75007 17.6828 7.19474 16.9602C6.76293 16.4369 6.50353 15.7661 6.50353 15.0347C6.50353 13.3629 7.85882 12.0076 9.53064 12.0076C11.2025 12.0076 12.5577 13.3629 12.5577 15.0347C12.5577 15.7661 12.2984 16.4369 11.8665 16.9602C13.3139 17.6841 14.3786 19.0599 14.6756 20.7028H16.6856C16.6852 18.9212 15.3817 17.4427 13.6765 17.171C13.2786 17.1076 12.9927 16.7698 12.981 16.3813C12.9429 16.0051 13.1686 15.6395 13.5424 15.5154C14.0905 15.3333 14.4841 14.8161 14.4841 14.2091C14.4841 13.4492 13.8681 12.8332 13.1081 12.8332C12.6522 12.8332 12.2826 12.4635 12.2826 12.0076C12.2826 11.5516 12.6522 11.182 13.1081 11.182C14.78 11.182 16.1352 12.5373 16.1352 14.2091C16.1352 14.9051 15.9005 15.5456 15.5063 16.0565C17.1873 16.9256 18.3364 18.6794 18.3368 20.7026C18.634 20.6973 18.918 20.5769 19.1286 20.3663ZM12.9843 20.7028C12.5727 19.1812 11.1824 18.0618 9.53064 18.0618C7.8789 18.0618 6.48861 19.1812 6.07699 20.7028H12.9843ZM9.53064 13.6587C8.77072 13.6587 8.15468 14.2748 8.15468 15.0347C8.15468 15.7946 8.77072 16.4107 9.53064 16.4107C10.2906 16.4107 10.9066 15.7946 10.9066 15.0347C10.9066 14.2748 10.2906 13.6587 9.53064 13.6587Z"
        fill={fill}
      />
    </G>
    <Defs>
      <ClipPath id="clip0">
        <Rect width="23" height="23" fill={fill} />
      </ClipPath>
    </Defs>
  </Svg>
);

// HomeIcon.tsx

Even though there’s nothing really special in our Circle Button, let’s try to create it by mixing two svg icons. One of them is the circle button, and another one is its background which is placed inside RectButton. It’s a special type of button which gives us the ripple effect on the Android system. We need to move the background a bit lower because this icon is too high, that’s why we are setting a negative bottom value on it.

import React, {FC} from 'react';
import {StyleSheet} from 'react-native';
import {RectButton} from 'react-native-gesture-handler';

import {Box, theme} from '../utils/theme/index';

import IconPlus from '../assets/icons/icon-plus.svg';
import CircleBg from '../assets/icons/circle-bg.svg';

type AddButtonProps = {
  onPress: () => void;
};

export const AddButton: FC<AddButtonProps> = ({onPress}) => (
  <Box position="relative" alignItems="center">
    <RectButton
      style={styles.button}
      onPress={onPress}
      rippleColor={theme.colors.lightGrey}>
      <IconPlus />
    </RectButton>
    <Box position="absolute" bottom={-13}>
      <CircleBg />
    </Box>
  </Box>
);

const styles = StyleSheet.create({
  button: {
    backgroundColor: theme.colors.tertiary,
    justifyContent: 'center',
    alignItems: 'center',
    width: 62,
    height: 62,
    borderRadius: theme.borderRadii.lplus,
    zIndex: 2,
  },
});

// AddButton.tsx

Next, we place our icons in ./assets and import them in the getBottomTabIcon function. Here we use the switch function and as an expression, we pass our tab name. Based on our activeTab name, we pass the inactive or active color value to our icons.

At this point your app should look like this:

Navigation without dot

I have already mentioned the AnimationDot component, which we could omit in terms of application usability, but it features a fancy animation and makes your app livelier. It’s also a good occasion for a React Reanimated library training.

Add animated dot

The animation dot is another regular component which accepts two props. The first one is the width of a single tab, which will let us position our dot in the center of each tab. The second is the active tab index, which we will need to let our dot know which tab it should stay under.

First, we need to import a few things from the Reanimated library.

import Animated, {
  interpolate,
  useAnimatedStyle,
  useSharedValue,
  withSequence,
  withTiming,
} from 'react-native-reanimated';

// AnimationDot.tsx

A full description of each is available in the official docs. here. That said, I will shortly describe those used in this component.

Animated - allows us to create an Animated component like Animated.View. Without it, our Animation wouldn’t run at all.

Interpolate - a method for interpolating value passed to it. We are passing the value which we want to interpolate, input range as an array, and output range as an array. This method allows for more precise control of our animation.

useAnimatedStyle - a hook for creating an association between shared values and view styles. It returns a style object, which we are passing to our Animated component.

useSharedValue - a hook for holding our values which we want to animate.

withSequence - a function allowing us to run animation in a sequence, for example, lower element height from 5px to 0 and back to 5.

withTiming - a function for controlling the duration of our animation.

All this theory may sound complicated, but as soon as we implement this in our project, everything will be clear.

Most animations start from declaring shared values. We will need three of those.

  const startingPos = (width - 5) / 2;
  const dotWidth = useSharedValue(5);
  const dotHeight = useSharedValue(5);
  const translateX = useSharedValue(startingPos);

// AnimationDot.tsx

A starting point is the place where the dot is in the first render. Width and Height are just our dot sizes

Our NavigationDot component returns our dot inside a container, which also requires some styling to be displayed properly. Here we use Animated.View, which was already mentioned above. As for Dot, we pass to it the value returned by useAnimatedStyle which is called progressStyle in here.

The dot moves from tab to tab, and during that movement, its height and width values decrease and increase. This tells us to compute these three values inside the useAnimatedStyle hook, and that’s exactly what we are going to do.

Our progressStyle const returns three values: transform:{translateX}, width, and height. Animation setup is finished, now we need to run it.

 const progressStyle = useAnimatedStyle(() => ({
  transform: [
   {
    translateX: translateX.value,
   },
  ],
  width: interpolate(dotWidth.value, [5, 100, 5], [5, 40, 5]),
  height: dotHeight.value,
 }))

// AnimationDot.tsx

Here with help comes useEffect Hook, which will re-render with every activeTabIndex change. Inside we declare what our animation should look like, and how long it should take.

useEffect(() => {
  translateX.value = withTiming(startingPos + activeTabIndex * width, { duration: 600 })

  dotWidth.value = withSequence(
   withTiming(width, { duration: 300 }),
   withTiming(5, { duration: 300 })
  )

  dotHeight.value = withSequence(
   withTiming(0, { duration: 300 }),
   withTiming(5, { duration: 300 })
  )
 }, [activeTabIndex])

// AnimationDot.tsx

TranslateX indicates the dot position, so we move it to startingPos (e.g 32px) + activeTabIndex(e.g 1) * width of single tab. This animation is made in 600ms as the duration value state. Next, we animate the dot width and height.

Width is increased to single tab width, and then back to 5px. Height is decreased to 0, and then increased to 5px.

By using withSequence, we divide those animations inside into two parts. Each part takes 300ms so that they end at the same time as the translateX animation.

I’m happy with the results. That said, if you have any ideas for improving them, then go ahead. Don’t forget to share it with us on our Twitter. here

Dot animation

So far, we have prepared our bottom tab and icons. Now we will add a Drawer navigation, and connect everything.

Create drawer tab

This part of the article will be the shortest. I leave styling, animations, etc. to your imagination. It’s just about clipping it together with other navigations (Stack and Bottom).

Add the next component named DrawerNavigator in our navigation folder. It contains three screens: the first one is our Home Screen where we have our BottomTabNavigator created earlier. The next two are screens without the bottom bar and with a disabled swipe, which means we are not able to open a drawer within them.

In order to be consistent with one name (Home), we import BottomTabNavigator like this:

import { BottomTabNavigator as Home } from ‘./BottomTabNavigator’

At the bottom of the drawer, we add a logout button. For this feature, we need to render a custom drawer content, just as we did before with the bottom bar.

import React from 'react';

import {createDrawerNavigator} from '@react-navigation/drawer';
import {About} from '../screens/about/About';
import {Settings} from '../screens/settings/Settings';
import {DrawerRoutes} from './types';
import {CustomDrawerContent} from './CustomDrawerContent';
import {BottomTabNavigator as Home} from './BottomTabNavigator';

const Drawer = createDrawerNavigator<DrawerRoutes>();

export const DrawerNavigator = () => {
  return (
    <Drawer.Navigator
      initialRouteName="Home"
      drawerContent={props => <CustomDrawerContent {...props} />}>
      <Drawer.Screen name="Home" component={Home} options={{title: 'Home'}} />
      <Drawer.Screen
        name="About"
        component={About}
        options={{title: 'About', swipeEnabled: false}}
      />
      <Drawer.Screen
        name="Settings"
        component={Settings}
        options={{title: 'Settings', swipeEnabled: false}}
      />
    </Drawer.Navigator>
  );
};

// DrawerNavigator.tsx

Our custom content will be the CustomDrawerContent component, which renders all screens passed in props and our button component. A bit of flexbox styling and we have our drawer ready.

import React, {FC} from 'react';
import {StyleSheet, TouchableOpacity} from 'react-native';
import {
  DrawerContentScrollView,
  DrawerItemList,
  DrawerContentComponentProps,
} from '@react-navigation/drawer';

import {Box, Text} from '../utils/theme';

export const CustomDrawerContent: FC<DrawerContentComponentProps> = props => {
  return (
    <DrawerContentScrollView
      {...props}
      contentContainerStyle={styles.container}>
      <Box>
        <DrawerItemList {...props} />
      </Box>

      <Box marginBottom="l" marginHorizontal="xl">
        <TouchableOpacity style={styles.logoutButton}>
          <Text variant="buttonText1">Logout</Text>
        </TouchableOpacity>
      </Box>
    </DrawerContentScrollView>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'space-between',
  },
  logoutButton: {
    backgroundColor: theme.colors.primary,
    paddingVertical: theme.spacing.s,
    borderRadius: theme.borderRadii.xxl,
  },
});

// CustomDrawerContent.tsx

Depending on how your authorization is configured, you should pass a proper function to the logout button, but that’s not covered in this article.

Dummy stack navigation

In our application, stack navigation is an onboarding flow. It means that if the user is not logged in, they have access only to this part of the application. I’m not going to create all these screens here, this is just an example stack navigation that we are using.

    export const AuthStackNavigation = () => (
     <AppStack.Navigator headerMode="none" initialRouteName="Slider">
      <AppStack.Screen name="Slider" component={Slider} />
      <AppStack.Screen name="Login" component={Login} />
      <AppStack.Screen name="Signup" component={Signup} />
      <AppStack.Screen name="ForgotPassword" component={ForgotPassword} />
      <AppStack.Screen name="RecoveryCode" component={RecoveryCode} />
      <AppStack.Screen name="NewPassword" component={NewPassword} />
      <AppStack.Screen name="SignupEmail" component={SignupEmail} />
      <AppStack.Screen name="ConfirmedAccount" component={ConfirmedAccount} />
     </AppStack.Navigator>
    )

// AuthStackNavigator.tsx

Now when every single navigation is ready to use, it’s time to combine them.

Connect all navigations

First, let’s create the index.tsx file. In a real app, it will look like a commented code. If user.firstName is present, we return ModalNavigation, which contains DrawerNavigation, BottomNavigation, and our modal screen. If not, we will show the user AuthStackNavigation - onboarding flow. For the sake of simplicity, I’ve decided to render only ModalNavigation.

    export const AppNavigation = () => {
    //  const { user } = useUserContext()

     return (
      <NavigationContainer>
        {/* {!user?.firstName ? <ModalNavigation /> : <AuthStackNavigation />} */}
        <ModalNavigation />
      </NavigationContainer>
     )
    }

// navigation/index.tsx

Now we create the ModalNavigation file. Here we have our modal implemented alongside DrawerNavigation.

export const ModalNavigation = () => (
 <Box flex={1}>
  <AppStack.Navigator
   mode="modal"
   headerMode="none"
   initialRouteName="DrawerNavigator"
   screenOptions={{
    ...TransitionPresets.ModalPresentationIOS,
    animationEnabled: true,
   }}>
   <AppStack.Screen name="RequestVacation" component={RequestVacation} />
   <AppStack.Screen name="DrawerNavigator" component={DrawerNavigator} />
  </AppStack.Navigator>
 </Box>
)

// ModalNavigation.tsx

This part is a bit tricky because the whole component is a Navigator, with mode set to “modal”, which gets TransitionPresets; this makes it look like an iOS modal. Our default route is DrawerNavigator, which just renders our app, but when we click on the circle button it opens a modal. 

Add typescript to our navigation

You can omit this part if you’re not using typescript yet (I think that you should start). Otherwise, create a types.ts file in the navigation folder.

First, we need types for each navigator created earlier. Before that we have a helper function for nested navigations.

type NestedNavigatorParams<ParamList> = {
 [K in keyof ParamList]?: { screen: K; params?: ParamList[K] }
}[keyof ParamList]

// navigation/types.ts

This type takes a ParamList argument which in this case will be a list of our screens in a single stack. K is a single screen here. This tells us that K must receive a screen and can receive params. When we define our main type, we wrap each stack in that type.

export type AppRoutes = {
 AuthStackNavigation: NestedNavigatorParams<AuthRoutes>
 DrawerNavigator: NestedNavigatorParams<DrawerRoutes>
 Home: NestedNavigatorParams<BottomTabRoutes>
 ModalRoutes: NestedNavigatorParams<DrawerRoutes>
}

// navigation/types.ts

Our whole app has four stacks: Auth, Drawer, Home, and Modal. Now let’s define the type passed in NestedNavigatorParams.

export type AuthRoutes = {
 Slider: undefined
 Login: undefined
 Signup: undefined
 SignupEmail: undefined
 ForgotPassword: undefined
 RecoveryCode: undefined
 NewPassword: undefined
 ConfirmedAccount: undefined
}

// navigation/types.ts

This type says that our AuthStack can only have screens listed here. Undefined means that the screen is not accepting any props. The same pattern applies to other stacks. The only difference is when route navigates to another Stack or gets props, then we give this stack types instead of undefined.

export type BottomTabRoutes = {
  Dashboard: undefined;
  Calendar: undefined;
  RequestModal: undefined;
  Panel: undefined;
  Chat: undefined;
}

export type DrawerRoutes = {
  Home: NestedNavigatorParams<BottomTabRoutes>;
  About: undefined;
  Settings: undefined;
}

export type ModalRoutes = {
  RequestVacation: undefined;
  DrawerNavigator: NestedNavigatorParams<DrawerRoutes>;
}

// navigation/types.ts

Now we need to perform some more complicated operations to be able to use the useNavigation hook and navigation prop. First of all, we need some imports from @react-navigation.

import { CompositeNavigationProp, RouteProp } from '@react-navigation/native'
import { StackNavigationProp } from '@react-navigation/stack'
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs'

// navigation/types.ts

Those are predefined types that we will combine with the types that we wrote before. Again, every stack has to be typed, but I will only describe the most complicated ones. You can browse the rest of them in project files. types.ts

    export type DrawerNavigationType<RouteName extends keyof DrawerRoutes> = CompositeNavigationProp<
      StackNavigationProp<DrawerRoutes, RouteName>,
      StackNavigationProp<AppRoutes, 'DrawerNavigator'>
    >

// navigation/types.ts

This is the type needed for useNavigation hook inside Drawer Navigation screens. It takes a RouteName argument, which must be one of the DrawerRoutes keys.CompositeNavigatorProp is used for nested navigators. Because its definition is quite complicated, I will refer you to the official documentation. CompositeNavigatorProps - ReactNative docs.

This is how it will be used in the code (in Settings screen which is in drawer navigator):

 const navigation = useNavigation<DrawerNavigationType<'Settings'>>()

// navigation/types.ts

Now we define the type for “navigation” and “route” props.

export type DrawerNavigationProps<RouteName extends keyof DrawerRoutes> = {
 navigation: CompositeNavigationProp<
  StackNavigationProp<DrawerRoutes, RouteName>,
  BottomTabNavigationProp<BottomTabRoutes>
 >
 route: RouteProp<DrawerRoutes, RouteName>
}

// navigation/types.ts

The idea of this type is very similar to the one above, but here we are typing navigation and route separately. In route typing we are using imported RouteProp with two arguments, first the desired stack types and second the name of the route that we will pass as an argument.

Summary

I hope that you found this article useful. I am well aware that combining all navigation types can be confusing, but hopefully I’ve cleared up a few things for you. I encourage you to at least try to write it by yourself but feel free to copy and improve my code :)


Written by Mateusz Bętka.