From c50e6b52fe3ff9e0ff3f8bfcd104c1a3d306c38c Mon Sep 17 00:00:00 2001 From: Luna Wei Date: Wed, 30 Mar 2022 19:12:42 -0700 Subject: [PATCH] Emit non-hover pointer events on Android (#33526) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/33526 Changelog: [Internal] Experimental pointer event dispatching on Android: touch pointer support only, hover events to come later. Reviewed By: vincentriemer Differential Revision: D34132667 fbshipit-source-id: 6506d43b4ad16e11b10c3cd23e0231428209411f --- Libraries/Components/View/ViewPropTypes.js | 15 ++ .../NativeComponent/PlatformBaseViewConfig.js | 38 ++++ Libraries/Types/CoreEventTypes.js | 12 ++ .../com/facebook/react/ReactRootView.java | 55 +++++- .../react/config/ReactFeatureFlags.java | 2 + .../react/uimanager/BaseViewManager.java | 55 ++++++ .../react/uimanager/JSPointerDispatcher.java | 186 ++++++++++++++++++ .../react/uimanager/events/PointerEvent.java | 180 +++++++++++++++++ .../uimanager/events/PointerEventHelper.java | 75 +++++++ .../java/com/facebook/react/views/modal/BUCK | 1 + .../react/views/modal/ReactModalHostView.java | 20 +- 11 files changed, 633 insertions(+), 6 deletions(-) create mode 100644 ReactAndroid/src/main/java/com/facebook/react/uimanager/JSPointerDispatcher.java create mode 100644 ReactAndroid/src/main/java/com/facebook/react/uimanager/events/PointerEvent.java create mode 100644 ReactAndroid/src/main/java/com/facebook/react/uimanager/events/PointerEventHelper.java diff --git a/Libraries/Components/View/ViewPropTypes.js b/Libraries/Components/View/ViewPropTypes.js index b151095d696..d057a621573 100644 --- a/Libraries/Components/View/ViewPropTypes.js +++ b/Libraries/Components/View/ViewPropTypes.js @@ -99,6 +99,20 @@ type TouchEventProps = $ReadOnly<{| onTouchStartCapture?: ?(e: PressEvent) => void, |}>; +type PointerEventCallbackProps = $ReadOnly<{| + onPointerCancel?: ?(e: PointerEvent) => void, + onPointerCancelCapture?: ?(e: PointerEvent) => void, + onPointerDown?: ?(e: PointerEvent) => void, + onPointerDownCapture?: ?(e: PointerEvent) => void, + onPointerEnter2?: ?(e: PointerEvent) => void, + onPointerLeave2?: ?(e: PointerEvent) => void, + onPointerEnter2Capture?: ?(e: PointerEvent) => void, + onPointerLeave2Capture?: ?(e: PointerEvent) => void, + onPointerMove2Capture?: ?(e: PointerEvent) => void, + onPointerUp?: ?(e: PointerEvent) => void, + onPointerUpCapture?: ?(e: PointerEvent) => void, +|}>; + /** * For most touch interactions, you'll simply want to wrap your component in * `TouchableHighlight` or `TouchableOpacity`. Check out `Touchable.js`, @@ -382,6 +396,7 @@ export type ViewProps = $ReadOnly<{| ...GestureResponderEventProps, ...MouseEventProps, ...TouchEventProps, + ...PointerEventCallbackProps, ...AndroidViewProps, ...IOSViewProps, diff --git a/Libraries/NativeComponent/PlatformBaseViewConfig.js b/Libraries/NativeComponent/PlatformBaseViewConfig.js index a34da238262..a0b501d2540 100644 --- a/Libraries/NativeComponent/PlatformBaseViewConfig.js +++ b/Libraries/NativeComponent/PlatformBaseViewConfig.js @@ -132,6 +132,44 @@ const PlatformBaseViewConfig: PartialViewConfigWithoutName = bubbled: 'onTouchMove', }, }, + topPointerCancel: { + phasedRegistrationNames: { + captured: 'onPointerCancelCapture', + bubbled: 'onPointerCancel', + }, + }, + topPointerDown: { + phasedRegistrationNames: { + captured: 'onPointerDownCapture', + bubbled: 'onPointerDown', + }, + }, + topPointerEnter2: { + phasedRegistrationNames: { + captured: 'onPointerEnter2Capture', + bubbled: 'onPointerEnter2', + skipBubbling: true, + }, + }, + topPointerLeave2: { + phasedRegistrationNames: { + captured: 'onPointerLeave2Capture', + bubbled: 'onPointerLeave2', + skipBubbling: true, + }, + }, + topPointerMove2: { + phasedRegistrationNames: { + captured: 'onPointerMove2Capture', + bubbled: 'onPointerMove2', + }, + }, + topPointerUp: { + phasedRegistrationNames: { + captured: 'onPointerUpCapture', + bubbled: 'onPointerUp', + }, + }, }, validAttributes: { // @ReactProps from BaseViewManager diff --git a/Libraries/Types/CoreEventTypes.js b/Libraries/Types/CoreEventTypes.js index 02922b69c40..064ede464eb 100644 --- a/Libraries/Types/CoreEventTypes.js +++ b/Libraries/Types/CoreEventTypes.js @@ -83,6 +83,18 @@ export type TextLayoutEvent = SyntheticEvent< |}>, >; +export type PointerEvent = ResponderSyntheticEvent< + $ReadOnly<{| + pointerId: number, + pressure: number, + pointerType: string, + clientX: number, + clientY: number, + target: ?number, + timestamp: number, + |}>, +>; + export type PressEvent = ResponderSyntheticEvent< $ReadOnly<{| changedTouches: $ReadOnlyArray<$PropertyType>, diff --git a/ReactAndroid/src/main/java/com/facebook/react/ReactRootView.java b/ReactAndroid/src/main/java/com/facebook/react/ReactRootView.java index fb70693013d..b4f0151ff37 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/ReactRootView.java +++ b/ReactAndroid/src/main/java/com/facebook/react/ReactRootView.java @@ -53,6 +53,7 @@ import com.facebook.react.modules.deviceinfo.DeviceInfoModule; import com.facebook.react.surface.ReactStage; import com.facebook.react.uimanager.DisplayMetricsHolder; import com.facebook.react.uimanager.IllegalViewOperationException; +import com.facebook.react.uimanager.JSPointerDispatcher; import com.facebook.react.uimanager.JSTouchDispatcher; import com.facebook.react.uimanager.PixelUtil; import com.facebook.react.uimanager.ReactClippingProhibitedView; @@ -97,6 +98,7 @@ public class ReactRootView extends FrameLayout implements RootView, ReactRoot { private boolean mIsAttachedToInstance; private boolean mShouldLogContentAppeared; private @Nullable JSTouchDispatcher mJSTouchDispatcher; + private @Nullable JSPointerDispatcher mJSPointerDispatcher; private final ReactAndroidHWInputDeviceHelper mAndroidHWInputDeviceHelper = new ReactAndroidHWInputDeviceHelper(this); private boolean mWasMeasured = false; @@ -188,6 +190,11 @@ public class ReactRootView extends FrameLayout implements RootView, ReactRoot { @Override public void onChildStartedNativeGesture(MotionEvent ev) { + onChildStartedNativeGesture(null, ev); + } + + @Override + public void onChildStartedNativeGesture(View childView, MotionEvent ev) { if (!isDispatcherReady()) { return; } @@ -197,14 +204,12 @@ public class ReactRootView extends FrameLayout implements RootView, ReactRoot { if (uiManager != null) { EventDispatcher eventDispatcher = uiManager.getEventDispatcher(); mJSTouchDispatcher.onChildStartedNativeGesture(ev, eventDispatcher); + if (childView != null && mJSPointerDispatcher != null) { + mJSPointerDispatcher.onChildStartedNativeGesture(childView, ev, eventDispatcher); + } } } - @Override - public void onChildStartedNativeGesture(View childView, MotionEvent ev) { - onChildStartedNativeGesture(ev); - } - @Override public void onChildEndedNativeGesture(View childView, MotionEvent ev) { if (!isDispatcherReady()) { @@ -216,6 +221,9 @@ public class ReactRootView extends FrameLayout implements RootView, ReactRoot { if (uiManager != null) { EventDispatcher eventDispatcher = uiManager.getEventDispatcher(); mJSTouchDispatcher.onChildEndedNativeGesture(ev, eventDispatcher); + if (mJSPointerDispatcher != null) { + mJSPointerDispatcher.onChildEndedNativeGesture(); + } } } @@ -230,6 +238,10 @@ public class ReactRootView extends FrameLayout implements RootView, ReactRoot { FLog.w(TAG, "Unable to dispatch touch to JS before the dispatcher is available"); return false; } + if (ReactFeatureFlags.dispatchPointerEvents && mJSPointerDispatcher == null) { + FLog.w(TAG, "Unable to dispatch pointer events to JS before the dispatcher is available"); + return false; + } return true; } @@ -245,6 +257,7 @@ public class ReactRootView extends FrameLayout implements RootView, ReactRoot { if (shouldDispatchJSTouchEvent(ev)) { dispatchJSTouchEvent(ev); } + dispatchJSPointerEvent(ev); return super.onInterceptTouchEvent(ev); } @@ -253,6 +266,7 @@ public class ReactRootView extends FrameLayout implements RootView, ReactRoot { if (shouldDispatchJSTouchEvent(ev)) { dispatchJSTouchEvent(ev); } + dispatchJSPointerEvent(ev); 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 @@ -312,6 +326,29 @@ public class ReactRootView extends FrameLayout implements RootView, ReactRoot { super.requestChildFocus(child, focused); } + private void dispatchJSPointerEvent(MotionEvent event) { + if (mReactInstanceManager == null + || !mIsAttachedToInstance + || mReactInstanceManager.getCurrentReactContext() == null) { + FLog.w(TAG, "Unable to dispatch touch to JS as the catalyst instance has not been attached"); + return; + } + if (mJSPointerDispatcher == null) { + if (!ReactFeatureFlags.dispatchPointerEvents) { + return; + } + FLog.w(TAG, "Unable to dispatch pointer events to JS before the dispatcher is available"); + return; + } + ReactContext reactContext = mReactInstanceManager.getCurrentReactContext(); + UIManager uiManager = UIManagerHelper.getUIManager(reactContext, getUIManagerType()); + + if (uiManager != null) { + EventDispatcher eventDispatcher = uiManager.getEventDispatcher(); + mJSPointerDispatcher.handleMotionEvent(event, eventDispatcher); + } + } + private void dispatchJSTouchEvent(MotionEvent event) { if (mReactInstanceManager == null || !mIsAttachedToInstance @@ -629,6 +666,11 @@ public class ReactRootView extends FrameLayout implements RootView, ReactRoot { // them. Otherwise, these events might break the states expected by JS. // Note that this callback was invoked from within the UI thread. mJSTouchDispatcher = new JSTouchDispatcher(this); + + if (ReactFeatureFlags.dispatchPointerEvents) { + mJSPointerDispatcher = new JSPointerDispatcher(this); + } + if (mRootViewEventListener != null) { mRootViewEventListener.onAttachedToReactInstance(this); } @@ -709,6 +751,9 @@ public class ReactRootView extends FrameLayout implements RootView, ReactRoot { /* package */ void simulateAttachForTesting() { mIsAttachedToInstance = true; mJSTouchDispatcher = new JSTouchDispatcher(this); + if (ReactFeatureFlags.dispatchPointerEvents) { + mJSPointerDispatcher = new JSPointerDispatcher(this); + } } @VisibleForTesting diff --git a/ReactAndroid/src/main/java/com/facebook/react/config/ReactFeatureFlags.java b/ReactAndroid/src/main/java/com/facebook/react/config/ReactFeatureFlags.java index c2924d31025..b0cd238d0f1 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/config/ReactFeatureFlags.java +++ b/ReactAndroid/src/main/java/com/facebook/react/config/ReactFeatureFlags.java @@ -112,4 +112,6 @@ public class ReactFeatureFlags { /** TODO: T113245006 Delete this flag. Enables caching of spannables for text */ public static boolean enableSpannableCache = false; + + public static boolean dispatchPointerEvents = false; } diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java index b349347ca42..3cf7db7be1b 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java @@ -443,6 +443,61 @@ public abstract class BaseViewManager getExportedCustomBubblingEventTypeConstants() { + Map baseEventTypeConstants = super.getExportedCustomDirectEventTypeConstants(); + Map eventTypeConstants = + baseEventTypeConstants == null ? new HashMap() : baseEventTypeConstants; + eventTypeConstants.putAll( + MapBuilder.builder() + .put( + "topPointerCancel", + MapBuilder.of( + "phasedRegistrationNames", + MapBuilder.of( + "bubbled", "onPointerCancel", "captured", "onPointerCancelCapture"))) + .put( + "topPointerDown", + MapBuilder.of( + "phasedRegistrationNames", + MapBuilder.of("bubbled", "onPointerDown", "captured", "onPointerDownCapture"))) + .put( + "topPointerEnter2", + MapBuilder.of( + "phasedRegistrationNames", + MapBuilder.of( + "bubbled", + "onPointerEnter2", + "captured", + "onPointerEnter2Capture", + "skipBubbling", + true))) + .put( + "topPointerLeave2", + MapBuilder.of( + "phasedRegistrationNames", + MapBuilder.of( + "bubbled", + "onPointerLeave2", + "captured", + "onPointerLeave2Capture", + "skipBubbling", + true))) + .put( + "topPointerMove2", + MapBuilder.of( + "phasedRegistrationNames", + MapBuilder.of( + "bubbled", "onPointerMove2", "captured", "onPointerMove2Capture"))) + .put( + "topPointerUp", + MapBuilder.of( + "phasedRegistrationNames", + MapBuilder.of("bubbled", "onPointerUp", "captured", "onPointerUpCapture"))) + .build()); + return eventTypeConstants; + } + @Override public @Nullable Map getExportedCustomDirectEventTypeConstants() { @Nullable diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/JSPointerDispatcher.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/JSPointerDispatcher.java new file mode 100644 index 00000000000..743da5af886 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/JSPointerDispatcher.java @@ -0,0 +1,186 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.uimanager; + +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import com.facebook.common.logging.FLog; +import com.facebook.infer.annotation.Assertions; +import com.facebook.react.common.ReactConstants; +import com.facebook.react.uimanager.events.EventDispatcher; +import com.facebook.react.uimanager.events.PointerEvent; +import com.facebook.react.uimanager.events.PointerEventHelper; +import com.facebook.react.uimanager.events.TouchEvent; +import com.facebook.react.uimanager.events.TouchEventCoalescingKeyHelper; + +/** + * JSPointerDispatcher handles dispatching pointer events to JS from RootViews. If you implement + * RootView you need to call handleMotionEvent from onTouchEvent, onInterceptTouchEvent, + * onHoverEvent, onInterceptHoverEvent. It will correctly find the right view to handle the touch + * and also dispatch the appropriate event to JS + */ +public class JSPointerDispatcher { + + private final float[] mTargetCoordinates = new float[2]; + private int mChildHandlingNativeGesture = -1; + private long mDownStartTime = TouchEvent.UNSET; + private final ViewGroup mRootViewGroup; + private final TouchEventCoalescingKeyHelper mTouchEventCoalescingKeyHelper = + new TouchEventCoalescingKeyHelper(); + + public JSPointerDispatcher(ViewGroup viewGroup) { + mRootViewGroup = viewGroup; + } + + public void onChildStartedNativeGesture( + View childView, MotionEvent motionEvent, EventDispatcher eventDispatcher) { + if (mChildHandlingNativeGesture != -1 || childView == null) { + // This means we previously had another child start handling this native gesture and now a + // different native parent of that child has decided to intercept the touch stream and handle + // the gesture itself. Example where this can happen: HorizontalScrollView in a ScrollView. + return; + } + + int targetTag = findTargetTagAndSetCoordinates(motionEvent); + dispatchCancelEvent(targetTag, motionEvent, eventDispatcher); + mChildHandlingNativeGesture = childView.getId(); + } + + public void onChildEndedNativeGesture() { + // There should be only one child gesture at any given time. We can safely turn off the flag. + mChildHandlingNativeGesture = -1; + } + + public void handleMotionEvent(MotionEvent motionEvent, EventDispatcher eventDispatcher) { + + // Ignore if child is handling native gesture + if (mChildHandlingNativeGesture != -1) { + return; + } + + boolean supportsHover = + PointerEventHelper.supportsHover(motionEvent.getToolType(motionEvent.getActionIndex())); + + int surfaceId = UIManagerHelper.getSurfaceId(mRootViewGroup); + int action = motionEvent.getActionMasked(); + int targetTag = findTargetTagAndSetCoordinates(motionEvent); + + // First down pointer + if (action == MotionEvent.ACTION_DOWN) { + + // Start a "down" coalescing key + mDownStartTime = motionEvent.getEventTime(); + mTouchEventCoalescingKeyHelper.addCoalescingKey(mDownStartTime); + + if (!supportsHover) { + eventDispatcher.dispatchEvent( + PointerEvent.obtain( + PointerEventHelper.POINTER_ENTER, surfaceId, targetTag, motionEvent)); + } + eventDispatcher.dispatchEvent( + PointerEvent.obtain(PointerEventHelper.POINTER_DOWN, surfaceId, targetTag, motionEvent)); + + return; + } + + // New pointer goes down, this can only happen after ACTION_DOWN is sent for the first pointer + if (action == MotionEvent.ACTION_POINTER_DOWN) { + mTouchEventCoalescingKeyHelper.incrementCoalescingKey(mDownStartTime); + + if (!supportsHover) { + eventDispatcher.dispatchEvent( + PointerEvent.obtain( + PointerEventHelper.POINTER_ENTER, surfaceId, targetTag, motionEvent)); + } + eventDispatcher.dispatchEvent( + PointerEvent.obtain(PointerEventHelper.POINTER_DOWN, surfaceId, targetTag, motionEvent)); + + return; + } + + if (action == MotionEvent.ACTION_MOVE) { + int coalescingKey = mTouchEventCoalescingKeyHelper.getCoalescingKey(mDownStartTime); + eventDispatcher.dispatchEvent( + PointerEvent.obtain( + PointerEventHelper.POINTER_MOVE, surfaceId, targetTag, motionEvent, coalescingKey)); + return; + } + + // Exactly one of the pointers goes up, not the last one + if (action == MotionEvent.ACTION_POINTER_UP) { + mTouchEventCoalescingKeyHelper.incrementCoalescingKey(mDownStartTime); + eventDispatcher.dispatchEvent( + PointerEvent.obtain(PointerEventHelper.POINTER_UP, surfaceId, targetTag, motionEvent)); + + return; + } + + // Last pointer comes up + if (action == MotionEvent.ACTION_UP) { + + // End of a "down" coalescing key + mTouchEventCoalescingKeyHelper.removeCoalescingKey(mDownStartTime); + mDownStartTime = TouchEvent.UNSET; + + eventDispatcher.dispatchEvent( + PointerEvent.obtain(PointerEventHelper.POINTER_UP, surfaceId, targetTag, motionEvent)); + + if (!supportsHover) { + eventDispatcher.dispatchEvent( + PointerEvent.obtain( + PointerEventHelper.POINTER_LEAVE, surfaceId, targetTag, motionEvent)); + } + return; + } + + if (action == MotionEvent.ACTION_CANCEL) { + dispatchCancelEvent(targetTag, motionEvent, eventDispatcher); + + return; + } + + FLog.w( + ReactConstants.TAG, + "Warning : Motion Event was ignored. Action=" + + action + + " Target=" + + targetTag + + " Supports Hover=" + + supportsHover); + } + + private int findTargetTagAndSetCoordinates(MotionEvent ev) { + // This method updates `mTargetCoordinates` with coordinates for the motion event. + return TouchTargetHelper.findTargetTagAndCoordinatesForTouch( + ev.getX(), ev.getY(), mRootViewGroup, mTargetCoordinates, null); + } + + private void dispatchCancelEvent( + int targetTag, MotionEvent motionEvent, EventDispatcher eventDispatcher) { + // This means the gesture has already ended, via some other CANCEL or UP event. This is not + // expected to happen very often as it would mean some child View has decided to intercept the + // touch stream and start a native gesture only upon receiving the UP/CANCEL event. + + Assertions.assertCondition( + mChildHandlingNativeGesture == -1, + "Expected to not have already sent a cancel for this gesture"); + int surfaceId = UIManagerHelper.getSurfaceId(mRootViewGroup); + + Assertions.assertNotNull(eventDispatcher) + .dispatchEvent( + PointerEvent.obtain( + PointerEventHelper.POINTER_CANCEL, surfaceId, targetTag, motionEvent)); + + eventDispatcher.dispatchEvent( + PointerEvent.obtain(PointerEventHelper.POINTER_LEAVE, surfaceId, targetTag, motionEvent)); + + mTouchEventCoalescingKeyHelper.removeCoalescingKey(mDownStartTime); + mDownStartTime = TouchEvent.UNSET; + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/PointerEvent.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/PointerEvent.java new file mode 100644 index 00000000000..031522bf979 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/PointerEvent.java @@ -0,0 +1,180 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.uimanager.events; + +import android.view.MotionEvent; +import androidx.annotation.Nullable; +import androidx.core.util.Pools; +import com.facebook.infer.annotation.Assertions; +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.ReactSoftExceptionLogger; +import com.facebook.react.bridge.WritableMap; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class PointerEvent extends Event { + private static final String TAG = PointerEvent.class.getSimpleName(); + private static final int POINTER_EVENTS_POOL_SIZE = 6; + private static final Pools.SynchronizedPool EVENTS_POOL = + new Pools.SynchronizedPool<>(POINTER_EVENTS_POOL_SIZE); + private static final int UNSET_COALESCING_KEY = -1; + + public static PointerEvent obtain( + String eventName, int surfaceId, int viewTag, MotionEvent motionEventToCopy) { + PointerEvent event = EVENTS_POOL.acquire(); + if (event == null) { + event = new PointerEvent(); + } + event.init(eventName, surfaceId, viewTag, Assertions.assertNotNull(motionEventToCopy), 0); + return event; + } + + public static PointerEvent obtain( + String eventName, + int surfaceId, + int viewTag, + MotionEvent motionEventToCopy, + int coalescingKey) { + PointerEvent event = EVENTS_POOL.acquire(); + if (event == null) { + event = new PointerEvent(); + } + event.init( + eventName, surfaceId, viewTag, Assertions.assertNotNull(motionEventToCopy), coalescingKey); + return event; + } + + private @Nullable MotionEvent mMotionEvent; + private @Nullable String mEventName; + private int mCoalescingKey = UNSET_COALESCING_KEY; + + private void init( + String eventName, + int surfaceId, + int viewTag, + MotionEvent motionEventToCopy, + int coalescingKey) { + super.init(surfaceId, viewTag, motionEventToCopy.getEventTime()); + mEventName = eventName; + mMotionEvent = MotionEvent.obtain(motionEventToCopy); + mCoalescingKey = coalescingKey; + } + + private PointerEvent() {} + + @Override + public String getEventName() { + return mEventName; + } + + @Override + public void dispatch(RCTEventEmitter rctEventEmitter) { + // Skip legacy stuff for now? + return; + } + + @Override + public void onDispose() { + MotionEvent motionEvent = mMotionEvent; + mMotionEvent = null; + if (motionEvent != null) { + motionEvent.recycle(); + } + + // Either `this` is in the event pool, or motionEvent + // is null. It is in theory not possible for a PointerEvent to + // be in the EVENTS_POOL but for motionEvent to be null. However, + // out of an abundance of caution and to avoid memory leaks or + // other crashes at all costs, we attempt to release here and log + // a soft exception here if release throws an IllegalStateException + // due to `this` being over-released. This may indicate that there is + // a logic error in our events system or pooling mechanism. + try { + EVENTS_POOL.release(this); + } catch (IllegalStateException e) { + ReactSoftExceptionLogger.logSoftException(TAG, e); + } + } + + private ArrayList createPointerEvents() { + MotionEvent motionEvent = mMotionEvent; + ArrayList pointerEvents = new ArrayList<>(); + + for (int index = 0; index < motionEvent.getPointerCount(); index++) { + pointerEvents.add(this.createPointerEvent(index)); + } + + return pointerEvents; + } + + private WritableMap createPointerEvent(int index) { + WritableMap pointerEvent = Arguments.createMap(); + + pointerEvent.putDouble("pointerId", mMotionEvent.getPointerId(index)); + pointerEvent.putDouble("pressure", mMotionEvent.getPressure(index)); + pointerEvent.putString( + "pointerType", PointerEventHelper.getW3CPointerType(mMotionEvent.getToolType(index))); + + // Client refers to upper left edge of the content area (viewport) + // We define the viewport to be ReactRootView + pointerEvent.putDouble("clientX", mMotionEvent.getX(index)); + pointerEvent.putDouble("clientY", mMotionEvent.getY(index)); + + pointerEvent.putInt("target", this.getViewTag()); + pointerEvent.putDouble("timestamp", this.getTimestampMs()); + return pointerEvent; + } + + @Override + public void dispatchModern(RCTModernEventEmitter rctEventEmitter) { + if (mMotionEvent == null) { + ReactSoftExceptionLogger.logSoftException( + TAG, + new IllegalStateException( + "Cannot dispatch a Pointer that has no MotionEvent; the PointerEvehas been recycled")); + return; + } + + List relevantPointerEventData = null; + + int activePointerIndex = mMotionEvent.getActionIndex(); + switch (mEventName) { + // Cases where all pointer info is relevant + case PointerEventHelper.POINTER_MOVE: + case PointerEventHelper.POINTER_CANCEL: + relevantPointerEventData = createPointerEvents(); + break; + // Cases where only the "active" pointer info is relevant + case PointerEventHelper.POINTER_ENTER: + case PointerEventHelper.POINTER_DOWN: + case PointerEventHelper.POINTER_UP: + case PointerEventHelper.POINTER_LEAVE: + relevantPointerEventData = Arrays.asList(createPointerEvent(activePointerIndex)); + break; + } + + if (relevantPointerEventData == null) { + // No relevant MotionEvent to dispatch + return; + } + + boolean shouldCopy = relevantPointerEventData.size() > 1; + for (WritableMap pointerEventData : relevantPointerEventData) { + WritableMap eventData = shouldCopy ? pointerEventData.copy() : pointerEventData; + rctEventEmitter.receiveEvent( + this.getSurfaceId(), + this.getViewTag(), + mEventName, + mCoalescingKey != UNSET_COALESCING_KEY, + mCoalescingKey, + eventData, + PointerEventHelper.getEventCategory(mEventName)); + } + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/PointerEventHelper.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/PointerEventHelper.java new file mode 100644 index 00000000000..f265b0f55ac --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/PointerEventHelper.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.uimanager.events; + +import android.view.MotionEvent; + +/** Class responsible for generating catalyst touch events based on android {@link MotionEvent}. */ +public class PointerEventHelper { + + public static final String POINTER_TYPE_TOUCH = "touch"; + public static final String POINTER_TYPE_PEN = "pen"; + public static final String POINTER_TYPE_MOUSE = "mouse"; + public static final String POINTER_TYPE_UNKNOWN = ""; + + public static final String POINTER_CANCEL = "topPointerCancel"; + public static final String POINTER_DOWN = "topPointerDown"; + public static final String POINTER_ENTER = "topPointerEnter2"; + public static final String POINTER_LEAVE = "topPointerLeave2"; + public static final String POINTER_MOVE = "topPointerMove2"; + public static final String POINTER_UP = "topPointerUp"; + + public static String getW3CPointerType(final int toolType) { + // https://www.w3.org/TR/pointerevents3/#dom-pointerevent-pointertype + switch (toolType) { + case MotionEvent.TOOL_TYPE_FINGER: + return POINTER_TYPE_TOUCH; + + case MotionEvent.TOOL_TYPE_STYLUS: + return POINTER_TYPE_PEN; + + case MotionEvent.TOOL_TYPE_MOUSE: + return POINTER_TYPE_MOUSE; + } + return POINTER_TYPE_UNKNOWN; + } + + public static int getEventCategory(String pointerEventType) { + if (pointerEventType == null) { + return EventCategoryDef.UNSPECIFIED; + } + // Following: + // https://github.com/facebook/react/blob/main/packages/react-dom/src/events/ReactDOMEventListener.js#L435-L437 + switch (pointerEventType) { + case POINTER_DOWN: + case POINTER_CANCEL: + case POINTER_UP: + return EventCategoryDef.DISCRETE; + case POINTER_MOVE: + case POINTER_ENTER: + case POINTER_LEAVE: + return EventCategoryDef.CONTINUOUS; + } + + return EventCategoryDef.UNSPECIFIED; + } + + public static boolean supportsHover(final int toolType) { + String pointerType = getW3CPointerType(toolType); + + if (pointerType.equals(POINTER_TYPE_MOUSE)) { + return true; + } else if (pointerType.equals(POINTER_TYPE_PEN)) { + return true; // true? + } else if (pointerType.equals(POINTER_TYPE_TOUCH)) { + return false; + } + + return false; + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/modal/BUCK b/ReactAndroid/src/main/java/com/facebook/react/views/modal/BUCK index a3679de486c..1c4b826e34d 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/modal/BUCK +++ b/ReactAndroid/src/main/java/com/facebook/react/views/modal/BUCK @@ -24,6 +24,7 @@ rn_android_library( react_native_root_target(":generated_components_java-FBReactNativeComponentSpec"), react_native_target("java/com/facebook/react/bridge:bridge"), react_native_target("java/com/facebook/react/common:common"), + react_native_target("java/com/facebook/react/config:config"), react_native_target("java/com/facebook/react/module/annotations:annotations"), react_native_target("java/com/facebook/react/touch:touch"), react_native_target("java/com/facebook/react/uimanager:uimanager"), 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 adbd81d7fd1..c3e943ffa81 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 @@ -34,7 +34,9 @@ import com.facebook.react.bridge.UiThreadUtil; import com.facebook.react.bridge.WritableMap; import com.facebook.react.bridge.WritableNativeMap; import com.facebook.react.common.annotations.VisibleForTesting; +import com.facebook.react.config.ReactFeatureFlags; import com.facebook.react.uimanager.FabricViewStateManager; +import com.facebook.react.uimanager.JSPointerDispatcher; import com.facebook.react.uimanager.JSTouchDispatcher; import com.facebook.react.uimanager.PixelUtil; import com.facebook.react.uimanager.RootView; @@ -413,9 +415,13 @@ public class ReactModalHostView extends ViewGroup private final FabricViewStateManager mFabricViewStateManager = new FabricViewStateManager(); private final JSTouchDispatcher mJSTouchDispatcher = new JSTouchDispatcher(this); + @Nullable private JSPointerDispatcher mJSPointerDispatcher; public DialogRootViewGroup(Context context) { super(context); + if (ReactFeatureFlags.dispatchPointerEvents) { + mJSPointerDispatcher = new JSPointerDispatcher(this); + } } private void setEventDispatcher(EventDispatcher eventDispatcher) { @@ -515,12 +521,18 @@ public class ReactModalHostView extends ViewGroup @Override public boolean onInterceptTouchEvent(MotionEvent event) { mJSTouchDispatcher.handleTouchEvent(event, mEventDispatcher); + if (mJSPointerDispatcher != null) { + mJSPointerDispatcher.handleMotionEvent(event, mEventDispatcher); + } return super.onInterceptTouchEvent(event); } @Override public boolean onTouchEvent(MotionEvent event) { mJSTouchDispatcher.handleTouchEvent(event, mEventDispatcher); + if (mJSPointerDispatcher != null) { + mJSPointerDispatcher.handleMotionEvent(event, mEventDispatcher); + } super.onTouchEvent(event); // 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 @@ -529,17 +541,23 @@ public class ReactModalHostView extends ViewGroup @Override public void onChildStartedNativeGesture(MotionEvent ev) { - mJSTouchDispatcher.onChildStartedNativeGesture(ev, mEventDispatcher); + this.onChildStartedNativeGesture(null, ev); } @Override public void onChildStartedNativeGesture(View childView, MotionEvent ev) { mJSTouchDispatcher.onChildStartedNativeGesture(ev, mEventDispatcher); + if (mJSPointerDispatcher != null) { + mJSPointerDispatcher.onChildStartedNativeGesture(childView, ev, mEventDispatcher); + } } @Override public void onChildEndedNativeGesture(View childView, MotionEvent ev) { mJSTouchDispatcher.onChildEndedNativeGesture(ev, mEventDispatcher); + if (mJSPointerDispatcher != null) { + mJSPointerDispatcher.onChildEndedNativeGesture(); + } } @Override