Skip to content

Commit

Permalink
feat(iOS): add customIcon and customIconName properties to the action…
Browse files Browse the repository at this point in the history
… object (#111)

* Add customIcon and customIconColor properties to ContextMenuAction

* Convert the new ContextMenuAction properties

* Set customIcon with customIconColor, if available, for the ContextMenu actions

* Add type declarations for new props

* Update README with new action props, fix other typos

* Add example for new action props

* Update readme and specify the higher priority of customIcon

* Refactor icon and iconColor props on iOS native side

* Remove "system" prefix from icon and iconColor on Android native side

* Update type declarations with new the new prop changes

* Update and format README

* Call processColor for iOS in index.js
  • Loading branch information
wilmxre authored Feb 20, 2024
1 parent a276222 commit bea0a1c
Show file tree
Hide file tree
Showing 11 changed files with 118 additions and 37 deletions.
42 changes: 25 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,48 +42,56 @@ See `example/` for basic usage.

## Props

###### `title`
### `title`

Optional. The title above the popup menu.

###### `actions`
### `actions`

Array of `{ title: string, subtitle?: string, systemIcon?: string, systemIconColor?: string, destructive?: boolean, selected?: boolean, disabled?: boolean, disabled?: boolean, inlineChildren?: boolean, actions?: Array<ContextMenuAction> }`.
Array of `{ title: string, subtitle?: string, systemIcon?: string, icon?: string, iconColor?: string, destructive?: boolean, selected?: boolean, disabled?: boolean, disabled?: boolean, inlineChildren?: boolean, actions?: Array<ContextMenuAction> }`.

Subtitle is only available on iOS 15+.
- `title` is the title of the action

System icon refers to an icon name within [SF Symbols](https://developer.apple.com/design/human-interface-guidelines/sf-symbols/overview/) on IOS and Drawable name on Android.
- `subtitle` is the subtitle of the action (iOS 15+ only)

System icon color is only available on Android.
- `systemIcon` refers to an icon name within [SF Symbols](https://developer.apple.com/design/human-interface-guidelines/sf-symbols/overview/) (iOS only)

Destructive items are rendered in red.
- `icon` refers to an SVG asset name that is provided in Assets.xcassets or to a Drawable on Android; when both `systemIcon` and `icon` are provided, `icon` will take a higher priority and it will override `systemIcon`

Selected items have a checkmark next to them on iOS, and unchanged on Android.
- `iconColor` will change the color of the icon provided to the `icon` prop and has no effect on `systemIcon` (default: black)

Menus can be nested one level deep. On iOS submenus can be rendered inline optionally.
- `destructive` items are rendered in red (iOS only, default: false)

###### `onPress`
- `selected` items have a checkmark next to them (iOS only, default: false)

- `disabled` marks whether the action is disabled or not (default: false)

- `actions` will provide a one level deep nested menu; when child actions are supplied, the child's callback will contain its name but the same index as the topmost parent menu/action index

- `inlineChildren` marks whether its children (if any) should be rendered inline instead of in their own child menu (iOS only, default: false)

### `onPress`

Optional. When the popup is opened and the user picks an option. Called with `{ nativeEvent: { index, indexPath, name } }`. When a nested action is selected the top level parent index is used for the callback.

To get the full path to the item, `indexPath` is an array of indices to reach the item. For a top-levle item, it'll be an array with a single index. For an item one deep, it'll be an array with two indicies.
To get the full path to the item, `indexPath` is an array of indices to reach the item. For a top-level item, it'll be an array with a single index. For an item one deep, it'll be an array with two indexes.

###### `onPreviewPress`
### `onPreviewPress`

Optional, iOS only. When the context menu preview is tapped.

###### `onCancel`
### `onCancel`

Optional. When the popop is opened and the user cancels.
Optional. When the popup is opened and the user cancels.

###### `previewBackgroundColor`
### `previewBackgroundColor`

Optional. The background color of the preview. This is displayed underneath your view. Set this to transparent (or another color) if the default causes issues.

###### `dropdownMenuMode`
### `dropdownMenuMode`

Optional. When set to `true`, the context menu is triggered with a single tap instead of a long press, and a preview is not show and no blur occurs. Uses the iOS 14 Menu API on iOS and a simple tap listener on android.

###### `disabled`
### `disabled`

Optional. Disable menu interaction.
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,8 @@ private void createContextMenuSubMenu(Menu menu, ReadableMap action, ReadableArr
String title = action.getString("title");
Menu parentMenu = menu.addSubMenu(title);

@Nullable Drawable systemIcon = getResourceWithName(getContext(), action.getString("systemIcon"));
menu.getItem(i).setIcon(systemIcon); // set icon to current item.
@Nullable Drawable icon = getResourceWithName(getContext(), action.getString("icon"));
menu.getItem(i).setIcon(icon); // set icon to current item.

for (int j = 0; j < childActions.size(); j++) {
createContextMenuAction(parentMenu, childActions.getMap(j), j, i);
Expand All @@ -135,16 +135,16 @@ private void createContextMenuSubMenu(Menu menu, ReadableMap action, ReadableArr

private void createContextMenuAction(Menu menu, ReadableMap action, int i, int parentIndex) {
String title = action.getString("title");
@Nullable Drawable systemIcon = getResourceWithName(getContext(), action.getString("systemIcon"));
@Nullable Drawable icon = getResourceWithName(getContext(), action.getString("icon"));

MenuItem item = menu.add(Menu.NONE, Menu.NONE, i, title);
item.setEnabled(!action.hasKey("disabled") || !action.getBoolean("disabled"));

if (action.hasKey("systemIconColor") && systemIcon != null) {
int color = Color.parseColor(action.getString("systemIconColor"));
systemIcon.setTint(color);
if (action.hasKey("iconColor") && icon != null) {
int color = Color.parseColor(action.getString("iconColor"));
icon.setTint(color);
}
item.setIcon(systemIcon);
item.setIcon(icon);
if (action.hasKey("destructive") && action.getBoolean("destructive")) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
item.setIconTintList(ColorStateList.valueOf(Color.RED));
Expand Down Expand Up @@ -185,12 +185,12 @@ private void setMenuIconDisplay(Menu contextMenu, boolean display) {
} catch (Exception ignored) {}
}

private Drawable getResourceWithName(Context context, @Nullable String systemIcon) {
if (systemIcon == null)
private Drawable getResourceWithName(Context context, @Nullable String icon) {
if (icon == null)
return null;

Resources resources = context.getResources();
int resourceId = resources.getIdentifier(systemIcon, "drawable", context.getPackageName());
int resourceId = resources.getIdentifier(icon, "drawable", context.getPackageName());
try {
return resourceId != 0 ? ResourcesCompat.getDrawable(resources, resourceId, context.getTheme()) : null;
} catch (Exception e) {
Expand Down
7 changes: 6 additions & 1 deletion example/App.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { SafeAreaView, View, StyleSheet, Platform, TouchableOpacity, Alert } from 'react-native';
import { SafeAreaView, View, StyleSheet, Platform, TouchableOpacity, Alert, processColor } from 'react-native';
import ContextMenu from 'react-native-context-menu-view';

const Icons = Platform.select({
Expand Down Expand Up @@ -43,6 +43,11 @@ const App = () => {
systemIcon: Icons.transparent,
destructive: true,
},
{
title: 'Custom Icon and Color',
customIcon: Platform.OS === 'ios' ? 'bluetooth' : '',
customIconColor: Platform.OS === 'ios' ? processColor('green') : '',
},
{
title: 'Toggle Circle',
systemIcon: Icons.toggleCircle,
Expand Down
6 changes: 6 additions & 0 deletions example/ios/example/Assets.xcassets/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "bluetooth.svg",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 14 additions & 5 deletions index.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import React, { Component } from "react";
import { NativeSyntheticEvent, ViewProps, ViewStyle } from "react-native";
import {
NativeSyntheticEvent,
ViewProps,
ViewStyle,
ProcessedColorValue,
} from "react-native";

export interface ContextMenuAction {
/**
Expand All @@ -11,13 +16,17 @@ export interface ContextMenuAction {
*/
subtitle?: string;
/**
* The icon to use. This is the name of the SFSymbols icon to use on IOS and name of the Drawable to use on Android.
* The system icon to use. This is the name of the SFSymbols icon (iOS only).
*/
systemIcon?: string;
/**
* Color of icon. (Android only)
* The icon to use. This is the name of the SVG that is provided in Assets.xcassets (iOS) or the name of the Drawable (Android). It overrides the systemIcon prop.
*/
systemIconColor?: string;
icon?: string;
/**
* Color of the icon (default: black). The color only applies to the icon provided to the icon prop, as the color of the systemIcon is always black and cannot be changed with this prop.
*/
iconColor?: string;
/**
* Destructive items are rendered in red on iOS, and unchanged on Android. (default: false)
*/
Expand All @@ -35,7 +44,7 @@ export interface ContextMenuAction {
*/
inlineChildren?: boolean;
/**
* Child actions. When child actions are supplied, the childs callback will contain its name but the same index as the topmost parent menu/action index
* Child actions. When child actions are supplied, the child's callback will contain its name but the same index as the topmost parent menu/action index
*/
actions?: Array<ContextMenuAction>;
}
Expand Down
10 changes: 8 additions & 2 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import React from "react";
import { requireNativeComponent, View, Platform, StyleSheet } from "react-native";
import { requireNativeComponent, View, Platform, StyleSheet, processColor } from "react-native";

const NativeContextMenu = requireNativeComponent("ContextMenu", null);

const ContextMenu = (props) => {
const iconColor = props?.iconColor
? Platform.OS === 'ios'
? processColor(props.iconColor)
: props.iconColor
: undefined;

return (
<NativeContextMenu {...props}>
<NativeContextMenu {...props} iconColor={iconColor}>
{props.children}
{props.preview != null && Platform.OS === 'ios' ? (
<View style={styles.preview} nativeID="ContextMenuPreview">{props.preview}</View>
Expand Down
2 changes: 2 additions & 0 deletions ios/ContextMenuAction.h
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
@property (nonnull, nonatomic, copy) NSString* title;
@property (nonnull, nonatomic, copy) NSString* subtitle;
@property (nullable, nonatomic, copy) NSString* systemIcon;
@property (nullable, nonatomic, copy) NSString* icon;
@property (nullable, nonatomic, copy) UIColor* iconColor;
@property (nonatomic, assign) BOOL destructive;
@property (nonatomic, assign) BOOL selected;
@property (nonatomic, assign) BOOL disabled;
Expand Down
19 changes: 17 additions & 2 deletions ios/ContextMenuView.m
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,21 @@ - (UITargetedPreview *)contextMenuInteraction:(UIContextMenuInteraction *)intera

- (UIMenuElement*) createMenuElementForAction:(ContextMenuAction *)action atIndexPath:(NSArray<NSNumber *> *)indexPath {
UIMenuElement* menuElement = nil;
UIImage *iconImage = nil;

if (action.icon != nil) {
UIColor *iconColor = [UIColor blackColor];

if (action.iconColor != nil) {
iconColor = action.iconColor;
}
// Use custom icon from Assets.xcassets
iconImage = [[UIImage imageNamed:action.icon] imageWithTintColor:iconColor];
} else {
// Use system icon from SF Symbols
iconImage = [UIImage systemImageNamed:action.systemIcon];
}

if (action.actions != nil && action.actions.count > 0) {
NSMutableArray<UIMenuElement*> *children = [[NSMutableArray alloc] init];
[action.actions enumerateObjectsUsingBlock:^(ContextMenuAction * _Nonnull childAction, NSUInteger childIdx, BOOL * _Nonnull stop) {
Expand All @@ -134,7 +149,7 @@ - (UIMenuElement*) createMenuElementForAction:(ContextMenuAction *)action atInde
(action.inlineChildren ? UIMenuOptionsDisplayInline : 0) |
(action.destructive ? UIMenuOptionsDestructive : 0);
UIMenu *actionMenu = [UIMenu menuWithTitle:action.title
image:[UIImage systemImageNamed:action.systemIcon]
image:iconImage
identifier:nil
options:actionMenuOptions
children:children];
Expand All @@ -146,7 +161,7 @@ - (UIMenuElement*) createMenuElementForAction:(ContextMenuAction *)action atInde
menuElement = actionMenu;
} else {
UIAction* actionMenuItem =
[UIAction actionWithTitle:action.title image:[UIImage systemImageNamed:action.systemIcon] identifier:nil handler:^(__kindof UIAction * _Nonnull action) {
[UIAction actionWithTitle:action.title image:iconImage identifier:nil handler:^(__kindof UIAction * _Nonnull action) {
if (self.onPress != nil) {
self->_cancelled = false;
self.onPress(@{
Expand Down
2 changes: 2 additions & 0 deletions ios/RCTConvert+ContextMenuAction.m
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ + (ContextMenuAction*) ContextMenuAction:(id)json {
action.title = [self NSString:json[@"title"]];
action.subtitle = [self NSString:json[@"subtitle"]];
action.systemIcon = [self NSString:json[@"systemIcon"]];
action.icon = [self NSString:json[@"icon"]];
action.iconColor = json[@"iconColor"] ? [RCTConvert UIColor:json[@"iconColor"]] : nil;
action.destructive = [self BOOL:json[@"destructive"]];
action.selected = [self BOOL:json[@"selected"]];
action.disabled = [self BOOL:json[@"disabled"]];
Expand Down

0 comments on commit bea0a1c

Please sign in to comment.