Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 29 additions & 177 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,14 @@ A native menu component for React Native that provides platform-specific context
- ✅ Customizable colors for menu items
- ✅ Checkmark support with custom colors
- ✅ SF Symbols support on iOS (iosSymbol property)
- ✅ Subtitle support for menu items
- ✅ Destructive action styling (Red text)
- ✅ Theme variant support (Light/Dark/System)
- ✅ Scrollable menus for long lists
- ✅ Event handling for menu item selection
- ✅ TypeScript support
- ✅ Fabric (New Architecture) compatible
- ✅ Improved Accessibility support

## Installation

Expand Down Expand Up @@ -198,192 +202,40 @@ const [isDisabled, setIsDisabled] = useState(false);
// Add these styles
const styles = StyleSheet.create({
// ... other styles
disabledButton: {
opacity: 0.6,
},
disabledText: {
color: '#999',
},
});
```

### SF Symbols (iOS only)

```tsx
import { MenuView } from 'react-native-menus';

<MenuView
menuItems={[
{ identifier: 'profile', title: 'View Profile', iosSymbol: 'person.circle' },
{ identifier: 'settings', title: 'Settings', iosSymbol: 'gear' },
{ identifier: 'logout', title: 'Logout', iosSymbol: 'arrow.right.square' },
]}
onMenuSelect={handleMenuSelect}
>
<View style={styles.menuButton}>
<Text>👤 Account Menu with SF Symbols</Text>
</View>
</MenuView>
```

For a list of available SF Symbols, refer to Apple's [SF Symbols app](https://developer.apple.com/sf-symbols/) or use common names like:
- `"arrow.up"`, `"arrow.down"`, `"arrow.left"`, `"arrow.right"`
- `"gear"`, `"heart"`, `"trash"`, `"checkmark"`, `"xmark"`
- Add `.fill` suffix for filled variants: `"heart.fill"`, `"gear.fill"`

## API Reference

### Props

| Prop | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `children` | `ReactNode` | **Yes** | - | The trigger component that opens the menu when tapped |
| `menuItems` | `MenuItem[]` | Yes | `[]` | Array of menu items to display |
| `onMenuSelect` | `(event: MenuSelectEvent) => void` | No | - | Callback fired when a menu item is selected |
| `selectedIdentifier` | `string` | No | - | Controlled selected item identifier; shows a native checkmark for the matching item |
| `checkedColor` | `string` | No | `#007AFF` | Color for checked/selected menu items (Android only) |
| `uncheckedColor` | `string` | No | `#8E8E93` | Color for unchecked/unselected menu items (Android only) |
| `color` | `string` | No | - | Reserved for future use |
| `disabled` | `boolean` | No | `false` | Disables the menu interaction when set to `true` |
| `style` | `ViewStyle` | No | - | Style applied to the container view |

### Types

#### MenuItem

```typescript
interface MenuItem {
identifier: string; // Unique identifier for the menu item
title: string; // Display text for the menu item
iosSymbol?: string; // iOS-only: SF Symbol name to show beside the title (e.g., "gear", "heart.fill")
}
```

#### MenuSelectEvent

```typescript
interface MenuSelectEvent {
identifier: string; // The identifier of the selected menu item
title: string; // The title of the selected menu item
}
```

## How It Works

### Architecture

The MenuView component accepts any React Native component as a child, which becomes the trigger for opening the menu. When tapped, a native context menu appears with the specified menu items.

### Android Implementation

**File:** `android/src/main/java/com/menu/MenuView.kt`

- **Container:** `FrameLayout` that accepts child views from React Native
- **Touch Handling:** Intercepts all touch events at the parent level using `onInterceptTouchEvent()`
- **UI:** Modal dialog with white background, rounded corners, and bottom positioning
- **Menu Items:** Implemented as `RadioButton` elements with custom styling
- **Colors:** Full support for `checkedColor` and `uncheckedColor` customization
- **Dividers:** 0.5px light gray dividers between menu items
- **Scrolling:** Automatic scrolling for long lists (max 90% of screen height)
- **Selection:** Visual feedback with radio button selection states

