Skip to content
Closed
Original file line number Diff line number Diff line change
Expand Up @@ -232,8 +232,8 @@ averageTouches: boolean | SharedValue<boolean>;

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.

<Badges platforms={['ios', 'web']}>
### enableTrackpadTwoFingerGesture [I][W]
<Badges platforms={['android', 'ios', 'web']}>
### enableTrackpadTwoFingerGesture [A][I][W]
</Badges>

```ts
Expand All @@ -242,6 +242,8 @@ enableTrackpadTwoFingerGesture: boolean | SharedValue<boolean>;

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 <kbd>Shift</kbd> 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.

<MouseButtonProp />

<BaseContinuousGestureConfig />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.*

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand All @@ -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)
}
Expand Down Expand Up @@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

if (event.actionMasked == MotionEvent.ACTION_MOVE) {
for (i in 0 until event.pointerCount) {
if (isTrackingPointer(event.getPointerId(i))) {
Expand Down Expand Up @@ -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() {}
Expand Down Expand Up @@ -872,6 +898,7 @@ open class GestureHandler {
trackedPointersCount = 0
trackedPointers.fill(null)
touchEventType = RNGestureHandlerTouchEvent.EVENT_UNDETERMINED
isHandlingScroll = false
onReset()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -658,7 +672,7 @@ class GestureHandlerOrchestrator(
continue
}

if (shouldHandlerSkipHoverEvents(handler, event)) {
if (shouldHandlerSkipEvent(handler, event)) {
continue
}

Expand Down Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -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"
}
}

Expand All @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Comment thread
sashrika marked this conversation as resolved.
Resolved
}

fun activateNativeHandlers(view: View) {
orchestrator?.activateNativeHandlersForView(view)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down