From 1e53f88b72e24ae2654b31f5c8bfbf8a07d51e09 Mon Sep 17 00:00:00 2001 From: Luna Wei Date: Wed, 1 Feb 2023 23:52:16 -0800 Subject: [PATCH] PointerEvents: Account for root view exits Summary: Changelog: [Internal] Support hovering in/out of root views Prior to this change, we did not have signal when an input device moved out of the root view and so our internal state would not be aware and we would not trigger enter/leave events. This diff starts listening to `HOVER_EXIT` events as dispatched from `onInterceptHoverEvent` and assumes that's the right event to signal a cursor has moved out of the root view. We dispatch the relevant leave events for this case and update our internal state to ensure the next `HOVER_MOVE` in our rootview, will properly dispatch the enter events. ## Investigation for creating this diff Determining the signal for when an inputDevice enters/exits our rootview wasn't straight-forward. From my understanding Android event dispatching follows a similar capture, bubbling phase as web. With `onIntercept..` handlers to swallow events. See this explanation: https://suragch.medium.com/how-touch-events-are-delivered-in-android-eee3b607b038 and this video talk: https://youtu.be/EZAoJU-nUyI?t=929 However when trying to understand hover enter/exit events on the root view, my understanding of this logic broke down. Here's what confused me: * When moving a cursor from inside to outside the root view, I would receive `HOVER_ENTER/EXIT` MotionEvents on `onInterceptHoverEvent` and since we did not swallow them, we'd receive those same events on the bubble up in `onHover`. That makes sense. * However, when I hovered from the rootview into a child view, I would receive MotionEvents of `HOVER_ENTER/HOVER_EXIT` in the `onHoverEvent` handler of the rootview without having seen them in the `onInterceptHoverEvent` (re: capture phase down). This was confusing, where was the capture down? * What tips me off that these events (`HOVER_ENTER/EXIT`) don't follow the classic capture, bubbling model as explained in the linked article, is that I don't receive `HOVER_ENTER/HOVER_EXIT` events for each child view in the root view's `onInterceptHoverEvent`. * Like when a cursor moves from root -> child, I'd expect to motion events 1. exit for the rootview, 2. enter for the child view. But I never receive the 2. from the root view -- * I also wonder if the wording for `HOVER_EXIT` events mean that these events are directly dispatched to the view? Re: ["This action is always delivered to the window or view that was previously under the pointer."](https://developer.android.com/reference/android/view/MotionEvent#ACTION_HOVER_ENTER) * There also seems to be some optimizations around the dispatch path as mentioned in this video at this timestamp: https://youtu.be/EZAoJU-nUyI?t=929 for the UP gesture.. so maybe there's some optimization happening with hover events? I'm not sure how hover events are account for in gesture handling for Android. Reviewed By: vincentriemer Differential Revision: D42817315 fbshipit-source-id: 412c971c1d1e7afc0d67fadcc4417189967fe48c --- .../com/facebook/react/ReactRootView.java | 22 ++--- .../react/uimanager/JSPointerDispatcher.java | 87 +++++++++++++------ .../react/views/modal/ReactModalHostView.java | 8 +- 3 files changed, 75 insertions(+), 42 deletions(-) diff --git a/ReactAndroid/src/main/java/com/facebook/react/ReactRootView.java b/ReactAndroid/src/main/java/com/facebook/react/ReactRootView.java index 5ac4cd77d23..9f4339bf490 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/ReactRootView.java +++ b/ReactAndroid/src/main/java/com/facebook/react/ReactRootView.java @@ -262,31 +262,31 @@ public class ReactRootView extends FrameLayout implements RootView, ReactRoot { if (shouldDispatchJSTouchEvent(ev)) { dispatchJSTouchEvent(ev); } - dispatchJSPointerEvent(ev); + dispatchJSPointerEvent(ev, true); return super.onInterceptTouchEvent(ev); } - @Override - public boolean onInterceptHoverEvent(MotionEvent ev) { - dispatchJSPointerEvent(ev); - return super.onInterceptHoverEvent(ev); - } - @Override public boolean onTouchEvent(MotionEvent ev) { if (shouldDispatchJSTouchEvent(ev)) { dispatchJSTouchEvent(ev); } - dispatchJSPointerEvent(ev); + dispatchJSPointerEvent(ev, false); super.onTouchEvent(ev); // In case when there is no children interested in handling touch event, we return true from // the root view in order to receive subsequent events related to that gesture return true; } + @Override + public boolean onInterceptHoverEvent(MotionEvent ev) { + dispatchJSPointerEvent(ev, true); + return super.onInterceptHoverEvent(ev); + } + @Override public boolean onHoverEvent(MotionEvent ev) { - dispatchJSPointerEvent(ev); + dispatchJSPointerEvent(ev, false); return super.onHoverEvent(ev); } @@ -343,7 +343,7 @@ public class ReactRootView extends FrameLayout implements RootView, ReactRoot { super.requestChildFocus(child, focused); } - protected void dispatchJSPointerEvent(MotionEvent event) { + protected void dispatchJSPointerEvent(MotionEvent event, boolean isCapture) { if (mReactInstanceManager == null || !mIsAttachedToInstance || mReactInstanceManager.getCurrentReactContext() == null) { @@ -362,7 +362,7 @@ public class ReactRootView extends FrameLayout implements RootView, ReactRoot { if (uiManager != null) { EventDispatcher eventDispatcher = uiManager.getEventDispatcher(); - mJSPointerDispatcher.handleMotionEvent(event, eventDispatcher); + mJSPointerDispatcher.handleMotionEvent(event, eventDispatcher, isCapture); } } diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/JSPointerDispatcher.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/JSPointerDispatcher.java index e1f75da6dc6..caeea1dec94 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/JSPointerDispatcher.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/JSPointerDispatcher.java @@ -199,7 +199,8 @@ public class JSPointerDispatcher { mHoveringPointerIds); // Creates a copy of hovering pointer ids, as they may be updated } - public void handleMotionEvent(MotionEvent motionEvent, EventDispatcher eventDispatcher) { + public void handleMotionEvent( + MotionEvent motionEvent, EventDispatcher eventDispatcher, boolean isCapture) { // Don't fire any pointer events if child view is handling native gesture if (mChildHandlingNativeGesture != -1) { return; @@ -214,16 +215,41 @@ public class JSPointerDispatcher { } PointerEventState eventState = createEventState(activePointerId, motionEvent); - List activeHitPath = - eventState.getHitPathByPointerId().get(eventState.getActivePointerId()); - if (activeHitPath == null || activeHitPath.isEmpty()) { - return; + // 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: + // 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; + + // 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; + + List activeHitPath; + if (isExitFromRoot) { + List lastHitPath = mLastHitPathByPointerId.get(eventState.getActivePointerId()); + if (lastHitPath == null || lastHitPath.isEmpty()) { + return; + } + activeTargetTag = lastHitPath.get(lastHitPath.size() - 1).getViewId(); + + // Explicitly make the hit path for this cursor empty + activeHitPath = new ArrayList<>(); + eventState.getHitPathByPointerId().put(activePointerId, activeHitPath); + } else { + activeHitPath = eventState.getHitPathByPointerId().get(activePointerId); + if (activeHitPath == null || activeHitPath.isEmpty()) { + return; + } + activeTargetTag = activeHitPath.get(0).getViewId(); } - TouchTargetHelper.ViewTarget activeViewTarget = activeHitPath.get(0); - int activeTargetTag = activeViewTarget.getViewId(); - + // Dispatch pointer events from the MotionEvents. When we want to ignore an event, we need to + // exit early so we don't record anything about this MotionEvent. switch (action) { case MotionEvent.ACTION_DOWN: case MotionEvent.ACTION_POINTER_DOWN: @@ -231,7 +257,18 @@ public class JSPointerDispatcher { break; case MotionEvent.ACTION_HOVER_MOVE: // TODO(luwe) - converge this with ACTION_MOVE - // HOVER_MOVE may occur before DOWN. Add its downTime as a coalescing key + + // If we don't move enough, ignore this event. + float[] eventCoordinates = eventState.getEventCoordinatesByPointerId().get(activePointerId); + float[] lastEventCoordinates = + mLastEventCoordinatesByPointerId != null + && mLastEventCoordinatesByPointerId.containsKey(activePointerId) + ? mLastEventCoordinatesByPointerId.get(activePointerId) + : new float[] {0, 0}; + if (!qualifiedMove(eventCoordinates, lastEventCoordinates)) { + return; + } + onMove(activeTargetTag, eventState, motionEvent, eventDispatcher); break; case MotionEvent.ACTION_MOVE: @@ -257,8 +294,15 @@ public class JSPointerDispatcher { dispatchCancelEvent(eventState, motionEvent, eventDispatcher); break; case MotionEvent.ACTION_HOVER_ENTER: + // Ignore these events as enters will be calculated from HOVER_MOVE + return; case MotionEvent.ACTION_HOVER_EXIT: - // These are handled by HOVER_MOVE + // For root exits, we need to update our stored eventState to reflect this exit because we + // won't receive future HOVER_MOVE events when cursor is outside root view + if (isExitFromRoot) { + // We've set the hit path for this pointer to be empty to calculate all exits + onMove(activeTargetTag, eventState, motionEvent, eventDispatcher); + } break; default: FLog.w( @@ -267,6 +311,7 @@ public class JSPointerDispatcher { return; } + // Caching the event state so we have a new "last" mLastHitPathByPointerId = eventState.getHitPathByPointerId(); mLastEventCoordinatesByPointerId = eventState.getEventCoordinatesByPointerId(); mLastButtonState = motionEvent.getButtonState(); @@ -335,7 +380,11 @@ public class JSPointerDispatcher { } } - // called on hover_move motion events only + private boolean qualifiedMove(float[] eventCoordinates, float[] lastEventCoordinates) { + return (Math.abs(lastEventCoordinates[0] - eventCoordinates[0]) > ONMOVE_EPSILON + || Math.abs(lastEventCoordinates[1] - eventCoordinates[1]) > ONMOVE_EPSILON); + } + private void onMove( int targetTag, PointerEventState eventState, @@ -343,7 +392,6 @@ public class JSPointerDispatcher { EventDispatcher eventDispatcher) { int activePointerId = eventState.getActivePointerId(); - float[] eventCoordinates = eventState.getEventCoordinatesByPointerId().get(activePointerId); List activeHitPath = eventState.getHitPathByPointerId().get(activePointerId); List lastHitPath = @@ -351,21 +399,6 @@ public class JSPointerDispatcher { ? mLastHitPathByPointerId.get(activePointerId) : new ArrayList(); - float[] lastEventCoordinates = - mLastEventCoordinatesByPointerId != null - && mLastEventCoordinatesByPointerId.containsKey(activePointerId) - ? mLastEventCoordinatesByPointerId.get(activePointerId) - : new float[] {0, 0}; - - boolean qualifiedMove = - (Math.abs(lastEventCoordinates[0] - eventCoordinates[0]) > ONMOVE_EPSILON - || Math.abs(lastEventCoordinates[1] - eventCoordinates[1]) > ONMOVE_EPSILON); - - // Early exit if active pointer has not moved enough - if (!qualifiedMove) { - return; - } - // hitState is list ordered from inner child -> parent tag // Traverse hitState back-to-front to find the first divergence with lastHitPath // FIXME: this may generate incorrect events when view collapsing changes the hierarchy diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.java b/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.java index 9f408a7668c..8c401a3544f 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.java @@ -534,7 +534,7 @@ public class ReactModalHostView extends ViewGroup public boolean onInterceptTouchEvent(MotionEvent event) { mJSTouchDispatcher.handleTouchEvent(event, mEventDispatcher); if (mJSPointerDispatcher != null) { - mJSPointerDispatcher.handleMotionEvent(event, mEventDispatcher); + mJSPointerDispatcher.handleMotionEvent(event, mEventDispatcher, true); } return super.onInterceptTouchEvent(event); } @@ -543,7 +543,7 @@ public class ReactModalHostView extends ViewGroup public boolean onTouchEvent(MotionEvent event) { mJSTouchDispatcher.handleTouchEvent(event, mEventDispatcher); if (mJSPointerDispatcher != null) { - mJSPointerDispatcher.handleMotionEvent(event, mEventDispatcher); + mJSPointerDispatcher.handleMotionEvent(event, mEventDispatcher, false); } super.onTouchEvent(event); // In case when there is no children interested in handling touch event, we return true from @@ -554,7 +554,7 @@ public class ReactModalHostView extends ViewGroup @Override public boolean onInterceptHoverEvent(MotionEvent event) { if (mJSPointerDispatcher != null) { - mJSPointerDispatcher.handleMotionEvent(event, mEventDispatcher); + mJSPointerDispatcher.handleMotionEvent(event, mEventDispatcher, true); } return super.onHoverEvent(event); } @@ -562,7 +562,7 @@ public class ReactModalHostView extends ViewGroup @Override public boolean onHoverEvent(MotionEvent event) { if (mJSPointerDispatcher != null) { - mJSPointerDispatcher.handleMotionEvent(event, mEventDispatcher); + mJSPointerDispatcher.handleMotionEvent(event, mEventDispatcher, false); } return super.onHoverEvent(event); }