From 833ab6fe1b252244bf7d8a2f158fa13470c27e76 Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Mon, 7 Apr 2025 17:54:03 -0700 Subject: [PATCH] Fix onPointerLeave computation by comparing button presses (#50402) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/50402 While investigating pointer events, I noticed that all Android views fire `onPointerLeave`/`onPointerEnter` in rapid succession on every button press. This is because Android fires `ACTION_HOVER_EXIT` on every frame before firing `ACTION_DOWN` (in the same frame). The logic in JSPointerDispatcher needed to be updated to account for this. Unfortunately, `ACTION_HOVER_EXIT` events have no means of distinguishing between whether they were driven by the mouse actually leaving the view's bounds or by a button press. Some OS implementations fire this event on the frame just before leaving the view's boundaries while others fire after, so pure X/Y position isn't helpful enough. The button state property on `ACTION_HOVER_EXIT` also never gets set in some OS. To work around this issue, this change posts a callback to the very next frame after receiving `ACTION_HOVER_EXIT`. If no `ACTION_DOWN` event was received in the same frame, it calls all the way through the existing logic. But, if an `ACTION_DOWN` event *is* received, we compare the event timestamps and if they match we don't post `onPointerLeave`. Changelog: [Android][Fixed] - Prevent onPointerLeave from dispatching during button presses Reviewed By: vincentriemer Differential Revision: D72078450 fbshipit-source-id: dcc7cfa4086963ed8599147d60d8406c54cd5d69 --- .../react/uimanager/JSPointerDispatcher.java | 63 +++++++++++++++++-- 1 file changed, 57 insertions(+), 6 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 c235b8c7ebd..96e1ea5c4f1 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 @@ -8,6 +8,7 @@ package com.facebook.react.uimanager; import android.graphics.Rect; +import android.view.Choreographer; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; @@ -44,12 +45,14 @@ public class JSPointerDispatcher { private Map> mLastHitPathByPointerId; private Map mLastEventCoordinatesByPointerId; private Map> mCurrentlyDownPointerIdsToHitPath; - private Set mHoveringPointerIds = new HashSet<>(); + private final Set mHoveringPointerIds = new HashSet<>(); private int mChildHandlingNativeGesture = UNSET_CHILD_VIEW_ID; private int mPrimaryPointerId = UNSET_POINTER_ID; private int mCoalescingKey = 0; private int mLastButtonState = 0; + private volatile long mLastActionDownEventTime = 0; + private boolean mRunHoverExitNextFrame = true; private final ViewGroup mRootViewGroup; private static final int[] sRootScreenCoords = {0, 0}; @@ -285,9 +288,54 @@ public class JSPointerDispatcher { return; } + /** + * Android does not provide a consistent mechanism for determining if a MotionEvent is outside + * the bounds of a view. It fires ACTION_HOVER_EXIT in two cases: + * + *
    + *
  1. If the cursor leaves the bounds of the view + *
  2. If the user presses a button + *
+ * + *

Some OS will fire ACTION_HOVER_EXIT on the frame before the cursor leaves the bounds of + * the view, while others will fire it on the frame after the cursor leaves the bounds of the + * view, so using bounds is not sufficient. Some OS will include the button state in the + * ACTION_HOVER_EXIT event while others will not, so using button state is not sufficient. + * Instead, we must wait for both the ACTION_HOVER_EXIT and ACTION_DOWN events to fire, and then + * compare their event times to determine if the ACTION_HOVER_EXIT event was triggered by the + * cursor leaving the bounds of the view or by a button press. If no ACTION_DOWN event has fired + * by the next frame, we know that the cursor has left the bounds of the root view. + * + *

As ACTION_DOWN fires after ACTION_HOVER_EXIT, we need to wait until the next frame to make + * this determination. We do this by posting a frame callback to the choreographer and + * re-running this method on the next frame should timestamps between the two events not align. + */ + if (isCapture + && mRunHoverExitNextFrame + && motionEvent.getActionMasked() == MotionEvent.ACTION_HOVER_EXIT) { + mRunHoverExitNextFrame = false; + Choreographer.getInstance() + .postFrameCallback( + new Choreographer.FrameCallback() { + @Override + public void doFrame(long frameTimeNanos) { + if (mLastActionDownEventTime != motionEvent.getEventTime()) { + handleMotionEventHelper(motionEvent, eventDispatcher, isCapture); + } + mRunHoverExitNextFrame = true; + } + }); + } else { + handleMotionEventHelper(motionEvent, eventDispatcher, isCapture); + } + } + + private void handleMotionEventHelper( + MotionEvent motionEvent, EventDispatcher eventDispatcher, boolean isCapture) { int action = motionEvent.getActionMasked(); int activePointerId = motionEvent.getPointerId(motionEvent.getActionIndex()); if (action == MotionEvent.ACTION_DOWN) { + mLastActionDownEventTime = motionEvent.getEventTime(); mPrimaryPointerId = motionEvent.getPointerId(0); } else if (action == MotionEvent.ACTION_HOVER_MOVE) { mHoveringPointerIds.add(activePointerId); @@ -296,13 +344,16 @@ public class JSPointerDispatcher { PointerEventState eventState = createEventState(activePointerId, motionEvent); // We've empirically determined that when we get a ACTION_HOVER_EXIT from the root view on the - // `onInterceptHoverEvent`, this means we've exited the root view. - // This logic may be wrong but reasoning about the dispatch sequence for HOVER_ENTER/HOVER_EXIT - // doesn't follow the capture/bubbling sequence like other MotionEvents. See: + // `onInterceptHoverEvent`, this means we've exited the root view. This logic may be wrong but + // reasoning about the dispatch sequence for HOVER_ENTER/HOVER_EXIT doesn't follow the + // capture/bubbling sequence like other MotionEvents. + // + // The choreographer logic above is a hack to try to work around this, but it's not perfect. + // + // For more information, see: // https://developer.android.com/reference/android/view/MotionEvent#ACTION_HOVER_ENTER // https://suragch.medium.com/how-touch-events-are-delivered-in-android-eee3b607b038 - boolean isExitFromRoot = - isCapture && motionEvent.getActionMasked() == MotionEvent.ACTION_HOVER_EXIT; + boolean isExitFromRoot = isCapture && action == MotionEvent.ACTION_HOVER_EXIT; // 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