skip to Main Content

I can’t seem to find custom hamburger menu dropdowns anywhere with react-native. I don’t want to use the Stack.Drawer in my app, I just want to control the menu myself, but I want the menu modal to appear over the previous route, but not the previous route header.

Directory View

enter image description here

Below I will list coded files in order of the directory view…

app/components/Hamburger.tsx

import React from 'react';
import { Pressable } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import type { StackNavigationProp } from '@react-navigation/stack';
import { MaterialIcons as Icon } from '@expo/vector-icons';
import { navScreens } from '@app/utils';
import type { StackParamsList } from '@app/types';

interface Props {
  isOpen: boolean;
}

export default function Hamburger({ isOpen }: Props): JSX.Element {
  const navigation = useNavigation<StackNavigationProp<StackParamsList>>();
  return isOpen ? (
    <Pressable onPress={() => navigation.goBack()}>
      <Icon name="close" size={24} color="#ffffff" />
    </Pressable>
  ) : (
    <Pressable onPress={() => navigation.navigate(navScreens.hamburgerMenu.route)}>
      <Icon name="menu" size={24} color="#ffffff" />
    </Pressable>
  );
}

app/components/HamburgerMenu.tsx

import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import type { StackNavigationProp } from '@react-navigation/stack';
import { StackParamsList } from '@app/types';
import ScreenLayout from './ScreenLayout';
import { navScreens } from '@app/utils';

export default function HamburgerMenu(): JSX.Element {
  const navigation = useNavigation<StackNavigationProp<StackParamsList>>();
  return (
    <ScreenLayout styles={{ flex: 1 }}>
      <Pressable style={styles.background} onPress={() => navigation.goBack()}></Pressable>
      <View style={styles.selectionContainer}>
        <Pressable style={styles.selection} onPress={() => navigation.navigate(navScreens.white.route)}>
          <Text style={styles.selectionFont}>White Screen</Text>
        </Pressable>
        <Pressable style={styles.selection} onPress={() => navigation.navigate(navScreens.blue.route)}>
          <Text style={styles.selectionFont}>Blue Screen</Text>
        </Pressable>
        <Pressable style={styles.selection} onPress={() => navigation.navigate(navScreens.pink.route)}>
          <Text style={styles.selectionFont}>Pink Screen</Text>
        </Pressable>
        <Pressable style={styles.selection} onPress={() => navigation.navigate(navScreens.red.route)}>
          <Text style={styles.selectionFont}>Red Screen</Text>
        </Pressable>
      </View>
    </ScreenLayout>
  );
}

const styles = StyleSheet.create({
  background: {
    position: 'absolute',
    top: 0,
    right: 0,
    bottom: 0,
    left: 0,
    zIndex: 5,
    paddingTop: 20,
    paddingBottom: 100,
    paddingHorizontal: 20,
    backgroundColor: 'rgba(22, 27, 34, 0.6)'
  },
  selection: {
    width: '100%',
    padding: 10,
    borderBottomColor: '#0ff',
    borderBottomWidth: 2
  },
  selectionContainer: {
    position: 'absolute',
    top: 0,
    zIndex: 10,
    width: '100%',
    borderTopColor: '#0ff',
    borderTopWidth: 2,
    backgroundColor: '#030E13'
  },
  selectionFont: {
    fontSize: 20,
    color: '#ffffff'
  }
});

app/components/index.ts

export { default as Hamburger } from './Hamburger';
export { default as HamburgerMenu } from './HamburgerMenu';
export { default as ScreenLayout } from './ScreenLayout';

app/components/ScreenLayout.tsx

import React, { ReactNode } from 'react';
import { View } from 'react-native';
import { StatusBar } from 'expo-status-bar';

interface Props {
  styles: any | null;
  children: ReactNode;
}

export default function ScreenLayout({ styles, children }: Props): JSX.Element {
  return (
    <View style={styles}>
      {children}
      <StatusBar style="auto" />
    </View>
  );
}

ScreenLayout.defaultProps = {
  styles: null,
  children: null
};

app/navigation/index.ts

export { default as NavigationConductor } from './NavigationConductor';

app/navigation/NavigationConductor.tsx

import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
import { Hamburger, HamburgerMenu } from '@app/components';
import { Blue, Pink, Red, White } from '@app/screens';
import { navScreens } from '@app/utils';
import type { StackParamsList } from '@app/types';

const Stack = createStackNavigator<StackParamsList>();