**Key Implementation Details:**
- Uses `ViewGroupManager` to support child views
- Touch events are intercepted to ensure child views don't block menu opening
- Modal appears at bottom with horizontal margins for mobile-friendly UX

### iOS Implementation

**File:** `ios/MenuView.mm`

- **Container:** `RCTViewComponentView` (Fabric architecture)
- **Child Mounting:** Uses `mountChildComponentView` and `unmountChildComponentView` for Fabric compatibility
- **UI:** Native `UIMenu` attached to an invisible `UIButton` overlay
- **Menu Items:** Native `UIAction` elements with system styling
- **Trigger:** Creates transparent button overlay on top of child view to show native menu
- **Checkmarks:** Controlled via `selectedIdentifier` and rendered using `UIMenuElementStateOn`
- **Selection:** Emits `onMenuSelect` without mutating native state; update `selectedIdentifier` in React to reflect changes

**Key Implementation Details:**
- Disables user interaction on child views recursively
- Creates invisible button overlay that shows `UIMenu` on tap
- Fully native iOS 14+ context menu appearance
- Supports both button and non-button child components

### Platform Differences

| Feature | iOS | Android |
|---------|-----|---------|
| Menu Style | Native UIMenu popover | Modal dialog at bottom |
| SF Symbols | ✅ Full support via `iosSymbol` property | ❌ Not supported |
| Checkmark Color | System default (not customizable) | Fully customizable |
| Unchecked Color | System default | Fully customizable |
| Animation | Native iOS animation | Slide up animation |
| Scrolling | Native UIMenu scrolling | Custom ScrollView (max 40% screen) |
| Selection State | Controlled via `selectedIdentifier` | use `selectedIdentifier` for cross-platform parity |
| Appearance | iOS system theme | White background with rounded corners |

## Example Project

The repository includes a complete example project with 6 different use cases:

1. **Theme Selector** - Shows state management with current selection
2. **Sort Options** - Demonstrates sorting menu with dynamic labels
3. **File Actions** - Common file operations menu
4. **Priority Selector** - Priority level selection
5. **Custom Trigger** - Fully custom styled button
6. **Long List** - Scrollable menu with many items

```bash
cd example
yarn install
# For iOS
cd ios && pod install && cd ..
yarn ios
# For Android
yarn android
```

## Requirements

- React Native >= 0.68.0
- iOS >= 14.0 (for UIMenu support)
- Android API >= 21

## Troubleshooting

### Menu not opening on tap

**iOS:** Make sure you're running iOS 14 or later, as UIMenu is only available from iOS 14+.

**Android:** Ensure your child component doesn't have `onPress` or other touch handlers that might interfere. The MenuView intercepts all touch events at the parent level.

### Checkmark not updating on iOS/Android

Pass and update `selectedIdentifier`. iOS does not shift the checkmark automatically—reflect selection in props via your `onMenuSelect` handler.

### Children prop is required

The MenuView component requires a child component to act as the trigger. Always wrap your trigger in the MenuView:

```tsx
// ✅ Correct
<MenuView menuItems={items}>
<View><Text>Open Menu</Text></View>
</MenuView>

// ❌ Wrong - no children
<MenuView menuItems={items} />
```
### MenuView Props

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `menuItems` | `MenuItem[]` | `[]` | Array of menu items to display |
| `title` | `string` | `undefined` | Title of the menu (Android only) |
| `androidDisplayMode` | `'dialog' \| 'tooltip'` | `'dialog'` | Display mode for the menu on Android (Android only) |
| `themeVariant` | `'light' \| 'dark' \| 'system'` | `'system'` | Theme variant for the menu background and text (Android only) |
| `selectedIdentifier` | `string` | `undefined` | Identifier of the currently selected item |
| `checkedColor` | `string` | `'#007AFF'` | Color of the checkmark for selected items |
| `uncheckedColor` | `string` | `'#8E8E93'` | Color of the checkmark for unselected items (Android only) |
| `color` | `string` | `undefined` | Tint color for the menu button text (if using default button) |
| `disabled` | `boolean` | `false` | Whether the menu is disabled |
| `onMenuSelect` | `(event: NativeSyntheticEvent<MenuSelectEvent>) => void` | `undefined` | Callback when a menu item is selected |

### MenuItem Object

