diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt index 9ced951328..95bc9a10bd 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt @@ -365,20 +365,13 @@ class GestureHandlerOrchestrator( event.recycle() } - /** - * Cancels all handlers created using API v1 and v2 - */ - fun cancelAllLegacyHandlers() { + private inline fun cancelHandlersMatching(predicate: (GestureHandler) -> Boolean) { val handlersToProcess = obtainHandlerList() handlersToProcess.addAll(gestureHandlers) try { handlersToProcess.forEach { - if (it.actionType == GestureHandler.ACTION_TYPE_JS_FUNCTION_OLD_API || - it.actionType == GestureHandler.ACTION_TYPE_JS_FUNCTION_NEW_API || - it.actionType == GestureHandler.ACTION_TYPE_REANIMATED_WORKLET || - it.actionType == GestureHandler.ACTION_TYPE_NATIVE_ANIMATED_EVENT - ) { + if (predicate(it)) { it.cancel() } } @@ -389,6 +382,20 @@ class GestureHandlerOrchestrator( } } + fun cancelAllLegacyHandlers() = cancelHandlersMatching { + it.actionType == GestureHandler.ACTION_TYPE_JS_FUNCTION_OLD_API || + it.actionType == GestureHandler.ACTION_TYPE_JS_FUNCTION_NEW_API || + it.actionType == GestureHandler.ACTION_TYPE_REANIMATED_WORKLET || + it.actionType == GestureHandler.ACTION_TYPE_NATIVE_ANIMATED_EVENT + } + + /** + * Cancels handlers whose view opted out of surviving a native view taking over the touch stream. + */ + fun cancelHandlersOnNativeTouchGrab(grabbedMidGesture: Boolean) = cancelHandlersMatching { + it is NativeViewGestureHandler && it.shouldCancelOnNativeTouchGrab(grabbedMidGesture) + } + /** * isViewAttachedUnderWrapper checks whether all of parents for view related to handler * view are attached. Since there might be an issue rarely observed when view diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt index d53ab52192..a3d36d95f1 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt @@ -241,6 +241,9 @@ class NativeViewGestureHandler : GestureHandler() { override fun wantsToAttachDirectlyToView() = true + fun shouldCancelOnNativeTouchGrab(grabbedMidGesture: Boolean): Boolean = + hook.shouldCancelOnNativeTouchGrab(grabbedMidGesture) + data class HitSlop( val left: Float = HIT_SLOP_NONE, val top: Float = HIT_SLOP_NONE, @@ -361,6 +364,11 @@ class NativeViewGestureHandler : GestureHandler() { */ fun shouldRecognizeSimultaneously(handler: GestureHandler): Boolean? = null + /** + * Called after a native view grabbed the touch lock; return true to cancel the handler. + */ + fun shouldCancelOnNativeTouchGrab(grabbedMidGesture: Boolean) = false + /** * shouldActivateOnStart and tryIntercept have priority over this method * diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt index 9b511049b1..1115667659 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt @@ -717,6 +717,10 @@ class RNGestureHandlerButtonViewManager : // event). private var lastEventWasInside = false + // Whether the native dispatch delivered DOWN for the current gesture. False when a native + // ancestor intercepted it — the orchestrator still delivers events then. + private var receivedNativeDown = false + override fun onHandlerUpdate(handler: NativeViewGestureHandler) { if (managedHandlerTag == null || handler.isWithinBounds == lastEventWasInside) { return @@ -744,6 +748,8 @@ class RNGestureHandlerButtonViewManager : val localLastEventWasInside = lastEventWasInside if (newState == GestureHandler.STATE_BEGAN) { + // Reset for the new gesture - BEGAN precedes the native dispatch of DOWN that sets the flag. + receivedNativeDown = false dispatchJSEvent(EventType.PressIn, handler) longPressDetected = false @@ -815,6 +821,16 @@ class RNGestureHandlerButtonViewManager : } } + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + receivedNativeDown = true + } + + return super.dispatchTouchEvent(event) + } + + override fun shouldCancelOnNativeTouchGrab(grabbedMidGesture: Boolean) = grabbedMidGesture || !receivedNativeDown + override fun onInterceptTouchEvent(event: MotionEvent): Boolean { if (super.onInterceptTouchEvent(event)) { return true diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt index 57979ea310..63001c1799 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt @@ -22,6 +22,8 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: private var shouldIntercept = false private var wasIntercepting = false private var passingTouch = false + private var passingNativeTouch = false + private var nativeTouchGrabRequested = false init { val registry = @@ -116,14 +118,46 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: fun requestDisallowInterceptTouchEvent() { // If this method gets called it means that some native view is attempting to grab lock for - // touch event delivery. In that case we cancel all gesture recognizers + // touch event delivery. Legacy handlers are cancelled right away; handlers opting into + // native-touch-grab cancellation are deferred to `onNativeDispatchEnd`. if (orchestrator != null && !passingTouch) { // if we are in the process of delivering touch events via GH orchestrator, we don't want to // treat it as a native gesture capturing the lock + if (passingNativeTouch) { + // Requests may also arrive outside any dispatch pass (e.g. RN's JS responder). Those have + // no pass to classify against and must not arm the sweep for a future gesture. + nativeTouchGrabRequested = true + } orchestrator.cancelAllLegacyHandlers() } } + fun onNativeDispatchStart() { + passingNativeTouch = true + } + + /** + * Deferred handling of a disallow-intercept request recorded during this dispatch pass. The + * request alone doesn't say what the caller did with the event: a scrollable calls it when it + * takes over the touch, but e.g. a nested pager calls it already on DOWN, just to keep its + * ancestors from stealing a swipe it may recognize later, and the event still reaches the + * button - at request time both calls look identical. They only become + * distinguishable once the native dispatch completes (did the button receive the DOWN?), which + * is why cancellation runs here instead of in `requestDisallowInterceptTouchEvent`. + */ + fun onNativeDispatchEnd(event: MotionEvent) { + passingNativeTouch = false + + if (nativeTouchGrabRequested) { + nativeTouchGrabRequested = false + + val grabbedMidGesture = event.actionMasked != MotionEvent.ACTION_DOWN && + event.actionMasked != MotionEvent.ACTION_POINTER_DOWN + + orchestrator?.cancelHandlersOnNativeTouchGrab(grabbedMidGesture) + } + } + fun dispatchTouchEvent(event: MotionEvent): Boolean { // We mark `mPassingTouch` before we get into `mOrchestrator.onTouchEvent` so that we can tell // if `requestDisallow` has been called as a result of a normal gesture handling process or diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootView.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootView.kt index 06c01bb8b5..c896945554 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootView.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootView.kt @@ -62,7 +62,10 @@ class RNGestureHandlerRootView(context: Context?) : ReactViewGroup(context) { return if (rootViewEnabled && rootHelper!!.dispatchTouchEvent(event)) { true } else { - super.dispatchTouchEvent(event) + rootHelper?.onNativeDispatchStart() + val handled = super.dispatchTouchEvent(event) + rootHelper?.onNativeDispatchEnd(event) + handled } }