export default function NavigationConductor() {
  return (
    <NavigationContainer>
      <Stack.Navigator
        initialRouteName={navScreens.white.route}
        screenOptions={({ route }) => {
          return {
            headerTitleAlign: 'center',
            headerStyle: { backgroundColor: '#043B2B' },
            headerRightContainerStyle: { paddingRight: 20 },
            headerLeftContainerStyle: { paddingLeft: 20 },
            headerTitleStyle: { color: '#ffffff' },
            headerTintColor: '#ffffff',
            headerLeft: () => <Hamburger isOpen={route.name === navScreens.hamburgerMenu.route} />
          };
        }}
      >
        <Stack.Group>
          <Stack.Screen name={navScreens.white.route} component={White} options={{ title: navScreens.white.title }} />
          <Stack.Screen name={navScreens.blue.route} component={Blue} options={{ title: navScreens.blue.title }} />
          <Stack.Screen name={navScreens.pink.route} component={Pink} options={{ title: navScreens.pink.title }} />
          <Stack.Screen name={navScreens.red.route} component={Red} options={{ title: navScreens.red.title }} />
        </Stack.Group>
        <Stack.Group>
          <Stack.Screen
            name={navScreens.hamburgerMenu.route}
            component={HamburgerMenu}
            options={{ headerShown: false, presentation: 'transparentModal' }}
          />
        </Stack.Group>
      </Stack.Navigator>
    </NavigationContainer>
  );
}

app/screens/Blue.tsx

import React from 'react';
import { StyleSheet, Text } from 'react-native';
import { ScreenLayout } from '@app/components';

export default function Blue(): JSX.Element {
  return (
    <ScreenLayout styles={styles.container}>
      <Text style={{ fontSize: 26, color: '#ffffff' }}>Blue Screen</Text>
    </ScreenLayout>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: 'blue'
  }
});

app/screens/index.ts

export { default as Blue } from './Blue';
export { default as Pink } from './Pink';
export { default as Red } from './Red';
export { default as White } from './White';

app/screens/Pink.tsx

import React from 'react';
import { StyleSheet, Text } from 'react-native';
import { ScreenLayout } from '@app/components';

export default function Pink(): JSX.Element {
  return (
    <ScreenLayout styles={styles.container}>
      <Text style={{ fontSize: 26 }}>Pink Screen</Text>
    </ScreenLayout>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: 'pink'
  }
});

app/screens/Red.tsx

import React from 'react';
import { StyleSheet, Text } from 'react-native';
import { ScreenLayout } from '@app/components';

export default function Red(): JSX.Element {
  return (
    <ScreenLayout styles={styles.container}>
      <Text style={{ fontSize: 26, color: '#ffffff' }}>Red Screen</Text>
    </ScreenLayout>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: 'red'
  }
});

app/screens/White.tsx

import React from 'react';
import { StyleSheet, Text } from 'react-native';
import { ScreenLayout } from '@app/components';

export default function White(): JSX.Element {
  return (
    <ScreenLayout styles={styles.container}>
      <Text style={{ fontSize: 26 }}>White Screen</Text>
    </ScreenLayout>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center'
  }
});

app/types/index.ts

export type { NavigationStack, StackParamsList } from './Navigation';

app/types/Navigation.ts

import type {
  DefaultNavigatorOptions,
  ParamListBase,
  StackNavigationState,
  StackRouterOptions,
  TypedNavigator
} from '@react-navigation/native';
import type { StackNavigationEventMap, StackNavigationOptions } from '@react-navigation/stack';
import type { StackNavigationConfig } from '@react-navigation/stack/lib/typescript/src/types';

export type StackParamsList = { [key: string]: undefined };

type NavigationStackProps = DefaultNavigatorOptions<
  ParamListBase,
  StackNavigationState<ParamListBase>,
  StackNavigationOptions,
  StackNavigationEventMap
> &
  StackRouterOptions &
  StackNavigationConfig;

export type NavigationStack = TypedNavigator<
  StackParamsList,
  StackNavigationState<ParamListBase>,
  StackNavigationOptions,
  StackNavigationEventMap,
  ({ id, initialRouteName, children, screenListeners, screenOptions, ...rest }: NavigationStackProps) => JSX.Element
>;

app/utils/index.ts

export { navScreens } from './Navigation';

app/utils/Navigation.ts

type NavScreen = { route: string; title: string };

type NavScreens = {
  blue: NavScreen;
  hamburgerMenu: NavScreen;
  pink: NavScreen;
  red: NavScreen;
  white: NavScreen;
};

export const navScreens: NavScreens = {
  blue: { route: 'blue', title: 'Blue Screen' },
  hamburgerMenu: { route: 'hamburger-menu', title: '' },
  pink: { route: 'pink', title: 'Pink Screen' },
  red: { route: 'red', title: 'Red Screen' },
  white: { route: 'white', title: 'White Screen' }
};

