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