Skip to content

Commit 833ab6f

Browse files
Abbondanzofacebook-github-bot
authored andcommitted
Fix onPointerLeave computation by comparing button presses (#50402)
Summary: Pull Request resolved: #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
1 parent 22eb457 commit 833ab6f

File tree

1 file changed

+57
-6
lines changed

1 file changed

+57
-6
lines changed

Diff for: packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/JSPointerDispatcher.java

+57-6
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
package com.facebook.react.uimanager;
99

1010
import android.graphics.Rect;
11+
import android.view.Choreographer;
1112
import android.view.MotionEvent;
1213
import android.view.View;
1314
import android.view.ViewGroup;
@@ -44,12 +45,14 @@ public class JSPointerDispatcher {
4445
private Map<Integer, List<ViewTarget>> mLastHitPathByPointerId;
4546
private Map<Integer, float[]> mLastEventCoordinatesByPointerId;
4647
private Map<Integer, List<ViewTarget>> mCurrentlyDownPointerIdsToHitPath;
47-
private Set<Integer> mHoveringPointerIds = new HashSet<>();
48+
private final Set<Integer> mHoveringPointerIds = new HashSet<>();
4849

4950
private int mChildHandlingNativeGesture = UNSET_CHILD_VIEW_ID;
5051
private int mPrimaryPointerId = UNSET_POINTER_ID;
5152
private int mCoalescingKey = 0;
5253
private int mLastButtonState = 0;
54+
private volatile long mLastActionDownEventTime = 0;
55+
private boolean mRunHoverExitNextFrame = true;
5356
private final ViewGroup mRootViewGroup;
5457

5558
private static final int[] sRootScreenCoords = {0, 0};
@@ -285,9 +288,54 @@ public void handleMotionEvent(
285288
return;
286289
}
287290

291+
/**
292+
* Android does not provide a consistent mechanism for determining if a MotionEvent is outside
293+
* the bounds of a view. It fires ACTION_HOVER_EXIT in two cases:
294+
*
295+
* <ol>
296+
* <li>If the cursor leaves the bounds of the view
297+
* <li>If the user presses a button
298+
* </ol>
299+
*
300+
* <p>Some OS will fire ACTION_HOVER_EXIT on the frame before the cursor leaves the bounds of
301+
* the view, while others will fire it on the frame after the cursor leaves the bounds of the
302+
* view, so using bounds is not sufficient. Some OS will include the button state in the
303+
* ACTION_HOVER_EXIT event while others will not, so using button state is not sufficient.
304+
* Instead, we must wait for both the ACTION_HOVER_EXIT and ACTION_DOWN events to fire, and then
305+
* compare their event times to determine if the ACTION_HOVER_EXIT event was triggered by the
306+
* cursor leaving the bounds of the view or by a button press. If no ACTION_DOWN event has fired
307+
* by the next frame, we know that the cursor has left the bounds of the root view.
308+
*
309+
* <p>As ACTION_DOWN fires after ACTION_HOVER_EXIT, we need to wait until the next frame to make
310+
* this determination. We do this by posting a frame callback to the choreographer and
311+
* re-running this method on the next frame should timestamps between the two events not align.
312+
*/
313+
if (isCapture
314+
&& mRunHoverExitNextFrame
315+
&& motionEvent.getActionMasked() == MotionEvent.ACTION_HOVER_EXIT) {
316+
mRunHoverExitNextFrame = false;
317+
Choreographer.getInstance()
318+
.postFrameCallback(
319+
new Choreographer.FrameCallback() {
320+
@Override
321+
public void doFrame(long frameTimeNanos) {
322+
if (mLastActionDownEventTime != motionEvent.getEventTime()) {
323+
handleMotionEventHelper(motionEvent, eventDispatcher, isCapture);
324+
}
325+
mRunHoverExitNextFrame = true;
326+
}
327+
});
328+
} else {
329+
handleMotionEventHelper(motionEvent, eventDispatcher, isCapture);
330+
}
331+
}
332+
333+
private void handleMotionEventHelper(
334+
MotionEvent motionEvent, EventDispatcher eventDispatcher, boolean isCapture) {
288335
int action = motionEvent.getActionMasked();
289336
int activePointerId = motionEvent.getPointerId(motionEvent.getActionIndex());
290337
if (action == MotionEvent.ACTION_DOWN) {
338+
mLastActionDownEventTime = motionEvent.getEventTime();
291339
mPrimaryPointerId = motionEvent.getPointerId(0);
292340
} else if (action == MotionEvent.ACTION_HOVER_MOVE) {
293341
mHoveringPointerIds.add(activePointerId);
@@ -296,13 +344,16 @@ public void handleMotionEvent(
296344
PointerEventState eventState = createEventState(activePointerId, motionEvent);
297345

298346
// We've empirically determined that when we get a ACTION_HOVER_EXIT from the root view on the
299-
// `onInterceptHoverEvent`, this means we've exited the root view.
300-
// This logic may be wrong but reasoning about the dispatch sequence for HOVER_ENTER/HOVER_EXIT
301-
// doesn't follow the capture/bubbling sequence like other MotionEvents. See:
347+
// `onInterceptHoverEvent`, this means we've exited the root view. This logic may be wrong but
348+
// reasoning about the dispatch sequence for HOVER_ENTER/HOVER_EXIT doesn't follow the
349+
// capture/bubbling sequence like other MotionEvents.
350+
//
351+
// The choreographer logic above is a hack to try to work around this, but it's not perfect.
352+
//
353+
// For more information, see:
302354
// https://developer.android.com/reference/android/view/MotionEvent#ACTION_HOVER_ENTER
303355
// https://suragch.medium.com/how-touch-events-are-delivered-in-android-eee3b607b038
304-
boolean isExitFromRoot =
305-
isCapture && motionEvent.getActionMasked() == MotionEvent.ACTION_HOVER_EXIT;
356+
boolean isExitFromRoot = isCapture && action == MotionEvent.ACTION_HOVER_EXIT;
306357

307358
// Calculate the targetTag, with special handling for when we exit the root view. In that case,
308359
// we use the root viewId of the last event

0 commit comments

Comments
 (0)