🏠
Author: haileyok.com (did:plc:oisofpd7lj26yvgiivf3lxsi)

Record🤔

uri:
"at://did:plc:oisofpd7lj26yvgiivf3lxsi/com.whtwnd.blog.entry/3kq3d6exm2k23"
cid:
"bafyreick2gfksyzfklyetmp7gw2z52hzodpor44cxb5zs4ism4e2tpeny4"
value:
$type:
"com.whtwnd.blog.entry"
theme:
"github-light"
title:
"Simple React Native Context Menus for iOS and Android"
content:
"So I've used [react-native-ios-context-menu](https://github.com/dominicstop/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](https://github.com/mpiannucci/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](https://zeego.dev/), 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](https://zeego.dev/components/dropdown-menu) 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](https://zeego.dev/components/context-menu)). 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.
![](__GHOST_URL__/content/images/2023/11/Screenshot-2023-11-22-at-1.49.47-PM.png)A list of two gorups of items, divided.![](__GHOST_URL__/content/images/2023/11/Screenshot-2023-11-22-at-1.50.18-PM.png)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 👍
"
createdAt:
"2024-04-14T08:28:04.964Z"