Skip to content

Commit

Permalink
feat: custom action menu on android + improved iOS (react-native-webv…
Browse files Browse the repository at this point in the history
…iew#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
  • Loading branch information
Titozzz committed Jun 10, 2023
1 parent 224e283 commit f2aef66
Show file tree
Hide file tree
Showing 11 changed files with 633 additions and 274 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -135,6 +147,75 @@ protected void onSizeChanged(int w, int h, int ow, int oh) {
}
}

protected @Nullable
List<Map<String, String>> menuCustomItems;

public void setMenuCustomItems(List<Map<String, String>> 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<String>() {
@Override
public void onReceiveValue(String selectionJson) {
Map<String, String> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import java.util.*

val invalidCharRegex = "[\\\\/%\"]".toRegex()


class RNCWebViewManagerImpl {
companion object {
const val NAME = "RNCWebView"
Expand Down Expand Up @@ -600,6 +599,10 @@ class RNCWebViewManagerImpl {
}
}

fun setMenuCustomItems(view: RNCWebView, value: ReadableArray) {
view.setMenuCustomItems(value.toArrayList() as List<Map<String, String>>)
}

fun setNestedScrollEnabled(view: RNCWebView, value: Boolean) {
view.nestedScrollEnabled = value
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TopCustomMenuSelectionEvent>(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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -488,6 +492,7 @@ public Map<String, Object> 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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -283,6 +289,7 @@ public Map<String, Object> 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;
}

Expand Down
59 changes: 42 additions & 17 deletions apple/RNCWebViewImpl.m
Original file line number Diff line number Diff line change
Expand Up @@ -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<NSDictionary *> * _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<UIMenuBuilder>)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");
Expand All @@ -61,20 +82,16 @@ - (void)scrollWheel:(NSEvent *)theEvent {
}
[super scrollWheel:theEvent];
}
@end
#endif // TARGET_OS_OSX
@end

@interface RNCWebViewImpl () <WKUIDelegate, WKNavigationDelegate, WKScriptMessageHandler, WKHTTPCookieStoreObserver,
#if !TARGET_OS_OSX
UIScrollViewDelegate,
#endif // !TARGET_OS_OSX
RCTAutoInsetsProtocol>

#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;
Expand Down Expand Up @@ -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];
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -741,6 +761,11 @@ - (void)visitSource
}

#if !TARGET_OS_OSX
-(void)setMenuItems:(NSArray<NSDictionary *> *)menuItems {
_menuItems = menuItems;
_webView.menuItems = menuItems;
}

-(void)setKeyboardDisplayRequiresUserAction:(BOOL)keyboardDisplayRequiresUserAction
{
if (_webView == nil) {
Expand Down
14 changes: 14 additions & 0 deletions example/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -101,6 +102,14 @@ const TESTS = {
render() {
return <ApplePay />;
},
},
CustomMenu: {
title: 'Custom Menu',
testId: 'CustomMenu',
description: 'Test to custom context menu shown on highlighting text',
render() {
return <CustomMenu />;
},
}
};

Expand Down Expand Up @@ -194,6 +203,11 @@ export default class App extends Component<Props, State> {
onPress={() => this._changeTest('ApplePay')}
/>
)}
<Button
testID="testType_customMenu"
title="CustomMenu"
onPress={() => this._changeTest('CustomMenu')}
/>
</View>

{restarting ? null : (
Expand Down

0 comments on commit f2aef66

Please sign in to comment.