.eslintignore

# Ignore linting on libraries
node_modules

# Don't run lint on dist directory
dist/

# Don't run lint under any test sub directories
test/test
test/coverage

# solve linting errors
babel.config.json
babel.config.js

.eslintrc.json

{
  "env": {
    "browser": true,
    "es2021": true
  },
  "extends": [
    "eslint:recommended",
    "plugin:react/recommended",
    "plugin:@typescript-eslint/eslint-recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:prettier/recommended",
    "prettier",
    "plugin:import/errors",
    "plugin:import/warnings",
    "plugin:import/typescript"
  ],
  "globals": {
    "fetch": false
  },
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "allowImportExportEverywhere": true,
    "ecmaFeatures": { "jsx": true },
    "ecmaVersion": "latest",
    "sourceType": "module",
    "project": ["./tsconfig.json"]
  },
  "plugins": ["@typescript-eslint", "import", "prettier", "react", "react-hooks"],
  "rules": {
    "@typescript-eslint/ban-types": [
      "error",
      {
        "extendDefaults": true,
        "types": { "{}": false }
      }
    ],
    "@typescript-eslint/explicit-module-boundary-types": "warn",
    "@typescript-eslint/no-empty-function": "off",
    "@typescript-eslint/no-empty-interface": "off",
    "@typescript-eslint/no-explicit-any": "off",
    "@typescript-eslint/no-unused-vars": "warn",
    "@typescript-eslint/no-non-null-assertion": "off",
    "class-methods-use-this": "off",
    "comma-dangle": "off",
    "indent": "off",
    "indent-legacy": 0,
    "import/no-unresolved": 0,
    "import/named": 0,
    "import/namespace": 0,
    "import/default": 0,
    "import/no-named-as-default-member": 0,
    "no-param-reassign": [2, { "props": false }],
    "no-tabs": ["off", { "allowIndentationTabs": true }],
    "no-use-before-define": "warn",
    "no-unused-vars": "warn",
    "quotes": ["error", "single", { "avoidEscape": true }],
    "react-hooks/rules-of-hooks": "off",
    "react-hooks/exhaustive-deps": "warn",
    "react/jsx-filename-extension": "off",
    "react/jsx-uses-react": "off",
    "react/jsx-uses-vars": "error",
    "react/prop-types": "off",
    "react/react-in-jsx-scope": "off",
    "react/require-default-props": "off",
    "sort-imports": [
      "error",
      {
        "ignoreCase": false,
        "ignoreDeclarationSort": true,
        "ignoreMemberSort": false,
        "memberSyntaxSortOrder": ["none", "all", "multiple", "single"]
      }
    ]
  },
  "settings": {
    "react": {
      "createClass": "createReactClass",
      "pragma": "React",
      "fragment": "Fragment",
      "version": "detect"
    }
  }
}

.prettierrc

{
  "arrowParens": "avoid",
  "bracketSpacing": true,
  "endOfLine": "auto",
  "eslintIntegration": true,
  "printWidth": 120,
  "semi": true,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "none"
}

app.json

{
  "expo": {
    "name": "react-native-hamburger",
    "slug": "react-native-hamburger",
    "version": "1.0.0",
    "orientation": "portrait",
    "icon": "./assets/icon.png",
    "userInterfaceStyle": "light",
    "splash": {
      "image": "./assets/splash.png",
      "resizeMode": "contain",
      "backgroundColor": "#ffffff"
    },
    "assetBundlePatterns": ["**/*"],
    "ios": {
      "supportsTablet": true
    },
    "android": {
      "adaptiveIcon": {
        "foregroundImage": "./assets/adaptive-icon.png",
        "backgroundColor": "#ffffff"
      },
      "package": "com.jamespageced.reactnativehamburger",
      "versionCode": 1
    },
    "web": {
      "favicon": "./assets/favicon.png"
    }
  }
}

App.tsx

import React from 'react';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { NavigationConductor } from '@app/navigation';

export default function App() {
  // render
  return (
    <SafeAreaProvider>
      <NavigationConductor />
    </SafeAreaProvider>
  );
}

babel.config.js

module.exports = function (api) {
  api.cache(true);
  return {
    presets: ['module:metro-react-native-babel-preset', 'babel-preset-expo'],
    plugins: [
      [
        require.resolve('babel-plugin-module-resolver'),
        {
          extensions: ['.js', '.jsx', '.ts', '.tsx', '.android.js', '.android.tsx', '.ios.js', '.ios.tsx'],
          alias: {
            '@': './',
            '@app': './app'
          }
        }
      ]
    ]
  };
};

package.json

