Simple React Native Context Menus for iOS and Android
Poorly written by user eater hailey on April 14, 2024So I've used react-native-ios-context-menu in the past, and it's great. Pretty straight forward to create menus with and uses the native iOS menus. However, once head over to Android, your menus do not do anything at all!
The first thing you might run into is react-native-context-menu-view, which as an Android fallback. However, maintenance on the package isn't nearly as good as the former library and there's a number of fairly significant issues that are still open. Probably not the best solution. Am I really going to need to implement my own logic for iOS vs Android?
Turns out there's a great option, Zeego, that handles not only native Android and iOS context and dropdown menus, but web dropdowns as well. The current project that I'm working on doesn't have any web support, so I won't be covering any of that here. Regardless, the documentation for Zeego is pretty good, and there are very few differences to take into account when implementing web dropdowns with Zeego.
Installing Zeego
Note: Zeego will not work with Expo Go as it relies on native code in react-native-ios-context-menu
and @react-native-menu/menu
. If you are using Expo, you will need to build your own your own development and production clients.
We can quickly get started by running the following:
yarn add zeego
yarn add react-native-ios-context-menu
yarn add @react-native-menu/menu
cd ios
pod install
After we do that, we simply need to build a new dev client and we are ready to go.
Using Zeego Easily
Following along with the Zeego documentation is pretty straight forward and as such I don't think there's any need to go into any specifics here on how to use Zeego. But the one thing that immediately bothered me was that instead of just passing in an array of options to a ContextMenuButton
component like I would have with react-native-ios-context-menu, I needed to actually create something like this:
import * as DropdownMenu from 'zeego/dropdown-menu'
export function MyMenu() {
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button />
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Label />
<DropdownMenu.Item>
<DropdownMenu.ItemTitle />
</DropdownMenu.Item>
<DropdownMenu.Group>
<DropdownMenu.Item />
</DropdownMenu.Group>
<DropdownMenu.CheckboxItem>
<DropdownMenu.ItemIndicator />
</DropdownMenu.CheckboxItem>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger />
<DropdownMenu.SubContent />
</DropdownMenu.Sub>
<DropdownMenu.Separator />
<DropdownMenu.Arrow />
</DropdownMenu.Content>
</DropdownMenu.Root>
)
}
Obviously this is not a very fun task, and we definitely don't want to do this every time we create a new component. Instead, the solution is to create a reusable ContextMenuButton
(or whatever else you want to name it).
Your implementation may look a bit different depending on what you need from Zeego. If you are not worried about groups for example, you can completely avoid the group rendering logic here.
Create some types
First we are going to create two types: ContextMenuAction
and ContextMenuActionGroup
. We will be passing an array of ContextMenuActionGroup
(each with its own array of ContextMenuAction
) to our component.
interface ContextMenuAction {
label: string;
key: string;
destructive?: boolean;
onSelect?: () => unknown;
actions?: ContextMenuAction[]; // These will be useful for sub groups
iosIconName?: string;
androidIconName?: string;
}
interface ContextMenuActionGroup {
actions: ContextMenuAction[];
}
Now that we know what the options we pass in are going to look like, let's work on the component.
Render the button
For me, the vast majority of the context menus I use are presented by pressing an ellipsis button. Therefore, I am going to by default use a Ellipsis
component (using Ionicons ellipsis-horizontal underneath) but also offer the ability to use some other component as my button.
import * as DropdownMenu from 'zeego/dropdown-menu';
import Ellipsis from './Ellipsis';
import { Pressable } from 'react-native';
interface IProps {
actions: ContextMenuActionGroup[];
size?: number;
children?: React.ReactElement;
}
export default function ContextMenuButton({
size = 20,
actions: groups, // While it made more sense to call this `actions` in the props type, when we use it in the component I prefer to call it `groups`. This might be confusing to you, so feel free to either change the name in the type or the component...
children,
}: IProps): React.JSX.Element {
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{children ?? (
<Pressable hitSlop={5}>
<Ellipsis size={size} color="white" />
</Pressable>
)}
</DropdownMenu.Trigger>
</DropdownMenu.Root>
);
}
As you can see here, whenever we render the ContextMenuButton
component, we will be presenting the user with an Ellipsis
as the "trigger button" for the context menu (you can also use a press-and-hold action for an entire view. See ContextMenu
in the documentation). If we want though, we are easily able to modify that by passing in some other child element to the context menu.
Render the dropdown items
Next we want to handle displaying the various options. There are three primary elements that we will use for this, DropdownMenu.Group
, DropdownMenu.Sub
, and DropdownMenu.Item
. Like their names indicate, a Group
is going to create separate, divided sections for our different options. A sub menu will open...a sub menu, and an item will display the individual item.
First we will add groups.map
to a Dropdown.Content
.
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{children ?? (
<Pressable hitSlop={5}>
<Ellipsis size={size} color="white" />
</Pressable>
)}
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{groups.map((group, index) => (
<DropdownMenu.Group key={index}>
</DropdownMenu.Group>
)}
</DropdownMenu.Content>
</DropdownMenu.Root>
);
Inside of each group, we want to handle two possible options: our list of individual items or a sub group of items. Let's handle that like this:
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{children ?? (
<Pressable hitSlop={5}>
<Ellipsis size={size} color="white" />
</Pressable>
)}
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{groups.map((group, index) => (
<DropdownMenu.Group key={index}>
{groups.actions.map((action) => {
if (action.actions != null) {
return (
<DropdownMenu.Sub key={action.key}>
</DropdownMenu.Sub>
);
} else {
return (
<DropdownMenu.Item key={action.key}>
</DropdownMenu.Item>
);
}
}}
</DropdownMenu.Group>
)}
</DropdownMenu.Content>
</DropdownMenu.Root>
);
Finally, we will render each of the individual action items where they belong.
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{children ?? (
<Pressable hitSlop={5}>
<Ellipsis size={size} color="white" />
</Pressable>
)}
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{groups.map((group, index) => (
<DropdownMenu.Group key={index}>
{groups.actions.map((action) => {
if (action.actions != null) {
return (
<DropdownMenu.Sub key={action.key}>
{/* First we need to add the sub trigger and sub component block */}
<DropdownMenu.SubTrigger
key={action.key + 'trigger'}
destructive={action.destructive}
>
{action.label}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent key={action.key + 'content'}>
{actions.actions.map((subAction) => (
<DropdownMenu.Item
key={subAction.key}
onSelect={subAction.onSelect}
destructive={subAction.destructive}
>
<DropdownMenu.ItemTitle>
{subAction.label}
</DropdownMenu.ItemTitle>
<DropdownMenu.ItemIcon
ios={subAction.iosIconName != null ? { name: subAction.iconName! } : undefined}
android={subAction.androidIconName}
/>
</DropdownMenu.Item>
)}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
);
} else {
return (
<DropdownMenu.Item
key={action.key}
destructive={action.destructive}
onSelect={action.onSelect}
>
{/* All we have to render here is the ItemTitle and ItemIcon */}
<DropdownMenu.ItemTitle>
{action.label}
</DropdownMenu.ItemTitle>
<DropdownMenu.ItemIcon
ios={action.iosIconName != null ? { name: action.iconName! } : undefined}
android={action.androidIconName}
/>
</DropdownMenu.Item>
);
}
}}
</DropdownMenu.Group>
)}
</DropdownMenu.Content>
</DropdownMenu.Root>
);
Awesome! Now we are ready to use it!
Use ContextMenuButton
Now we can easily create a component for each different context menu that we want to present. For example, if we want to show a dropdown menu on each Post
in a list, we can make something like this:
import React from 'react';
import { IPost } from '@src/types/data';
import { Alert } from 'react-native';
import { IContextMenuActionGroup } from '@src/types/IContextMenuAction';
import ContextMenuButton from '@src/components/Common/Button/ContextMenuButton';
import * as Clipboard from 'expo-clipboard';
interface IProps {
post: IPost;
}
function PostContextButton({ post }: IProps): React.JSX.Element {
const actions: IContextMenuActionGroup[] = [
{
actions: [
{
label: 'Translate',
key: 'translate',
iconName: 'character.book.closed',
onSelect: () => {
Alert.alert('Hello');
},
},
{
label: 'Copy Text',
key: 'copy',
iconName: 'doc.on.doc',
onSelect: () => {
if (post.body == null) return;
void Clipboard.setStringAsync(post.body);
},
},
{
label: 'Share',
key: 'share',
iconName: 'square.and.arrow.up',
onSelect: () => {
// Share
},
},
],
},
{
actions: [
{
label: 'Moderation',
key: 'moderation',
actions: [
{
label: 'Report Post',
key: 'reportPost',
iconName: 'flag',
onSelect: () => {
Alert.alert('Report');
},
},
{
label: 'Report User',
key: 'reportUser',
iconName: 'flag',
onSelect: () => {
Alert.alert('Report');
},
},
],
},
{
label: 'Blocking and Muting',
key: 'blocking',
actions: [
{
label: 'Block User',
key: 'blockUser',
iconName: 'hand.raised',
onSelect: () => {
Alert.alert('Report');
},
},
{
label: 'Mute User',
key: 'muteUser',
iconName: 'speaker.slash',
onSelect: () => {
Alert.alert('Report');
},
},
],
},
],
},
];
return <ContextMenuButton size={20} actions={actions} />;
}
export default React.memo(PostContextButton);
Note that we are using React.memo()
to memoize the component. We want to do this so that - especially in large lists such as a FlatList
or FlashList
, we do not unnecessarily render these menus over and over - especially if you're doing any sort of calculations on what options to render, what labels to give them, etc.
This can be extended to offer some of the other options, or to use a single wrapper for both DropdownMenu
and ContextMenu
with a menuType
option, perhaps, in the props. However, this should cover most of the normal use cases for iOS and Android context menus 👍