From f2aef667b28ac36ac6b75ec81fc95055a1c5743a Mon Sep 17 00:00:00 2001 From: Thibault Malbranche Date: Sat, 10 Jun 2023 13:56:05 +0200 Subject: [PATCH] feat: custom action menu on android + improved iOS (#2993) * Update RNCWebView.java * Update RNCWebView.java * wip * wip * fix build on latest xcode * add example + fix a few things * Update RNCWebViewImpl.m * fix macOS build --- .../webview/RNCWebView.java | 81 ++++ .../webview/RNCWebViewManagerImpl.kt | 5 +- .../events/TopCustomMenuSelectionEvent.kt | 24 + .../webview/RNCWebViewManager.java | 11 +- .../webview/RNCWebViewManager.java | 7 + apple/RNCWebViewImpl.m | 59 ++- example/App.tsx | 14 + example/examples/CustomMenu.tsx | 88 ++++ example/ios/Podfile.lock | 452 +++++++++--------- package.json | 4 +- yarn.lock | 162 ++++++- 11 files changed, 633 insertions(+), 274 deletions(-) create mode 100644 android/src/main/java/com/reactnativecommunity/webview/events/TopCustomMenuSelectionEvent.kt create mode 100644 example/examples/CustomMenu.tsx diff --git a/android/src/main/java/com/reactnativecommunity/webview/RNCWebView.java b/android/src/main/java/com/reactnativecommunity/webview/RNCWebView.java index f99861417..467677953 100644 --- a/android/src/main/java/com/reactnativecommunity/webview/RNCWebView.java +++ b/android/src/main/java/com/reactnativecommunity/webview/RNCWebView.java @@ -4,13 +4,18 @@ import android.annotation.SuppressLint; import android.content.Context; +import android.graphics.Rect; import android.os.Build; import android.text.TextUtils; import android.util.AttributeSet; +import android.view.ActionMode; +import android.view.Menu; +import android.view.MenuItem; import android.view.MotionEvent; import android.view.View; import android.webkit.JavascriptInterface; +import android.webkit.ValueCallback; import android.webkit.WebChromeClient; import android.webkit.WebView; import android.webkit.WebViewClient; @@ -28,14 +33,21 @@ import com.facebook.react.uimanager.events.ContentSizeChangeEvent; import com.facebook.react.uimanager.events.Event; import com.facebook.react.uimanager.events.EventDispatcher; +import com.facebook.react.uimanager.events.RCTEventEmitter; import com.facebook.react.views.scroll.OnScrollDispatchHelper; import com.facebook.react.views.scroll.ScrollEvent; import com.facebook.react.views.scroll.ScrollEventType; +import com.reactnativecommunity.webview.events.TopCustomMenuSelectionEvent; import com.reactnativecommunity.webview.events.TopLoadingProgressEvent; import com.reactnativecommunity.webview.events.TopMessageEvent; +import org.json.JSONException; +import org.json.JSONObject; + import java.io.UnsupportedEncodingException; import java.net.URLEncoder; +import java.util.List; +import java.util.Map; public class RNCWebView extends WebView implements LifecycleEventListener { protected @Nullable @@ -135,6 +147,75 @@ protected void onSizeChanged(int w, int h, int ow, int oh) { } } + protected @Nullable + List> menuCustomItems; + + public void setMenuCustomItems(List> menuCustomItems) { + this.menuCustomItems = menuCustomItems; + } + + @Override + public ActionMode startActionMode(ActionMode.Callback callback, int type) { + if(menuCustomItems == null ){ + return super.startActionMode(callback, type); + } + + return super.startActionMode(new ActionMode.Callback2() { + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + for (int i = 0; i < menuCustomItems.size(); i++) { + menu.add(Menu.NONE, i, i, (menuCustomItems.get(i)).get("label")); + } + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode actionMode, Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + WritableMap wMap = Arguments.createMap(); + RNCWebView.this.evaluateJavascript( + "(function(){return {selection: window.getSelection().toString()} })()", + new ValueCallback() { + @Override + public void onReceiveValue(String selectionJson) { + Map menuItemMap = menuCustomItems.get(item.getItemId()); + wMap.putString("label", menuItemMap.get("label")); + wMap.putString("key", menuItemMap.get("key")); + String selectionText = ""; + try { + selectionText = new JSONObject(selectionJson).getString("selection"); + } catch (JSONException ignored) {} + wMap.putString("selectedText", selectionText); + dispatchEvent(RNCWebView.this, new TopCustomMenuSelectionEvent(RNCWebView.this.getId(), wMap)); + mode.finish(); + } + } + ); + return true; + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + mode = null; + } + + @Override + public void onGetContentRect (ActionMode mode, + View view, + Rect outRect){ + if (callback instanceof ActionMode.Callback2) { + ((ActionMode.Callback2) callback).onGetContentRect(mode, view, outRect); + } else { + super.onGetContentRect(mode, view, outRect); + } + } + }, type); + } + @Override public void setWebViewClient(WebViewClient client) { super.setWebViewClient(client); diff --git a/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewManagerImpl.kt b/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewManagerImpl.kt index ddbb5d60d..3e67bdcb3 100644 --- a/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewManagerImpl.kt +++ b/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewManagerImpl.kt @@ -31,7 +31,6 @@ import java.util.* val invalidCharRegex = "[\\\\/%\"]".toRegex() - class RNCWebViewManagerImpl { companion object { const val NAME = "RNCWebView" @@ -600,6 +599,10 @@ class RNCWebViewManagerImpl { } } + fun setMenuCustomItems(view: RNCWebView, value: ReadableArray) { + view.setMenuCustomItems(value.toArrayList() as List>) + } + fun setNestedScrollEnabled(view: RNCWebView, value: Boolean) { view.nestedScrollEnabled = value } diff --git a/android/src/main/java/com/reactnativecommunity/webview/events/TopCustomMenuSelectionEvent.kt b/android/src/main/java/com/reactnativecommunity/webview/events/TopCustomMenuSelectionEvent.kt new file mode 100644 index 000000000..47f9f8b12 --- /dev/null +++ b/android/src/main/java/com/reactnativecommunity/webview/events/TopCustomMenuSelectionEvent.kt @@ -0,0 +1,24 @@ +package com.reactnativecommunity.webview.events + +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.events.Event +import com.facebook.react.uimanager.events.RCTEventEmitter + +/** + * Event emitted when there is a loading progress event. + */ +class TopCustomMenuSelectionEvent(viewId: Int, private val mEventData: WritableMap) : + Event(viewId) { + companion object { + const val EVENT_NAME = "topCustomMenuSelection" + } + + override fun getEventName(): String = EVENT_NAME + + override fun canCoalesce(): Boolean = false + + override fun getCoalescingKey(): Short = 0 + + override fun dispatch(rctEventEmitter: RCTEventEmitter) = + rctEventEmitter.receiveEvent(viewTag, eventName, mEventData) +} diff --git a/android/src/newarch/com/reactnativecommunity/webview/RNCWebViewManager.java b/android/src/newarch/com/reactnativecommunity/webview/RNCWebViewManager.java index 28ef443ec..b3f1c337f 100644 --- a/android/src/newarch/com/reactnativecommunity/webview/RNCWebViewManager.java +++ b/android/src/newarch/com/reactnativecommunity/webview/RNCWebViewManager.java @@ -14,6 +14,7 @@ import com.facebook.react.viewmanagers.RNCWebViewManagerDelegate; import com.facebook.react.viewmanagers.RNCWebViewManagerInterface; import com.facebook.react.views.scroll.ScrollEventType; +import com.reactnativecommunity.webview.events.TopCustomMenuSelectionEvent; import com.reactnativecommunity.webview.events.TopHttpErrorEvent; import com.reactnativecommunity.webview.events.TopLoadingErrorEvent; import com.reactnativecommunity.webview.events.TopLoadingFinishEvent; @@ -204,6 +205,12 @@ public void setMediaPlaybackRequiresUserAction(RNCWebView view, boolean value) { mRNCWebViewManagerImpl.setMediaPlaybackRequiresUserAction(view, value); } + @Override + @ReactProp(name = "menuItems") + public void setMenuItems(RNCWebView view, @Nullable ReadableArray items) { + mRNCWebViewManagerImpl.setMenuCustomItems(view, items); + } + @Override @ReactProp(name = "messagingEnabled") public void setMessagingEnabled(RNCWebView view, boolean value) { @@ -376,9 +383,6 @@ public void setTextInteractionEnabled(RNCWebView view, boolean value) {} @Override public void setHasOnFileDownload(RNCWebView view, boolean value) {} - @Override - public void setMenuItems(RNCWebView view, ReadableArray value) {} - @Override public void setMediaCapturePermissionGrantType(RNCWebView view, @Nullable String value) {} /* !iOS PROPS - no implemented here */ @@ -488,6 +492,7 @@ public Map getExportedCustomDirectEventTypeConstants() { export.put(ScrollEventType.getJSEventName(ScrollEventType.SCROLL), MapBuilder.of("registrationName", "onScroll")); export.put(TopHttpErrorEvent.EVENT_NAME, MapBuilder.of("registrationName", "onHttpError")); export.put(TopRenderProcessGoneEvent.EVENT_NAME, MapBuilder.of("registrationName", "onRenderProcessGone")); + export.put(TopCustomMenuSelectionEvent.EVENT_NAME, MapBuilder.of("registrationName", "onCustomMenuSelection")); return export; } diff --git a/android/src/oldarch/com/reactnativecommunity/webview/RNCWebViewManager.java b/android/src/oldarch/com/reactnativecommunity/webview/RNCWebViewManager.java index 4c03b51e9..4d786f559 100644 --- a/android/src/oldarch/com/reactnativecommunity/webview/RNCWebViewManager.java +++ b/android/src/oldarch/com/reactnativecommunity/webview/RNCWebViewManager.java @@ -12,6 +12,7 @@ import com.facebook.react.uimanager.annotations.ReactProp; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.views.scroll.ScrollEventType; +import com.reactnativecommunity.webview.events.TopCustomMenuSelectionEvent; import com.reactnativecommunity.webview.events.TopHttpErrorEvent; import com.reactnativecommunity.webview.events.TopLoadingErrorEvent; import com.reactnativecommunity.webview.events.TopLoadingFinishEvent; @@ -179,6 +180,11 @@ public void setMessagingEnabled(RNCWebView view, boolean value) { mRNCWebViewManagerImpl.setMessagingEnabled(view, value); } + @ReactProp(name = "menuItems") + public void setMenuCustomItems(RNCWebView view, @Nullable ReadableArray items) { + mRNCWebViewManagerImpl.setMenuCustomItems(view, items); + } + @ReactProp(name = "messagingModuleName") public void setMessagingModuleName(RNCWebView view, @Nullable String value) { mRNCWebViewManagerImpl.setMessagingModuleName(view, value); @@ -283,6 +289,7 @@ public Map getExportedCustomDirectEventTypeConstants() { export.put(ScrollEventType.getJSEventName(ScrollEventType.SCROLL), MapBuilder.of("registrationName", "onScroll")); export.put(TopHttpErrorEvent.EVENT_NAME, MapBuilder.of("registrationName", "onHttpError")); export.put(TopRenderProcessGoneEvent.EVENT_NAME, MapBuilder.of("registrationName", "onRenderProcessGone")); + export.put(TopCustomMenuSelectionEvent.EVENT_NAME, MapBuilder.of("registrationName", "onCustomMenuSelection")); return export; } diff --git a/apple/RNCWebViewImpl.m b/apple/RNCWebViewImpl.m index c6d18bb09..d590095e7 100644 --- a/apple/RNCWebViewImpl.m +++ b/apple/RNCWebViewImpl.m @@ -48,10 +48,31 @@ -(id)inputAccessoryView @end #endif // !TARGET_OS_OSX -#if TARGET_OS_OSX @interface RNCWKWebView : WKWebView +#if !TARGET_OS_OSX +@property (nonatomic, copy) NSArray * _Nullable menuItems; +#endif // !TARGET_OS_OSX @end @implementation RNCWKWebView +#if !TARGET_OS_OSX +- (BOOL)canPerformAction:(SEL)action + withSender:(id)sender{ + + if (!self.menuItems) { + return [super canPerformAction:action withSender:sender]; + } + + return NO; +} +- (void)buildMenuWithBuilder:(id)builder API_AVAILABLE(ios(13.0)) { + if (@available(iOS 16.0, *)) { + if(self.menuItems){ + [builder removeMenuForIdentifier:UIMenuLookup]; + } + } + [super buildMenuWithBuilder:builder]; +} +#else // TARGET_OS_OSX - (void)scrollWheel:(NSEvent *)theEvent { RNCWebViewImpl *rncWebView = (RNCWebViewImpl *)[self superview]; RCTAssert([rncWebView isKindOfClass:[rncWebView class]], @"superview must be an RNCWebViewImpl"); @@ -61,8 +82,8 @@ - (void)scrollWheel:(NSEvent *)theEvent { } [super scrollWheel:theEvent]; } -@end #endif // TARGET_OS_OSX +@end @interface RNCWebViewImpl () -#if !TARGET_OS_OSX -@property (nonatomic, copy) WKWebView *webView; -#else @property (nonatomic, copy) RNCWKWebView *webView; -#endif // !TARGET_OS_OSX @property (nonatomic, strong) WKUserScript *postMessageScript; @property (nonatomic, strong) WKUserScript *atStartScript; @property (nonatomic, strong) WKUserScript *atEndScript; @@ -196,10 +213,15 @@ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecogni // Listener for long presses - (void)startLongPress:(UILongPressGestureRecognizer *)pressSender { - // When a long press ends, bring up our custom UIMenu - if(pressSender.state == UIGestureRecognizerStateEnded) { - if (!self.menuItems || self.menuItems.count == 0) { - return; + if (pressSender.state != UIGestureRecognizerStateEnded || !self.menuItems) { + return; + } + // When a long press ends, bring up our custom UIMenu if defined + if (self.menuItems.count == 0) { + UIMenuController *menuController = [UIMenuController sharedMenuController]; + menuController.menuItems = nil; + [menuController setMenuVisible:NO animated:YES]; + return; } UIMenuController *menuController = [UIMenuController sharedMenuController]; NSMutableArray *menuControllerItems = [NSMutableArray arrayWithCapacity:self.menuItems.count]; @@ -210,13 +232,11 @@ - (void)startLongPress:(UILongPressGestureRecognizer *)pressSender NSString *sel = [NSString stringWithFormat:@"%@%@", CUSTOM_SELECTOR, menuItemKey]; UIMenuItem *item = [[UIMenuItem alloc] initWithTitle: menuItemLabel action: NSSelectorFromString(sel)]; - [menuControllerItems addObject: item]; } menuController.menuItems = menuControllerItems; [menuController setMenuVisible:YES animated:YES]; - } } #endif // !TARGET_OS_OSX @@ -412,14 +432,10 @@ - (void)didMoveToWindow { if (self.window != nil && _webView == nil) { WKWebViewConfiguration *wkWebViewConfig = [self setUpWkWebViewConfig]; -#if !TARGET_OS_OSX - _webView = [[WKWebView alloc] initWithFrame:self.bounds configuration: wkWebViewConfig]; -#else _webView = [[RNCWKWebView alloc] initWithFrame:self.bounds configuration: wkWebViewConfig]; -#endif // !TARGET_OS_OSX - [self setBackgroundColor: _savedBackgroundColor]; #if !TARGET_OS_OSX + _webView.menuItems = _menuItems; _webView.scrollView.delegate = self; #endif // !TARGET_OS_OSX _webView.UIDelegate = self; @@ -492,6 +508,10 @@ - (void)removeFromSuperview [_webView removeFromSuperview]; #if !TARGET_OS_OSX _webView.scrollView.delegate = nil; + if (_menuItems) { + UIMenuController *menuController = [UIMenuController sharedMenuController]; + menuController.menuItems = nil; + } #endif // !TARGET_OS_OSX _webView = nil; if (_onContentProcessDidTerminate) { @@ -741,6 +761,11 @@ - (void)visitSource } #if !TARGET_OS_OSX +-(void)setMenuItems:(NSArray *)menuItems { + _menuItems = menuItems; + _webView.menuItems = menuItems; +} + -(void)setKeyboardDisplayRequiresUserAction:(BOOL)keyboardDisplayRequiresUserAction { if (_webView == nil) { diff --git a/example/App.tsx b/example/App.tsx index ebb4ae32f..db93fa99d 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -20,6 +20,7 @@ import LocalPageLoad from './examples/LocalPageLoad'; import Messaging from './examples/Messaging'; import NativeWebpage from './examples/NativeWebpage'; import ApplePay from './examples/ApplePay'; +import CustomMenu from './examples/CustomMenu'; const TESTS = { Messaging: { @@ -101,6 +102,14 @@ const TESTS = { render() { return ; }, + }, + CustomMenu: { + title: 'Custom Menu', + testId: 'CustomMenu', + description: 'Test to custom context menu shown on highlighting text', + render() { + return ; + }, } }; @@ -194,6 +203,11 @@ export default class App extends Component { onPress={() => this._changeTest('ApplePay')} /> )} +