Skip to content

Commit

Permalink
PointerEvents: Account for root view exits
Browse files Browse the repository at this point in the history
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
  • Loading branch information
lunaleaps authored and facebook-github-bot committed Feb 2, 2023
1 parent a0800ff commit 1e53f88
Show file tree
Hide file tree
Showing 3 changed files with 75 additions and 42 deletions.
22 changes: 11 additions & 11 deletions ReactAndroid/src/main/java/com/facebook/react/ReactRootView.java
Original file line number Diff line number Diff line change
Expand Up @@ -262,31 +262,31 @@ public boolean onInterceptTouchEvent(MotionEvent ev) {
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);
}

Expand Down Expand Up @@ -343,7 +343,7 @@ public void requestChildFocus(View child, View focused) {
super.requestChildFocus(child, focused);
}

protected void dispatchJSPointerEvent(MotionEvent event) {
protected void dispatchJSPointerEvent(MotionEvent event, boolean isCapture) {
if (mReactInstanceManager == null
|| !mIsAttachedToInstance
|| mReactInstanceManager.getCurrentReactContext() == null) {
Expand All @@ -362,7 +362,7 @@ protected void dispatchJSPointerEvent(MotionEvent event) {

if (uiManager != null) {
EventDispatcher eventDispatcher = uiManager.getEventDispatcher();
mJSPointerDispatcher.handleMotionEvent(event, eventDispatcher);
mJSPointerDispatcher.handleMotionEvent(event, eventDispatcher, isCapture);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,8 @@ private PointerEventState createEventState(int activePointerId, MotionEvent moti
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;
Expand All @@ -214,24 +215,60 @@ public void handleMotionEvent(MotionEvent motionEvent, EventDispatcher eventDisp
}

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:
onDown(activeTargetTag, eventState, motionEvent, eventDispatcher);
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:
Expand All @@ -257,8 +294,15 @@ public void handleMotionEvent(MotionEvent motionEvent, EventDispatcher eventDisp
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(
Expand All @@ -267,6 +311,7 @@ public void handleMotionEvent(MotionEvent motionEvent, EventDispatcher eventDisp
return;
}

// Caching the event state so we have a new "last"
mLastHitPathByPointerId = eventState.getHitPathByPointerId();
mLastEventCoordinatesByPointerId = eventState.getEventCoordinatesByPointerId();
mLastButtonState = motionEvent.getButtonState();
Expand Down Expand Up @@ -335,37 +380,25 @@ private void dispatchEventForViewTargets(
}
}

// 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,
MotionEvent motionEvent,
EventDispatcher eventDispatcher) {

int activePointerId = eventState.getActivePointerId();
float[] eventCoordinates = eventState.getEventCoordinatesByPointerId().get(activePointerId);
List<ViewTarget> activeHitPath = eventState.getHitPathByPointerId().get(activePointerId);

List<ViewTarget> lastHitPath =
mLastHitPathByPointerId != null && mLastHitPathByPointerId.containsKey(activePointerId)
? 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -534,7 +534,7 @@ private ThemedReactContext getReactContext() {
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);
}
Expand All @@ -543,7 +543,7 @@ public boolean onInterceptTouchEvent(MotionEvent event) {
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
Expand All @@ -554,15 +554,15 @@ public boolean onTouchEvent(MotionEvent event) {
@Override
public boolean onInterceptHoverEvent(MotionEvent event) {
if (mJSPointerDispatcher != null) {
mJSPointerDispatcher.handleMotionEvent(event, mEventDispatcher);
mJSPointerDispatcher.handleMotionEvent(event, mEventDispatcher, true);
}
return super.onHoverEvent(event);
}

@Override
public boolean onHoverEvent(MotionEvent event) {
if (mJSPointerDispatcher != null) {
mJSPointerDispatcher.handleMotionEvent(event, mEventDispatcher);
mJSPointerDispatcher.handleMotionEvent(event, mEventDispatcher, false);
}
return super.onHoverEvent(event);
}
Expand Down

0 comments on commit 1e53f88

Please sign in to comment.