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 c2125fa3eed..bc904f755f9 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 class JSPointerDispatcher { 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 class JSPointerDispatcher { // 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 class JSPointerDispatcher { 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 class JSPointerDispatcher { 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 class JSPointerDispatcher { 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 @@ public class JSPointerDispatcher { } } - 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 @@ public class JSPointerDispatcher { 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 @@ public class JSPointerDispatcher { 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 @@ public class JSPointerDispatcher { } } + // 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 bdcab7b048a..9b5bc00a973 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 class PointerEvent extends Event { return mHoveringPointerIds.contains(pointerId); } + public Set getHoveringPointerIds() { + return mHoveringPointerIds; + } + public final Map getOffsetByPointerId() { return mOffsetByPointerId; }