Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -365,20 +365,13 @@ class GestureHandlerOrchestrator(
event.recycle()
}

/**
* Cancels all handlers created using API v1 and v2
*/
fun cancelAllLegacyHandlers() {
private inline fun cancelHandlersMatching(predicate: (GestureHandler) -> Boolean) {
val handlersToProcess = obtainHandlerList()
handlersToProcess.addAll(gestureHandlers)

try {
handlersToProcess.forEach {
if (it.actionType == GestureHandler.ACTION_TYPE_JS_FUNCTION_OLD_API ||
it.actionType == GestureHandler.ACTION_TYPE_JS_FUNCTION_NEW_API ||
it.actionType == GestureHandler.ACTION_TYPE_REANIMATED_WORKLET ||
it.actionType == GestureHandler.ACTION_TYPE_NATIVE_ANIMATED_EVENT
) {
if (predicate(it)) {
it.cancel()
}
}
Expand All @@ -389,6 +382,20 @@ class GestureHandlerOrchestrator(
}
}

fun cancelAllLegacyHandlers() = cancelHandlersMatching {
it.actionType == GestureHandler.ACTION_TYPE_JS_FUNCTION_OLD_API ||
it.actionType == GestureHandler.ACTION_TYPE_JS_FUNCTION_NEW_API ||
it.actionType == GestureHandler.ACTION_TYPE_REANIMATED_WORKLET ||
it.actionType == GestureHandler.ACTION_TYPE_NATIVE_ANIMATED_EVENT
}

/**
* Cancels handlers whose view opted out of surviving a native view taking over the touch stream.
*/
fun cancelHandlersOnNativeTouchGrab(grabbedMidGesture: Boolean) = cancelHandlersMatching {
it is NativeViewGestureHandler && it.shouldCancelOnNativeTouchGrab(grabbedMidGesture)
}

/**
* isViewAttachedUnderWrapper checks whether all of parents for view related to handler
* view are attached. Since there might be an issue rarely observed when view
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,9 @@ class NativeViewGestureHandler : GestureHandler() {

override fun wantsToAttachDirectlyToView() = true

fun shouldCancelOnNativeTouchGrab(grabbedMidGesture: Boolean): Boolean =
hook.shouldCancelOnNativeTouchGrab(grabbedMidGesture)

data class HitSlop(
val left: Float = HIT_SLOP_NONE,
val top: Float = HIT_SLOP_NONE,
Expand Down Expand Up @@ -361,6 +364,11 @@ class NativeViewGestureHandler : GestureHandler() {
*/
fun shouldRecognizeSimultaneously(handler: GestureHandler): Boolean? = null

/**
* Called after a native view grabbed the touch lock; return true to cancel the handler.
*/
fun shouldCancelOnNativeTouchGrab(grabbedMidGesture: Boolean) = false

/**
* shouldActivateOnStart and tryIntercept have priority over this method
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,10 @@ class RNGestureHandlerButtonViewManager :
// event).
private var lastEventWasInside = false

// Whether the native dispatch delivered DOWN for the current gesture. False when a native
// ancestor intercepted it — the orchestrator still delivers events then.
private var receivedNativeDown = false

override fun onHandlerUpdate(handler: NativeViewGestureHandler) {
if (managedHandlerTag == null || handler.isWithinBounds == lastEventWasInside) {
return
Expand Down Expand Up @@ -744,6 +748,8 @@ class RNGestureHandlerButtonViewManager :
val localLastEventWasInside = lastEventWasInside

if (newState == GestureHandler.STATE_BEGAN) {
// Reset for the new gesture - BEGAN precedes the native dispatch of DOWN that sets the flag.
receivedNativeDown = false
Comment on lines +751 to +752

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't it make more sense to reset this in the end/failed/canceled branch?

Currently, the flag is set to true after the gesture finishes until a new one is started, instead of for the gesture duration.

@m-bert m-bert Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It has been done this way to ensure that the flag is reset before the next gesture needs it, even if it for some reason misses the terminal states. We can change it if you prefer to.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

even if it for some reason misses the terminal states

How would that happen? If it does, it should fail very loudly IMO.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might happen if the gesture is disabled, since the flag is updated on the DOWN event. I'm not sure if we care that much about this case though

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When it's disabled, wouldn't it simply not change state? So clearing in BEGIN also wouldn't work?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When disabled - yes, correct. But if you then switch it to enabled, then we would be left with stale value, as the terminal state has not been reached and there's no reset in BEGIN in that case.

dispatchJSEvent(EventType.PressIn, handler)
longPressDetected = false

Expand Down Expand Up @@ -815,6 +821,16 @@ class RNGestureHandlerButtonViewManager :
}
}

override fun dispatchTouchEvent(event: MotionEvent): Boolean {
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
receivedNativeDown = true
}

return super.dispatchTouchEvent(event)
}

override fun shouldCancelOnNativeTouchGrab(grabbedMidGesture: Boolean) = grabbedMidGesture || !receivedNativeDown

override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
if (super.onInterceptTouchEvent(event)) {
return true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView:
private var shouldIntercept = false
private var wasIntercepting = false
private var passingTouch = false
private var passingNativeTouch = false
private var nativeTouchGrabRequested = false

init {
val registry =
Expand Down Expand Up @@ -116,14 +118,46 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView:

fun requestDisallowInterceptTouchEvent() {
// If this method gets called it means that some native view is attempting to grab lock for
// touch event delivery. In that case we cancel all gesture recognizers
// touch event delivery. Legacy handlers are cancelled right away; handlers opting into
// native-touch-grab cancellation are deferred to `onNativeDispatchEnd`.
if (orchestrator != null && !passingTouch) {
// if we are in the process of delivering touch events via GH orchestrator, we don't want to
// treat it as a native gesture capturing the lock
if (passingNativeTouch) {
// Requests may also arrive outside any dispatch pass (e.g. RN's JS responder). Those have
// no pass to classify against and must not arm the sweep for a future gesture.
nativeTouchGrabRequested = true
}
orchestrator.cancelAllLegacyHandlers()
}
}

fun onNativeDispatchStart() {
passingNativeTouch = true
}

/**
* Deferred handling of a disallow-intercept request recorded during this dispatch pass. The
* request alone doesn't say what the caller did with the event: a scrollable calls it when it
* takes over the touch, but e.g. a nested pager calls it already on DOWN, just to keep its
* ancestors from stealing a swipe it may recognize later, and the event still reaches the
* button - at request time both calls look identical. They only become
* distinguishable once the native dispatch completes (did the button receive the DOWN?), which
* is why cancellation runs here instead of in `requestDisallowInterceptTouchEvent`.
*/
Comment thread
j-piasecki marked this conversation as resolved.
fun onNativeDispatchEnd(event: MotionEvent) {
passingNativeTouch = false

if (nativeTouchGrabRequested) {
nativeTouchGrabRequested = false

val grabbedMidGesture = event.actionMasked != MotionEvent.ACTION_DOWN &&
event.actionMasked != MotionEvent.ACTION_POINTER_DOWN

orchestrator?.cancelHandlersOnNativeTouchGrab(grabbedMidGesture)
}
}

fun dispatchTouchEvent(event: MotionEvent): Boolean {
// We mark `mPassingTouch` before we get into `mOrchestrator.onTouchEvent` so that we can tell
// if `requestDisallow` has been called as a result of a normal gesture handling process or
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,10 @@ class RNGestureHandlerRootView(context: Context?) : ReactViewGroup(context) {
return if (rootViewEnabled && rootHelper!!.dispatchTouchEvent(event)) {
true
} else {
super.dispatchTouchEvent(event)
rootHelper?.onNativeDispatchStart()
val handled = super.dispatchTouchEvent(event)
rootHelper?.onNativeDispatchEnd(event)
Comment thread
j-piasecki marked this conversation as resolved.
handled
}
}

Expand Down
Loading