diff --git a/android/src/main/java/com/reactnativecommunity/webview/RNCWebView.java b/android/src/main/java/com/reactnativecommunity/webview/RNCWebView.java index 80c68034b1..a2eb76f306 100644 --- a/android/src/main/java/com/reactnativecommunity/webview/RNCWebView.java +++ b/android/src/main/java/com/reactnativecommunity/webview/RNCWebView.java @@ -40,6 +40,7 @@ import com.facebook.react.views.scroll.ScrollEventType; import com.reactnativecommunity.webview.events.TopCustomMenuSelectionEvent; import com.reactnativecommunity.webview.events.TopMessageEvent; +import com.reactnativecommunity.webview.events.TopNativeTouchEndEvent; import org.json.JSONException; import org.json.JSONObject; @@ -130,6 +131,39 @@ public boolean onTouchEvent(MotionEvent event) { return super.onTouchEvent(event); } + /** + * Observe-only hook into the WebView's own MotionEvent stream. On a terminal action + * (UP / POINTER_UP / CANCEL) we emit {@code onNativeTouchEnd} so JS can detect a finger + * lift even when Chromium's native text-selection controller has seized the gesture and + * suppressed the usual RN/DOM release signals. Always returns super — no behavior change. + */ + @Override + public boolean dispatchTouchEvent(MotionEvent event) { + String action = null; + switch (event.getActionMasked()) { + case MotionEvent.ACTION_UP: + action = "up"; + break; + case MotionEvent.ACTION_CANCEL: + action = "cancel"; + break; + case MotionEvent.ACTION_POINTER_UP: + action = "pointerUp"; + break; + default: + break; + } + if (action != null) { + WritableMap data = Arguments.createMap(); + data.putString("action", action); + data.putInt("pointerCount", event.getPointerCount()); + data.putDouble("x", event.getX()); + data.putDouble("y", event.getY()); + dispatchEvent(this, new TopNativeTouchEndEvent(RNCWebViewWrapper.getReactTagFromWebView(this), data)); + } + return super.dispatchTouchEvent(event); + } + @Override protected void onSizeChanged(int w, int h, int ow, int oh) { super.onSizeChanged(w, h, ow, oh); diff --git a/android/src/main/java/com/reactnativecommunity/webview/events/TopNativeTouchEndEvent.kt b/android/src/main/java/com/reactnativecommunity/webview/events/TopNativeTouchEndEvent.kt new file mode 100644 index 0000000000..f5ba4cd9a6 --- /dev/null +++ b/android/src/main/java/com/reactnativecommunity/webview/events/TopNativeTouchEndEvent.kt @@ -0,0 +1,26 @@ +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 from the WebView's own MotionEvent stream when a gesture ends + * (ACTION_UP / ACTION_POINTER_UP / ACTION_CANCEL). Unlike the RN touch responder, + * this observes the WebView's native touch stream directly. + */ +class TopNativeTouchEndEvent(viewId: Int, private val mEventData: WritableMap) : + Event(viewId) { + companion object { + const val EVENT_NAME = "topNativeTouchEnd" + } + + 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 136a550e93..0eec8db5d2 100644 --- a/android/src/newarch/com/reactnativecommunity/webview/RNCWebViewManager.java +++ b/android/src/newarch/com/reactnativecommunity/webview/RNCWebViewManager.java @@ -15,6 +15,7 @@ import com.facebook.react.viewmanagers.RNCWebViewManagerInterface; import com.facebook.react.views.scroll.ScrollEventType; import com.reactnativecommunity.webview.events.TopCustomMenuSelectionEvent; +import com.reactnativecommunity.webview.events.TopNativeTouchEndEvent; import com.reactnativecommunity.webview.events.SubResourceErrorEvent; import com.reactnativecommunity.webview.events.TopHttpErrorEvent; import com.reactnativecommunity.webview.events.TopLoadingErrorEvent; @@ -547,6 +548,7 @@ public Map getExportedCustomDirectEventTypeConstants() { 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")); + export.put(TopNativeTouchEndEvent.EVENT_NAME, MapBuilder.of("registrationName", "onNativeTouchEnd")); export.put(TopOpenWindowEvent.EVENT_NAME, MapBuilder.of("registrationName", "onOpenWindow")); return export; } diff --git a/android/src/oldarch/com/reactnativecommunity/webview/RNCWebViewManager.java b/android/src/oldarch/com/reactnativecommunity/webview/RNCWebViewManager.java index 974337f41c..8155c08f4f 100644 --- a/android/src/oldarch/com/reactnativecommunity/webview/RNCWebViewManager.java +++ b/android/src/oldarch/com/reactnativecommunity/webview/RNCWebViewManager.java @@ -11,6 +11,7 @@ import com.facebook.react.uimanager.annotations.ReactProp; import com.facebook.react.views.scroll.ScrollEventType; import com.reactnativecommunity.webview.events.TopCustomMenuSelectionEvent; +import com.reactnativecommunity.webview.events.TopNativeTouchEndEvent; import com.reactnativecommunity.webview.events.SubResourceErrorEvent; import com.reactnativecommunity.webview.events.TopHttpErrorEvent; import com.reactnativecommunity.webview.events.TopLoadingErrorEvent; @@ -305,6 +306,7 @@ public Map getExportedCustomDirectEventTypeConstants() { 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")); + export.put(TopNativeTouchEndEvent.EVENT_NAME, MapBuilder.of("registrationName", "onNativeTouchEnd")); export.put(TopOpenWindowEvent.EVENT_NAME, MapBuilder.of("registrationName", "onOpenWindow")); return export; } diff --git a/apple/RNCWebViewImpl.h b/apple/RNCWebViewImpl.h index 26db2e5cae..e3abfa79d9 100644 --- a/apple/RNCWebViewImpl.h +++ b/apple/RNCWebViewImpl.h @@ -115,6 +115,9 @@ shouldStartLoadForRequest:(NSMutableDictionary *)request @property (nonatomic, copy) NSArray * _Nullable menuItems; @property (nonatomic, copy) NSArray * _Nullable suppressMenuItems; @property (nonatomic, copy) RCTDirectEventBlock onCustomMenuSelection; +// Android-only event; declared here as a no-op so the shared codegen interface +// stays consistent across platforms. Never fired on iOS/macOS. +@property (nonatomic, copy) RCTDirectEventBlock onNativeTouchEnd; #if !TARGET_OS_OSX @property (nonatomic, assign) WKDataDetectorTypes dataDetectorTypes; @property (nonatomic, weak) UIRefreshControl * _Nullable refreshControl; diff --git a/apple/RNCWebViewManager.mm b/apple/RNCWebViewManager.mm index 339fea5864..c82429142a 100644 --- a/apple/RNCWebViewManager.mm +++ b/apple/RNCWebViewManager.mm @@ -124,6 +124,8 @@ - (RNCView *)view RCT_CUSTOM_VIEW_PROPERTY(hasOnOpenWindowEvent, BOOL, RNCWebViewImpl) {} RCT_EXPORT_VIEW_PROPERTY(onCustomMenuSelection, RCTDirectEventBlock) +// Android-only event; exported here as a no-op for codegen-interface parity. Never fired on iOS. +RCT_EXPORT_VIEW_PROPERTY(onNativeTouchEnd, RCTDirectEventBlock) RCT_CUSTOM_VIEW_PROPERTY(pullToRefreshEnabled, BOOL, RNCWebViewImpl) { view.pullToRefreshEnabled = json == nil ? false : [RCTConvert BOOL: json]; } diff --git a/lib/RNCWebViewNativeComponent.d.ts b/lib/RNCWebViewNativeComponent.d.ts index dd81536faa..8f53a7c370 100644 --- a/lib/RNCWebViewNativeComponent.d.ts +++ b/lib/RNCWebViewNativeComponent.d.ts @@ -25,6 +25,12 @@ export type WebViewMessageEvent = Readonly<{ export type WebViewOpenWindowEvent = Readonly<{ targetUrl: string; }>; +export type WebViewNativeTouchEndEvent = Readonly<{ + action: 'up' | 'cancel' | 'pointerUp'; + pointerCount: Int32; + x: Double; + y: Double; +}>; export type WebViewHttpErrorEvent = Readonly<{ url: string; loading: boolean; @@ -129,6 +135,7 @@ export interface NativeProps extends ViewProps { nestedScrollEnabled?: boolean; onContentSizeChange?: DirectEventHandler; onRenderProcessGone?: DirectEventHandler; + onNativeTouchEnd?: DirectEventHandler; overScrollMode?: string; saveFormDataDisabled?: boolean; scalesPageToFit?: WithDefault; diff --git a/lib/RNCWebViewNativeComponent.js b/lib/RNCWebViewNativeComponent.js index f78d60f6d0..8b7451b702 100644 --- a/lib/RNCWebViewNativeComponent.js +++ b/lib/RNCWebViewNativeComponent.js @@ -1 +1 @@ -var _interopRequireDefault=require("@babel/runtime/helpers/interopRequireDefault");Object.defineProperty(exports,"__esModule",{value:true});exports.default=exports.__INTERNAL_VIEW_CONFIG=exports.Commands=void 0;var _codegenNativeComponent=_interopRequireDefault(require("react-native/Libraries/Utilities/codegenNativeComponent"));var _codegenNativeCommands=_interopRequireDefault(require("react-native/Libraries/Utilities/codegenNativeCommands"));var NativeComponentRegistry=require('react-native/Libraries/NativeComponent/NativeComponentRegistry');var _require=require('react-native/Libraries/NativeComponent/ViewConfigIgnore'),ConditionallyIgnoredEventHandlers=_require.ConditionallyIgnoredEventHandlers;var _require2=require("react-native/Libraries/ReactNative/RendererProxy"),dispatchCommand=_require2.dispatchCommand;var nativeComponentName='RNCWebView';var __INTERNAL_VIEW_CONFIG=exports.__INTERNAL_VIEW_CONFIG={uiViewClassName:'RNCWebView',directEventTypes:{topContentSizeChange:{registrationName:'onContentSizeChange'},topRenderProcessGone:{registrationName:'onRenderProcessGone'},topContentProcessDidTerminate:{registrationName:'onContentProcessDidTerminate'},topCustomMenuSelection:{registrationName:'onCustomMenuSelection'},topFileDownload:{registrationName:'onFileDownload'},topLoadingError:{registrationName:'onLoadingError'},topLoadingSubResourceError:{registrationName:'onLoadingSubResourceError'},topLoadingFinish:{registrationName:'onLoadingFinish'},topLoadingProgress:{registrationName:'onLoadingProgress'},topLoadingStart:{registrationName:'onLoadingStart'},topHttpError:{registrationName:'onHttpError'},topMessage:{registrationName:'onMessage'},topOpenWindow:{registrationName:'onOpenWindow'},topScroll:{registrationName:'onScroll'},topShouldStartLoadWithRequest:{registrationName:'onShouldStartLoadWithRequest'}},validAttributes:Object.assign({allowFileAccess:true,allowsProtectedMedia:true,allowsFullscreenVideo:true,androidLayerType:true,cacheMode:true,domStorageEnabled:true,downloadingMessage:true,forceDarkOn:true,geolocationEnabled:true,lackPermissionToDownloadMessage:true,messagingModuleName:true,minimumFontSize:true,mixedContentMode:true,nestedScrollEnabled:true,overScrollMode:true,saveFormDataDisabled:true,scalesPageToFit:true,setBuiltInZoomControls:true,setDisplayZoomControls:true,setSupportMultipleWindows:true,textZoom:true,thirdPartyCookiesEnabled:true,hasOnScroll:true,allowingReadAccessToURL:true,allowsBackForwardNavigationGestures:true,allowsInlineMediaPlayback:true,allowsPictureInPictureMediaPlayback:true,allowsAirPlayForMediaPlayback:true,allowsLinkPreview:true,automaticallyAdjustContentInsets:true,autoManageStatusBarEnabled:true,bounces:true,contentInset:true,contentInsetAdjustmentBehavior:true,contentMode:true,dataDetectorTypes:true,decelerationRate:true,directionalLockEnabled:true,enableApplePay:true,hideKeyboardAccessoryView:true,keyboardDisplayRequiresUserAction:true,limitsNavigationsToAppBoundDomains:true,preventUniversalLinks:true,mediaCapturePermissionGrantType:true,pagingEnabled:true,pullToRefreshEnabled:true,refreshControlLightMode:true,scrollEnabled:true,scrollsToTop:true,sharedCookiesEnabled:true,dragInteractionEnabled:true,textInteractionEnabled:true,useSharedProcessPool:true,menuItems:true,suppressMenuItems:true,hasOnFileDownload:true,fraudulentWebsiteWarningEnabled:true,allowFileAccessFromFileURLs:true,allowUniversalAccessFromFileURLs:true,applicationNameForUserAgent:true,basicAuthCredential:true,cacheEnabled:true,incognito:true,injectedJavaScript:true,injectedJavaScriptBeforeContentLoaded:true,injectedJavaScriptForMainFrameOnly:true,injectedJavaScriptBeforeContentLoadedForMainFrameOnly:true,javaScriptCanOpenWindowsAutomatically:true,javaScriptEnabled:true,webviewDebuggingEnabled:true,mediaPlaybackRequiresUserAction:true,messagingEnabled:true,hasOnOpenWindowEvent:true,showsHorizontalScrollIndicator:true,showsVerticalScrollIndicator:true,indicatorStyle:true,newSource:true,userAgent:true,injectedJavaScriptObject:true,paymentRequestEnabled:true},ConditionallyIgnoredEventHandlers({onContentSizeChange:true,onRenderProcessGone:true,onContentProcessDidTerminate:true,onCustomMenuSelection:true,onFileDownload:true,onLoadingError:true,onLoadingSubResourceError:true,onLoadingFinish:true,onLoadingProgress:true,onLoadingStart:true,onHttpError:true,onMessage:true,onOpenWindow:true,onScroll:true,onShouldStartLoadWithRequest:true}))};var _default=exports.default=NativeComponentRegistry.get(nativeComponentName,function(){return __INTERNAL_VIEW_CONFIG;});var Commands=exports.Commands={goBack:function goBack(ref){dispatchCommand(ref,"goBack",[]);},goForward:function goForward(ref){dispatchCommand(ref,"goForward",[]);},reload:function reload(ref){dispatchCommand(ref,"reload",[]);},stopLoading:function stopLoading(ref){dispatchCommand(ref,"stopLoading",[]);},injectJavaScript:function injectJavaScript(ref,javascript){dispatchCommand(ref,"injectJavaScript",[javascript]);},requestFocus:function requestFocus(ref){dispatchCommand(ref,"requestFocus",[]);},postMessage:function postMessage(ref,data){dispatchCommand(ref,"postMessage",[data]);},loadUrl:function loadUrl(ref,url){dispatchCommand(ref,"loadUrl",[url]);},clearFormData:function clearFormData(ref){dispatchCommand(ref,"clearFormData",[]);},clearCache:function clearCache(ref,includeDiskFiles){dispatchCommand(ref,"clearCache",[includeDiskFiles]);},clearHistory:function clearHistory(ref){dispatchCommand(ref,"clearHistory",[]);},setTintColor:function setTintColor(ref,red,green,blue,alpha){dispatchCommand(ref,"setTintColor",[red,green,blue,alpha]);}}; \ No newline at end of file +var _interopRequireDefault=require("@babel/runtime/helpers/interopRequireDefault");Object.defineProperty(exports,"__esModule",{value:true});exports.default=exports.__INTERNAL_VIEW_CONFIG=exports.Commands=void 0;var _codegenNativeComponent=_interopRequireDefault(require("react-native/Libraries/Utilities/codegenNativeComponent"));var _codegenNativeCommands=_interopRequireDefault(require("react-native/Libraries/Utilities/codegenNativeCommands"));var NativeComponentRegistry=require('react-native/Libraries/NativeComponent/NativeComponentRegistry');var _require=require('react-native/Libraries/NativeComponent/ViewConfigIgnore'),ConditionallyIgnoredEventHandlers=_require.ConditionallyIgnoredEventHandlers;var _require2=require("react-native/Libraries/ReactNative/RendererProxy"),dispatchCommand=_require2.dispatchCommand;var nativeComponentName='RNCWebView';var __INTERNAL_VIEW_CONFIG=exports.__INTERNAL_VIEW_CONFIG={uiViewClassName:'RNCWebView',directEventTypes:{topContentSizeChange:{registrationName:'onContentSizeChange'},topRenderProcessGone:{registrationName:'onRenderProcessGone'},topNativeTouchEnd:{registrationName:'onNativeTouchEnd'},topContentProcessDidTerminate:{registrationName:'onContentProcessDidTerminate'},topCustomMenuSelection:{registrationName:'onCustomMenuSelection'},topFileDownload:{registrationName:'onFileDownload'},topLoadingError:{registrationName:'onLoadingError'},topLoadingSubResourceError:{registrationName:'onLoadingSubResourceError'},topLoadingFinish:{registrationName:'onLoadingFinish'},topLoadingProgress:{registrationName:'onLoadingProgress'},topLoadingStart:{registrationName:'onLoadingStart'},topHttpError:{registrationName:'onHttpError'},topMessage:{registrationName:'onMessage'},topOpenWindow:{registrationName:'onOpenWindow'},topScroll:{registrationName:'onScroll'},topShouldStartLoadWithRequest:{registrationName:'onShouldStartLoadWithRequest'}},validAttributes:Object.assign({allowFileAccess:true,allowsProtectedMedia:true,allowsFullscreenVideo:true,androidLayerType:true,cacheMode:true,domStorageEnabled:true,downloadingMessage:true,forceDarkOn:true,geolocationEnabled:true,lackPermissionToDownloadMessage:true,messagingModuleName:true,minimumFontSize:true,mixedContentMode:true,nestedScrollEnabled:true,overScrollMode:true,saveFormDataDisabled:true,scalesPageToFit:true,setBuiltInZoomControls:true,setDisplayZoomControls:true,setSupportMultipleWindows:true,textZoom:true,thirdPartyCookiesEnabled:true,hasOnScroll:true,allowingReadAccessToURL:true,allowsBackForwardNavigationGestures:true,allowsInlineMediaPlayback:true,allowsPictureInPictureMediaPlayback:true,allowsAirPlayForMediaPlayback:true,allowsLinkPreview:true,automaticallyAdjustContentInsets:true,autoManageStatusBarEnabled:true,bounces:true,contentInset:true,contentInsetAdjustmentBehavior:true,contentMode:true,dataDetectorTypes:true,decelerationRate:true,directionalLockEnabled:true,enableApplePay:true,hideKeyboardAccessoryView:true,keyboardDisplayRequiresUserAction:true,limitsNavigationsToAppBoundDomains:true,preventUniversalLinks:true,mediaCapturePermissionGrantType:true,pagingEnabled:true,pullToRefreshEnabled:true,refreshControlLightMode:true,scrollEnabled:true,scrollsToTop:true,sharedCookiesEnabled:true,dragInteractionEnabled:true,textInteractionEnabled:true,useSharedProcessPool:true,menuItems:true,suppressMenuItems:true,hasOnFileDownload:true,fraudulentWebsiteWarningEnabled:true,allowFileAccessFromFileURLs:true,allowUniversalAccessFromFileURLs:true,applicationNameForUserAgent:true,basicAuthCredential:true,cacheEnabled:true,incognito:true,injectedJavaScript:true,injectedJavaScriptBeforeContentLoaded:true,injectedJavaScriptForMainFrameOnly:true,injectedJavaScriptBeforeContentLoadedForMainFrameOnly:true,javaScriptCanOpenWindowsAutomatically:true,javaScriptEnabled:true,webviewDebuggingEnabled:true,mediaPlaybackRequiresUserAction:true,messagingEnabled:true,hasOnOpenWindowEvent:true,showsHorizontalScrollIndicator:true,showsVerticalScrollIndicator:true,indicatorStyle:true,newSource:true,userAgent:true,injectedJavaScriptObject:true,paymentRequestEnabled:true},ConditionallyIgnoredEventHandlers({onContentSizeChange:true,onRenderProcessGone:true,onNativeTouchEnd:true,onContentProcessDidTerminate:true,onCustomMenuSelection:true,onFileDownload:true,onLoadingError:true,onLoadingSubResourceError:true,onLoadingFinish:true,onLoadingProgress:true,onLoadingStart:true,onHttpError:true,onMessage:true,onOpenWindow:true,onScroll:true,onShouldStartLoadWithRequest:true}))};var _default=exports.default=NativeComponentRegistry.get(nativeComponentName,function(){return __INTERNAL_VIEW_CONFIG;});var Commands=exports.Commands={goBack:function goBack(ref){dispatchCommand(ref,"goBack",[]);},goForward:function goForward(ref){dispatchCommand(ref,"goForward",[]);},reload:function reload(ref){dispatchCommand(ref,"reload",[]);},stopLoading:function stopLoading(ref){dispatchCommand(ref,"stopLoading",[]);},injectJavaScript:function injectJavaScript(ref,javascript){dispatchCommand(ref,"injectJavaScript",[javascript]);},requestFocus:function requestFocus(ref){dispatchCommand(ref,"requestFocus",[]);},postMessage:function postMessage(ref,data){dispatchCommand(ref,"postMessage",[data]);},loadUrl:function loadUrl(ref,url){dispatchCommand(ref,"loadUrl",[url]);},clearFormData:function clearFormData(ref){dispatchCommand(ref,"clearFormData",[]);},clearCache:function clearCache(ref,includeDiskFiles){dispatchCommand(ref,"clearCache",[includeDiskFiles]);},clearHistory:function clearHistory(ref){dispatchCommand(ref,"clearHistory",[]);},setTintColor:function setTintColor(ref,red,green,blue,alpha){dispatchCommand(ref,"setTintColor",[red,green,blue,alpha]);}}; \ No newline at end of file diff --git a/lib/WebViewTypes.d.ts b/lib/WebViewTypes.d.ts index d98677e9d1..489d7489ed 100644 --- a/lib/WebViewTypes.d.ts +++ b/lib/WebViewTypes.d.ts @@ -88,6 +88,26 @@ export interface WebViewRenderProcessGoneDetail { export interface WebViewOpenWindow { targetUrl: string; } +export interface WebViewNativeTouchEnd { + /** + * Which terminal MotionEvent action ended the gesture: + * - `'up'` — the final finger lifted off (`ACTION_UP`). + * - `'cancel'` — the gesture was seized by the system, e.g. the native + * text-selection controller took over (`ACTION_CANCEL`). + * - `'pointerUp'` — a non-final pointer lifted in a multi-touch gesture + * (`ACTION_POINTER_UP`). + */ + action: 'up' | 'cancel' | 'pointerUp'; + /** + * Number of pointers present at the event. + */ + pointerCount: number; + /** + * Event coordinates in pixels. + */ + x: number; + y: number; +} export type WebViewEvent = NativeSyntheticEvent; export type WebViewProgressEvent = NativeSyntheticEvent; export type WebViewNavigationEvent = NativeSyntheticEvent; @@ -99,6 +119,7 @@ export type WebViewTerminatedEvent = NativeSyntheticEvent; export type WebViewHttpErrorEvent = NativeSyntheticEvent; export type WebViewRenderProcessGoneEvent = NativeSyntheticEvent; export type WebViewOpenWindowEvent = NativeSyntheticEvent; +export type WebViewNativeTouchEndEvent = NativeSyntheticEvent; export type WebViewScrollEvent = NativeSyntheticEvent; export type DataDetectorTypes = 'phoneNumber' | 'link' | 'address' | 'calendarEvent' | 'trackingNumber' | 'flightNumber' | 'lookupSuggestion' | 'none' | 'all'; export type OverScrollModeType = 'always' | 'content' | 'never'; @@ -200,6 +221,7 @@ export interface CommonNativeWebViewProps extends ViewProps { onHttpError: (event: WebViewHttpErrorEvent) => void; onMessage: (event: WebViewMessageEvent) => void; onShouldStartLoadWithRequest: (event: ShouldStartLoadRequestEvent) => void; + onNativeTouchEnd?: (event: WebViewNativeTouchEndEvent) => void; showsHorizontalScrollIndicator?: boolean; showsVerticalScrollIndicator?: boolean; paymentRequestEnabled?: boolean; @@ -817,6 +839,19 @@ export interface AndroidWebViewProps extends WebViewSharedProps { * @platform android */ onOpenWindow?: (event: WebViewOpenWindowEvent) => void; + /** + * Function that is invoked when the WebView's own native MotionEvent stream + * reports a gesture end (`ACTION_UP`, `ACTION_POINTER_UP`, or `ACTION_CANCEL`). + * + * Unlike RN's `onTouchEnd` responder, this observes the WebView's own touch + * stream, so it can fire even when Chromium's native text-selection controller + * seizes the gesture (the case where RN-side release signals get cancelled). + * Inspect `event.nativeEvent.action` to distinguish a genuine lift (`'up'`) from + * a cancel (`'cancel'`). + * + * @platform android + */ + onNativeTouchEnd?: (event: WebViewNativeTouchEndEvent) => void; /** * https://developer.android.com/reference/android/webkit/WebSettings.html#setCacheMode(int) * Set the cacheMode. Possible values are: diff --git a/src/RNCWebViewNativeComponent.ts b/src/RNCWebViewNativeComponent.ts index f7e8b06628..3b8716e655 100644 --- a/src/RNCWebViewNativeComponent.ts +++ b/src/RNCWebViewNativeComponent.ts @@ -33,6 +33,12 @@ export type WebViewMessageEvent = Readonly<{ export type WebViewOpenWindowEvent = Readonly<{ targetUrl: string; }>; +export type WebViewNativeTouchEndEvent = Readonly<{ + action: 'up' | 'cancel' | 'pointerUp'; + pointerCount: Int32; + x: Double; + y: Double; +}>; export type WebViewHttpErrorEvent = Readonly<{ url: string; loading: boolean; @@ -166,6 +172,9 @@ export interface NativeProps extends ViewProps { nestedScrollEnabled?: boolean; onContentSizeChange?: DirectEventHandler; onRenderProcessGone?: DirectEventHandler; + // Fires from the WebView's own MotionEvent stream on gesture end (UP/POINTER_UP/CANCEL). + // Android-only behavior; iOS keeps a no-op so the shared codegen interface stays consistent. + onNativeTouchEnd?: DirectEventHandler; overScrollMode?: string; saveFormDataDisabled?: boolean; scalesPageToFit?: WithDefault; diff --git a/src/WebViewTypes.ts b/src/WebViewTypes.ts index c6acdc5548..cb34e7a16e 100644 --- a/src/WebViewTypes.ts +++ b/src/WebViewTypes.ts @@ -136,6 +136,27 @@ export interface WebViewOpenWindow { targetUrl: string; } +export interface WebViewNativeTouchEnd { + /** + * Which terminal MotionEvent action ended the gesture: + * - `'up'` — the final finger lifted off (`ACTION_UP`). + * - `'cancel'` — the gesture was seized by the system, e.g. the native + * text-selection controller took over (`ACTION_CANCEL`). + * - `'pointerUp'` — a non-final pointer lifted in a multi-touch gesture + * (`ACTION_POINTER_UP`). + */ + action: 'up' | 'cancel' | 'pointerUp'; + /** + * Number of pointers present at the event. + */ + pointerCount: number; + /** + * Event coordinates in pixels. + */ + x: number; + y: number; +} + export type WebViewEvent = NativeSyntheticEvent; export type WebViewProgressEvent = @@ -161,6 +182,9 @@ export type WebViewRenderProcessGoneEvent = export type WebViewOpenWindowEvent = NativeSyntheticEvent; +export type WebViewNativeTouchEndEvent = + NativeSyntheticEvent; + export type WebViewScrollEvent = NativeSyntheticEvent; export type DataDetectorTypes = @@ -308,6 +332,7 @@ export interface CommonNativeWebViewProps extends ViewProps { onHttpError: (event: WebViewHttpErrorEvent) => void; onMessage: (event: WebViewMessageEvent) => void; onShouldStartLoadWithRequest: (event: ShouldStartLoadRequestEvent) => void; + onNativeTouchEnd?: (event: WebViewNativeTouchEndEvent) => void; showsHorizontalScrollIndicator?: boolean; showsVerticalScrollIndicator?: boolean; paymentRequestEnabled?: boolean; @@ -1005,6 +1030,20 @@ export interface AndroidWebViewProps extends WebViewSharedProps { */ onOpenWindow?: (event: WebViewOpenWindowEvent) => void; + /** + * Function that is invoked when the WebView's own native MotionEvent stream + * reports a gesture end (`ACTION_UP`, `ACTION_POINTER_UP`, or `ACTION_CANCEL`). + * + * Unlike RN's `onTouchEnd` responder, this observes the WebView's own touch + * stream, so it can fire even when Chromium's native text-selection controller + * seizes the gesture (the case where RN-side release signals get cancelled). + * Inspect `event.nativeEvent.action` to distinguish a genuine lift (`'up'`) from + * a cancel (`'cancel'`). + * + * @platform android + */ + onNativeTouchEnd?: (event: WebViewNativeTouchEndEvent) => void; + /** * https://developer.android.com/reference/android/webkit/WebSettings.html#setCacheMode(int) * Set the cacheMode. Possible values are: