diff --git a/apps/basic-example/android/app/build.gradle b/apps/basic-example/android/app/build.gradle index fb0f30240c..66d925ab91 100644 --- a/apps/basic-example/android/app/build.gradle +++ b/apps/basic-example/android/app/build.gradle @@ -45,7 +45,7 @@ react { /* Hermes Commands */ // The hermes compiler command to run. By default it is 'hermesc' - hermesCommand = "../../../../node_modules/react-native/sdks/hermesc/osx-bin/hermesc" + hermesCommand = "../../node_modules/hermes-compiler/hermesc/%OS-BIN%/hermesc" // // The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map" // hermesFlags = ["-O", "-output-source-map"] diff --git a/packages/docs-gesture-handler/docs/fundamentals/installation.md b/packages/docs-gesture-handler/docs/fundamentals/installation.md index 4ca8b0e532..73675abee2 100644 --- a/packages/docs-gesture-handler/docs/fundamentals/installation.md +++ b/packages/docs-gesture-handler/docs/fundamentals/installation.md @@ -101,18 +101,6 @@ export function CustomModal({ children, ...rest }) { } ``` -##### Kotlin - -Gesture Handler on Android is implemented in Kotlin. If you need to set a specific Kotlin version to be used by the library, set the `kotlinVersion` ext property in `android/build.gradle` file and RNGH will use that version: - -```groovy -buildscript { - ext { - kotlinVersion = "1.6.21" - } -} -``` - #### iOS While developing for iOS, make sure to install [pods](https://cocoapods.org/) first before running the app: diff --git a/packages/docs-gesture-handler/docs/gesture-handlers/pan-gh.md b/packages/docs-gesture-handler/docs/gesture-handlers/pan-gh.md index d70c80cdd0..29aa28349f 100644 --- a/packages/docs-gesture-handler/docs/gesture-handlers/pan-gh.md +++ b/packages/docs-gesture-handler/docs/gesture-handlers/pan-gh.md @@ -52,6 +52,18 @@ See [set of properties inherited from base handler class](/docs/gesture-handlers Minimum distance the finger (or multiple finger) need to travel before the handler [activates](/docs/under-the-hood/state#active). Expressed in points. +### `minVelocity` + +Minimum speed the pointer has to reach in order for the handler to [activate](/docs/under-the-hood/state#active). Expressed in points per second. + +### `minVelocityX` + +Minimum speed along X axis the pointer has to reach in order for the handler to [activate](/docs/under-the-hood/state#active). Expressed in points per second. + +### `minVelocityY` + +Minimum speed along Y axis the pointer has to reach in order for the handler to [activate](/docs/under-the-hood/state#active). Expressed in points per second. + ### `minPointers` A number of fingers that is required to be placed before handler can [activate](/docs/under-the-hood/state#active). Should be a higher or equal to 0 integer. diff --git a/packages/docs-gesture-handler/docs/gestures/pan-gesture.md b/packages/docs-gesture-handler/docs/gestures/pan-gesture.md index 0e8f16ad33..3a03b733b8 100644 --- a/packages/docs-gesture-handler/docs/gestures/pan-gesture.md +++ b/packages/docs-gesture-handler/docs/gestures/pan-gesture.md @@ -130,6 +130,18 @@ If you wish to track the "center of mass" virtual pointer and account for its ch Minimum distance the finger (or multiple finger) need to travel before the gesture [activates](/docs/fundamentals/states-events#active). Expressed in points. +### `minVelocity(value: number)` + +Minimum speed the pointer has to reach in order for the gesture to [activate](/docs/fundamentals/states-events#active). Expressed in points per second. + +### `minVelocityX(value: number)` + +Minimum speed along X axis the pointer has to reach in order for the gesture to [activate](/docs/fundamentals/states-events#active). Expressed in points per second. + +### `minVelocityY(value: number)` + +Minimum speed along Y axis the pointer has to reach in order for the gesture to [activate](/docs/fundamentals/states-events#active). Expressed in points per second. + ### `minPointers(value: number)` A number of fingers that is required to be placed before gesture can [activate](/docs/fundamentals/states-events#active). Should be a higher or equal to 0 integer. diff --git a/packages/docs-gesture-handler/static/examples/LongPressGestureBasic.js b/packages/docs-gesture-handler/static/examples/LongPressGestureBasic.js index 6df3275205..d88472bd25 100644 --- a/packages/docs-gesture-handler/static/examples/LongPressGestureBasic.js +++ b/packages/docs-gesture-handler/static/examples/LongPressGestureBasic.js @@ -4,8 +4,9 @@ import { GestureDetector, GestureHandlerRootView, } from 'react-native-gesture-handler'; -import { Easing, StyleSheet } from 'react-native'; +import { StyleSheet } from 'react-native'; import Animated, { + Easing, interpolateColor, useAnimatedStyle, useSharedValue, diff --git a/packages/react-native-gesture-handler/android/build.gradle b/packages/react-native-gesture-handler/android/build.gradle index 6348d17da8..05818a67fb 100644 --- a/packages/react-native-gesture-handler/android/build.gradle +++ b/packages/react-native-gesture-handler/android/build.gradle @@ -2,17 +2,18 @@ import groovy.json.JsonSlurper import com.android.build.gradle.tasks.ExternalNativeBuildJsonTask buildscript { - def kotlin_version = rootProject.ext.has('kotlinVersion') ? rootProject.ext.get('kotlinVersion') : project.properties['RNGH_kotlinVersion'] - repositories { mavenCentral() google() } dependencies { - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") - classpath("com.android.tools.build:gradle:8.10.1") - classpath("com.diffplug.spotless:spotless-plugin-gradle:7.0.4") + if (project == rootProject) { + def kotlin_version = project.properties['RNGH_kotlinVersion'] + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") + classpath("com.android.tools.build:gradle:8.10.1") + classpath("com.diffplug.spotless:spotless-plugin-gradle:7.0.4") + } } } @@ -62,7 +63,22 @@ if (isNewArchitectureEnabled()) { } apply plugin: 'com.android.library' -apply plugin: 'kotlin-android' + +def shouldEnableAgpFallback() { + def agpMajorVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.tokenize('.')[0].toInteger() + if (agpMajorVersion <= 8) { + return true + } + + def propertyVal = providers.gradleProperty("android.builtInKotlin").orNull + def isBuiltInKotlinEnabled = propertyVal != null ? propertyVal.toBoolean() : true + + return !isBuiltInKotlinEnabled +} + +if (shouldEnableAgpFallback()) { + apply plugin: 'kotlin-android' +} if (project == rootProject) { apply from: "spotless.gradle" @@ -190,17 +206,17 @@ android { } sourceSets.main { - java { + kotlin { if (shouldUseCommonInterfaceFromReanimated()) { - srcDirs += 'reanimated/src/main/java' + directories.add('reanimated/src/main/java') } else { - srcDirs += 'noreanimated/src/main/java' + directories.add('noreanimated/src/main/java') } if (shouldUseCommonInterfaceFromRNSVG()) { - srcDirs += 'svg/src/main/java' + directories.add('svg/src/main/java') } else { - srcDirs += 'nosvg/src/main/java' + directories.add('nosvg/src/main/java') } if (isNewArchitectureEnabled()) { diff --git a/packages/react-native-gesture-handler/android/gradle.properties b/packages/react-native-gesture-handler/android/gradle.properties index 2061ead6df..da5eaedbcb 100644 --- a/packages/react-native-gesture-handler/android/gradle.properties +++ b/packages/react-native-gesture-handler/android/gradle.properties @@ -16,4 +16,4 @@ org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemor # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true -RNGH_kotlinVersion=2.0.21 +RNGH_kotlinVersion=2.2.0 diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/FlingGestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/FlingGestureHandler.kt index 3a61180ec8..77cfba50d1 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/FlingGestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/FlingGestureHandler.kt @@ -88,7 +88,7 @@ class FlingGestureHandler : GestureHandler() { } override fun onHandle(event: MotionEvent, sourceEvent: MotionEvent) { - if (!shouldActivateWithMouse(sourceEvent)) { + if (shouldSkipEvent(sourceEvent)) { return } 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 91abef225b..a0caa5ae99 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 @@ -4,7 +4,6 @@ import android.app.Activity import android.content.Context import android.content.ContextWrapper import android.graphics.PointF -import android.os.Build import android.view.MotionEvent import android.view.MotionEvent.PointerCoords import android.view.MotionEvent.PointerProperties @@ -426,10 +425,15 @@ open class GestureHandler { } private fun dispatchTouchUpEvent(event: MotionEvent, sourceEvent: MotionEvent) { + val pointerId = event.getPointerId(event.actionIndex) + + if (trackedPointers[pointerId] == null) { + return + } + extractAllPointersData() changedTouchesPayload = null touchEventType = RNGestureHandlerTouchEvent.EVENT_TOUCH_UP - val pointerId = event.getPointerId(event.actionIndex) val offsetX = sourceEvent.rawX - sourceEvent.x val offsetY = sourceEvent.rawY - sourceEvent.y @@ -754,44 +758,38 @@ open class GestureHandler { return clickedButton and mouseButton != 0 } - protected fun shouldActivateWithMouse(sourceEvent: MotionEvent): Boolean { - // While using mouse, we get both sets of events, for example ACTION_DOWN and ACTION_BUTTON_PRESS. That's why we want to take actions to only one of them. - // On API >= 23, we will use events with infix BUTTON, otherwise we use standard action events (like ACTION_DOWN). + // Decides whether the gesture should ignore this event. While using a mouse we receive both the + // touch-compatible stream (ACTION_DOWN/UP/...) and the BUTTON_* events, so we act on only the + // latter, and we drop events coming from a button other than the configured `mouseButton`. + // Non-mouse input is never skipped here. + protected fun shouldSkipEvent(sourceEvent: MotionEvent): Boolean { + if (sourceEvent.getToolType(0) != MotionEvent.TOOL_TYPE_MOUSE) { + return false + } with(sourceEvent) { - // To use actionButton, we need API >= 23. - if (getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE && - Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + // While using mouse, we want to ignore default events for touch. + if (actionMasked == MotionEvent.ACTION_DOWN || + actionMasked == MotionEvent.ACTION_UP || + actionMasked == MotionEvent.ACTION_POINTER_UP || + actionMasked == MotionEvent.ACTION_POINTER_DOWN ) { - // While using mouse, we want to ignore default events for touch. - if (action == MotionEvent.ACTION_DOWN || - action == MotionEvent.ACTION_UP || - action == MotionEvent.ACTION_POINTER_UP || - action == MotionEvent.ACTION_POINTER_DOWN - ) { - return@shouldActivateWithMouse false - } + return@shouldSkipEvent true + } - // We don't want to do anything if wrong button was clicked. If we received event for BUTTON, we have to use actionButton to get which one was clicked. - if (action != MotionEvent.ACTION_MOVE && !isButtonInConfig(actionButton)) { - return@shouldActivateWithMouse false - } + // Skip events from a button other than the configured one. For BUTTON_* events the clicked + // button is read from `actionButton`. + if (actionMasked != MotionEvent.ACTION_MOVE && !isButtonInConfig(actionButton)) { + return@shouldSkipEvent true + } - // When we receive ACTION_MOVE, we have to check buttonState field. - if (action == MotionEvent.ACTION_MOVE && !isButtonInConfig(buttonState)) { - return@shouldActivateWithMouse false - } - } else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - // We do not fully support mouse below API 23, so we will ignore BUTTON events. - if (action == MotionEvent.ACTION_BUTTON_PRESS || - action == MotionEvent.ACTION_BUTTON_RELEASE - ) { - return@shouldActivateWithMouse false - } + // For ACTION_MOVE the pressed button is read from `buttonState`. + if (actionMasked == MotionEvent.ACTION_MOVE && !isButtonInConfig(buttonState)) { + return@shouldSkipEvent true } } - return true + return false } /** @@ -984,7 +982,8 @@ open class GestureHandler { const val POINTER_TYPE_TOUCH = 0 const val POINTER_TYPE_STYLUS = 1 const val POINTER_TYPE_MOUSE = 2 - const val POINTER_TYPE_OTHER = 3 + const val POINTER_TYPE_KEY = 3 + const val POINTER_TYPE_OTHER = 4 private const val MAX_POINTERS_COUNT = 12 private lateinit var pointerProps: Array private lateinit var pointerCoords: Array 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 8d78aeac38..7d9cb45bf2 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 @@ -26,7 +27,20 @@ class GestureHandlerOrchestrator( var minimumAlphaForTraversal = DEFAULT_MIN_ALPHA_FOR_TRAVERSAL private val gestureHandlers = arrayListOf() private val awaitingHandlers = arrayListOf() - private val preparedHandlers = arrayListOf() + + // Pool of reusable lists for snapshotting `gestureHandlers` during event delivery. + private val handlerListPool = ArrayDeque>() + + private fun obtainHandlerList() = handlerListPool.pollLast() ?: ArrayList() + + private fun recycleHandlerList(list: ArrayList) { + list.clear() + handlerListPool.addLast(list) + } + + // 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 @@ -46,6 +60,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 @@ -105,7 +120,7 @@ class GestureHandlerOrchestrator( private fun shouldBeCancelledByActiveHandler(handler: GestureHandler) = gestureHandlers.any { handler.hasCommonPointers(it) && - it.state == GestureHandler.STATE_ACTIVE && + it.isActive && !canRunSimultaneously(handler, it) && handler.isDescendantOf(it) } @@ -141,6 +156,13 @@ class GestureHandlerOrchestrator( /*package*/ fun onHandlerStateChange(handler: GestureHandler, newState: Int, prevState: Int) { handlingChangeSemaphore += 1 + + if (handler.isAwaiting && + (newState == GestureHandler.STATE_CANCELLED || newState == GestureHandler.STATE_FAILED) + ) { + handler.isAwaiting = false + } + if (isFinished(newState)) { // We have to loop through copy in order to avoid modifying collection // while iterating over its elements @@ -252,20 +274,24 @@ class GestureHandlerOrchestrator( } private fun deliverEventToGestureHandlers(event: MotionEvent) { - // Copy handlers to "prepared handlers" array, because the list of active handlers can change - // as a result of state updates - preparedHandlers.clear() - preparedHandlers.addAll(gestureHandlers) + // Snapshot handlers into a pooled list, because the list of active handlers can change + // as a result of state updates (and delivery can be re-entrant). + val handlersToProcess = obtainHandlerList() + handlersToProcess.addAll(gestureHandlers) // We want to deliver events to active handlers first in order of their activation (handlers // that activated first will first get event delivered). Otherwise we deliver events in the // order in which handlers has been added ("most direct" children goes first). Therefore we rely // on Arrays.sort providing a stable sort (as children are registered in order in which they // should be tested) - preparedHandlers.sortWith(handlersComparator) + handlersToProcess.sortWith(handlersComparator) - for (handler in preparedHandlers) { - deliverEventToGestureHandler(handler, event) + try { + for (handler in handlersToProcess) { + deliverEventToGestureHandler(handler, event) + } + } finally { + recycleHandlerList(handlersToProcess) } } @@ -276,12 +302,9 @@ class GestureHandlerOrchestrator( handler.cancel() } - // Copy handlers to "prepared handlers" array, because the list of active handlers can change + // Iterate over a copy, because the list of active handlers can change // as a result of state updates - preparedHandlers.clear() - preparedHandlers.addAll(gestureHandlers) - - for (handler in gestureHandlers.asReversed()) { + for (handler in gestureHandlers.reversed()) { handler.cancel() } } @@ -314,7 +337,7 @@ class GestureHandlerOrchestrator( if (!handler.isAwaiting || action != MotionEvent.ACTION_MOVE) { val isFirstEvent = handler.state == 0 handler.handle(event, sourceEvent) - if (handler.isActive) { + if (handler.state == GestureHandler.STATE_ACTIVE && handler.isActive) { // After handler is done waiting for other one to fail its progress should be // reset, otherwise there may be a visible jump in values sent by the handler. // When handler is waiting it's already activated but the `isAwaiting` flag @@ -622,6 +645,112 @@ 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. + // 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 { + 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/core/LongPressGestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/LongPressGestureHandler.kt index 32bd755b37..614e97dfd9 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/LongPressGestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/LongPressGestureHandler.kt @@ -29,13 +29,14 @@ class LongPressGestureHandler(context: Context) : GestureHandler() { val systemDefaultMaxDist = DEFAULT_MAX_DIST_DP * context.resources.displayMetrics.density defaultMaxDist = systemDefaultMaxDist maxDist = defaultMaxDist - numberOfPointersRequired = 1 + numberOfPointersRequired = DEFAULT_NUMBER_OF_POINTERS_REQUIRED } override fun resetConfig() { super.resetConfig() minDurationMs = DEFAULT_MIN_DURATION_MS maxDist = defaultMaxDist + numberOfPointersRequired = DEFAULT_NUMBER_OF_POINTERS_REQUIRED shouldCancelWhenOutside = DEFAULT_SHOULD_CANCEL_WHEN_OUTSIDE } @@ -66,7 +67,7 @@ class LongPressGestureHandler(context: Context) : GestureHandler() { } override fun onHandle(event: MotionEvent, sourceEvent: MotionEvent) { - if (!shouldActivateWithMouse(sourceEvent)) { + if (shouldSkipEvent(sourceEvent)) { return } @@ -99,7 +100,8 @@ class LongPressGestureHandler(context: Context) : GestureHandler() { currentPointers == numberOfPointersRequired && ( sourceEvent.actionMasked == MotionEvent.ACTION_DOWN || - sourceEvent.actionMasked == MotionEvent.ACTION_POINTER_DOWN + sourceEvent.actionMasked == MotionEvent.ACTION_POINTER_DOWN || + sourceEvent.actionMasked == MotionEvent.ACTION_BUTTON_PRESS ) ) { handler = Handler(Looper.getMainLooper()) @@ -207,5 +209,6 @@ class LongPressGestureHandler(context: Context) : GestureHandler() { private const val DEFAULT_SHOULD_CANCEL_WHEN_OUTSIDE = true private const val DEFAULT_MIN_DURATION_MS: Long = 500 private const val DEFAULT_MAX_DIST_DP = 10f + private const val DEFAULT_NUMBER_OF_POINTERS_REQUIRED = 1 } } 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 8329224455..27b208d8fe 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 @@ -109,6 +109,9 @@ class NativeViewGestureHandler : GestureHandler() { cancel() } else { hook.sendTouchEvent(view, event) + if (shouldStopNestedScroll()) { + view.stopNestedScroll() + } if ((state == STATE_UNDETERMINED || state == STATE_BEGAN) && hook.canActivate(view)) { activate() @@ -156,9 +159,19 @@ class NativeViewGestureHandler : GestureHandler() { action = MotionEvent.ACTION_CANCEL } hook.sendTouchEvent(view, event) + if (shouldStopNestedScroll()) { + view?.stopNestedScroll() + } event.recycle() } + // Once the handler is active, it delivers touches straight to the view's `onTouchEvent`. Normally + // touches arrive through `View.dispatchTouchEvent`, which also ends the nested scroll when the finger + // goes up. Because we skip it, the nested scroll stays open and the parent never finds out that the + // gesture is over - e.g. SwipeRefreshLayout never fires refresh (#4485). While the handler is not + // active, the view still receives touches the regular way, so Android takes care of it. + private fun shouldStopNestedScroll() = state == STATE_ACTIVE && hook.shouldStopNestedScroll() + override fun onCancel() = dispatchCancelEventToView() override fun onFail() = dispatchCancelEventToView() @@ -211,6 +224,12 @@ class NativeViewGestureHandler : GestureHandler() { */ fun canBegin(event: MotionEvent) = true + /** + * Whether the view's nested scroll should be stopped when the active gesture ends. Touches + * are fed through `onTouchEvent`, so `View.dispatchTouchEvent` never gets to do it. + */ + fun shouldStopNestedScroll() = false + /** * Checks whether handler can activate. Used by TextViewHook. */ @@ -336,6 +355,10 @@ class NativeViewGestureHandler : GestureHandler() { private class ScrollViewHook : NativeViewGestureHandlerHook { override fun shouldCancelRootViewGestureHandlerIfNecessary() = true + + // ScrollView starts a nested scroll on DOWN but never stops it itself. Without this the + // parent's `onStopNestedScroll` never runs, e.g. SwipeRefreshLayout never triggers refresh. + override fun shouldStopNestedScroll() = true } private class ReactViewGroupHook : NativeViewGestureHandlerHook { 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 33bd963595..02767d50ae 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 @@ -11,6 +11,7 @@ import com.facebook.react.uimanager.PixelUtil import com.swmansion.gesturehandler.core.GestureUtils.getLastPointerX import com.swmansion.gesturehandler.core.GestureUtils.getLastPointerY import com.swmansion.gesturehandler.react.eventbuilders.PanGestureHandlerEventDataBuilder +import kotlin.math.abs class PanGestureHandler(context: Context?) : GestureHandler() { var velocityX = 0f @@ -113,15 +114,11 @@ class PanGestureHandler(context: Context?) : GestureHandler() { return true } val vx = velocityX - if (minVelocityX != MIN_VALUE_IGNORE && - (minVelocityX < 0 && vx <= minVelocityX || minVelocityX in 0.0f..vx) - ) { + if (minVelocityX != MIN_VALUE_IGNORE && abs(vx) >= abs(minVelocityX)) { return true } val vy = velocityY - if (minVelocityY != MIN_VALUE_IGNORE && - (minVelocityY < 0 && vx <= minVelocityY || minVelocityY in 0.0f..vx) - ) { + if (minVelocityY != MIN_VALUE_IGNORE && abs(vy) >= abs(minVelocityY)) { return true } val velocitySq = vx * vx + vy * vy @@ -149,7 +146,7 @@ class PanGestureHandler(context: Context?) : GestureHandler() { } override fun onHandle(event: MotionEvent, sourceEvent: MotionEvent) { - if (!shouldActivateWithMouse(sourceEvent)) { + if (shouldSkipEvent(sourceEvent)) { return } diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/TapGestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/TapGestureHandler.kt index 3a4d828b5b..9169f6ef85 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/TapGestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/TapGestureHandler.kt @@ -82,7 +82,7 @@ class TapGestureHandler : GestureHandler() { } override fun onHandle(event: MotionEvent, sourceEvent: MotionEvent) { - if (!shouldActivateWithMouse(sourceEvent)) { + if (shouldSkipEvent(sourceEvent)) { return } 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 51180b29de..4509ff6fe9 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 @@ -19,3 +19,6 @@ fun Context.isScreenReaderOn() = fun MotionEvent.isHoverAction(): Boolean = action == MotionEvent.ACTION_HOVER_MOVE || action == MotionEvent.ACTION_HOVER_ENTER || action == MotionEvent.ACTION_HOVER_EXIT + +fun MotionEvent.isButtonAction(): Boolean = actionMasked == MotionEvent.ACTION_BUTTON_PRESS || + actionMasked == MotionEvent.ACTION_BUTTON_RELEASE 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 e4114c40fa..b3b5d2c845 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 @@ -554,6 +554,8 @@ class RNGestureHandlerButtonViewManager : // by default Viewgroup would pass hotspot change events } + override fun shouldDelayChildPressedState(): Boolean = false + private fun findGestureHandlerRootView(): RNGestureHandlerRootView? { var parent: ViewParent? = this.parent var gestureHandlerRootView: RNGestureHandlerRootView? = null 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 629c155117..edbdff9b19 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 @@ -19,6 +19,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 { @@ -118,6 +119,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 } 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 2801b8a0f5..fe75e3a06c 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 @@ -40,12 +40,14 @@ class RNGestureHandlerRootView(context: Context?) : ReactViewGroup(context) { super.dispatchTouchEvent(event) } - override fun dispatchGenericMotionEvent(ev: MotionEvent) = - if (rootViewEnabled && ev.isHoverAction() && rootHelper!!.dispatchTouchEvent(ev)) { - true - } else { - super.dispatchGenericMotionEvent(ev) - } + override fun dispatchGenericMotionEvent(ev: MotionEvent) = if (rootViewEnabled && + (ev.isHoverAction() || ev.isButtonAction()) && + rootHelper!!.dispatchTouchEvent(ev) + ) { + true + } else { + super.dispatchGenericMotionEvent(ev) + } override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) { if (rootViewEnabled) { diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerTouchEvent.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerTouchEvent.kt index 69876ecd48..90ff4d2c71 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerTouchEvent.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerTouchEvent.kt @@ -54,13 +54,8 @@ class RNGestureHandlerTouchEvent private constructor() : Event= 130400 if (@available(iOS 13.4, *)) { - bool enableTrackpadTwoFingerGesture = [RCTConvert BOOL:config[@"enableTrackpadTwoFingerGesture"]]; - if (enableTrackpadTwoFingerGesture) { - recognizer.allowedScrollTypesMask = UIScrollTypeMaskAll; + id enableTrackpadTwoFingerGesture = config[@"enableTrackpadTwoFingerGesture"]; + if (enableTrackpadTwoFingerGesture != nil) { + recognizer.allowedScrollTypesMask = [RCTConvert BOOL:enableTrackpadTwoFingerGesture] ? UIScrollTypeMaskAll : 0; } } diff --git a/packages/react-native-gesture-handler/apple/Handlers/RNTapHandler.m b/packages/react-native-gesture-handler/apple/Handlers/RNTapHandler.m index eafe7192b3..8c0289fc78 100644 --- a/packages/react-native-gesture-handler/apple/Handlers/RNTapHandler.m +++ b/packages/react-native-gesture-handler/apple/Handlers/RNTapHandler.m @@ -38,6 +38,12 @@ @implementation RNBetterTapGestureRecognizer { NSUInteger _tapsSoFar; CGPoint _initPosition; NSInteger _maxNumberOfTouches; + // Pending `cancel` invocations scheduled via dispatch_after. We use dispatch blocks instead of + // performSelector:afterDelay: because the latter schedules its timer in NSDefaultRunLoopMode only, + // which means it is starved while a sibling UIScrollView keeps the run loop in UITrackingRunLoopMode + // (during a drag or momentum deceleration). dispatch_after fires regardless of run loop mode, so the + // tap can still fail/finalize on time while a list is scrolling. See issue #3471. + NSMutableArray *_pendingCancellations; } static const NSUInteger defaultNumberOfTaps = 1; @@ -57,10 +63,33 @@ - (id)initWithGestureHandler:(RNGestureHandler *)gestureHandler _maxDeltaX = NAN; _maxDeltaY = NAN; _maxDistSq = NAN; + _pendingCancellations = [NSMutableArray array]; } return self; } +- (void)scheduleCancelAfterDelay:(NSTimeInterval)delay +{ + __weak typeof(self) weakSelf = self; + + dispatch_block_t block = dispatch_block_create(0, ^{ + [weakSelf cancel]; + }); + + [_pendingCancellations addObject:block]; + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delay * NSEC_PER_SEC)), dispatch_get_main_queue(), block); +} + +- (void)cancelPendingCancellations +{ + for (dispatch_block_t block in _pendingCancellations) { + dispatch_block_cancel(block); + } + + [_pendingCancellations removeAllObjects]; +} + - (void)triggerAction { [_gestureHandler handleGesture:self fromReset:NO]; @@ -93,14 +122,14 @@ - (void)interactionsBegan:(NSSet *)touches withEvent:(UIEvent *)event } _tapsSoFar++; if (_tapsSoFar) { - [NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(cancel) object:nil]; + [self cancelPendingCancellations]; } NSInteger numberOfTouches = [touches count]; if (numberOfTouches > _maxNumberOfTouches) { _maxNumberOfTouches = numberOfTouches; } if (!isnan(_maxDuration)) { - [self performSelector:@selector(cancel) withObject:nil afterDelay:_maxDuration]; + [self scheduleCancelAfterDelay:_maxDuration]; } self.state = UIGestureRecognizerStatePossible; [self triggerAction]; @@ -132,10 +161,12 @@ - (void)interactionsEnded:(NSSet *)touches withEvent:(UIEvent *)event { [_gestureHandler.pointerTracker touchesEnded:touches withEvent:event]; + [self cancelPendingCancellations]; + if (_numberOfTaps == _tapsSoFar && _maxNumberOfTouches >= _minPointers) { self.state = UIGestureRecognizerStateEnded; } else { - [self performSelector:@selector(cancel) withObject:nil afterDelay:_maxDelay]; + [self scheduleCancelAfterDelay:_maxDelay]; } } @@ -250,7 +281,7 @@ - (void)reset [_gestureHandler.pointerTracker reset]; - [NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(cancel) object:nil]; + [self cancelPendingCancellations]; _tapsSoFar = 0; _maxNumberOfTouches = 0; self.enabled = YES; diff --git a/packages/react-native-gesture-handler/apple/RNGHUIKit.h b/packages/react-native-gesture-handler/apple/RNGHUIKit.h index dc0b742ca2..9ebefd5d14 100644 --- a/packages/react-native-gesture-handler/apple/RNGHUIKit.h +++ b/packages/react-native-gesture-handler/apple/RNGHUIKit.h @@ -6,11 +6,11 @@ typedef UIView RNGHUIView; typedef UITouch RNGHUITouch; typedef UIScrollView RNGHUIScrollView; -#define RNGHGestureRecognizerStateFailed UIGestureRecognizerStateFailed; -#define RNGHGestureRecognizerStatePossible UIGestureRecognizerStatePossible; -#define RNGHGestureRecognizerStateCancelled UIGestureRecognizerStateCancelled; -#define RNGHGestureRecognizerStateBegan UIGestureRecognizerStateBegan; -#define RNGHGestureRecognizerStateEnded UIGestureRecognizerStateEnded; +#define RNGHGestureRecognizerStateFailed UIGestureRecognizerStateFailed +#define RNGHGestureRecognizerStatePossible UIGestureRecognizerStatePossible +#define RNGHGestureRecognizerStateCancelled UIGestureRecognizerStateCancelled +#define RNGHGestureRecognizerStateBegan UIGestureRecognizerStateBegan +#define RNGHGestureRecognizerStateEnded UIGestureRecognizerStateEnded #else // TARGET_OS_OSX [ @@ -20,10 +20,10 @@ typedef RCTUIView RNGHUIView; typedef RCTUITouch RNGHUITouch; typedef NSScrollView RNGHUIScrollView; -#define RNGHGestureRecognizerStateFailed NSGestureRecognizerStateFailed; -#define RNGHGestureRecognizerStatePossible NSGestureRecognizerStatePossible; -#define RNGHGestureRecognizerStateCancelled NSGestureRecognizerStateCancelled; -#define RNGHGestureRecognizerStateBegan NSGestureRecognizerStateBegan; -#define RNGHGestureRecognizerStateEnded NSGestureRecognizerStateEnded; +#define RNGHGestureRecognizerStateFailed NSGestureRecognizerStateFailed +#define RNGHGestureRecognizerStatePossible NSGestureRecognizerStatePossible +#define RNGHGestureRecognizerStateCancelled NSGestureRecognizerStateCancelled +#define RNGHGestureRecognizerStateBegan NSGestureRecognizerStateBegan +#define RNGHGestureRecognizerStateEnded NSGestureRecognizerStateEnded #endif // ] TARGET_OS_OSX diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandler.h b/packages/react-native-gesture-handler/apple/RNGestureHandler.h index 4f11824a2f..c7fff09461 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandler.h +++ b/packages/react-native-gesture-handler/apple/RNGestureHandler.h @@ -14,6 +14,8 @@ #define TEST_MIN_IF_NOT_NAN(value, limit) \ (!isnan(limit) && ((limit < 0 && value <= limit) || (limit >= 0 && value >= limit))) +#define TEST_ABS_MIN_IF_NOT_NAN(value, limit) (!isnan(limit) && fabs(value) >= fabs(limit)) + #define TEST_MAX_IF_NOT_NAN(value, max) (!isnan(max) && ((max < 0 && value < max) || (max >= 0 && value > max))) #define APPLY_PROP(recognizer, config, type, prop, propName) \ diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandler.mm b/packages/react-native-gesture-handler/apple/RNGestureHandler.mm index b79554fdb7..ea38a8334b 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandler.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandler.mm @@ -253,6 +253,24 @@ - (void)bindToView:(RNGHUIView *)view - (void)unbindFromView { + // If the gesture is still in flight - e.g. the view is being unmounted mid-gesture - deliver + // the final event now, while the recognizer is still attached and the target view is known. + // Otherwise the onBegin/onFinalize and onActivate/onDeactivate guarantees would be broken + // and `_lastState` would never be cleared by `reset`. + if (self.recognizer.view != nil && + (_lastState == RNGestureHandlerStateBegan || _lastState == RNGestureHandlerStateActive)) { + if ([self eventTagForRecognizer:self.recognizer] != nil) { + [self handleGesture:self.recognizer + inState:_lastState == RNGestureHandlerStateActive ? RNGestureHandlerStateCancelled + : RNGestureHandlerStateFailed]; + } else { + // The event has no tag to be dispatched with, so it cannot be delivered on any path - reset + // the bookkeeping so the handler doesn't stay in-flight forever. + _lastState = RNGestureHandlerStateUndetermined; + _state = RNGestureHandlerStateBegan; + } + } + [self.recognizer.view removeGestureRecognizer:self.recognizer]; self.recognizer.delegate = nil; self.viewTag = nil; @@ -314,21 +332,38 @@ - (void)handleGesture:(UIGestureRecognizer *)recognizer fromReset:(BOOL)fromRese // // While this solution is not great, we decided to check whether sending events was triggered from `reset` method. // This way we can detect double Began mapping by checking previous sent state and current state of recognizer. - if (fromReset && _lastState == RNGestureHandlerStateBegan && - self.recognizer.state == UIGestureRecognizerStatePossible) { - _state = RNGestureHandlerStateFailed; + // + // The same applies to gestures interrupted mid-flight, e.g. when the view is unmounted during an active + // gesture the recognizer may be reset without its cancel action ever firing. + // If the last sent state is not final, synthesize the missing final event so that the + // `onBegin`/`onFinalize` and `onActivate`/`onDeactivate` guarantees hold. + if (fromReset && self.recognizer.state == UIGestureRecognizerStatePossible) { + if (_lastState == RNGestureHandlerStateBegan) { + _state = RNGestureHandlerStateFailed; + } else if (_lastState == RNGestureHandlerStateActive) { + _state = RNGestureHandlerStateCancelled; + } else { + // The final event was already delivered; mapping Possible to Began here would emit a stray + // BEGAN event after the gesture has finished. + return; + } } [self handleGesture:recognizer inState:_state]; } +- (nullable NSNumber *)eventTagForRecognizer:(UIGestureRecognizer *)recognizer +{ + return [self chooseViewForInteraction:recognizer].reactTag; +} + - (void)handleGesture:(UIGestureRecognizer *)recognizer inState:(RNGestureHandlerState)state { _state = state; RNGestureHandlerEventExtraData *eventData = [self eventExtraData:recognizer]; - RNGHUIView *view = [self chooseViewForInteraction:recognizer]; + NSNumber *tag = [self eventTagForRecognizer:recognizer]; - [self sendEventsInState:self.state forViewWithTag:view.reactTag withExtraData:eventData]; + [self sendEventsInState:self.state forViewWithTag:tag withExtraData:eventData]; } - (void)sendEventsInState:(RNGestureHandlerState)state @@ -636,7 +671,13 @@ - (void)reset // might be called after some pointers are down, and after state manipulation by the user. // Pointer tracker calls this method when it resets, and in that case it no longer tracks // any pointers, thus entering this if - if (!_needsPointerData || _pointerTracker.trackedPointersCount == 0) { + // + // Also do not clear _lastState while the gesture is in flight (BEGAN/ACTIVE) - the final + // state-change event hasn't been dispatched yet. When the view is removed mid-gesture, + // the pointer tracker resets before the recognizer's cancel action fires; clearing _lastState + // here would corrupt the prevState of the outgoing CANCELLED event and break the onActivate/onDeactivate guarantee. + if ((!_needsPointerData || _pointerTracker.trackedPointersCount == 0) && _lastState != RNGestureHandlerStateBegan && + _lastState != RNGestureHandlerStateActive) { _lastState = RNGestureHandlerStateUndetermined; _state = RNGestureHandlerStateBegan; } diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerManager.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerManager.mm index f4eb812cec..62d53b8bec 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerManager.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerManager.mm @@ -274,20 +274,50 @@ - (void)reattachHandlersIfNeeded #pragma mark Root Views Management +#ifdef RCT_NEW_ARCH_ENABLED +#if !TARGET_OS_OSX +static BOOL RNGHIsScreensTouchHandlerHost(RNGHUIView *view) +{ + static Class fullWindowOverlayContainerClass; + static Class screenViewClass; + static SEL isModalSelector; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + fullWindowOverlayContainerClass = NSClassFromString(@"RNSFullWindowOverlayContainer"); + screenViewClass = NSClassFromString(@"RNSScreenView"); + isModalSelector = NSSelectorFromString(@"isModal"); + }); + + if (fullWindowOverlayContainerClass != nil && [view isKindOfClass:fullWindowOverlayContainerClass]) { + return YES; + } + + if (screenViewClass != nil && [view isKindOfClass:screenViewClass]) { + // For now we consider only modals + return [view respondsToSelector:isModalSelector] && [[view valueForKey:@"isModal"] boolValue]; + } + + return NO; +} +#endif // !TARGET_OS_OSX +#endif // RCT_NEW_ARCH_ENABLED + - (void)registerViewWithGestureRecognizerAttachedIfNeeded:(RNGHUIView *)childView +{ + [self registerViewWithGestureRecognizerAttachedIfNeeded:childView isRetry:NO]; +} + +- (void)registerViewWithGestureRecognizerAttachedIfNeeded:(RNGHUIView *)childView isRetry:(BOOL)isRetry { #ifdef RCT_NEW_ARCH_ENABLED RNGHUIView *touchHandlerView = childView; #if !TARGET_OS_OSX - Class fullWindowOverlayContainerClass = NSClassFromString(@"RNSFullWindowOverlayContainer"); - if ([[childView reactViewController] isKindOfClass:[RCTFabricModalHostViewController class]]) { touchHandlerView = [childView reactViewController].view; } else { - while ( - touchHandlerView != nil && ![touchHandlerView isKindOfClass:[RCTSurfaceView class]] && - (fullWindowOverlayContainerClass == nil || ![touchHandlerView isKindOfClass:fullWindowOverlayContainerClass])) { + while (touchHandlerView != nil && ![touchHandlerView isKindOfClass:[RCTSurfaceView class]] && + !RNGHIsScreensTouchHandlerHost(touchHandlerView)) { touchHandlerView = touchHandlerView.superview; } } @@ -323,6 +353,19 @@ - (void)registerViewWithGestureRecognizerAttachedIfNeeded:(RNGHUIView *)childVie #endif // RCT_NEW_ARCH_ENABLED if (touchHandlerView == nil) { +#ifdef RCT_NEW_ARCH_ENABLED +#if !TARGET_OS_OSX + // Handlers are attached while the mounting transaction is still in progress — the view's + // ancestor chain may not be assembled yet (Fabric connects subtrees children-first), in + // which case the walk above dead-ends before reaching any touch-handling root. Retry once + // on the next run loop turn, when mounting has finished and the hierarchy is connected. + if (!isRetry) { + dispatch_async(dispatch_get_main_queue(), ^{ + [self registerViewWithGestureRecognizerAttachedIfNeeded:childView isRetry:YES]; + }); + } +#endif // !TARGET_OS_OSX +#endif // RCT_NEW_ARCH_ENABLED return; } @@ -381,6 +424,21 @@ - (void)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer break; } } + +#ifdef RCT_NEW_ARCH_ENABLED +#if !TARGET_OS_OSX + if (touchHandler == nil && [viewWithTouchHandler respondsToSelector:NSSelectorFromString(@"touchHandler")]) { + // An RNSScreenView may not have the touch handler attached directly (e.g. touches on it are + // still driven by an ancestor's touch handler) — ask react-native-screens for the one + // responsible for this screen. + id screenTouchHandler = [viewWithTouchHandler valueForKey:@"touchHandler"]; + if ([screenTouchHandler isKindOfClass:[RCTSurfaceTouchHandler class]]) { + touchHandler = screenTouchHandler; + } + } +#endif // !TARGET_OS_OSX +#endif // RCT_NEW_ARCH_ENABLED + [touchHandler setEnabled:NO]; [touchHandler setEnabled:YES]; } diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerPointerTracker.m b/packages/react-native-gesture-handler/apple/RNGestureHandlerPointerTracker.m index 2bef5e5d40..c9eea3c079 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerPointerTracker.m +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerPointerTracker.m @@ -38,7 +38,14 @@ - (int)registerTouch:(RNGHUITouch *)touch - (int)unregisterTouch:(RNGHUITouch *)touch { for (int index = 0; index < MAX_POINTERS_COUNT; index++) { +#if TARGET_OS_OSX + // A macOS mouse sequence delivers a fresh NSEvent per event, so identity matching + // (valid on iOS, where a UITouch is one stable object for the whole touch) never + // matches. There is only one mouse pointer — match the tracked slot instead. + if (_trackedPointers[index] != nil) { +#else if (_trackedPointers[index] == touch) { +#endif _trackedPointers[index] = nil; return index; } @@ -50,7 +57,12 @@ - (int)unregisterTouch:(RNGHUITouch *)touch - (int)findTouchIndex:(RNGHUITouch *)touch { for (int index = 0; index < MAX_POINTERS_COUNT; index++) { +#if TARGET_OS_OSX + // See unregisterTouch: — identity matching cannot work for NSEvents. + if (_trackedPointers[index] != nil) { +#else if (_trackedPointers[index] == touch) { +#endif return index; } } @@ -115,18 +127,25 @@ - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event _eventType = RNGHTouchEventTypePointerDown; NSDictionary *data[touches.count]; + int changedCount = 0; for (int i = 0; i < [touches count]; i++) { RNGHUITouch *touch = [[touches allObjects] objectAtIndex:i]; int index = [self registerTouch:touch]; - if (index >= 0) { - _trackedPointersCount++; + + if (index < 0) { + continue; } - data[i] = [self extractPointerData:index forTouch:touch]; + _trackedPointersCount++; + data[changedCount++] = [self extractPointerData:index forTouch:touch]; } - _changedPointersData = [[NSArray alloc] initWithObjects:data count:[touches count]]; + if (changedCount == 0) { + return; + } + + _changedPointersData = [[NSArray alloc] initWithObjects:data count:changedCount]; // extract all touches last to include the ones that were just added [self extractAllTouches]; [self sendEvent]; @@ -141,14 +160,30 @@ - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event _eventType = RNGHTouchEventTypePointerMove; NSDictionary *data[touches.count]; + int changedCount = 0; for (int i = 0; i < [touches count]; i++) { RNGHUITouch *touch = [[touches allObjects] objectAtIndex:i]; int index = [self findTouchIndex:touch]; - data[i] = [self extractPointerData:index forTouch:touch]; + + if (index < 0) { + continue; + } + +#if TARGET_OS_OSX + // Replace the stored mouse-down event with the latest one so extractAllTouches + // reads the pointer's current position, not where the sequence started. + _trackedPointers[index] = touch; +#endif + + data[changedCount++] = [self extractPointerData:index forTouch:touch]; } - _changedPointersData = [[NSArray alloc] initWithObjects:data count:[touches count]]; + if (changedCount == 0) { + return; + } + + _changedPointersData = [[NSArray alloc] initWithObjects:data count:changedCount]; [self extractAllTouches]; [self sendEvent]; } @@ -159,24 +194,43 @@ - (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event return; } +#if TARGET_OS_OSX + // Refresh the tracked slot with the ending event so extractAllTouches below reports + // the pointer's final position. On iOS the stored UITouch is a live object that + // always reads its current location; a stored NSEvent is a stale snapshot. + for (RNGHUITouch *touch in touches) { + int index = [self findTouchIndex:touch]; + if (index >= 0) { + _trackedPointers[index] = touch; + } + } +#endif + // extract all touches first to include the ones that were just lifted [self extractAllTouches]; _eventType = RNGHTouchEventTypePointerUp; NSDictionary *data[touches.count]; + int changedCount = 0; for (int i = 0; i < [touches count]; i++) { RNGHUITouch *touch = [[touches allObjects] objectAtIndex:i]; int index = [self unregisterTouch:touch]; - if (index >= 0) { - _trackedPointersCount--; + + if (index < 0) { + continue; } - data[i] = [self extractPointerData:index forTouch:touch]; + _trackedPointersCount--; + data[changedCount++] = [self extractPointerData:index forTouch:touch]; + } + + if (changedCount == 0) { + return; } - _changedPointersData = [[NSArray alloc] initWithObjects:data count:[touches count]]; + _changedPointersData = [[NSArray alloc] initWithObjects:data count:changedCount]; [self sendEvent]; } diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerPointerType.h b/packages/react-native-gesture-handler/apple/RNGestureHandlerPointerType.h index 90179d2613..e41df538fb 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerPointerType.h +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerPointerType.h @@ -4,5 +4,6 @@ typedef NS_ENUM(NSInteger, RNGestureHandlerPointerType) { RNGestureHandlerTouch = 0, RNGestureHandlerStylus, RNGestureHandlerMouse, + RNGestureHandlerKey, RNGestureHandlerOtherPointer, }; diff --git a/packages/react-native-gesture-handler/apple/RNManualActivationRecognizer.m b/packages/react-native-gesture-handler/apple/RNManualActivationRecognizer.m index 2ecaef6e2e..d99b1eed3c 100644 --- a/packages/react-native-gesture-handler/apple/RNManualActivationRecognizer.m +++ b/packages/react-native-gesture-handler/apple/RNManualActivationRecognizer.m @@ -22,24 +22,31 @@ - (id)initWithGestureHandler:(RNGestureHandler *)gestureHandler - (void)handleGesture:(UIGestureRecognizer *)recognizer { if (recognizer.state == UIGestureRecognizerStateBegan) { +#if TARGET_OS_OSX + // On iOS, this recognizer completing is enough to deny the handler's recognizer — + // UIKit fails a recognizer whose required-to-fail dependency recognizes. AppKit + // does not do this reliably: completing the blocker can flush the dependent + // recognizer's buffered recognition instead of discarding it. + _handler.recognizer.state = UIGestureRecognizerStateFailed; +#endif self.state = UIGestureRecognizerStateEnded; [self reset]; } } #if TARGET_OS_OSX -- (void)mouseUp:(NSEvent *)event +- (void)mouseDown:(NSEvent *)event { - [super mouseUp:event]; + [super mouseDown:event]; - _activePointers -= 1; + _activePointers += 1; } -- (void)mouseDown:(NSEvent *)event +- (void)mouseUp:(NSEvent *)event { - [super mouseDown:event]; + [super mouseUp:event]; - _activePointers += 1; + _activePointers -= 1; if (_activePointers == 0) { self.state = UIGestureRecognizerStateBegan; @@ -80,6 +87,7 @@ - (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)ev - (void)reset { self.enabled = YES; + _activePointers = 0; [super reset]; } @@ -106,4 +114,15 @@ - (BOOL)shouldBeRequiredToFailByGestureRecognizer:(UIGestureRecognizer *)otherGe return NO; } +#if TARGET_OS_OSX +// On iOS the method above is a UIGestureRecognizer subclass override, called by the system +// to establish the failure requirement. NSGestureRecognizer has no such subclass hook — +// AppKit only consults the delegate, so forward the delegate callback to the shared logic. +- (BOOL)gestureRecognizer:(NSGestureRecognizer *)gestureRecognizer + shouldBeRequiredToFailByGestureRecognizer:(NSGestureRecognizer *)otherGestureRecognizer +{ + return [self shouldBeRequiredToFailByGestureRecognizer:otherGestureRecognizer]; +} +#endif + @end diff --git a/packages/react-native-gesture-handler/src/__tests__/legacyWrapRef.test.tsx b/packages/react-native-gesture-handler/src/__tests__/legacyWrapRef.test.tsx new file mode 100644 index 0000000000..9bbd74484d --- /dev/null +++ b/packages/react-native-gesture-handler/src/__tests__/legacyWrapRef.test.tsx @@ -0,0 +1,156 @@ +import { cleanup, render } from '@testing-library/react-native'; +import React from 'react'; +import { findNodeHandle, View } from 'react-native'; + +import { Gesture, GestureDetector, GestureHandlerRootView } from '../index'; +import RNGestureHandlerModule from '../RNGestureHandlerModule'; + +jest.mock('react-native/Libraries/ReactNative/RendererProxy', () => ({ + findNodeHandle: jest.fn(), +})); + +const VIEW_TAG = 123; + +function ChildIgnoringRef(props: { children?: React.ReactNode }) { + return {props.children}; +} + +class ChildWithHostInstance extends React.Component<{ + children?: React.ReactNode; +}> { + // eslint-disable-next-line @eslint-react/no-unused-class-component-members + public __internalInstanceHandle = {}; + + override render() { + return {this.props.children}; + } +} + +describe('Legacy GestureDetector ref forwarding', () => { + let attachSpy: jest.SpyInstance; + + beforeEach(() => { + cleanup(); + jest.clearAllMocks(); + (findNodeHandle as jest.Mock).mockReturnValue(VIEW_TAG); + attachSpy = jest.spyOn(RNGestureHandlerModule, 'attachGestureHandler'); + }); + + afterEach(() => { + attachSpy.mockRestore(); + }); + + test('resolves the tag from the child host instance when its ref resolves', () => { + render( + + + + + + ); + + const resolvedRefs = (findNodeHandle as jest.Mock).mock.calls.map( + (call) => call[0] + ); + + expect(resolvedRefs.length).toBeGreaterThan(0); + expect( + resolvedRefs.every( + (ref) => ref instanceof ChildWithHostInstance && ref !== null + ) + ).toBe(true); + expect(attachSpy).toHaveBeenCalledWith( + expect.any(Number), + VIEW_TAG, + expect.any(Number) + ); + }); + + test('attaches gestures when the child ignores its ref', () => { + render( + + + + + + ); + + const resolvedRefs = (findNodeHandle as jest.Mock).mock.calls.map( + (call) => call[0] + ); + + expect(resolvedRefs.length).toBeGreaterThan(0); + expect( + resolvedRefs.every((ref) => ref instanceof ChildWithHostInstance) + ).toBe(false); + expect(attachSpy).toHaveBeenCalledWith( + expect.any(Number), + VIEW_TAG, + expect.any(Number) + ); + }); + + test('does not clobber a ref the child already has', () => { + const childRef = jest.fn(); + + render( + + + + + + ); + + expect(childRef).toHaveBeenCalled(); + expect(childRef.mock.calls[0][0]).not.toBeNull(); + }); + + test('moves the child instance when its ref is replaced', () => { + const gesture = Gesture.Tap(); + const firstRef = jest.fn(); + const secondRef = jest.fn(); + + function App({ useSecondRef }: { useSecondRef: boolean }) { + return ( + + + + + + ); + } + + const { rerender } = render(); + + const childInstance = firstRef.mock.calls[0][0]; + expect(childInstance).not.toBeNull(); + firstRef.mockClear(); + + rerender(); + + expect(firstRef).toHaveBeenCalledWith(null); + expect(secondRef).toHaveBeenCalledWith(childInstance); + }); + + test('does not reattach gestures on re-render', () => { + const gesture = Gesture.Tap(); + + function App() { + return ( + + + + + + ); + } + + const { rerender } = render(); + expect(attachSpy).toHaveBeenCalledTimes(1); + + rerender(); + rerender(); + + expect(attachSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react-native-gesture-handler/src/components/GestureButtonsProps.ts b/packages/react-native-gesture-handler/src/components/GestureButtonsProps.ts index a49104aac1..5bc4e4b635 100644 --- a/packages/react-native-gesture-handler/src/components/GestureButtonsProps.ts +++ b/packages/react-native-gesture-handler/src/components/GestureButtonsProps.ts @@ -89,6 +89,20 @@ export interface RawButtonProps */ // eslint-disable-next-line @typescript-eslint/ban-types testOnly_onLongPress?: Function | null | undefined; + + /** + * Used for testing-library compatibility, not passed to the native component. + * @deprecated test-only props are deprecated and will be removed in the future. + */ + // eslint-disable-next-line @typescript-eslint/ban-types + testOnly_onHoverIn?: Function | null | undefined; + + /** + * Used for testing-library compatibility, not passed to the native component. + * @deprecated test-only props are deprecated and will be removed in the future. + */ + // eslint-disable-next-line @typescript-eslint/ban-types + testOnly_onHoverOut?: Function | null | undefined; } interface ButtonWithRefProps { innerRef?: React.ForwardedRef> | undefined; diff --git a/packages/react-native-gesture-handler/src/components/GestureComponents.tsx b/packages/react-native-gesture-handler/src/components/GestureComponents.tsx index dae8fb3690..2f1552a36d 100644 --- a/packages/react-native-gesture-handler/src/components/GestureComponents.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureComponents.tsx @@ -5,6 +5,8 @@ import { RefAttributes, ReactElement, } from 'react'; +// Type-only: the value export is a deprecation-warning getter on RN 0.87+ (see below). +import type { DrawerLayoutAndroid as RNDrawerLayoutAndroid } from 'react-native'; import { ScrollView as RNScrollView, ScrollViewProps as RNScrollViewProps, @@ -12,7 +14,6 @@ import { SwitchProps as RNSwitchProps, TextInput as RNTextInput, TextInputProps as RNTextInputProps, - DrawerLayoutAndroid as RNDrawerLayoutAndroid, DrawerLayoutAndroidProps as RNDrawerLayoutAndroidProps, FlatList as RNFlatList, FlatListProps as RNFlatListProps, @@ -85,15 +86,41 @@ export const TextInput = createNativeWrapper(RNTextInput); // eslint-disable-next-line @typescript-eslint/no-redeclare export type TextInput = typeof TextInput & RNTextInput; +// RN's `DrawerLayoutAndroid` export is a getter that logs a deprecation +// warning on access, so resolve it on first render instead of module load. +// `require` is used on purpose: `import * as RN` would read every export +// eagerly under Metro's `experimentalImportSupport`. +let DrawerLayoutAndroidImpl: typeof RNDrawerLayoutAndroid | undefined; + +const LazyDrawerLayoutAndroid = ( + props: PropsWithChildren & { + ref?: React.Ref | null>; + } +) => { + if (!DrawerLayoutAndroidImpl) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { DrawerLayoutAndroid } = require('react-native') as { + DrawerLayoutAndroid: typeof RNDrawerLayoutAndroid; + }; + DrawerLayoutAndroidImpl = DrawerLayoutAndroid; + } + return ; +}; +LazyDrawerLayoutAndroid.displayName = 'DrawerLayoutAndroid'; + export const DrawerLayoutAndroid: React.ComponentType< - PropsWithChildren & NativeViewGestureHandlerProps + PropsWithChildren & + NativeViewGestureHandlerProps & { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ref?: React.Ref | null>; + } > = createNativeWrapper>( - RNDrawerLayoutAndroid, + LazyDrawerLayoutAndroid, { disallowInterruption: true } ); // eslint-disable-next-line @typescript-eslint/no-redeclare export type DrawerLayoutAndroid = typeof DrawerLayoutAndroid & - RNDrawerLayoutAndroid; + React.ComponentRef; export const FlatList = React.forwardRef((props, ref) => { const refreshControlGestureRef = React.useRef(null); diff --git a/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx b/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx index 10bd906227..5fc66f5bdb 100644 --- a/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx +++ b/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx @@ -79,7 +79,7 @@ const Pressable = (props: PressableProps) => { blocksExternalGesture, }; - const [pressedState, setPressedState] = useState(testOnly_pressed ?? false); + const [pressedState, setPressedState] = useState(false); const longPressTimeoutRef = useRef(null); const pressDelayTimeoutRef = useRef(null); @@ -218,6 +218,24 @@ const Pressable = (props: PressableProps) => { const hoverInTimeout = useRef(null); const hoverOutTimeout = useRef(null); + useEffect( + () => () => { + if (longPressTimeoutRef.current) { + clearTimeout(longPressTimeoutRef.current); + } + if (pressDelayTimeoutRef.current) { + clearTimeout(pressDelayTimeoutRef.current); + } + if (hoverInTimeout.current) { + clearTimeout(hoverInTimeout.current); + } + if (hoverOutTimeout.current) { + clearTimeout(hoverOutTimeout.current); + } + }, + [] + ); + const hoverGesture = useMemo( () => Gesture.Hover() @@ -354,12 +372,17 @@ const Pressable = (props: PressableProps) => { const pointerStyle: StyleProp = Platform.OS === 'web' ? { cursor: 'pointer' } : {}; + // `testOnly_pressed` forces the pressed state for snapshots/tests. Derive the + // displayed value from it each render, keeping the interactive `pressedState` + // independent (seeded to false) so clearing the prop doesn't leave it stuck. + const displayPressed = testOnly_pressed ?? pressedState; + const styleProp = - typeof style === 'function' ? style({ pressed: pressedState }) : style; + typeof style === 'function' ? style({ pressed: displayPressed }) : style; const childrenProp = typeof children === 'function' - ? children({ pressed: pressedState }) + ? children({ pressed: displayPressed }) : children; const rippleColor = useMemo(() => { @@ -394,11 +417,15 @@ const Pressable = (props: PressableProps) => { touchSoundDisabled={android_disableSound ?? undefined} rippleColor={rippleColor} rippleRadius={android_ripple?.radius ?? undefined} + borderless={android_ripple?.borderless ?? undefined} + foreground={android_ripple?.foreground ?? undefined} style={[pointerStyle, styleProp]} testOnly_onPress={IS_TEST_ENV ? onPress : undefined} testOnly_onPressIn={IS_TEST_ENV ? onPressIn : undefined} testOnly_onPressOut={IS_TEST_ENV ? onPressOut : undefined} - testOnly_onLongPress={IS_TEST_ENV ? onLongPress : undefined}> + testOnly_onLongPress={IS_TEST_ENV ? onLongPress : undefined} + testOnly_onHoverIn={IS_TEST_ENV ? onHoverIn : undefined} + testOnly_onHoverOut={IS_TEST_ENV ? onHoverOut : undefined}> {childrenProp} {__DEV__ ? ( diff --git a/packages/react-native-gesture-handler/src/components/Pressable/index.ts b/packages/react-native-gesture-handler/src/components/Pressable/index.ts index 79ce14a8f0..eb017f3feb 100644 --- a/packages/react-native-gesture-handler/src/components/Pressable/index.ts +++ b/packages/react-native-gesture-handler/src/components/Pressable/index.ts @@ -1,4 +1,5 @@ export type { + PressableEvent, PressableProps, PressableStateCallbackType, } from './PressableProps'; diff --git a/packages/react-native-gesture-handler/src/components/ReanimatedDrawerLayout.tsx b/packages/react-native-gesture-handler/src/components/ReanimatedDrawerLayout.tsx index 58710553b0..2ff995cabe 100644 --- a/packages/react-native-gesture-handler/src/components/ReanimatedDrawerLayout.tsx +++ b/packages/react-native-gesture-handler/src/components/ReanimatedDrawerLayout.tsx @@ -322,7 +322,7 @@ const DrawerLayout = forwardRef( useDerivedValue(() => { onDrawerSlide && runOnJS(onDrawerSlide)(openValue.value); - }, []); + }); const isDrawerOpen = useSharedValue(false); @@ -422,6 +422,7 @@ const DrawerLayout = forwardRef( ); }, [ + animationSpeedProp, openValue, emitStateChanged, isDrawerOpen, diff --git a/packages/react-native-gesture-handler/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx b/packages/react-native-gesture-handler/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx index b6134c9086..928862cb10 100644 --- a/packages/react-native-gesture-handler/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx +++ b/packages/react-native-gesture-handler/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx @@ -4,8 +4,15 @@ import { useImperativeHandle, ForwardedRef, useState, + useRef, } from 'react'; -import { LayoutChangeEvent, View, I18nManager, StyleSheet } from 'react-native'; +import { + LayoutChangeEvent, + View, + I18nManager, + Platform, + StyleSheet, +} from 'react-native'; import Animated, { useSharedValue, interpolate, @@ -42,6 +49,21 @@ const DEFAULT_OVERSHOOT_FRICTION = 1; const DEFAULT_DRAG_OFFSET = 10; const DEFAULT_ENABLE_TRACKING_TWO_FINGER_GESTURE = false; +function useEventCallback( + callback: ((...args: Args) => void) | undefined +): ((...args: Args) => void) | undefined { + const callbackRef = useRef(callback); + callbackRef.current = callback; + + const stableCallback = useCallback((...args: Args) => { + callbackRef.current?.(...args); + }, []); + + // Keep a stable wrapper only while a user callback exists, so the existing + // truthiness checks can still skip `runOnJS` when the prop is absent. + return callback ? stableCallback : undefined; +} + const Swipeable = (props: SwipeableProps) => { const { ref, @@ -60,12 +82,12 @@ const Swipeable = (props: SwipeableProps) => { dragOffsetFromRightEdge = DEFAULT_DRAG_OFFSET, friction = DEFAULT_FRICTION, overshootFriction = DEFAULT_OVERSHOOT_FRICTION, - onSwipeableOpenStartDrag, - onSwipeableCloseStartDrag, - onSwipeableWillOpen, - onSwipeableWillClose, - onSwipeableOpen, - onSwipeableClose, + onSwipeableOpenStartDrag: onSwipeableOpenStartDragProp, + onSwipeableCloseStartDrag: onSwipeableCloseStartDragProp, + onSwipeableWillOpen: onSwipeableWillOpenProp, + onSwipeableWillClose: onSwipeableWillCloseProp, + onSwipeableOpen: onSwipeableOpenProp, + onSwipeableClose: onSwipeableCloseProp, renderLeftActions, renderRightActions, simultaneousWithExternalGesture, @@ -88,6 +110,17 @@ const Swipeable = (props: SwipeableProps) => { ] ); + const onSwipeableOpenStartDrag = useEventCallback( + onSwipeableOpenStartDragProp + ); + const onSwipeableCloseStartDrag = useEventCallback( + onSwipeableCloseStartDragProp + ); + const onSwipeableWillOpen = useEventCallback(onSwipeableWillOpenProp); + const onSwipeableWillClose = useEventCallback(onSwipeableWillCloseProp); + const onSwipeableOpen = useEventCallback(onSwipeableOpenProp); + const onSwipeableClose = useEventCallback(onSwipeableCloseProp); + const [shouldEnableTap, setShouldEnableTap] = useState(false); const rowState = useSharedValue(0); @@ -371,7 +404,10 @@ const Swipeable = (props: SwipeableProps) => { const leftActionAnimation = useAnimatedStyle(() => { return { - opacity: showLeftProgress.value === 0 ? 0 : 1, + // Both action containers use `absoluteFill` and overlap, so the + // inactive one must not intercept touches meant for the visible + // actions. + pointerEvents: showLeftProgress.value === 0 ? 'none' : 'auto', }; }); @@ -401,7 +437,10 @@ const Swipeable = (props: SwipeableProps) => { const rightActionAnimation = useAnimatedStyle(() => { return { - opacity: showRightProgress.value === 0 ? 0 : 1, + // Both action containers use `absoluteFill` and overlap, so the + // inactive one must not intercept touches meant for the visible + // actions. + pointerEvents: showRightProgress.value === 0 ? 'none' : 'auto', }; }); @@ -570,7 +609,7 @@ const Swipeable = (props: SwipeableProps) => { transform: [{ translateX: appliedTranslation.value }], pointerEvents: rowState.value === 0 ? 'auto' : 'box-only', }), - [appliedTranslation, rowState] + Platform.OS === 'web' ? [appliedTranslation, rowState] : undefined ); const swipeableComponent = ( diff --git a/packages/react-native-gesture-handler/src/getShadowNodeFromRef.ts b/packages/react-native-gesture-handler/src/getShadowNodeFromRef.ts index f46d501a9b..1c13833094 100644 --- a/packages/react-native-gesture-handler/src/getShadowNodeFromRef.ts +++ b/packages/react-native-gesture-handler/src/getShadowNodeFromRef.ts @@ -1,3 +1,5 @@ +import { isHostInstance } from './hostInstance'; + // Used by GestureDetector (unsupported on web at the moment) to check whether the // attached view may get flattened on Fabric. This implementation causes errors // on web due to the static resolution of `require` statements by webpack breaking @@ -8,8 +10,10 @@ let getInternalInstanceHandleFromPublicInstance: (ref: unknown) => { }; export function getShadowNodeFromRef(ref: unknown) { + const isAlreadyHostInstance = isHostInstance(ref); + // Load findHostInstance_DEPRECATED lazily because it may not be available before render - if (findHostInstance_DEPRECATED === undefined) { + if (!isAlreadyHostInstance && findHostInstance_DEPRECATED === undefined) { try { // eslint-disable-next-line @typescript-eslint/no-var-requires const ReactFabric = require('react-native/Libraries/Renderer/shims/ReactFabric'); @@ -43,8 +47,11 @@ export function getShadowNodeFromRef(ref: unknown) { } } + const hostInstance = isAlreadyHostInstance + ? ref + : findHostInstance_DEPRECATED(ref); + // @ts-ignore Fabric - return getInternalInstanceHandleFromPublicInstance( - findHostInstance_DEPRECATED(ref) - ).stateNode.node; + return getInternalInstanceHandleFromPublicInstance(hostInstance).stateNode + .node; } diff --git a/packages/react-native-gesture-handler/src/handlers/PanGestureHandler.ts b/packages/react-native-gesture-handler/src/handlers/PanGestureHandler.ts index 53ad17e589..b3c78dd2b4 100644 --- a/packages/react-native-gesture-handler/src/handlers/PanGestureHandler.ts +++ b/packages/react-native-gesture-handler/src/handlers/PanGestureHandler.ts @@ -65,9 +65,24 @@ interface CommonPanProperties { */ maxPointers?: number; + /** + * Minimum speed the pointer has to reach in order to activate the handler. + * Expressed in points per second. + */ minVelocity?: number; + + /** + * Minimum speed along X axis the pointer has to reach in order to activate + * the handler. Expressed in points per second. + */ minVelocityX?: number; + + /** + * Minimum speed along Y axis the pointer has to reach in order to activate + * the handler. Expressed in points per second. + */ minVelocityY?: number; + activateAfterLongPress?: number; } diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/Wrap.tsx b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/Wrap.tsx index 06c79267d0..8abb581e25 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/Wrap.tsx +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/Wrap.tsx @@ -1,4 +1,10 @@ import React from 'react'; +import type { WrapRef } from '../../../hostInstance'; +import { + assignRef, + isHostInstance, + preferHostInstance, +} from '../../../hostInstance'; import { Reanimated } from '../reanimatedWrapper'; import { tagMessage } from '../../../utils'; @@ -7,20 +13,69 @@ export class Wrap extends React.Component<{ // Implicit `children` prop has been removed in @types/react^18.0.0 children?: React.ReactNode; }> { - render() { + private childInstance: unknown = null; + private hostInstance: unknown = null; + private childRef: WrapRef = undefined; + private attachedChildRef: WrapRef = undefined; + private childRefCleanup: (() => void) | undefined = undefined; + + // eslint-disable-next-line @eslint-react/no-unused-class-component-members + public getHostInstance() { + return this.hostInstance; + } + + private detachChildRef() { + if (this.childRefCleanup !== undefined) { + this.childRefCleanup(); + } else if (this.attachedChildRef) { + assignRef(this.attachedChildRef, null); + } + + this.childRefCleanup = undefined; + this.attachedChildRef = undefined; + } + + private attachChildRef(instance: unknown) { + this.attachedChildRef = this.childRef; + + this.childRefCleanup = assignRef(this.attachedChildRef, instance); + } + + private handleChildRef = (instance: unknown) => { + this.childInstance = instance; + + const resolved = preferHostInstance(instance); + this.hostInstance = isHostInstance(resolved) ? resolved : null; + + this.detachChildRef(); + + if (instance !== null && instance !== undefined) { + this.attachChildRef(instance); + } + }; + + override componentDidUpdate() { + if ( + this.childRef === this.attachedChildRef || + this.childInstance === null + ) { + return; + } + + this.detachChildRef(); + this.attachChildRef(this.childInstance); + } + + override render() { + // I don't think that fighting with types over such a simple function is worth it + // The only thing it does is add 'collapsable: false' to the child component + // to make sure it is in the native view hierarchy so the detector can find + // correct viewTag to attach to. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let child: any; + try { - // I don't think that fighting with types over such a simple function is worth it - // The only thing it does is add 'collapsable: false' to the child component - // to make sure it is in the native view hierarchy so the detector can find - // correct viewTag to attach to. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const child: any = React.Children.only(this.props.children); - return React.cloneElement( - child, - { collapsable: false }, - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - child.props.children - ); + child = React.Children.only(this.props.children); } catch (e) { throw new Error( tagMessage( @@ -28,6 +83,16 @@ export class Wrap extends React.Component<{ ) ); } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + this.childRef = child.props.ref as WrapRef; + + return React.cloneElement( + child, + { collapsable: false, ref: this.handleChildRef }, + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + child.props.children + ); } } diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/index.tsx b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/index.tsx index 2df46feffb..0ad930fe04 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/index.tsx +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/index.tsx @@ -6,6 +6,7 @@ import { GestureType } from '../gesture'; import { UserSelect, TouchAction } from '../../gestureHandlerCommon'; import { ComposedGesture } from '../gestureComposition'; import { isTestEnv } from '../../../utils'; +import { resolveHostInstance } from '../../../hostInstance'; import GestureHandlerRootViewContext from '../../../GestureHandlerRootViewContext'; import { AttachedGestureState, GestureDetectorState } from './types'; @@ -149,7 +150,9 @@ export const GestureDetector = (props: GestureDetectorProps) => { useAnimatedGesture(preparedGesture, needsToRebuildReanimatedEvent); useIsomorphicLayoutEffect(() => { - const viewTag = findNodeHandle(state.viewRef) as number; + const viewTag = findNodeHandle( + resolveHostInstance(state.viewRef) + ) as number; preparedGesture.isMounted = true; attachHandlers({ diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/useDetectorUpdater.ts b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/useDetectorUpdater.ts index e3113a27b0..f77c22a8cb 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/useDetectorUpdater.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/useDetectorUpdater.ts @@ -2,6 +2,7 @@ import React, { useCallback } from 'react'; import { GestureType } from '../gesture'; import { ComposedGesture } from '../gestureComposition'; +import { resolveHostInstance } from '../../../hostInstance'; import { AttachedGestureState, GestureDetectorState, @@ -29,7 +30,9 @@ export function useDetectorUpdater( // skipConfigUpdate is used to prevent unnecessary updates when only checking if the view has changed (skipConfigUpdate?: boolean) => { // If the underlying view has changed we need to reattach handlers to the new view - const viewTag = findNodeHandle(state.viewRef) as number; + const viewTag = findNodeHandle( + resolveHostInstance(state.viewRef) + ) as number; const didUnderlyingViewChange = viewTag !== state.previousViewTag; if ( diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/useMountReactions.ts b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/useMountReactions.ts index 61190d29a9..ff4628f24f 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/useMountReactions.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/useMountReactions.ts @@ -27,6 +27,12 @@ export function useMountReactions( ) { useEffect(() => { return MountRegistry.addMountListener((gesture) => { + // The detector may already be unmounted when this fires; bail out to avoid + // updating a detached detector. + if (!state.isMounted) { + return; + } + // At this point the ref in the gesture config should be updated, so we can check if one of the gestures // set in a relation with the gesture got mounted. If so, we need to update the detector to propagate // the changes to the native side. diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/useViewRefHandler.ts b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/useViewRefHandler.ts index 10679000c6..2d7c3defa8 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/useViewRefHandler.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/useViewRefHandler.ts @@ -4,6 +4,7 @@ import { getShadowNodeFromRef } from '../../../getShadowNodeFromRef'; import { GestureDetectorState } from './types'; import React, { useCallback } from 'react'; import findNodeHandle from '../../../findNodeHandle'; +import { resolveHostInstance } from '../../../hostInstance'; declare const global: { isViewFlatteningDisabled: (node: unknown) => boolean | null; // JSI function @@ -26,7 +27,9 @@ export function useViewRefHandler( // if it's the first render, also set the previousViewTag to prevent reattaching gestures when not needed if (state.previousViewTag === -1) { - state.previousViewTag = findNodeHandle(state.viewRef) as number; + state.previousViewTag = findNodeHandle( + resolveHostInstance(state.viewRef) + ) as number; } // Pass true as `skipConfigUpdate`. Here we only want to trigger the eventual reattaching of handlers @@ -36,7 +39,7 @@ export function useViewRefHandler( } if (__DEV__ && isFabric() && global.isViewFlatteningDisabled) { - const node = getShadowNodeFromRef(ref); + const node = getShadowNodeFromRef(resolveHostInstance(ref)); if (global.isViewFlatteningDisabled(node) === false) { console.error( tagMessage( diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/panGesture.ts b/packages/react-native-gesture-handler/src/handlers/gestures/panGesture.ts index 366a2725a0..7b517d8b96 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/panGesture.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/panGesture.ts @@ -147,7 +147,8 @@ export class PanGesture extends ContinousBaseGesture< } /** - * Minimum velocity the finger has to reach in order to activate handler. + * Minimum speed the pointer has to reach in order to activate handler. + * Expressed in points per second. * @param velocity */ minVelocity(velocity: number) { @@ -156,7 +157,8 @@ export class PanGesture extends ContinousBaseGesture< } /** - * Minimum velocity along X axis the finger has to reach in order to activate handler. + * Minimum speed along X axis the pointer has to reach in order to activate handler. + * Expressed in points per second. * @param velocity */ minVelocityX(velocity: number) { @@ -165,7 +167,8 @@ export class PanGesture extends ContinousBaseGesture< } /** - * Minimum velocity along Y axis the finger has to reach in order to activate handler. + * Minimum speed along Y axis the pointer has to reach in order to activate handler. + * Expressed in points per second. * @param velocity */ minVelocityY(velocity: number) { diff --git a/packages/react-native-gesture-handler/src/hostInstance.ts b/packages/react-native-gesture-handler/src/hostInstance.ts new file mode 100644 index 0000000000..dfcdb58471 --- /dev/null +++ b/packages/react-native-gesture-handler/src/hostInstance.ts @@ -0,0 +1,57 @@ +import type { Ref } from 'react'; + +export type WrapRef = Ref | undefined; + +export function assignRef( + ref: WrapRef, + instance: unknown +): (() => void) | undefined { + if (typeof ref === 'function') { + const cleanup = ref(instance as never); + return typeof cleanup === 'function' ? cleanup : undefined; + } + + if (ref) { + ref.current = instance; + } + + return undefined; +} + +export function isHostInstance(instance: unknown) { + return ( + (instance as { __internalInstanceHandle?: unknown } | null | undefined) + ?.__internalInstanceHandle !== undefined + ); +} + +export function preferHostInstance(instance: unknown) { + if (instance === null || instance === undefined || isHostInstance(instance)) { + return instance; + } + + const nativeRef = ( + instance as { getNativeScrollRef?: () => unknown } + ).getNativeScrollRef?.(); + + return isHostInstance(nativeRef) ? nativeRef : instance; +} + +export interface HostInstanceProvider { + getHostInstance: () => unknown; +} + +function providesHostInstance(ref: unknown): ref is HostInstanceProvider { + return ( + typeof (ref as HostInstanceProvider | null | undefined)?.getHostInstance === + 'function' + ); +} + +export function resolveHostInstance(ref: T): T { + if (!providesHostInstance(ref)) { + return ref; + } + + return (ref.getHostInstance() ?? ref) as T; +} diff --git a/packages/react-native-gesture-handler/src/index.ts b/packages/react-native-gesture-handler/src/index.ts index dcf7998e59..e04f577a45 100644 --- a/packages/react-native-gesture-handler/src/index.ts +++ b/packages/react-native-gesture-handler/src/index.ts @@ -146,6 +146,7 @@ export type { export type { SwipeableProps } from './components/Swipeable'; export { default as Swipeable } from './components/Swipeable'; export type { + PressableEvent, PressableProps, PressableStateCallbackType, } from './components/Pressable'; diff --git a/packages/react-native-gesture-handler/src/web/detectors/RotationGestureDetector.ts b/packages/react-native-gesture-handler/src/web/detectors/RotationGestureDetector.ts index c294d6f0c8..711f27f341 100644 --- a/packages/react-native-gesture-handler/src/web/detectors/RotationGestureDetector.ts +++ b/packages/react-native-gesture-handler/src/web/detectors/RotationGestureDetector.ts @@ -163,6 +163,6 @@ export default class RotationGestureDetector } public get timeDelta() { - return this.currentTime + this.previousTime; + return this.currentTime - this.previousTime; } } diff --git a/packages/react-native-gesture-handler/src/web/handlers/LongPressGestureHandler.ts b/packages/react-native-gesture-handler/src/web/handlers/LongPressGestureHandler.ts index 3bf4da8b5d..5a164e78aa 100644 --- a/packages/react-native-gesture-handler/src/web/handlers/LongPressGestureHandler.ts +++ b/packages/react-native-gesture-handler/src/web/handlers/LongPressGestureHandler.ts @@ -5,6 +5,7 @@ import GestureHandler from './GestureHandler'; const DEFAULT_MIN_DURATION_MS = 500; const DEFAULT_MAX_DIST_DP = 10; +const DEFAULT_NUMBER_OF_POINTERS = 1; const SCALING_FACTOR = 10; export default class LongPressGestureHandler extends GestureHandler { @@ -12,7 +13,7 @@ export default class LongPressGestureHandler extends GestureHandler { private defaultMaxDistSq = DEFAULT_MAX_DIST_DP * SCALING_FACTOR; private maxDistSq = this.defaultMaxDistSq; - private numberOfPointers = 1; + private numberOfPointers = DEFAULT_NUMBER_OF_POINTERS; private startX = 0; private startY = 0; @@ -56,6 +57,7 @@ export default class LongPressGestureHandler extends GestureHandler { super.resetConfig(); this.minDurationMs = DEFAULT_MIN_DURATION_MS; this.maxDistSq = this.defaultMaxDistSq; + this.numberOfPointers = DEFAULT_NUMBER_OF_POINTERS; } protected onStateChange(_newState: State, _oldState: State): void { diff --git a/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts b/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts index 8c6732123f..b515fc820a 100644 --- a/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts +++ b/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts @@ -20,8 +20,6 @@ export default class NativeViewGestureHandler extends GestureHandler { public init(ref: number, propsRef: React.RefObject): void { super.init(ref, propsRef); - this.shouldCancelWhenOutside = true; - if (Platform.OS !== 'web') { return; } @@ -33,6 +31,9 @@ export default class NativeViewGestureHandler extends GestureHandler { } public updateGestureConfig({ enabled = true, ...props }: Config): void { + // Config updates are full replaces, so restore the defaults first - the + // module never calls `resetConfig` on its own. + this.resetConfig(); super.updateGestureConfig({ enabled: enabled, ...props }); if (this.config.shouldActivateOnStart !== undefined) { @@ -46,6 +47,14 @@ export default class NativeViewGestureHandler extends GestureHandler { this.restoreViewStyles(view); } + protected override resetConfig(): void { + super.resetConfig(); + + this.shouldCancelWhenOutside = true; + this.shouldActivateOnStart = false; + this.disallowInterruption = false; + } + private restoreViewStyles(view: HTMLElement) { if (!view) { return; diff --git a/packages/react-native-gesture-handler/src/web/handlers/PanGestureHandler.ts b/packages/react-native-gesture-handler/src/web/handlers/PanGestureHandler.ts index e812bf6a7b..da38edac04 100644 --- a/packages/react-native-gesture-handler/src/web/handlers/PanGestureHandler.ts +++ b/packages/react-native-gesture-handler/src/web/handlers/PanGestureHandler.ts @@ -82,8 +82,7 @@ export default class PanGestureHandler extends GestureHandler { } if (this.config.minVelocity !== undefined) { - this.minVelocityX = this.config.minVelocity; - this.minVelocityY = this.config.minVelocity; + this.minVelocitySq = this.config.minVelocity * this.config.minVelocity; } if (this.config.minVelocityX !== undefined) { @@ -191,6 +190,7 @@ export default class PanGestureHandler extends GestureHandler { this.maxPointers = DEFAULT_MAX_POINTERS; this.activateAfterLongPress = 0; + this.enableTrackpadTwoFingerGesture = false; } protected transformNativeEvent() { @@ -354,14 +354,13 @@ export default class PanGestureHandler extends GestureHandler { } } - private scheduleWheelEnd(event: AdaptedEvent) { + private scheduleWheelEnd() { clearTimeout(this.endWheelTimeout); this.endWheelTimeout = setTimeout(() => { if (this.state === State.ACTIVE) { this.end(); - this.tracker.removeFromTracker(event.pointerId); - this.state = State.UNDETERMINED; + this.reset(); } this.wheelDevice = WheelDevice.UNDETERMINED; @@ -383,7 +382,7 @@ export default class PanGestureHandler extends GestureHandler { : WheelDevice.MOUSE; if (this.wheelDevice === WheelDevice.MOUSE) { - this.scheduleWheelEnd(event); + this.scheduleWheelEnd(); return; } @@ -403,7 +402,7 @@ export default class PanGestureHandler extends GestureHandler { this.updateVelocity(event.pointerId); this.tryToSendMoveEvent(false, event); - this.scheduleWheelEnd(event); + this.scheduleWheelEnd(); } private shouldActivate(): boolean { @@ -452,8 +451,7 @@ export default class PanGestureHandler extends GestureHandler { if ( this.minVelocityX !== Number.MAX_SAFE_INTEGER && - ((this.minVelocityX < 0 && vx <= this.minVelocityX) || - (this.minVelocityX >= 0 && this.minVelocityX <= vx)) + Math.abs(vx) >= Math.abs(this.minVelocityX) ) { return true; } @@ -461,8 +459,7 @@ export default class PanGestureHandler extends GestureHandler { const vy: number = this.velocityY; if ( this.minVelocityY !== Number.MAX_SAFE_INTEGER && - ((this.minVelocityY < 0 && vy <= this.minVelocityY) || - (this.minVelocityY >= 0 && this.minVelocityY <= vy)) + Math.abs(vy) >= Math.abs(this.minVelocityY) ) { return true; } diff --git a/packages/react-native-gesture-handler/src/web/handlers/TapGestureHandler.ts b/packages/react-native-gesture-handler/src/web/handlers/TapGestureHandler.ts index fce9212881..d690eca51e 100644 --- a/packages/react-native-gesture-handler/src/web/handlers/TapGestureHandler.ts +++ b/packages/react-native-gesture-handler/src/web/handlers/TapGestureHandler.ts @@ -159,7 +159,7 @@ export default class TapGestureHandler extends GestureHandler { this.tracker.removeFromTracker(event.pointerId); this.offsetX += this.lastX - this.startX; - this.offsetY += this.lastY = this.startY; + this.offsetY += this.lastY - this.startY; this.updateLastCoords(); diff --git a/packages/react-native-gesture-handler/src/web/tools/WheelEventManager.ts b/packages/react-native-gesture-handler/src/web/tools/WheelEventManager.ts index fce1ec31c6..627cbaac09 100644 --- a/packages/react-native-gesture-handler/src/web/tools/WheelEventManager.ts +++ b/packages/react-native-gesture-handler/src/web/tools/WheelEventManager.ts @@ -44,5 +44,6 @@ export default class WheelEventManager extends EventManager { public resetManager(): void { super.resetManager(); + this.wheelDelta = { x: 0, y: 0 }; } }