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
This commit is contained in:
Luna Wei 2023-02-01 23:52:16 -08:00 коммит произвёл Facebook GitHub Bot
Родитель a0800ffc7a
Коммит 1e53f88b72
3 изменённых файлов: 75 добавлений и 42 удалений

Просмотреть файл

@ -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);
}
}

Просмотреть файл

@ -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<ViewTarget> 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<ViewTarget> activeHitPath;
if (isExitFromRoot) {
List<ViewTarget> 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<ViewTarget> activeHitPath = eventState.getHitPathByPointerId().get(activePointerId);
List<ViewTarget> lastHitPath =
@ -351,21 +399,6 @@ public class JSPointerDispatcher {
? mLastHitPathByPointerId.get(activePointerId)
: new ArrayList<ViewTarget>();
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

Просмотреть файл

@ -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);
}