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..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 @@ -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,22 @@ 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 + + // 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 || (numberOfPointers == 0 && state != STATE_BEGAN && state != STATE_ACTIVE)) + protected var shouldCancelWhenOutside = false protected var orchestrator: GestureHandlerOrchestrator? = null var onTouchEventListener: OnTouchEventListener? = null @@ -163,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 @@ -401,6 +428,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 +455,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 +678,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 (!canHandleScroll) { + 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 +845,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() {} @@ -870,8 +914,10 @@ open class GestureHandler { Arrays.fill(trackedPointerIDs, -1) trackedPointersIDsCount = 0 trackedPointersCount = 0 + numberOfPointers = 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..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 @@ -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( @@ -66,9 +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_HOVER_MOVE || + (action == MotionEvent.ACTION_SCROLL && !isAnyHandlerHandlingScroll()) ) { extractGestureHandlers(event) } else if (action == MotionEvent.ACTION_CANCEL) { @@ -273,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) } @@ -454,6 +460,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 +601,7 @@ class GestureHandlerOrchestrator( handlerRegistry.getHandlersForView(parent)?.let { synchronized(it) { for (handler in it) { - if (shouldHandlerSkipHoverEvents(handler, event)) { + if (shouldHandlerSkipEvent(handler, event)) { continue } @@ -623,6 +633,16 @@ class GestureHandlerOrchestrator( return shouldSkipHoverEvents && event.isHoverAction() } + // 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. 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) = + if (event.isScrollAction()) !handler.canHandleScroll else handler.isHandlingScroll + + private fun shouldHandlerSkipEvent(handler: GestureHandler, event: MotionEvent) = + shouldHandlerSkipHoverEvents(handler, event) || shouldHandlerSkipScrollEvents(handler, event) + private fun recordViewHandlersForPointer( view: View, coords: FloatArray, @@ -658,7 +678,7 @@ class GestureHandlerOrchestrator( continue } - if (shouldHandlerSkipHoverEvents(handler, event)) { + if (shouldHandlerSkipEvent(handler, event)) { continue } @@ -695,7 +715,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..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 @@ -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,22 @@ 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 + // 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 { + fail() + } + } private val handler = Handler(Looper.getMainLooper()) var stylusData: StylusData = StylusData() private set @@ -80,8 +100,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 +127,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 +266,58 @@ 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) { + // 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 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 +461,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 +485,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 +496,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 +509,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 {