From 9a9066bdc716f88bdf2590f087ae392cf0ec88f7 Mon Sep 17 00:00:00 2001 From: sashrikasd Date: Wed, 4 Nov 2026 11:26:24 +0530 Subject: [PATCH 1/8] [Android] Pan with the mouse wheel when `enableTrackpadTwoFingerGesture` is set Android delivers mouse wheel and trackpad scrolls as ACTION_SCROLL generic motion events, which the root view never handed to the orchestrator, so `enableTrackpadTwoFingerGesture` did nothing there. Forward pointer ACTION_SCROLL events from the root view to the orchestrator, extract only handlers that want them, and let PanGestureHandler turn them into a pan: it begins and activates on the first scroll with zero pointers, as on iOS, moves the translation by the scrolled distance (Shift turns the wheel sideways, as in HorizontalScrollView), and ends with zero velocity once scrolls stop for 100 ms. Scrolls no handler takes still reach the views, so a ScrollView under the pointer keeps scrolling. Co-Authored-By: Claude Opus 5.5 --- .../docs/gestures/use-pan-gesture.mdx | 6 +- .../gesturehandler/core/GestureHandler.kt | 29 +++++- .../core/GestureHandlerOrchestrator.kt | 22 ++++- .../gesturehandler/core/PanGestureHandler.kt | 90 +++++++++++++++++++ .../gesturehandler/react/Extensions.kt | 6 ++ .../react/RNGestureHandlerRootHelper.kt | 12 +++ .../react/RNGestureHandlerRootView.kt | 8 +- 7 files changed, 164 insertions(+), 9 deletions(-) diff --git a/packages/docs-gesture-handler/docs/gestures/use-pan-gesture.mdx b/packages/docs-gesture-handler/docs/gestures/use-pan-gesture.mdx index b75881679d..1168c901ea 100644 --- a/packages/docs-gesture-handler/docs/gestures/use-pan-gesture.mdx +++ b/packages/docs-gesture-handler/docs/gestures/use-pan-gesture.mdx @@ -232,8 +232,8 @@ averageTouches: boolean | SharedValue; Android, by default, will calculate translation values based on the position of the leading pointer (the first one that was placed on the screen). This modifier allows that behavior to be changed to the one that is default on iOS - the averaged position of all active pointers will be used to calculate the translation values. - -### enableTrackpadTwoFingerGesture [I][W] + +### enableTrackpadTwoFingerGesture [A][I][W] ```ts @@ -242,6 +242,8 @@ enableTrackpadTwoFingerGesture: boolean | SharedValue; Enables two-finger gestures on supported devices, for example iPads with trackpads. If not enabled the gesture will require click + drag, with enableTrackpadTwoFingerGesture swiping with two fingers will also trigger the gesture. +On Android, scrolling a mouse wheel or a trackpad over the view pans it too, with `numberOfPointers` of `0`. Holding Shift turns a vertical scroll sideways. Since a scroll has no end event, the gesture ends once scrolling has stopped for a moment, reporting zero velocity. A scroll that no gesture takes is passed on to the views, so a `ScrollView` under the pointer still scrolls. + diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt index 2dde59e8a2..ec279256ad 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt @@ -20,6 +20,7 @@ import com.swmansion.gesturehandler.react.events.eventbuilders.GestureHandlerEve import com.swmansion.gesturehandler.react.findActivity import com.swmansion.gesturehandler.react.getPointerType import com.swmansion.gesturehandler.react.isHoverAction +import com.swmansion.gesturehandler.react.isScrollAction import java.lang.IllegalStateException import java.util.* @@ -116,6 +117,14 @@ open class GestureHandler { private var lastEventOffsetY = 0f var numberOfPointers = 0 protected set + + // Set while a mouse wheel or trackpad scroll drives the gesture rather than a pointer stream. + var isHandlingScroll = false + protected set + + // Whether mouse wheel and trackpad scrolls (ACTION_SCROLL) are delivered to `onHandleScroll`. + open val wantsScrollEvents: Boolean + get() = false protected var shouldCancelWhenOutside = false protected var orchestrator: GestureHandlerOrchestrator? = null var onTouchEventListener: OnTouchEventListener? = null @@ -401,6 +410,8 @@ open class GestureHandler { numberOfPointers = when (adaptedTransformedEvent.actionMasked) { MotionEvent.ACTION_POINTER_UP -> adaptedTransformedEvent.pointerCount - 1 + // A scroll has no pointers down, as on iOS. + MotionEvent.ACTION_SCROLL -> 0 else -> adaptedTransformedEvent.pointerCount } @@ -426,13 +437,16 @@ open class GestureHandler { if (sourceEvent.action == MotionEvent.ACTION_DOWN || sourceEvent.action == MotionEvent.ACTION_HOVER_ENTER || - sourceEvent.action == MotionEvent.ACTION_HOVER_MOVE + sourceEvent.action == MotionEvent.ACTION_HOVER_MOVE || + sourceEvent.action == MotionEvent.ACTION_SCROLL ) { setPointerType(sourceEvent) } if (sourceEvent.isHoverAction()) { onHandleHover(adaptedTransformedEvent, adaptedSourceEvent) + } else if (sourceEvent.isScrollAction()) { + onHandleScroll(adaptedTransformedEvent, adaptedSourceEvent) } else { onHandle(adaptedTransformedEvent, adaptedSourceEvent) } @@ -646,6 +660,16 @@ open class GestureHandler { return false } + // Scrolls reach only the handlers that take them, and a gesture driven by one takes nothing + // else: a hover or click by the same mouse must not move or end it. + if (event.isScrollAction()) { + if (!wantsScrollEvents) { + return false + } + } else if (isHandlingScroll) { + return false + } + if (event.actionMasked == MotionEvent.ACTION_MOVE) { for (i in 0 until event.pointerCount) { if (isTrackingPointer(event.getPointerId(i))) { @@ -803,6 +827,8 @@ open class GestureHandler { protected open fun onHandleHover(event: MotionEvent, sourceEvent: MotionEvent) {} + protected open fun onHandleScroll(event: MotionEvent, sourceEvent: MotionEvent) {} + protected open fun onStateChange(newState: Int, previousState: Int) {} protected open fun onReset() {} protected open fun onCancel() {} @@ -872,6 +898,7 @@ open class GestureHandler { trackedPointersCount = 0 trackedPointers.fill(null) touchEventType = RNGestureHandlerTouchEvent.EVENT_UNDETERMINED + isHandlingScroll = false onReset() } 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 4df56b6e3a..39481ab0e2 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 @@ -13,6 +13,7 @@ import com.swmansion.gesturehandler.react.RNGestureHandlerDetectorView import com.swmansion.gesturehandler.react.RNGestureHandlerRootHelper import com.swmansion.gesturehandler.react.RNGestureHandlerRootView import com.swmansion.gesturehandler.react.isHoverAction +import com.swmansion.gesturehandler.react.isScrollAction import java.util.* class GestureHandlerOrchestrator( @@ -68,7 +69,8 @@ class GestureHandlerOrchestrator( trackPointerDownPoints(event) if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_POINTER_DOWN || - action == MotionEvent.ACTION_HOVER_MOVE + action == MotionEvent.ACTION_HOVER_MOVE || + action == MotionEvent.ACTION_SCROLL ) { extractGestureHandlers(event) } else if (action == MotionEvent.ACTION_CANCEL) { @@ -454,6 +456,10 @@ class GestureHandlerOrchestrator( fun isAnyHandlerActive() = gestureHandlers.any { it.state == GestureHandler.STATE_ACTIVE } + fun isAnyHandlerHandlingScroll() = gestureHandlers.any { + it.isHandlingScroll && (it.state == GestureHandler.STATE_BEGAN || it.state == GestureHandler.STATE_ACTIVE) + } + /** * Transforms an event in the coordinates of wrapperView into the coordinate space of the received view. * @@ -591,7 +597,7 @@ class GestureHandlerOrchestrator( handlerRegistry.getHandlersForView(parent)?.let { synchronized(it) { for (handler in it) { - if (shouldHandlerSkipHoverEvents(handler, event)) { + if (shouldHandlerSkipEvent(handler, event)) { continue } @@ -623,6 +629,14 @@ class GestureHandlerOrchestrator( return shouldSkipHoverEvents && event.isHoverAction() } + // Likewise only the handlers that use scroll events get them. Anything else would begin and + // never end, since a scroll has no up event. + private fun shouldHandlerSkipScrollEvents(handler: GestureHandler, event: MotionEvent) = + event.isScrollAction() && !handler.wantsScrollEvents + + private fun shouldHandlerSkipEvent(handler: GestureHandler, event: MotionEvent) = + shouldHandlerSkipHoverEvents(handler, event) || shouldHandlerSkipScrollEvents(handler, event) + private fun recordViewHandlersForPointer( view: View, coords: FloatArray, @@ -658,7 +672,7 @@ class GestureHandlerOrchestrator( continue } - if (shouldHandlerSkipHoverEvents(handler, event)) { + if (shouldHandlerSkipEvent(handler, event)) { continue } @@ -695,7 +709,7 @@ class GestureHandlerOrchestrator( handlerRegistry.getHandlersForViewWithTag(tagForCoords)?.let { synchronized(it) { for (handler in it) { - if (shouldHandlerSkipHoverEvents(handler, event)) { + if (shouldHandlerSkipEvent(handler, event)) { continue } diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/PanGestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/PanGestureHandler.kt index cbab701f0e..b3986341a2 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/PanGestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/PanGestureHandler.kt @@ -3,9 +3,11 @@ package com.swmansion.gesturehandler.core import android.content.Context import android.os.Handler import android.os.Looper +import android.view.KeyEvent import android.view.MotionEvent import android.view.VelocityTracker import android.view.ViewConfiguration +import androidx.core.view.ViewConfigurationCompat import com.facebook.react.bridge.ReadableMap import com.facebook.react.uimanager.PixelUtil import com.swmansion.gesturehandler.core.GestureUtils.getLastPointerX @@ -28,6 +30,10 @@ class PanGestureHandler(context: Context?) : GestureHandler() { private val defaultMinDist: Float private var minDist = MAX_VALUE_IGNORE + // Pixels moved per unit of a scroll axis, as a ScrollView reads them. + private val horizontalScrollFactor: Float + private val verticalScrollFactor: Float + // Config updates may be partial (e.g. a single key sent when a shared value used in the config // changes), so both of these need to persist between updateConfig calls - deriving them from the // keys present in a single update would reset minDist based on an incomplete picture. @@ -58,8 +64,20 @@ class PanGestureHandler(context: Context?) : GestureHandler() { private var lastY = 0f private var velocityTracker: VelocityTracker? = null private var averageTouches = false + private var enableTrackpadTwoFingerGesture = false private var activateAfterLongPress = DEFAULT_ACTIVATE_AFTER_LONG_PRESS private val activateDelayed = Runnable { activate() } + private val endScroll = Runnable { + // A scroll has no up event, so the gesture ends once the scrolls stop. The content is at rest + // by then, so it reports no velocity. + velocityX = 0f + velocityY = 0f + if (state == STATE_ACTIVE) { + end() + } else { + fail() + } + } private val handler = Handler(Looper.getMainLooper()) var stylusData: StylusData = StylusData() private set @@ -80,8 +98,13 @@ class PanGestureHandler(context: Context?) : GestureHandler() { val vc = ViewConfiguration.get(context!!) defaultMinDist = vc.scaledTouchSlop.toFloat() minDist = defaultMinDist + horizontalScrollFactor = ViewConfigurationCompat.getScaledHorizontalScrollFactor(vc, context) + verticalScrollFactor = ViewConfigurationCompat.getScaledVerticalScrollFactor(vc, context) } + override val wantsScrollEvents: Boolean + get() = enableTrackpadTwoFingerGesture + override fun resetConfig() { super.resetConfig() activeOffsetXStart = DEFAULT_ACTIVE_OFFSET_X_START @@ -102,6 +125,7 @@ class PanGestureHandler(context: Context?) : GestureHandler() { maxPointers = DEFAULT_MAX_POINTERS activateAfterLongPress = DEFAULT_ACTIVATE_AFTER_LONG_PRESS averageTouches = DEFAULT_AVERAGE_TOUCHES + enableTrackpadTwoFingerGesture = DEFAULT_ENABLE_TRACKPAD_TWO_FINGER_GESTURE } private fun shouldActivate(): Boolean { @@ -240,6 +264,63 @@ class PanGestureHandler(context: Context?) : GestureHandler() { } } + /** + * A mouse wheel or trackpad scroll pans like a two-finger trackpad swipe does on iOS: it begins + * and activates at once, moves the translation by the scrolled distance, and ends when no scroll + * has followed for [SCROLL_END_TIMEOUT_MS]. The pointer itself staying put or moving changes + * nothing, so x and y follow it while the translation counts only the scrolls. + */ + override fun onHandleScroll(event: MotionEvent, sourceEvent: MotionEvent) { + if (state != STATE_UNDETERMINED && !isHandlingScroll) { + // Already following a drag. + return + } + + // Signs as ScrollView and HorizontalScrollView read the axes, where Shift turns the wheel + // sideways, flipped so the content follows the scroll as it would follow two fingers. + val vScroll = sourceEvent.getAxisValue(MotionEvent.AXIS_VSCROLL) + val hScroll = sourceEvent.getAxisValue(MotionEvent.AXIS_HSCROLL) + val shift = (sourceEvent.metaState and KeyEvent.META_SHIFT_ON) != 0 + val dx = (if (shift) vScroll - hScroll else -hScroll) * horizontalScrollFactor + val dy = (if (shift) 0f else vScroll) * verticalScrollFactor + + lastX = sourceEvent.x + lastY = sourceEvent.y + + if (state == STATE_UNDETERMINED) { + initialize(event, sourceEvent) + isHandlingScroll = true + begin() + } + + startX = lastX + startY = lastY + offsetX += dx + offsetY += dy + + // The tracker follows the translation, not the pointer. + val movement = MotionEvent.obtain( + sourceEvent.downTime, + sourceEvent.eventTime, + MotionEvent.ACTION_MOVE, + offsetX, + offsetY, + 0, + ) + velocityTracker!!.addMovement(movement) + movement.recycle() + velocityTracker!!.computeCurrentVelocity(1000) + velocityX = velocityTracker!!.xVelocity + velocityY = velocityTracker!!.yVelocity + + if (state == STATE_BEGAN) { + activate() + } + + handler.removeCallbacks(endScroll) + handler.postDelayed(endScroll, SCROLL_END_TIMEOUT_MS) + } + override fun activate(force: Boolean) { // reset starting point if the handler has not yet activated if (state != STATE_ACTIVE) { @@ -383,6 +464,9 @@ class PanGestureHandler(context: Context?) : GestureHandler() { if (config.hasKey(KEY_ACTIVATE_AFTER_LONG_PRESS)) { handler.activateAfterLongPress = config.getInt(KEY_ACTIVATE_AFTER_LONG_PRESS).toLong() } + if (config.hasKey(KEY_ENABLE_TRACKPAD_TWO_FINGER_GESTURE)) { + handler.enableTrackpadTwoFingerGesture = config.getBoolean(KEY_ENABLE_TRACKPAD_TWO_FINGER_GESTURE) + } } override fun createEventBuilder(handler: PanGestureHandler) = PanGestureHandlerEventDataBuilder(handler) @@ -404,6 +488,7 @@ class PanGestureHandler(context: Context?) : GestureHandler() { private const val KEY_MAX_POINTERS = "maxPointers" private const val KEY_AVG_TOUCHES = "avgTouches" private const val KEY_ACTIVATE_AFTER_LONG_PRESS = "activateAfterLongPress" + private const val KEY_ENABLE_TRACKPAD_TWO_FINGER_GESTURE = "enableTrackpadTwoFingerGesture" } } @@ -414,6 +499,7 @@ class PanGestureHandler(context: Context?) : GestureHandler() { private const val DEFAULT_MAX_POINTERS = 10 private const val DEFAULT_ACTIVATE_AFTER_LONG_PRESS = 0L private const val DEFAULT_AVERAGE_TOUCHES = false + private const val DEFAULT_ENABLE_TRACKPAD_TWO_FINGER_GESTURE = false private const val DEFAULT_ACTIVE_OFFSET_X_START = MIN_VALUE_IGNORE private const val DEFAULT_ACTIVE_OFFSET_X_END = MAX_VALUE_IGNORE private const val DEFAULT_FAIL_OFFSET_X_START = MAX_VALUE_IGNORE @@ -426,6 +512,10 @@ class PanGestureHandler(context: Context?) : GestureHandler() { private const val DEFAULT_MIN_VELOCITY_Y = MIN_VALUE_IGNORE private const val DEFAULT_MIN_VELOCITY = MIN_VALUE_IGNORE + // Longer than the gap between the scroll events of a wheel turned briskly, so one turn makes + // one gesture. + private const val SCROLL_END_TIMEOUT_MS = 100L + /** * This method adds movement to {@class VelocityTracker} first resetting offset of the event so * that the velocity is calculated based on the absolute position of touch pointers. This is diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/Extensions.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/Extensions.kt index 545cc19c37..5a9c21e017 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/Extensions.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/Extensions.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.content.Context import android.content.ContextWrapper import android.view.Display +import android.view.InputDevice import android.view.MotionEvent import android.view.accessibility.AccessibilityManager import com.facebook.react.bridge.ReactContext @@ -32,6 +33,11 @@ fun MotionEvent.isHoverAction(): Boolean = action == MotionEvent.ACTION_HOVER_MO fun MotionEvent.isButtonAction(): Boolean = actionMasked == MotionEvent.ACTION_BUTTON_PRESS || actionMasked == MotionEvent.ACTION_BUTTON_RELEASE +// A mouse wheel or trackpad scroll. Joysticks and rotary encoders send ACTION_SCROLL too, but from +// sources without a pointer position. +fun MotionEvent.isScrollAction(): Boolean = actionMasked == MotionEvent.ACTION_SCROLL && + isFromSource(InputDevice.SOURCE_CLASS_POINTER) + // Defaults to the first pointer, which is the one every single-pointer stream // (hover included) describes. fun MotionEvent.getPointerType(pointerIndex: Int = 0): Int = when (getToolType(pointerIndex)) { 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 0467c2ef47..60216299d0 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 @@ -192,6 +192,18 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: return shouldIntercept } + /** + * Delivers a mouse wheel or trackpad scroll to the gesture handlers. Returns whether one of them + * took it; when none did, the scroll should go on to the views, so a ScrollView still scrolls. + */ + fun dispatchScrollEvent(event: MotionEvent): Boolean { + passingTouch = true + orchestrator!!.onTouchEvent(event) + passingTouch = false + + return orchestrator.isAnyHandlerHandlingScroll() + } + fun activateNativeHandlers(view: View) { orchestrator?.activateNativeHandlersForView(view) } 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 297c60740b..b720134876 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 @@ -70,8 +70,12 @@ class RNGestureHandlerRootView(context: Context?) : ReactViewGroup(context) { } override fun dispatchGenericMotionEvent(ev: MotionEvent) = if (rootViewEnabled && - (ev.isHoverAction() || ev.isButtonAction()) && - rootHelper!!.dispatchTouchEvent(ev) + ( + (ev.isHoverAction() || ev.isButtonAction()) && + rootHelper!!.dispatchTouchEvent(ev) || + ev.isScrollAction() && + rootHelper!!.dispatchScrollEvent(ev) + ) ) { true } else { From 08e49be6c7637d580caa36168a5e1cb1089183af Mon Sep 17 00:00:00 2001 From: sashrikasd Date: Wed, 4 Nov 2026 15:41:07 +0530 Subject: [PATCH 2/8] [Android] Ignore scrolls while Pan follows a pointer drag A Pan that was already BEGAN or ACTIVE from a mouse drag still accepted a wheel scroll. GestureHandler.handle() then set numberOfPointers to 0, moved x/y to the scroll's position, could fail the gesture through shouldCancelWhenOutside, and the orchestrator sent the active handler an update with those values before onHandleScroll bailed out. The extraction also started tracking the scroll's pointer on that handler. Add GestureHandler.canHandleScroll, true unless the handler is following a pointer stream, and check it both when extracting handlers for a scroll and in wantsEvent, so such scrolls never reach the handler and fall through to the views as before. Finished states don't block it, since a handler keeps its last state after reset until it's recorded again. The early return in PanGestureHandler.onHandleScroll is now unreachable, so it goes. Co-Authored-By: Claude Opus 5.5 --- .../com/swmansion/gesturehandler/core/GestureHandler.kt | 8 +++++++- .../gesturehandler/core/GestureHandlerOrchestrator.kt | 6 +++--- .../swmansion/gesturehandler/core/PanGestureHandler.kt | 5 ----- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt index ec279256ad..fa4188313d 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt @@ -125,6 +125,12 @@ open class GestureHandler { // Whether mouse wheel and trackpad scrolls (ACTION_SCROLL) are delivered to `onHandleScroll`. open val wantsScrollEvents: Boolean get() = false + + // A scroll may reach this handler unless it is following a pointer stream. Finished states don't + // block it: a handler keeps its last state after reset, until it is recorded again. + val canHandleScroll: Boolean + get() = wantsScrollEvents && (isHandlingScroll || (state != STATE_BEGAN && state != STATE_ACTIVE)) + protected var shouldCancelWhenOutside = false protected var orchestrator: GestureHandlerOrchestrator? = null var onTouchEventListener: OnTouchEventListener? = null @@ -663,7 +669,7 @@ open class GestureHandler { // Scrolls reach only the handlers that take them, and a gesture driven by one takes nothing // else: a hover or click by the same mouse must not move or end it. if (event.isScrollAction()) { - if (!wantsScrollEvents) { + if (!canHandleScroll) { return false } } else if (isHandlingScroll) { 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 39481ab0e2..d8b75ff44c 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 @@ -629,10 +629,10 @@ class GestureHandlerOrchestrator( return shouldSkipHoverEvents && event.isHoverAction() } - // Likewise only the handlers that use scroll events get them. Anything else would begin and - // never end, since a scroll has no up event. + // Likewise only the handlers that use scroll events get them, and not while they follow a pointer + // stream. Anything else would begin and never end, since a scroll has no up event. private fun shouldHandlerSkipScrollEvents(handler: GestureHandler, event: MotionEvent) = - event.isScrollAction() && !handler.wantsScrollEvents + event.isScrollAction() && !handler.canHandleScroll private fun shouldHandlerSkipEvent(handler: GestureHandler, event: MotionEvent) = shouldHandlerSkipHoverEvents(handler, event) || shouldHandlerSkipScrollEvents(handler, event) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/PanGestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/PanGestureHandler.kt index b3986341a2..e208e08403 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/PanGestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/PanGestureHandler.kt @@ -271,11 +271,6 @@ class PanGestureHandler(context: Context?) : GestureHandler() { * nothing, so x and y follow it while the translation counts only the scrolls. */ override fun onHandleScroll(event: MotionEvent, sourceEvent: MotionEvent) { - if (state != STATE_UNDETERMINED && !isHandlingScroll) { - // Already following a drag. - return - } - // Signs as ScrollView and HorizontalScrollView read the axes, where Shift turns the wheel // sideways, flipped so the content follows the scroll as it would follow two fingers. val vScroll = sourceEvent.getAxisValue(MotionEvent.AXIS_VSCROLL) From 316ec26c98d543ea4fb7f999824969feaec4cd7b Mon Sep 17 00:00:00 2001 From: sashrikasd Date: Wed, 4 Nov 2026 18:24:20 +0530 Subject: [PATCH 3/8] [Android] Keep scrolls away from a Pan still waiting for its pointers A Pan with minPointers above 1 stays UNDETERMINED while it tracks fewer pointers than that, for example while a mouse button is held on a two-finger Pan. canHandleScroll let a wheel scroll in then, and the scroll began a gesture that ignored the held pointer until it ended, including its release, so the handler was left tracking a pointer that was no longer down. Only let a scroll start a gesture when the handler has no pointers of its own (numberOfPointers is 0). Reset numberOfPointers along with the other pointer state, so a count left from the last gesture, such as a cancelled touch, can't block the next scroll. Co-Authored-By: Claude Opus 5.5 --- .../com/swmansion/gesturehandler/core/GestureHandler.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt index fa4188313d..970d973099 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt @@ -126,10 +126,12 @@ open class GestureHandler { open val wantsScrollEvents: Boolean get() = false - // A scroll may reach this handler unless it is following a pointer stream. Finished states don't - // block it: a handler keeps its last state after reset, until it is recorded again. + // A scroll may reach this handler unless it is following a pointer stream, begun or still short of + // its required pointers. Finished states don't block it: a handler keeps its last state after + // reset, until it is recorded again. val canHandleScroll: Boolean - get() = wantsScrollEvents && (isHandlingScroll || (state != STATE_BEGAN && state != STATE_ACTIVE)) + get() = wantsScrollEvents && + (isHandlingScroll || (numberOfPointers == 0 && state != STATE_BEGAN && state != STATE_ACTIVE)) protected var shouldCancelWhenOutside = false protected var orchestrator: GestureHandlerOrchestrator? = null @@ -902,6 +904,7 @@ open class GestureHandler { Arrays.fill(trackedPointerIDs, -1) trackedPointersIDsCount = 0 trackedPointersCount = 0 + numberOfPointers = 0 trackedPointers.fill(null) touchEventType = RNGestureHandlerTouchEvent.EVENT_UNDETERMINED isHandlingScroll = false From e899d147455bce5083f5fb32dd866fbd3f363ce2 Mon Sep 17 00:00:00 2001 From: sashrikasd Date: Wed, 4 Nov 2026 20:19:40 +0530 Subject: [PATCH 4/8] [Android] Read only the vertical wheel when Shift turns a scroll sideways With Shift held, the horizontal pan distance added AXIS_HSCROLL to the vertical wheel, so Shift with a tilt wheel or a diagonal trackpad scroll moved further than either axis alone. HorizontalScrollView, which the comment cites, reads only AXIS_VSCROLL while Shift is held, so do the same. Co-Authored-By: Claude Opus 5.5 --- .../java/com/swmansion/gesturehandler/core/PanGestureHandler.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/PanGestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/PanGestureHandler.kt index e208e08403..1435c25bbb 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/PanGestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/PanGestureHandler.kt @@ -276,7 +276,7 @@ class PanGestureHandler(context: Context?) : GestureHandler() { val vScroll = sourceEvent.getAxisValue(MotionEvent.AXIS_VSCROLL) val hScroll = sourceEvent.getAxisValue(MotionEvent.AXIS_HSCROLL) val shift = (sourceEvent.metaState and KeyEvent.META_SHIFT_ON) != 0 - val dx = (if (shift) vScroll - hScroll else -hScroll) * horizontalScrollFactor + val dx = (if (shift) vScroll else -hScroll) * horizontalScrollFactor val dy = (if (shift) 0f else vScroll) * verticalScrollFactor lastX = sourceEvent.x From b4b5832356be972be73cd78892874be2c4931fcc Mon Sep 17 00:00:00 2001 From: sashrikasd Date: Wed, 4 Nov 2026 21:05:00 +0530 Subject: [PATCH 5/8] [Android] Don't let a scroll-driven Pan cancel gestures on touches A finger landing on a child view while a scroll drives an ancestor Pan had the child's gesture cancelled as soon as it tried to activate. The orchestrator recorded the Pan for the finger and started tracking its pointer, although a scroll-driven handler takes no other events, and even without that the mouse and the finger usually both report pointer id 0, so hasCommonPointers saw the active Pan and the child as sharing a pointer. Skip recording a handler for non-scroll events while a scroll drives it, and treat a scroll-driven handler and one that has begun on a pointer stream as sharing no pointers. Handlers that haven't begun still compare by pointer id, so when one of two nested Pans activates on a scroll, the other is still cancelled. Without a scroll-driven handler involved nothing changes. Co-Authored-By: Claude Opus 5.5 --- .../swmansion/gesturehandler/core/GestureHandler.kt | 10 ++++++++++ .../gesturehandler/core/GestureHandlerOrchestrator.kt | 6 ++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt index 970d973099..007e0020ed 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt @@ -180,7 +180,17 @@ open class GestureHandler { cancelsJSResponder = DEFAULT_CANCELS_JS_RESPONDER } + // Begun or active on touches, mouse buttons and the like, rather than on scrolls. + private val isFollowingPointers: Boolean + get() = !isHandlingScroll && (state == STATE_BEGAN || state == STATE_ACTIVE) + fun hasCommonPointers(other: GestureHandler): Boolean { + // A scroll isn't a pointer: a gesture it drives shares none with one following a pointer stream, + // even when the mouse and a finger report the same pointer id. + if (isHandlingScroll && other.isFollowingPointers || other.isHandlingScroll && isFollowingPointers) { + return false + } + for (i in trackedPointerIDs.indices) { if (trackedPointerIDs[i] != -1 && other.trackedPointerIDs[i] != -1) { return true 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 d8b75ff44c..514c90eedf 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 @@ -630,9 +630,11 @@ class GestureHandlerOrchestrator( } // Likewise only the handlers that use scroll events get them, and not while they follow a pointer - // stream. Anything else would begin and never end, since a scroll has no up event. + // stream. Anything else would begin and never end, since a scroll has no up event. A handler a + // scroll drives takes no other events, so it isn't recorded for them either: tracking their + // pointers would let it cancel the handlers of the views beneath it. private fun shouldHandlerSkipScrollEvents(handler: GestureHandler, event: MotionEvent) = - event.isScrollAction() && !handler.canHandleScroll + if (event.isScrollAction()) !handler.canHandleScroll else handler.isHandlingScroll private fun shouldHandlerSkipEvent(handler: GestureHandler, event: MotionEvent) = shouldHandlerSkipHoverEvents(handler, event) || shouldHandlerSkipScrollEvents(handler, event) From a78de6c367b77dc2b9a810456d7717634660c5c6 Mon Sep 17 00:00:00 2001 From: sashrikasd Date: Wed, 4 Nov 2026 21:13:02 +0530 Subject: [PATCH 6/8] [Android] Pick a scroll's handlers once per burst, as a DOWN does Handlers were extracted on every ACTION_SCROLL. With two nested Pans that both follow the wheel, the first notch activated the inner one and cancelled the outer one, but the next notch picked the cancelled outer Pan again as a fresh gesture, so both ended up active and both moved. Extract handlers for a scroll only while no handler is handling one, so later notches of the burst go to the gesture the first one started, the way the events after a DOWN do. Co-Authored-By: Claude Opus 5.5 --- .../gesturehandler/core/GestureHandlerOrchestrator.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 514c90eedf..79fa03f377 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 @@ -67,10 +67,12 @@ class GestureHandlerOrchestrator( isHandlingTouch = true val action = event.actionMasked trackPointerDownPoints(event) + // Like a DOWN, only the first scroll of a burst picks the handlers. Later ones go to the gesture + // it started, so a handler cancelled when another one activated isn't picked again mid-burst. if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_POINTER_DOWN || action == MotionEvent.ACTION_HOVER_MOVE || - action == MotionEvent.ACTION_SCROLL + (action == MotionEvent.ACTION_SCROLL && !isAnyHandlerHandlingScroll()) ) { extractGestureHandlers(event) } else if (action == MotionEvent.ACTION_CANCEL) { From b2a473de7926b47e0e4f6f97eb48676c79df0862 Mon Sep 17 00:00:00 2001 From: sashrikasd Date: Wed, 4 Nov 2026 22:03:13 +0530 Subject: [PATCH 7/8] [Android] Don't start an RN native gesture for scroll-driven Pans When a handler activated with cancelsJSResponder, the orchestrator called onChildStartedNativeGesture on the root view, and only ends that native gesture on an UP, CANCEL or HOVER_EXIT with no handlers left. A scroll-driven Pan ends on its timeout instead, so RN's JSPointerDispatcher kept dropping pointer events (onPointerMove and the hover callbacks built on them) until some unrelated touch or hover exit. A scroll takes no touch away from the JS responder, so skip the request for a handler a scroll drives. Co-Authored-By: Claude Opus 5.5 --- .../gesturehandler/core/GestureHandlerOrchestrator.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 79fa03f377..32e6b66ac1 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 @@ -277,7 +277,9 @@ class GestureHandlerOrchestrator( return } - if (handler.cancelsJSResponder) { + // A scroll takes no touch away from the JS responder, and nothing would end the native gesture + // this starts: RN would keep dropping pointer events until an unrelated up or hover exit. + if (handler.cancelsJSResponder && !handler.isHandlingScroll) { onJSResponderCancelListener?.onCancelJSResponderRequested(handler) } From 01a430e08f5e5aadec40e67185147532524fabc2 Mon Sep 17 00:00:00 2001 From: sashrikasd Date: Thu, 5 Nov 2026 13:39:12 +0530 Subject: [PATCH 8/8] [Android] Explain why a waiting scroll Pan ends rather than cancels When the scrolls stop while the Pan still waits for another gesture to fail, endScroll ends it. Cancelling it there instead would lose the scroll: if the other gesture then fails, the orchestrator replays the activation of an ended handler, but not of a cancelled one. Say so where it happens. Co-Authored-By: Claude Opus 5.5 --- .../java/com/swmansion/gesturehandler/core/PanGestureHandler.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/PanGestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/PanGestureHandler.kt index 1435c25bbb..9c1fae592b 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/PanGestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/PanGestureHandler.kt @@ -72,6 +72,8 @@ class PanGestureHandler(context: Context?) : GestureHandler() { // by then, so it reports no velocity. velocityX = 0f velocityY = 0f + // A pan still waiting for another gesture to fail ends too, rather than cancelling: if that + // gesture fails, the orchestrator replays the activation, so the scroll isn't lost. if (state == STATE_ACTIVE) { end() } else {