| Property | Type | Description |
|----------|------|-------------|
| `identifier` | `string` | Unique identifier for the item |
| `title` | `string` | Text to display |
| `subtitle` | `string` | Subtitle text (optional) |
| `destructive` | `boolean` | Whether the item represents a destructive action (red text) |
| `iosSymbol` | `string` | SF Symbol name (iOS only) |

## Contributing

- [Development workflow](CONTRIBUTING.md#development-workflow)
- [Sending a pull request](CONTRIBUTING.md#sending-a-pull-request)
- [Code of conduct](CODE_OF_CONDUCT.md)
See the [contributing guide](CONTRIBUTING.md) to learn how to contribute to the repository and the development workflow.

## License

MIT

Made with [create-react-native-library](https://github.com/callstack/react-native-builder-bob)
72 changes: 72 additions & 0 deletions android/src/main/java/com/menu/MenuView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class MenuView(context: Context) : FrameLayout(context) {
private var uncheckedColor: String = "#8E8E93" // Default iOS gray
private var textColor: String? = null
private var disabled: Boolean = false
private var androidDisplayMode: String? = "dialog"

init {
setupMenuTrigger()
Expand Down Expand Up @@ -67,6 +68,10 @@ class MenuView(context: Context) : FrameLayout(context) {
return true
}

fun setAndroidDisplayMode(mode: String?) {
this.androidDisplayMode = mode
}

fun setColor(color: String?) {
// Store text color for potential future use with child views
textColor = color
Expand Down Expand Up @@ -174,6 +179,73 @@ class MenuView(context: Context) : FrameLayout(context) {
private var currentDialog: Dialog? = null

private fun showMenu() {
// Check if tooltip mode is requested
val useTooltip = androidDisplayMode == "tooltip"
Comment thread
sbaiahmed1 marked this conversation as resolved.

if (useTooltip) {
showTooltipMenu()
} else {
showDialogMenu()
}
}

private fun showTooltipMenu() {
// Apply theme wrapper for PopupMenu
val contextThemeWrapper = android.view.ContextThemeWrapper(
context,
if (themeVariant == "dark") android.R.style.Theme_DeviceDefault_NoActionBar
else android.R.style.Theme_DeviceDefault_Light_NoActionBar
)

val popup = android.widget.PopupMenu(contextThemeWrapper, this)

// Add items to the menu
menuItems.forEachIndexed { index, item ->
val title = item["title"] as String
val identifier = item["identifier"] as String
val destructive = item["destructive"] as? Boolean == true

val menuItem = popup.menu.add(0, index, index, title)

// Handle destructive items (Red text)
if (destructive) {
val spannableTitle = android.text.SpannableString(title)
spannableTitle.setSpan(
android.text.style.ForegroundColorSpan(Color.RED),
0,
spannableTitle.length,
android.text.Spannable.SPAN_INCLUSIVE_INCLUSIVE
)
menuItem.title = spannableTitle
}

// Handle selection state
if (identifier == selectedItemIdentifier) {
menuItem.isCheckable = true
menuItem.isChecked = true
}
}

// Force show icons/checkmarks if possible (requires internal API or Android Q+)
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
popup.setForceShowIcon(true)
}

popup.setOnMenuItemClickListener { menuItem ->
val index = menuItem.itemId
if (index >= 0 && index < menuItems.size) {
val item = menuItems[index]
selectMenuItem(item["identifier"] as String, item["title"] as String)
true
} else {
false
}
}

popup.show()
}

private fun showDialogMenu() {
val dialogView = createModalMenuView()

currentDialog = Dialog(context).apply {
Expand Down
5 changes: 5 additions & 0 deletions android/src/main/java/com/menu/MenuViewManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ class MenuViewManager : ViewGroupManager<MenuView>() {
view.setDisabled(disabled)
}

@ReactProp(name = "androidDisplayMode")
fun setAndroidDisplayMode(view: MenuView, androidDisplayMode: String?) {
view.setAndroidDisplayMode(androidDisplayMode)
}

override fun getExportedCustomBubblingEventTypeConstants(): Map<String, Any> {
return mapOf(
"onMenuSelect" to mapOf(
Expand Down
Loading
Loading