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
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
Actual Results
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
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 thetitle
into thescreenOptions
, and remove thetitle
's from the<Stack.Screen .../>
'sapp/navigation/NavigationConductor.tsx
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.
To fix that you need to set
headerShown
tofalse
:app/navigation/NavigationConductor.tsx