From d0b97a8c4c050ee8f9463e7bc08c12201024cecf Mon Sep 17 00:00:00 2001 From: Alex Danoff Date: Fri, 28 Apr 2023 09:28:55 -0700 Subject: [PATCH] W3CPointerEvents: transform coordinates appropriately for cancel events (#36967) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/36967 Changelog: [Internal] - W3CPointerEvents: transform coordinates appropriately for cancel events This change updates the behavior of pointer cancel events on Android to fire on the correct target. In particular: 1. Transform motion event from child frame into root frame before processing 2. Zero-out coordinates on pointer cancel event in frame of target before dispatching This is specifically necessary for cancel events due to the way they're propagated between views: we fire a cancel event when a child view starts handling a native gesture (e.g. a scroll view starts being scrolled). However, the native gesture event (MotionEvent) is in the frame of the child view, whereas the rest of the existing pointer event code expects events to be in the frame of the react root view. We don't have to do this conversion for other events since the corresponding MotionEvents are received directly by the root view. Reviewed By: lunaleaps Differential Revision: D45064308 fbshipit-source-id: f21d260f6aeb85e5d0efd53a48da6dd1e7b5efbc --- .../react/uimanager/JSPointerDispatcher.java | 95 +++++++++++++++++-- .../react/uimanager/events/PointerEvent.java | 4 + 2 files changed, 89 insertions(+), 10 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/JSPointerDispatcher.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/JSPointerDispatcher.java index c2125fa3eed78a..bc904f755f9941 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/JSPointerDispatcher.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/JSPointerDispatcher.java @@ -7,6 +7,7 @@ package com.facebook.react.uimanager; +import android.graphics.Rect; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; @@ -63,10 +64,24 @@ public void onChildStartedNativeGesture( return; } - dispatchCancelEvent(motionEvent, eventDispatcher); + MotionEvent motionInRoot = convertMotionToRootFrame(childView, motionEvent); + + dispatchCancelEventForTarget(childView, motionInRoot, eventDispatcher); mChildHandlingNativeGesture = childView.getId(); } + private MotionEvent convertMotionToRootFrame(View childView, MotionEvent childMotion) { + MotionEvent motionInRoot = MotionEvent.obtain(childMotion); + final int cX = (int) childMotion.getX(); + final int cY = (int) childMotion.getY(); + Rect childCoords = new Rect(cX, cY, cX + 1, cY + 1); + // note: should be safe since we're only looking at descendants of the root + mRootViewGroup.offsetDescendantRectToMyCoords(childView, childCoords); + motionInRoot.setLocation(childCoords.left, childCoords.top); + + return motionInRoot; + } + public void onChildEndedNativeGesture() { // There should be only one child gesture at any given time. We can safely turn off the flag. mChildHandlingNativeGesture = -1; @@ -228,6 +243,7 @@ public void handleMotionEvent( // Calculate the targetTag, with special handling for when we exit the root view. In that case, // we use the root viewId of the last event int activeTargetTag; + View activeTargetView; List activeHitPath; if (isExitFromRoot) { @@ -238,7 +254,9 @@ public void handleMotionEvent( if (lastHitPath == null || lastHitPath.isEmpty()) { return; } - activeTargetTag = lastHitPath.get(lastHitPath.size() - 1).getViewId(); + ViewTarget activeTarget = lastHitPath.get(lastHitPath.size() - 1); + activeTargetTag = activeTarget.getViewId(); + activeTargetView = activeTarget.getView(); // Explicitly make the hit path for this cursor empty activeHitPath = new ArrayList<>(); @@ -248,7 +266,9 @@ public void handleMotionEvent( if (activeHitPath == null || activeHitPath.isEmpty()) { return; } - activeTargetTag = activeHitPath.get(0).getViewId(); + ViewTarget activeTarget = activeHitPath.get(0); + activeTargetTag = activeTarget.getViewId(); + activeTargetView = activeTarget.getView(); } handleHitStateDivergence(activeTargetTag, eventState, motionEvent, eventDispatcher); @@ -284,7 +304,7 @@ public void handleMotionEvent( onUp(activeTargetTag, eventState, motionEvent, eventDispatcher); break; case MotionEvent.ACTION_CANCEL: - dispatchCancelEvent(eventState, motionEvent, eventDispatcher); + dispatchCancelEventForTarget(activeTargetView, eventState, motionEvent, eventDispatcher); break; case MotionEvent.ACTION_HOVER_ENTER: // Ignore these events as enters will be calculated from HOVER_MOVE @@ -504,7 +524,8 @@ private void onMove( } } - private void dispatchCancelEvent(MotionEvent motionEvent, EventDispatcher eventDispatcher) { + private void dispatchCancelEventForTarget( + View targetView, MotionEvent motionEvent, EventDispatcher eventDispatcher) { Assertions.assertCondition( mChildHandlingNativeGesture == -1, "Expected to not have already sent a cancel for this gesture"); @@ -512,11 +533,14 @@ private void dispatchCancelEvent(MotionEvent motionEvent, EventDispatcher eventD int activeIndex = motionEvent.getActionIndex(); int activePointerId = motionEvent.getPointerId(activeIndex); PointerEventState eventState = createEventState(activePointerId, motionEvent); - dispatchCancelEvent(eventState, motionEvent, eventDispatcher); + dispatchCancelEventForTarget(targetView, eventState, motionEvent, eventDispatcher); } - private void dispatchCancelEvent( - PointerEventState eventState, MotionEvent motionEvent, EventDispatcher eventDispatcher) { + private void dispatchCancelEventForTarget( + View targetView, + PointerEventState eventState, + MotionEvent motionEvent, + EventDispatcher eventDispatcher) { // This means the gesture has already ended, via some other CANCEL or UP event. This is not // expected to happen very often as it would mean some child View has decided to intercept the // touch stream and start a native gesture only upon receiving the UP/CANCEL event. @@ -532,18 +556,31 @@ private void dispatchCancelEvent( isAnyoneListeningForBubblingEvent(activeHitPath, EVENT.CANCEL, EVENT.CANCEL_CAPTURE); if (listeningForCancel) { int targetTag = activeHitPath.get(0).getViewId(); + + // cancel events need to report client coordinates of (0, 0) and offset coordinates relative + // to the root view + int[] childOffset = getChildOffsetRelativeToRoot(targetView); + PointerEventState normalizedEventState = + normalizeToRoot(eventState, childOffset[0], childOffset[1]); Assertions.assertNotNull(eventDispatcher) .dispatchEvent( PointerEvent.obtain( - PointerEventHelper.POINTER_CANCEL, targetTag, eventState, motionEvent)); + PointerEventHelper.POINTER_CANCEL, + targetTag, + normalizedEventState, + motionEvent)); } - // TODO(luwe) - Need to fire pointer out here as well: + // Need to fire pointer out + pointer leave here as well: // https://w3c.github.io/pointerevents/#dfn-suppress-a-pointer-event-stream + List outViewTargets = + filterByShouldDispatch(activeHitPath, EVENT.OUT, EVENT.OUT_CAPTURE, false); List leaveViewTargets = filterByShouldDispatch(activeHitPath, EVENT.LEAVE, EVENT.LEAVE_CAPTURE, false); // dispatch from target -> root + dispatchEventForViewTargets( + PointerEventHelper.POINTER_OUT, eventState, motionEvent, outViewTargets, eventDispatcher); dispatchEventForViewTargets( PointerEventHelper.POINTER_LEAVE, eventState, @@ -558,6 +595,44 @@ private void dispatchCancelEvent( } } + // returns child (0, 0) relative to root coordinate system + private int[] getChildOffsetRelativeToRoot(View childView) { + Rect childCoords = new Rect(0, 0, 1, 1); + mRootViewGroup.offsetDescendantRectToMyCoords(childView, childCoords); + return new int[] {childCoords.top, childCoords.left}; + } + + // Returns a copy of `original` with coordinates zeroed relative to the provided root coordinates. + // In particular, + // - the event (client) coordinates will all be set to 0 + // - the offset coordinates will be set to the root coordinates + private PointerEventState normalizeToRoot(PointerEventState original, float rootX, float rootY) { + Map newOffsets = new HashMap<>(original.getOffsetByPointerId()); + Map newEventCoords = new HashMap<>(original.getEventCoordinatesByPointerId()); + + for (Map.Entry offsetEntry : newOffsets.entrySet()) { + float[] offsetValue = offsetEntry.getValue(); + offsetValue[0] = rootX; + offsetValue[1] = rootY; + } + + for (Map.Entry eventCoordsEntry : newEventCoords.entrySet()) { + float[] eventCoordsValue = eventCoordsEntry.getValue(); + eventCoordsValue[0] = 0; + eventCoordsValue[1] = 0; + } + + return new PointerEventState( + original.getPrimaryPointerId(), + original.getActivePointerId(), + original.getLastButtonState(), + original.getSurfaceId(), + newOffsets, + new HashMap<>(original.getHitPathByPointerId()), + newEventCoords, + new HashSet<>(original.getHoveringPointerIds())); + } + private static void debugPrintHitPath(List hitPath) { StringBuilder builder = new StringBuilder("hitPath: "); for (ViewTarget viewTarget : hitPath) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/PointerEvent.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/PointerEvent.java index bdcab7b048a6e7..9b5bc00a973376 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/PointerEvent.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/PointerEvent.java @@ -356,6 +356,10 @@ public boolean supportsHover(int pointerId) { return mHoveringPointerIds.contains(pointerId); } + public Set getHoveringPointerIds() { + return mHoveringPointerIds; + } + public final Map getOffsetByPointerId() { return mOffsetByPointerId; }