From 88b39fcb4d10927888a497e09db63aa0d0df603a Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Thu, 30 Apr 2026 10:07:47 +0200 Subject: [PATCH 1/4] Fix Android --- .../gesturehandler/core/GestureHandler.kt | 63 ++++++++----------- .../core/GestureHandlerOrchestrator.kt | 2 +- 2 files changed, 27 insertions(+), 38 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt index 2042529e2d..a6a13a56ef 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 @@ -48,6 +48,24 @@ open class GestureHandler { } } + /** + * The view whose coordinate space should be used when reporting event positions to JS. + * + * Handlers attached via the V3 NativeDetector are registered against the DetectorView + * which never carries user-applied transforms — those live on the detector's single child. + * Descending one level keeps reported coordinates consistent with V2 and the V3 + * InterceptingGestureDetector path. For all other attachment styles this is just [view]. + */ + val coordinateView: View? + get() { + val v = view + return if (v is RNGestureHandlerDetectorView && v.isNotEmpty()) { + v.getChildAt(0) + } else { + v + } + } + var state = STATE_UNDETERMINED private set var x = 0f @@ -387,42 +405,13 @@ open class GestureHandler { numberOfPointers = adaptedTransformedEvent.pointerCount - // TODO: this is likely wrong, and the transformed event itself should be - // in the coordinate system of the child view, but I'm not sure of the - // consequences - val detectorView = hostDetectorView - if (detectorView != null && view == detectorView && detectorView.isNotEmpty()) { - val outPoint = PointF() - var foundChild = false - - for (i in 0 until detectorView.childCount) { - val child = detectorView.getChildAt(i) - GestureHandlerOrchestrator.transformPointToChildViewCoords( - adaptedTransformedEvent.x, - adaptedTransformedEvent.y, - detectorView, - child, - outPoint, - ) - if (isWithinBounds(child, outPoint.x, outPoint.y)) { - x = outPoint.x - y = outPoint.y - isWithinBounds = true - foundChild = true - break - } - } - - if (!foundChild) { - x = adaptedTransformedEvent.x - y = adaptedTransformedEvent.y - isWithinBounds = false - } - } else { - x = adaptedTransformedEvent.x - y = adaptedTransformedEvent.y - isWithinBounds = isWithinBounds(view, x, y) - } + x = adaptedTransformedEvent.x + y = adaptedTransformedEvent.y + // The orchestrator transforms incoming events into the coordinate space of the detector's + // child (when the handler is attached to a NativeDetector wrapper), so bounds-checking must + // also use that child rather than the wrapper, otherwise hit-testing would ignore the user's + // transforms applied to the visible view. + isWithinBounds = isWithinBounds(coordinateView, x, y) if (shouldCancelWhenOutside) { if (!isWithinBounds && (state == STATE_ACTIVE || state == STATE_BEGAN)) { @@ -872,7 +861,7 @@ open class GestureHandler { * This method modifies and transforms the received point. */ protected fun transformPoint(point: PointF): PointF = - orchestrator?.transformPointToViewCoords(this.view, point) ?: run { + orchestrator?.transformPointToViewCoords(coordinateView, point) ?: run { point.x = Float.NaN point.y = Float.NaN point 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 21fe9d94fb..f63916e7f7 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 @@ -297,7 +297,7 @@ class GestureHandlerOrchestrator( } val action = sourceEvent.actionMasked - val event = transformEventToViewCoords(handler.view, MotionEvent.obtain(sourceEvent)) + val event = transformEventToViewCoords(handler.coordinateView, MotionEvent.obtain(sourceEvent)) if (handler.needsPointerData) { handler.updatePointerData(event, sourceEvent) From 310d913e9e169761c08d0d2e2bdee0a9b2530141 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Thu, 30 Apr 2026 10:13:10 +0200 Subject: [PATCH 2/4] Fix iOS --- .../apple/Handlers/RNForceTouchHandler.m | 2 +- .../apple/Handlers/RNHoverHandler.m | 2 +- .../apple/Handlers/RNLongPressHandler.m | 4 ++-- .../apple/Handlers/RNPanHandler.m | 4 ++-- .../apple/Handlers/RNPinchHandler.m | 7 ++++--- .../apple/Handlers/RNRotationHandler.m | 4 ++-- .../apple/RNGestureHandler.h | 9 +++++++++ .../apple/RNGestureHandler.mm | 14 ++++++++++++-- 8 files changed, 33 insertions(+), 13 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/Handlers/RNForceTouchHandler.m b/packages/react-native-gesture-handler/apple/Handlers/RNForceTouchHandler.m index 62a792a2c9..113dcddafd 100644 --- a/packages/react-native-gesture-handler/apple/Handlers/RNForceTouchHandler.m +++ b/packages/react-native-gesture-handler/apple/Handlers/RNForceTouchHandler.m @@ -170,7 +170,7 @@ - (void)updateConfig:(NSDictionary *)config - (RNGestureHandlerEventExtraData *)eventExtraData:(RNForceTouchGestureRecognizer *)recognizer { return [RNGestureHandlerEventExtraData forForce:recognizer.force - forPosition:[recognizer locationInView:recognizer.view] + forPosition:[recognizer locationInView:self.coordinateView] withAbsolutePosition:[recognizer locationInView:recognizer.view.window] withNumberOfTouches:recognizer.numberOfTouches withPointerType:_pointerType]; diff --git a/packages/react-native-gesture-handler/apple/Handlers/RNHoverHandler.m b/packages/react-native-gesture-handler/apple/Handlers/RNHoverHandler.m index e854671b84..3cd9a115e9 100644 --- a/packages/react-native-gesture-handler/apple/Handlers/RNHoverHandler.m +++ b/packages/react-native-gesture-handler/apple/Handlers/RNHoverHandler.m @@ -177,7 +177,7 @@ - (void)setCurrentPointerType:(RNGestureHandlerPointerType)pointerType - (RNGestureHandlerEventExtraData *)eventExtraData:(UIGestureRecognizer *)recognizer { - return [RNGestureHandlerEventExtraData forPosition:[recognizer locationInView:recognizer.view] + return [RNGestureHandlerEventExtraData forPosition:[recognizer locationInView:self.coordinateView] withAbsolutePosition:[recognizer locationInView:recognizer.view.window] withPointerType:_pointerType]; } diff --git a/packages/react-native-gesture-handler/apple/Handlers/RNLongPressHandler.m b/packages/react-native-gesture-handler/apple/Handlers/RNLongPressHandler.m index ae8ec2953e..00cdf0421e 100644 --- a/packages/react-native-gesture-handler/apple/Handlers/RNLongPressHandler.m +++ b/packages/react-native-gesture-handler/apple/Handlers/RNLongPressHandler.m @@ -287,7 +287,7 @@ - (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer - (RNGestureHandlerEventExtraData *)eventExtraData:(UIGestureRecognizer *)recognizer { - return [RNGestureHandlerEventExtraData forPosition:[recognizer locationInView:recognizer.view] + return [RNGestureHandlerEventExtraData forPosition:[recognizer locationInView:self.coordinateView] withAbsolutePosition:[recognizer locationInView:recognizer.view.window] withNumberOfTouches:recognizer.numberOfTouches withDuration:[(RNBetterLongPressGestureRecognizer *)recognizer getDuration] @@ -298,7 +298,7 @@ - (RNGestureHandlerEventExtraData *)eventExtraData:(UIGestureRecognizer *)recogn - (RNGestureHandlerEventExtraData *)eventExtraData:(NSGestureRecognizer *)recognizer { - return [RNGestureHandlerEventExtraData forPosition:[recognizer locationInView:recognizer.view] + return [RNGestureHandlerEventExtraData forPosition:[recognizer locationInView:self.coordinateView] withAbsolutePosition:[recognizer locationInView:recognizer.view.window.contentView] withNumberOfTouches:1 withDuration:[(RNBetterLongPressGestureRecognizer *)recognizer getDuration] diff --git a/packages/react-native-gesture-handler/apple/Handlers/RNPanHandler.m b/packages/react-native-gesture-handler/apple/Handlers/RNPanHandler.m index 4396f8543b..3d99add8ad 100644 --- a/packages/react-native-gesture-handler/apple/Handlers/RNPanHandler.m +++ b/packages/react-native-gesture-handler/apple/Handlers/RNPanHandler.m @@ -452,7 +452,7 @@ - (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer #if TARGET_OS_OSX - (RNGestureHandlerEventExtraData *)eventExtraData:(NSPanGestureRecognizer *)recognizer { - return [RNGestureHandlerEventExtraData forPan:[recognizer locationInView:recognizer.view] + return [RNGestureHandlerEventExtraData forPan:[recognizer locationInView:self.coordinateView] withAbsolutePosition:[recognizer locationInView:recognizer.view.window.contentView] withTranslation:[recognizer translationInView:recognizer.view.window.contentView] withVelocity:[recognizer velocityInView:recognizer.view.window.contentView] @@ -466,7 +466,7 @@ - (RNGestureHandlerEventExtraData *)eventExtraData:(UIPanGestureRecognizer *)rec RNBetterPanGestureRecognizer *panRecognizer = (RNBetterPanGestureRecognizer *)recognizer; return [RNGestureHandlerEventExtraData - forPan:[recognizer locationInView:recognizer.view] + forPan:[recognizer locationInView:self.coordinateView] withAbsolutePosition:[recognizer locationInView:recognizer.view.window] withTranslation:[recognizer translationInView:recognizer.view.window] withVelocity:[recognizer velocityInView:recognizer.view.window] diff --git a/packages/react-native-gesture-handler/apple/Handlers/RNPinchHandler.m b/packages/react-native-gesture-handler/apple/Handlers/RNPinchHandler.m index 96de2d9830..7a2b224f29 100644 --- a/packages/react-native-gesture-handler/apple/Handlers/RNPinchHandler.m +++ b/packages/react-native-gesture-handler/apple/Handlers/RNPinchHandler.m @@ -167,7 +167,7 @@ - (instancetype)initWithTag:(NSNumber *)tag - (RNGestureHandlerEventExtraData *)eventExtraData:(NSMagnificationGestureRecognizer *)recognizer { return [RNGestureHandlerEventExtraData forPinch:recognizer.magnification - withFocalPoint:[recognizer locationInView:recognizer.view] + withFocalPoint:[recognizer locationInView:self.coordinateView] withVelocity:((RNBetterPinchRecognizer *)recognizer).velocity withNumberOfTouches:2 withPointerType:RNGestureHandlerMouse]; @@ -177,12 +177,13 @@ - (RNGestureHandlerEventExtraData *)eventExtraData:(UIPinchGestureRecognizer *)r { CGPoint focalPoint; NSUInteger numberOfTouches = recognizer.numberOfTouches; + RNGHUIView *coordinateView = self.coordinateView; if (numberOfTouches > 0) { CGPoint accumulatedPoint = CGPointZero; for (int i = 0; i < numberOfTouches; i++) { - CGPoint location = [recognizer locationOfTouch:i inView:recognizer.view]; + CGPoint location = [recognizer locationOfTouch:i inView:coordinateView]; accumulatedPoint.x += location.x; accumulatedPoint.y += location.y; } @@ -190,7 +191,7 @@ - (RNGestureHandlerEventExtraData *)eventExtraData:(UIPinchGestureRecognizer *)r focalPoint = CGPointMake(accumulatedPoint.x / numberOfTouches, accumulatedPoint.y / numberOfTouches); } else { // Trackpad pinch gestures may report 0 touches - use the recognizer's location instead - focalPoint = [recognizer locationInView:recognizer.view]; + focalPoint = [recognizer locationInView:coordinateView]; } return [RNGestureHandlerEventExtraData forPinch:recognizer.scale diff --git a/packages/react-native-gesture-handler/apple/Handlers/RNRotationHandler.m b/packages/react-native-gesture-handler/apple/Handlers/RNRotationHandler.m index 85c2be9aca..86896909f0 100644 --- a/packages/react-native-gesture-handler/apple/Handlers/RNRotationHandler.m +++ b/packages/react-native-gesture-handler/apple/Handlers/RNRotationHandler.m @@ -161,7 +161,7 @@ - (instancetype)initWithTag:(NSNumber *)tag - (RNGestureHandlerEventExtraData *)eventExtraData:(NSRotationGestureRecognizer *)recognizer { return [RNGestureHandlerEventExtraData forRotation:-recognizer.rotation - withAnchorPoint:[recognizer locationInView:recognizer.view] + withAnchorPoint:[recognizer locationInView:self.coordinateView] withVelocity:((RNBetterRotationRecognizer *)recognizer).velocity withNumberOfTouches:2 withPointerType:RNGestureHandlerMouse]; @@ -170,7 +170,7 @@ - (RNGestureHandlerEventExtraData *)eventExtraData:(NSRotationGestureRecognizer - (RNGestureHandlerEventExtraData *)eventExtraData:(UIRotationGestureRecognizer *)recognizer { return [RNGestureHandlerEventExtraData forRotation:recognizer.rotation - withAnchorPoint:[recognizer locationInView:recognizer.view] + withAnchorPoint:[recognizer locationInView:self.coordinateView] withVelocity:recognizer.velocity withNumberOfTouches:recognizer.numberOfTouches withPointerType:_pointerType]; diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandler.h b/packages/react-native-gesture-handler/apple/RNGestureHandler.h index 0b66cff5b3..b549e8a57a 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandler.h +++ b/packages/react-native-gesture-handler/apple/RNGestureHandler.h @@ -88,6 +88,15 @@ @property (nonatomic, copy, nullable) NSNumber *viewTag; @property (nonatomic, readonly) RNGestureHandlerState lastState; +/** + The view whose coordinate space should be used when reporting event positions to JS. + Handlers attached via the V3 NativeDetector are bound to the `RNGestureHandlerDetector` wrapper, + which never carries user-applied transforms — those live on the detector's single subview. + Descending one level keeps reported coordinates consistent with V2 and the V3 + InterceptingGestureDetector path. For all other attachment styles this is just `recognizer.view`. + */ +@property (nonatomic, readonly, nullable) RNGHUIView *coordinateView; + - (BOOL)isViewParagraphComponent:(nullable RNGHUIView *)view; - (nonnull RNGHUIView *)chooseViewForInteraction:(nonnull UIGestureRecognizer *)recognizer; - (void)bindToView:(nonnull RNGHUIView *)view; diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandler.mm b/packages/react-native-gesture-handler/apple/RNGestureHandler.mm index 8cf4bf41f3..9479cbace7 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandler.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandler.mm @@ -286,12 +286,12 @@ - (void)unbindFromView - (RNGestureHandlerEventExtraData *)eventExtraData:(UIGestureRecognizer *)recognizer { #if TARGET_OS_OSX - return [RNGestureHandlerEventExtraData forPosition:[recognizer locationInView:recognizer.view] + return [RNGestureHandlerEventExtraData forPosition:[recognizer locationInView:self.coordinateView] withAbsolutePosition:[recognizer locationInView:recognizer.view.window.contentView] withNumberOfTouches:1 withPointerType:RNGestureHandlerMouse]; #else - return [RNGestureHandlerEventExtraData forPosition:[recognizer locationInView:recognizer.view] + return [RNGestureHandlerEventExtraData forPosition:[recognizer locationInView:self.coordinateView] withAbsolutePosition:[recognizer locationInView:recognizer.view.window] withNumberOfTouches:recognizer.numberOfTouches withPointerType:_pointerType]; @@ -307,6 +307,16 @@ - (RNGHUIView *)chooseViewForInteraction:(UIGestureRecognizer *)recognizer return [self isViewParagraphComponent:recognizer.view] ? recognizer.view.subviews[0] : recognizer.view; } +- (RNGHUIView *)coordinateView +{ + RNGHUIView *recognizerView = _recognizer.view; + if ([self usesNativeOrVirtualDetector] && recognizerView == self.hostDetectorView && + recognizerView.subviews.count > 0) { + return recognizerView.subviews[0]; + } + return recognizerView; +} + - (BOOL)shouldSuppressActiveEvent:(RNGestureHandlerEventExtraData *)extraData { return NO; From dab2f5c902fbb6cb61959d1ace62ac28faad188f Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Thu, 30 Apr 2026 10:48:10 +0200 Subject: [PATCH 3/4] Handle multiple detector children explicitly --- .../swmansion/gesturehandler/core/GestureHandler.kt | 13 +++++++------ .../apple/RNGestureHandler.h | 8 +++++--- .../apple/RNGestureHandler.mm | 2 +- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt index a6a13a56ef..b1f5dc4b1d 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 @@ -9,7 +9,6 @@ import android.view.MotionEvent import android.view.MotionEvent.PointerCoords import android.view.MotionEvent.PointerProperties import android.view.View -import androidx.core.view.isNotEmpty import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.ReactContext import com.facebook.react.bridge.ReadableMap @@ -51,15 +50,17 @@ open class GestureHandler { /** * The view whose coordinate space should be used when reporting event positions to JS. * - * Handlers attached via the V3 NativeDetector are registered against the DetectorView - * which never carries user-applied transforms — those live on the detector's single child. - * Descending one level keeps reported coordinates consistent with V2 and the V3 - * InterceptingGestureDetector path. For all other attachment styles this is just [view]. + * Handlers attached via the V3 NativeDetector are registered against the DetectorView wrapper, + * which never carries user-applied transforms — those live on its child. When the detector has + * exactly one child we descend into it so reported coordinates match the visible (transformed) + * view, the same coordinate space V2 and the V3 InterceptingGestureDetector report in. With + * multiple children there is no JS-side way to disambiguate which child caught the pointer, + * so we keep the detector itself as the reference frame. */ val coordinateView: View? get() { val v = view - return if (v is RNGestureHandlerDetectorView && v.isNotEmpty()) { + return if (v is RNGestureHandlerDetectorView && v.childCount == 1) { v.getChildAt(0) } else { v diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandler.h b/packages/react-native-gesture-handler/apple/RNGestureHandler.h index b549e8a57a..699d182d67 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandler.h +++ b/packages/react-native-gesture-handler/apple/RNGestureHandler.h @@ -91,9 +91,11 @@ /** The view whose coordinate space should be used when reporting event positions to JS. Handlers attached via the V3 NativeDetector are bound to the `RNGestureHandlerDetector` wrapper, - which never carries user-applied transforms — those live on the detector's single subview. - Descending one level keeps reported coordinates consistent with V2 and the V3 - InterceptingGestureDetector path. For all other attachment styles this is just `recognizer.view`. + which never carries user-applied transforms — those live on its child. When the detector has + exactly one subview we descend into it so reported coordinates match the visible (transformed) + view, the same coordinate space V2 and the V3 InterceptingGestureDetector report in. With + multiple subviews there is no JS-side way to disambiguate which child caught the pointer, so we + keep the detector itself as the reference frame. */ @property (nonatomic, readonly, nullable) RNGHUIView *coordinateView; diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandler.mm b/packages/react-native-gesture-handler/apple/RNGestureHandler.mm index 9479cbace7..0a5452fc2d 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandler.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandler.mm @@ -311,7 +311,7 @@ - (RNGHUIView *)coordinateView { RNGHUIView *recognizerView = _recognizer.view; if ([self usesNativeOrVirtualDetector] && recognizerView == self.hostDetectorView && - recognizerView.subviews.count > 0) { + recognizerView.subviews.count == 1) { return recognizerView.subviews[0]; } return recognizerView; From 8d00e5b5c75915caf096f4e341569a6a432b57fb Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Mon, 4 May 2026 07:48:13 +0200 Subject: [PATCH 4/4] Update comment --- .../com/swmansion/gesturehandler/core/GestureHandler.kt | 2 +- .../react-native-gesture-handler/apple/RNGestureHandler.h | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt index b1f5dc4b1d..88c85744d1 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 @@ -53,7 +53,7 @@ open class GestureHandler { * Handlers attached via the V3 NativeDetector are registered against the DetectorView wrapper, * which never carries user-applied transforms — those live on its child. When the detector has * exactly one child we descend into it so reported coordinates match the visible (transformed) - * view, the same coordinate space V2 and the V3 InterceptingGestureDetector report in. With + * view, the same coordinate space V2 and the V3 VirtualGestureDetector report in. With * multiple children there is no JS-side way to disambiguate which child caught the pointer, * so we keep the detector itself as the reference frame. */ diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandler.h b/packages/react-native-gesture-handler/apple/RNGestureHandler.h index 699d182d67..49aa70ba7e 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandler.h +++ b/packages/react-native-gesture-handler/apple/RNGestureHandler.h @@ -93,9 +93,9 @@ Handlers attached via the V3 NativeDetector are bound to the `RNGestureHandlerDetector` wrapper, which never carries user-applied transforms — those live on its child. When the detector has exactly one subview we descend into it so reported coordinates match the visible (transformed) - view, the same coordinate space V2 and the V3 InterceptingGestureDetector report in. With - multiple subviews there is no JS-side way to disambiguate which child caught the pointer, so we - keep the detector itself as the reference frame. + view, the same coordinate space V2 and the V3 VirtualGestureDetector report in. With multiple + subviews there is no JS-side way to disambiguate which child caught the pointer, so we keep + the detector itself as the reference frame. */ @property (nonatomic, readonly, nullable) RNGHUIView *coordinateView;