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
This commit is contained in:
Peter Abbondanzo
2025-04-07 17:54:03 -07:00
committed by Facebook GitHub Bot
parent 22eb457c94
commit 833ab6fe1b
@@ -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<Integer, List<ViewTarget>> mLastHitPathByPointerId;
private Map<Integer, float[]> mLastEventCoordinatesByPointerId;
private Map<Integer, List<ViewTarget>> mCurrentlyDownPointerIdsToHitPath;
private Set<Integer> mHoveringPointerIds = new HashSet<>();
private final Set<Integer> 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:
*
* <ol>
* <li>If the cursor leaves the bounds of the view
* <li>If the user presses a button
* </ol>
*
* <p>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.
*
* <p>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