{
  "name": "react-native-hamburger",
  "version": "1.0.0",
  "main": "node_modules/expo/AppEntry.js",
  "scripts": {
    "start": "expo start --port 8082",
    "android": "expo run:android --port 8082",
    "ios": "expo run:ios",
    "web": "expo start --web"
  },
  "dependencies": {
    "@react-navigation/native": "^6.1.9",
    "@react-navigation/stack": "^6.3.20",
    "expo": "~49.0.15",
    "expo-status-bar": "~1.6.0",
    "react": "18.2.0",
    "react-native": "0.72.6",
    "react-native-gesture-handler": "~2.12.0",
    "react-native-safe-area-context": "4.6.3",
    "react-native-screens": "~3.22.0",
    "expo-splash-screen": "~0.20.5"
  },
  "devDependencies": {
    "@babel/core": "^7.20.0",
    "@types/react": "~18.2.14",
    "typescript": "^5.1.3"
  },
  "private": true
}

tsconfig.json

{
  "extends": "expo/tsconfig.base",
  "compilerOptions": {
    "allowJs": true,
    "allowSyntheticDefaultImports": true,
    "baseUrl": "./",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "isolatedModules": true,
    "jsx": "react",
    "lib": ["dom", "dom.iterable", "esnext"],
    "module": "esnext",
    "moduleResolution": "node",
    "noEmit": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitReturns": false,
    "paths": {
      "@/*": ["/*/index", "/*"],
      "@app/*": ["app/*/index", "app/*"]
    },
    "resolveJsonModule": true,
    "skipLibCheck": true,
    "strict": true,
    "target": "es5",
    "useUnknownInCatchVariables": true
  },
  "include": ["app", "App.tsx", "test", "**/*.[jt]s?(x)"],
  "exclude": ["node_modules", ".expo", "yarn.lock"]
}

Expected Results

enter image description here

Actual Results

enter image description here

Issue #1: As we can see, the issue is the actual results is showing the hamburger menu covering over the header.

Issue #2: An additional bug… while pulling up the hamburger menu from any other screen than the white screen, it will first default and go to the white screen, then it will pull up the menu to overlay the white screen.

2

Answers


  1. Chosen as BEST ANSWER

    This solves the first issue where the menu is covering the header.

    We can have the modal show and duplicate the header while keeping the same rules and showing the previous route name by accessing navigate. Move the title into the screenOptions, and remove the title's from the <Stack.Screen .../>'s

    app/navigation/NavigationConductor.tsx

    export default function NavigationConductor() {
      return (
        <NavigationContainer>
          <Stack.Navigator
            initialRouteName={navScreens.white.route}
            screenOptions={({ route, navigation }) => {
              const routes = navigation.getState().routes;
              const routeName = routes.length < 2 ? route.name : routes[routes.length - 2].name;
              console.log('routeName', routeName);
              return {
                headerTitleAlign: 'center',
                headerStyle: { backgroundColor: '#043B2B' },
                headerRightContainerStyle: { paddingRight: 20 },
                headerLeftContainerStyle: { paddingLeft: 20 },
                headerTitleStyle: { color: '#ffffff' },
                headerTintColor: '#ffffff',
                title: routeName,
                headerLeft: () => <Hamburger isOpen={route.name === navScreens.hamburgerMenu.route} />
              };
            }}
          >
            <Stack.Group>
              <Stack.Screen name={navScreens.white.route} component={White} />
              <Stack.Screen name={navScreens.blue.route} component={Blue} />
              <Stack.Screen name={navScreens.pink.route} component={Pink} />
              <Stack.Screen name={navScreens.red.route} component={Red} />
              <Stack.Screen
                name={navScreens.hamburgerMenu.route}
                component={HamburgerMenu}
                options={{ detachPreviousScreen: false, presentation: 'transparentModal' }}
              />
            </Stack.Group>
          </Stack.Navigator>
        </NavigationContainer>
      );
    }
    

    However, issue #2 still exists when you are on any screen other than the white screen, it will first navigate back to the white screen before opening the hamburger menu.


  2. To fix that you need to set headerShown to false:

    app/navigation/NavigationConductor.tsx

    
    
              <Stack.Screen name={navScreens.white.route} component={White} options={{ title: navScreens.white.title }} />
              <Stack.Screen name={navScreens.blue.route} component={Blue} options={{ title: navScreens.blue.title , headerShown:false}} />
              <Stack.Screen name={navScreens.pink.route} component={Pink} options={{ title: navScreens.pink.title, headerShown:false }} />
              <Stack.Screen name={navScreens.red.route} component={Red} options={{ title: navScreens.red.title , headerShown:false}} />
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search