From 01dae908cde83ceeacdfc011d7b7470597cbab67 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Thu, 18 Jun 2026 10:21:35 +0200 Subject: [PATCH 1/2] Fix text getting selected on gestures --- .../core/GestureHandlerOrchestrator.kt | 108 ++++++++++++++++++ .../react/RNGestureHandlerRootHelper.kt | 9 ++ 2 files changed, 117 insertions(+) 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 3d371e0076..7ecf98afd5 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 @@ -2,6 +2,7 @@ package com.swmansion.gesturehandler.core import android.graphics.Matrix import android.graphics.PointF +import android.util.SparseArray import android.view.MotionEvent import android.view.View import android.view.ViewGroup @@ -31,6 +32,10 @@ class GestureHandlerOrchestrator( private val awaitingHandlers = arrayListOf() private val preparedHandlers = arrayListOf() + // Used by `cancelTouchesInInterceptedViews`. + private val viewsToCancel = arrayListOf() + private val pointerDownPoints = SparseArray() + // In `onHandlerStateChange` method we iterate through `awaitingHandlers`, but calling `tryActivate` may modify this list. // To avoid `ConcurrentModificationException` we iterate through copy. There is one more problem though - if handler was // removed from `awaitingHandlers`, it was still present in copy of original list. This hashset helps us identify which handlers @@ -49,6 +54,7 @@ class GestureHandlerOrchestrator( fun onTouchEvent(event: MotionEvent): Boolean { isHandlingTouch = true val action = event.actionMasked + trackPointerDownPoints(event) if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_POINTER_DOWN || action == MotionEvent.ACTION_HOVER_MOVE @@ -681,6 +687,108 @@ class GestureHandlerOrchestrator( return false } + private fun trackPointerDownPoints(event: MotionEvent) { + val index = event.actionIndex + when (event.actionMasked) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> + pointerDownPoints.put(event.getPointerId(index), PointF(event.getX(index), event.getY(index))) + MotionEvent.ACTION_POINTER_UP -> + pointerDownPoints.remove(event.getPointerId(index)) + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> + pointerDownPoints.clear() + } + } + + fun cancelTouchesInInterceptedViews(event: MotionEvent) { + viewsToCancel.clear() + for (i in 0 until pointerDownPoints.size()) { + val point = pointerDownPoints.valueAt(i) + tempCoords[0] = point.x + tempCoords[1] = point.y + collectViewsAtPoint(wrapperView, tempCoords, viewsToCancel) + } + + if (viewsToCancel.isEmpty()) { + return + } + + val activeHandlers = gestureHandlers.filter { it.isActive } + val cancelEvent = MotionEvent.obtain(event).apply { action = MotionEvent.ACTION_CANCEL } + + for (view in viewsToCancel) { + if (view === wrapperView || isViewDrivenByActiveNativeGesture(view, activeHandlers)) { + continue + } + view.onTouchEvent(cancelEvent) + } + + cancelEvent.recycle() + viewsToCancel.clear() + } + + // Whether the view's touch is still owned by a NativeViewGestureHandler that survived arbitration + // (active, or non-conflicting with an active handler). Only those are fed through `onTouchEvent`, + // so only those break if cancelled. Other handlers are orchestrator-driven and unaffected. + private fun isViewDrivenByActiveNativeGesture(view: View, activeHandlers: List): Boolean { + val handlers = handlerRegistry.getHandlersForView(view) ?: return false + return handlers.any { handler -> + handler is NativeViewGestureHandler && + (handler.isActive || activeHandlers.none { active -> shouldHandlerBeCancelledBy(handler, active) }) + } + } + + // Collects the view path under the point (topmost child first, like touch dispatch), leaf to root. + private fun collectViewsAtPoint(view: View, coords: FloatArray, out: MutableList): Boolean { + if (shouldIgnoreSubtreeIfGestureHandlerRootView(view)) { + // A nested active root view manages its own subtree (and its own interception cancellation). + return false + } + + val pointerEvents = viewConfigHelper.getPointerEventsConfigForView(view) + if (pointerEvents == PointerEventsConfig.NONE) { + return false + } + + var found = false + if (view is ViewGroup && pointerEvents != PointerEventsConfig.BOX_ONLY) { + for (i in view.childCount - 1 downTo 0) { + val child = view.getChildAt(i) + if (!canReceiveEvents(child)) { + continue + } + val childPoint = tempPoint + transformPointToChildViewCoords(coords[0], coords[1], view, child, childPoint) + if (isClipping(child) && !isTransformedTouchPointInView(childPoint.x, childPoint.y, child)) { + continue + } + val restoreX = coords[0] + val restoreY = coords[1] + coords[0] = childPoint.x + coords[1] = childPoint.y + found = collectViewsAtPoint(child, coords, out) + coords[0] = restoreX + coords[1] = restoreY + + if (found) { + break + } + } + } + + // BOX_NONE views can't be the target themselves, only their children can + val selfIsTarget = pointerEvents != PointerEventsConfig.BOX_NONE && + isTransformedTouchPointInView(coords[0], coords[1], view) + + if (found || selfIsTarget) { + // `out` may already contain this view when several pointers share part of their path. + if (!out.contains(view)) { + out.add(view) + } + return true + } + return false + } + private fun traverseWithPointerEvents(view: View, coords: FloatArray, pointerId: Int, event: MotionEvent): Boolean = if (shouldIgnoreSubtreeIfGestureHandlerRootView(view)) { // When we encounter another active root view while traversing the view hierarchy, we want 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 6026eab193..1b27fb8e9d 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 @@ -20,6 +20,7 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: private val jsGestureHandler: GestureHandler? val rootView: ViewGroup private var shouldIntercept = false + private var wasIntercepting = false private var passingTouch = false init { @@ -129,6 +130,14 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: passingTouch = true orchestrator!!.onTouchEvent(event) passingTouch = false + + // On the transition into interception, cancel the native views the pointers landed on - the + // framework's ACTION_CANCEL never reaches them since RNGH ignores `onInterceptTouchEvent` + if (shouldIntercept && !wasIntercepting) { + orchestrator!!.cancelTouchesInInterceptedViews(event) + } + wasIntercepting = shouldIntercept + return shouldIntercept } From 132968ee8c3a9bb5b1dfe02f3764253b3d0cc215 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Thu, 18 Jun 2026 10:56:19 +0200 Subject: [PATCH 2/2] Add synchronized --- .../core/GestureHandlerOrchestrator.kt | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) 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 7ecf98afd5..862328cdcf 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 @@ -726,16 +726,20 @@ class GestureHandlerOrchestrator( viewsToCancel.clear() } - // Whether the view's touch is still owned by a NativeViewGestureHandler that survived arbitration - // (active, or non-conflicting with an active handler). Only those are fed through `onTouchEvent`, - // so only those break if cancelled. Other handlers are orchestrator-driven and unaffected. - private fun isViewDrivenByActiveNativeGesture(view: View, activeHandlers: List): Boolean { - val handlers = handlerRegistry.getHandlersForView(view) ?: return false - return handlers.any { handler -> - handler is NativeViewGestureHandler && - (handler.isActive || activeHandlers.none { active -> shouldHandlerBeCancelledBy(handler, active) }) - } - } + // Whether the view's touch is still owned by a NativeViewGestureHandler that survived arbitration. + // Only those are fed through `onTouchEvent`, so only those break if cancelled. Other handlers are + // orchestrator-driven and unaffected. + private fun isViewDrivenByActiveNativeGesture(view: View, activeHandlers: List) = + handlerRegistry.getHandlersForView(view)?.let { handlers -> + synchronized(handlers) { + handlers.any { nativeGestureSurvivesArbitration(it, activeHandlers) } + } + } ?: false + + // A native handler survives arbitration if it is active, or it does not conflict with any active handler. + private fun nativeGestureSurvivesArbitration(handler: GestureHandler, activeHandlers: List) = + handler is NativeViewGestureHandler && + (handler.isActive || activeHandlers.none { shouldHandlerBeCancelledBy(handler, it) }) // Collects the view path under the point (topmost child first, like touch dispatch), leaf to root. private fun collectViewsAtPoint(view: View, coords: FloatArray, out: MutableList): Boolean {