Skip to content

Commit e14b4ea

Browse files
authored
Switch Android ContextMenu Implementation (#107)
* Switch android impl to use native contextmenu view * Fix click callbacks * Nested menus working on android * Clean up * Bump version * Add icon functionality back to android context menus
1 parent 986b2f4 commit e14b4ea

File tree

4 files changed

+174
-159
lines changed

4 files changed

+174
-159
lines changed

android/src/main/java/com/mpiannucci/reactnativecontextmenu/ContextMenuView.java

Lines changed: 91 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -3,80 +3,68 @@
33
import android.content.Context;
44
import android.content.res.ColorStateList;
55
import android.content.res.Resources;
6-
import android.gesture.Gesture;
76
import android.graphics.Color;
87
import android.graphics.drawable.Drawable;
98
import android.os.Build;
109
import android.text.SpannableString;
1110
import android.text.Spanned;
1211
import android.text.style.ForegroundColorSpan;
13-
import android.util.Log;
1412
import android.view.GestureDetector;
1513
import android.view.Menu;
1614
import android.view.MenuItem;
1715
import android.view.MotionEvent;
1816
import android.view.View;
19-
import android.view.ViewGroup;
20-
import android.widget.PopupMenu;
17+
import android.view.ContextMenu;
2118

19+
import androidx.annotation.NonNull;
2220
import androidx.core.content.res.ResourcesCompat;
2321

2422
import com.facebook.react.bridge.Arguments;
2523
import com.facebook.react.bridge.ReactContext;
26-
import com.facebook.react.bridge.ReactContextBaseJavaModule;
2724
import com.facebook.react.bridge.ReadableArray;
2825
import com.facebook.react.bridge.ReadableMap;
26+
import com.facebook.react.bridge.WritableArray;
2927
import com.facebook.react.bridge.WritableMap;
30-
import com.facebook.react.touch.OnInterceptTouchEventListener;
3128
import com.facebook.react.uimanager.events.RCTEventEmitter;
3229
import com.facebook.react.views.view.ReactViewGroup;
3330

34-
import java.util.List;
31+
import java.lang.reflect.Method;
3532

3633
import javax.annotation.Nullable;
3734

38-
public class ContextMenuView extends ReactViewGroup implements PopupMenu.OnMenuItemClickListener, PopupMenu.OnDismissListener {
39-
40-
public class Action {
41-
String title;
42-
boolean disabled;
43-
44-
public Action(String title, boolean disabled) {
45-
this.title = title;
46-
this.disabled = disabled;
47-
}
48-
}
49-
50-
PopupMenu contextMenu;
51-
52-
GestureDetector gestureDetector;
35+
public class ContextMenuView extends ReactViewGroup implements View.OnCreateContextMenuListener {
36+
@Nullable ReadableArray actions;
5337

5438
boolean cancelled = true;
5539

5640
protected boolean dropdownMenuMode = false;
5741

5842
protected boolean disabled = false;
5943

44+
private GestureDetector gestureDetector;
45+
6046
public ContextMenuView(final Context context) {
6147
super(context);
6248

63-
contextMenu = new PopupMenu(getContext(), this);
64-
contextMenu.setOnMenuItemClickListener(this);
65-
contextMenu.setOnDismissListener(this);
49+
this.setOnCreateContextMenuListener(this);
6650

6751
gestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() {
6852
@Override
6953
public boolean onSingleTapConfirmed(MotionEvent e) {
7054
if (dropdownMenuMode && !disabled) {
71-
contextMenu.show();
55+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
56+
showContextMenu(e.getX(), e.getY());
57+
}
7258
}
7359
return super.onSingleTapConfirmed(e);
7460
}
7561

7662
@Override
7763
public void onLongPress(MotionEvent e) {
7864
if (!dropdownMenuMode && !disabled) {
79-
contextMenu.show();
65+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
66+
showContextMenu(e.getX(), e.getY());
67+
}
8068
}
8169
}
8270
});
@@ -100,37 +88,29 @@ public boolean onTouchEvent(MotionEvent ev) {
10088
return true;
10189
}
10290

103-
public void setActions(@Nullable ReadableArray actions) {
104-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
105-
contextMenu.setForceShowIcon(true);
106-
}
107-
Menu menu = contextMenu.getMenu();
108-
menu.clear();
91+
@Override
92+
public void onCreateContextMenu(ContextMenu contextMenu, View view, ContextMenu.ContextMenuInfo contextMenuInfo) {
93+
contextMenu.clear();
94+
setMenuIconDisplay(contextMenu, true);
10995

11096
for (int i = 0; i < actions.size(); i++) {
11197
ReadableMap action = actions.getMap(i);
112-
@Nullable Drawable systemIcon = getResourceWithName(getContext(), action.getString("systemIcon"));
113-
String title = action.getString("title");
114-
int order = i;
115-
menu.add(Menu.NONE, Menu.NONE, order, title);
116-
menu.getItem(i).setEnabled(!action.hasKey("disabled") || !action.getBoolean("disabled"));
117-
118-
if (action.hasKey("systemIconColor") && systemIcon != null) {
119-
int color = Color.parseColor(action.getString("systemIconColor"));
120-
systemIcon.setTint(color);
121-
}
122-
menu.getItem(i).setIcon(systemIcon);
123-
if (action.hasKey("destructive") && action.getBoolean("destructive")) {
124-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
125-
menu.getItem(i).setIconTintList(ColorStateList.valueOf(Color.RED));
126-
}
127-
SpannableString redTitle = new SpannableString(title);
128-
redTitle.setSpan(new ForegroundColorSpan(Color.RED), 0, title.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
129-
menu.getItem(i).setTitle(redTitle);
98+
ReadableArray childActions = action.getArray("actions");
99+
100+
// If there are child actions, this action is a submenu
101+
if (childActions != null) {
102+
createContextMenuSubMenu(contextMenu, action, childActions, i);
103+
} else {
104+
// Otherwise its a normal menu item
105+
createContextMenuAction(contextMenu, action, i, -1);
130106
}
131107
}
132108
}
133109

110+
public void setActions(@Nullable ReadableArray actions) {
111+
this.actions = actions;
112+
}
113+
134114
public void setDropdownMenuMode(@Nullable boolean enabled) {
135115
this.dropdownMenuMode = enabled;
136116
}
@@ -139,25 +119,70 @@ public void setDisabled(@Nullable boolean disabled) {
139119
this.disabled = disabled;
140120
}
141121

142-
@Override
143-
public boolean onMenuItemClick(MenuItem menuItem) {
144-
cancelled = false;
145-
ReactContext reactContext = (ReactContext) getContext();
146-
WritableMap event = Arguments.createMap();
147-
event.putInt("index", menuItem.getOrder());
148-
event.putString("name", menuItem.getTitle().toString());
149-
reactContext.getJSModule(RCTEventEmitter.class).receiveEvent(getId(), "onPress", event);
150-
return false;
122+
private void createContextMenuSubMenu(Menu menu, ReadableMap action, ReadableArray childActions, int i) {
123+
String title = action.getString("title");
124+
Menu parentMenu = menu.addSubMenu(title);
125+
126+
@Nullable Drawable systemIcon = getResourceWithName(getContext(), action.getString("systemIcon"));
127+
menu.getItem(i).setIcon(systemIcon); // set icon to current item.
128+
129+
for (int j = 0; j < childActions.size(); j++) {
130+
createContextMenuAction(parentMenu, childActions.getMap(j), j, i);
131+
}
132+
133+
parentMenu.setGroupVisible(0, true);
151134
}
152135

153-
@Override
154-
public void onDismiss(PopupMenu popupMenu) {
155-
if (cancelled) {
156-
ReactContext reactContext = (ReactContext) getContext();
157-
reactContext.getJSModule(RCTEventEmitter.class).receiveEvent(getId(), "onCancel", null);
136+
private void createContextMenuAction(Menu menu, ReadableMap action, int i, int parentIndex) {
137+
String title = action.getString("title");
138+
@Nullable Drawable systemIcon = getResourceWithName(getContext(), action.getString("systemIcon"));
139+
140+
MenuItem item = menu.add(Menu.NONE, Menu.NONE, i, title);
141+
item.setEnabled(!action.hasKey("disabled") || !action.getBoolean("disabled"));
142+
143+
if (action.hasKey("systemIconColor") && systemIcon != null) {
144+
int color = Color.parseColor(action.getString("systemIconColor"));
145+
systemIcon.setTint(color);
146+
}
147+
item.setIcon(systemIcon);
148+
if (action.hasKey("destructive") && action.getBoolean("destructive")) {
149+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
150+
item.setIconTintList(ColorStateList.valueOf(Color.RED));
151+
}
152+
SpannableString redTitle = new SpannableString(title);
153+
redTitle.setSpan(new ForegroundColorSpan(Color.RED), 0, title.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
154+
item.setTitle(redTitle);
158155
}
159156

160-
cancelled = true;
157+
// We need a different listener for nested menus and parent menus
158+
item.setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() {
159+
@Override
160+
public boolean onMenuItemClick(@NonNull MenuItem menuItem) {
161+
cancelled = false;
162+
ReactContext reactContext = (ReactContext) getContext();
163+
WritableMap event = Arguments.createMap();
164+
event.putInt("index", i);
165+
event.putString("name", title);
166+
if (parentIndex >= 0) {
167+
WritableArray indexPath = Arguments.createArray();
168+
indexPath.pushInt(parentIndex);
169+
indexPath.pushInt(i);
170+
event.putArray("indexPath", indexPath);
171+
}
172+
reactContext.getJSModule(RCTEventEmitter.class).receiveEvent(getId(), "onPress", event);
173+
return false;
174+
}
175+
});
176+
}
177+
178+
// Call this function after menu created. Both submenu and root menu should call this function.
179+
private void setMenuIconDisplay(Menu contextMenu, boolean display) {
180+
try {
181+
Class<?> clazz = Class.forName("com.android.internal.view.menu.MenuBuilder");
182+
Method m = clazz.getDeclaredMethod("setOptionalIconsVisible", boolean.class);
183+
m.setAccessible(true);
184+
m.invoke(contextMenu, display);
185+
} catch (Exception ignored) {}
161186
}
162187

163188
private Drawable getResourceWithName(Context context, @Nullable String systemIcon) {

example/App.js

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -54,18 +54,8 @@ const App = () => {
5454
]} onPress={(event) => {
5555
const { index, indexPath, name } = event.nativeEvent;
5656
if (indexPath?.at(0) == 0) {
57-
// On iOS the first item is nested in a submenu
57+
// The first item is nested in a submenu
5858
setColor(name.toLowerCase());
59-
} else if (name === 'Change Color') {
60-
// On Android the first item is simply a change color
61-
// button because nested menus are not supported
62-
if (color === 'transparent') {
63-
setColor(previousColor);
64-
} else if (color === 'blue') {
65-
setColor('red');
66-
} else if (color === 'red') {
67-
setColor('blue');
68-
}
6959
} else if (index == 1) {
7060
if (color !== 'transparent') {
7161
setPreviousColor(color);

0 commit comments

Comments
 (0)