Home

Bluesky

GitHub

Simple React Native Context Menus for iOS and Android

Poorly written by hailey on April 14, 2024

So 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.

A list of two gorups of items, divided.
A sub menu Note that if we only pass in a single group, there will not be separator rendered, so there's no need to not pass it in as a group.

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 👍