diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/TouchesHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/TouchesHelper.kt index 435ac67afbf6..176373a7de57 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/TouchesHelper.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/TouchesHelper.kt @@ -7,9 +7,9 @@ package com.facebook.react.uimanager.events -import com.facebook.react.bridge.Arguments -import com.facebook.react.bridge.WritableArray -import com.facebook.react.bridge.WritableMap +import com.facebook.proguard.annotations.DoNotStrip +import com.facebook.react.bridge.ReactNativeJNISoLoader +import com.facebook.react.bridge.WritableNativeMap import com.facebook.react.uimanager.PixelUtil.pxToDp import com.facebook.systrace.Systrace @@ -17,28 +17,23 @@ import com.facebook.systrace.Systrace * Class responsible for generating catalyst touch events based on android * [android.view.MotionEvent]. */ +@DoNotStrip internal object TouchesHelper { - @JvmField @Deprecated("Not used in New Architecture") val TARGET_KEY: String = "target" - - private const val TARGET_SURFACE_KEY = "targetSurface" - private const val CHANGED_TOUCHES_KEY = "changedTouches" - private const val TOUCHES_KEY = "touches" - private const val PAGE_X_KEY = "pageX" - private const val PAGE_Y_KEY = "pageY" - private const val TIMESTAMP_KEY = "timestamp" - private const val POINTER_IDENTIFIER_KEY = "identifier" + init { + ReactNativeJNISoLoader.staticInit() + } - private const val LOCATION_X_KEY = "locationX" - private const val LOCATION_Y_KEY = "locationY" + // Values per pointer in the data passed to nativeCreateTouchPayload. + private const val POINTER_FIELDS = 8 /** - * Creates catalyst pointers array in format that is expected by RCTEventEmitter JS module from - * given {@param event} instance. This method use {@param reactTarget} parameter to set as a - * target view id associated with current gesture. + * Fills the pointer data of [nativeCreateTouchPayload]: for each pointer of the + * [android.view.MotionEvent], in order, pageX, pageY, locationX, locationY, targetSurface, + * target, timestamp and identifier. */ - private fun createPointersArray(event: TouchEvent): Array { + private fun createPointerData(event: TouchEvent): DoubleArray { val motionEvent = event.getMotionEvent() - val touches = arrayOfNulls(motionEvent.pointerCount) + val pointerData = DoubleArray(motionEvent.pointerCount * POINTER_FIELDS) // Calculate the coordinates for the target view. // The MotionEvent contains the X,Y of the touch in the coordinate space of the root view @@ -51,29 +46,27 @@ internal object TouchesHelper { val targetViewCoordinateY = motionEvent.y - event.viewY for (index in 0 until motionEvent.pointerCount) { - val touch = Arguments.createMap() + var field = index * POINTER_FIELDS // pageX,Y values are relative to the RootReactView // the motionEvent already contains coordinates in that view - touch.putDouble(PAGE_X_KEY, motionEvent.getX(index).pxToDp().toDouble()) - touch.putDouble(PAGE_Y_KEY, motionEvent.getY(index).pxToDp().toDouble()) + pointerData[field++] = motionEvent.getX(index).pxToDp().toDouble() + pointerData[field++] = motionEvent.getY(index).pxToDp().toDouble() // locationX,Y values are relative to the target view // To compute the values for the view, we subtract that views location from the event X,Y val locationX = motionEvent.getX(index) - targetViewCoordinateX val locationY = motionEvent.getY(index) - targetViewCoordinateY - touch.putDouble(LOCATION_X_KEY, locationX.pxToDp().toDouble()) - touch.putDouble(LOCATION_Y_KEY, locationY.pxToDp().toDouble()) + pointerData[field++] = locationX.pxToDp().toDouble() + pointerData[field++] = locationY.pxToDp().toDouble() - touch.putInt(TARGET_SURFACE_KEY, event.surfaceId) - @Suppress("DEPRECATION") touch.putInt(TARGET_KEY, event.viewTag) - touch.putDouble(TIMESTAMP_KEY, event.timestampMs.toDouble()) - touch.putDouble(POINTER_IDENTIFIER_KEY, motionEvent.getPointerId(index).toDouble()) - - touches[index] = touch + pointerData[field++] = event.surfaceId.toDouble() + pointerData[field++] = event.viewTag.toDouble() + pointerData[field++] = event.timestampMs.toDouble() + pointerData[field] = motionEvent.getPointerId(index).toDouble() } - return touches + return pointerData } /** @@ -82,8 +75,9 @@ internal object TouchesHelper { * more efficient manner. * * Touches have to be dispatched as separate events for each changed pointer to make JS process - * them correctly. To avoid allocations, we preprocess touch events in Java world and then convert - * them to native before dispatch. + * them correctly. The pointers are collected once into a [DoubleArray], and each event's payload + * is built from it in a single JNI call, instead of putting every value into a native map one by + * one. * * @param eventEmitter emitter to dispatch event to * @param event the touch event to extract data from @@ -95,52 +89,44 @@ internal object TouchesHelper { "TouchesHelper.sentTouchEventModern(" + event.getEventName() + ")", ) try { - val type = event.getTouchEventType() val motionEvent = event.getMotionEvent() - var touches = createPointersArray(event) - val changedTouches: Array? - - when (type) { + val pointerCount = motionEvent.pointerCount + // Pointers are passed to native as bit masks of their indices. + check(pointerCount < Int.SIZE_BITS) { "Too many pointers: $pointerCount" } + val allPointers = (1 shl pointerCount) - 1 + val changedPointers: Int + val touchPointers: Int + + when (event.getTouchEventType()) { TouchEventType.START -> { - val newPointerIndex = motionEvent.actionIndex - - changedTouches = arrayOf(touches[newPointerIndex]?.copy()) + changedPointers = 1 shl motionEvent.actionIndex + touchPointers = allPointers } TouchEventType.END -> { - val finishedPointerIndex = motionEvent.actionIndex + changedPointers = 1 shl motionEvent.actionIndex /* - * Clear finished pointer index for compatibility with W3C touch "end" events, where the + * Leave out the finished pointer for compatibility with W3C touch "end" events, where the * active touches don't include the set that has just been "ended". */ - val finishedPointer = touches[finishedPointerIndex] - touches[finishedPointerIndex] = null - - changedTouches = arrayOf(finishedPointer) + touchPointers = allPointers and changedPointers.inv() } TouchEventType.MOVE -> { - changedTouches = arrayOfNulls(touches.size) - var i = 0 - while (i < touches.size) { - changedTouches[i] = touches[i]?.copy() - i++ - } + changedPointers = allPointers + touchPointers = allPointers } TouchEventType.CANCEL -> { - changedTouches = touches - touches = arrayOfNulls(0) + changedPointers = allPointers + touchPointers = 0 } } - for (touchData in changedTouches) { + val pointerData = createPointerData(event) + for (pointer in 0 until pointerCount) { + if (changedPointers and (1 shl pointer) == 0) { + continue + } val eventData = - touchData?.let { td -> - val ed = td.copy() - val changedTouchesArray = getWritableArray(/* copyObjects */ true, changedTouches) - val touchesArray = getWritableArray(/* copyObjects */ true, touches) - ed.putArray(CHANGED_TOUCHES_KEY, changedTouchesArray) - ed.putArray(TOUCHES_KEY, touchesArray) - ed - } + nativeCreateTouchPayload(pointerData, pointer, changedPointers, touchPointers) eventEmitter.receiveEvent( event.surfaceId, @@ -158,13 +144,20 @@ internal object TouchesHelper { } } - private fun getWritableArray(copyObjects: Boolean, objects: Array): WritableArray { - val result = Arguments.createArray() - for (obj in objects) { - if (obj != null) { - result.pushMap(if (copyObjects) obj.copy() else obj) - } - } - return result - } + /** + * Creates the payload of a touch event: the fields of pointer [eventPointer] and the + * `changedTouches` and `touches` arrays of the pointers whose bits are set in [changedPointers] + * and [touchPointers]. + * + * @param pointerData 8 values for each pointer, in order: pageX, pageY, locationX, locationY, + * targetSurface, target, timestamp and identifier. + */ + @DoNotStrip + @JvmStatic + private external fun nativeCreateTouchPayload( + pointerData: DoubleArray, + eventPointer: Int, + changedPointers: Int, + touchPointers: Int, + ): WritableNativeMap } diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/jni/CMakeLists.txt b/packages/react-native/ReactAndroid/src/main/jni/react/jni/CMakeLists.txt index 5229c2af141a..1b58d0879f3d 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/jni/CMakeLists.txt +++ b/packages/react-native/ReactAndroid/src/main/jni/react/jni/CMakeLists.txt @@ -32,6 +32,7 @@ add_library( OnLoad-common.cpp ReadableNativeArray.cpp ReadableNativeMap.cpp + TouchesHelper.cpp TransformHelper.cpp WritableNativeArray.cpp WritableNativeMap.cpp diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/jni/OnLoad.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/jni/OnLoad.cpp index 04ef88c09e98..ba536d2cce35 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/jni/OnLoad.cpp +++ b/packages/react-native/ReactAndroid/src/main/jni/react/jni/OnLoad.cpp @@ -11,6 +11,7 @@ #include #include "InspectorNetworkRequestListener.h" +#include "TouchesHelper.h" #include "TransformHelper.h" #ifndef WITH_GLOGINIT @@ -34,6 +35,7 @@ extern "C" JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) { FLAGS_minloglevel = 0; #endif InspectorNetworkRequestListener::registerNatives(); + TouchesHelper::registerNatives(); TransformHelper::registerNatives(); }); } diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/jni/TouchesHelper.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/jni/TouchesHelper.cpp new file mode 100644 index 000000000000..7f52b1ace1b2 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/jni/react/jni/TouchesHelper.cpp @@ -0,0 +1,91 @@ +/* + * 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. + */ + +#include "TouchesHelper.h" + +#include + +#include "WritableNativeArray.h" +#include "WritableNativeMap.h" + +using namespace facebook::jni; + +namespace facebook::react { + +namespace { + +// Must match TouchesHelper.POINTER_FIELDS and the order documented on +// TouchesHelper.nativeCreateTouchPayload. +constexpr jint kPointerFields = 8; + +folly::dynamic createTouch(const double* fields) { + folly::dynamic touch = folly::dynamic::object(); + touch.insert("pageX", fields[0]); + touch.insert("pageY", fields[1]); + touch.insert("locationX", fields[2]); + touch.insert("locationY", fields[3]); + touch.insert("targetSurface", static_cast(fields[4])); + touch.insert("target", static_cast(fields[5])); + touch.insert("timestamp", fields[6]); + touch.insert("identifier", fields[7]); + return touch; +} + +local_ref createTouchPayload( + alias_ref /*unused*/, + alias_ref pointerData, + jint eventPointer, + jint changedPointers, + jint touchPointers) { + auto length = static_cast(pointerData->size()); + jint pointerCount = length / kPointerFields; + // Pointers are selected by bits of the jint masks. + if (length % kPointerFields != 0 || pointerCount >= 32 || eventPointer < 0 || + eventPointer >= pointerCount) { + throw std::invalid_argument("Invalid touch pointer data"); + } + auto values = pointerData->getRegion(0, length); + + folly::dynamic changedTouches = folly::dynamic::array(); + folly::dynamic touches = folly::dynamic::array(); + for (jint index = 0; index < pointerCount; index++) { + const double* fields = values.get() + index * kPointerFields; + if ((changedPointers & (1 << index)) != 0) { + changedTouches.push_back(createTouch(fields)); + } + if ((touchPointers & (1 << index)) != 0) { + touches.push_back(createTouch(fields)); + } + } + + auto jPayload = WritableNativeMap::newObjectJavaArgs(); + auto* payload = jPayload->cthis(); + const double* fields = values.get() + eventPointer * kPointerFields; + payload->putDouble("pageX", fields[0]); + payload->putDouble("pageY", fields[1]); + payload->putDouble("locationX", fields[2]); + payload->putDouble("locationY", fields[3]); + payload->putInt("targetSurface", static_cast(fields[4])); + payload->putInt("target", static_cast(fields[5])); + payload->putDouble("timestamp", fields[6]); + payload->putDouble("identifier", fields[7]); + WritableNativeArray changedTouchesArray(std::move(changedTouches)); + payload->putNativeArray("changedTouches", &changedTouchesArray); + WritableNativeArray touchesArray(std::move(touches)); + payload->putNativeArray("touches", &touchesArray); + return jPayload; +} + +} // namespace + +void TouchesHelper::registerNatives() { + javaClassLocal()->registerNatives({ + makeNativeMethod("nativeCreateTouchPayload", createTouchPayload), + }); +} + +} // namespace facebook::react diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/jni/TouchesHelper.h b/packages/react-native/ReactAndroid/src/main/jni/react/jni/TouchesHelper.h new file mode 100644 index 000000000000..5037ca0edb56 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/jni/react/jni/TouchesHelper.h @@ -0,0 +1,21 @@ +/* + * 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. + */ + +#pragma once + +#include + +namespace facebook::react { + +class TouchesHelper : public jni::JavaClass { + public: + static auto constexpr *kJavaDescriptor = "Lcom/facebook/react/uimanager/events/TouchesHelper;"; + + static void registerNatives(); +}; + +} // namespace facebook::react diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/events/TouchEventDispatchTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/events/TouchEventDispatchTest.kt index 00fc7e3489db..55cef1f4abf0 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/events/TouchEventDispatchTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/events/TouchEventDispatchTest.kt @@ -27,7 +27,11 @@ import com.facebook.react.uimanager.events.TouchEvent import com.facebook.react.uimanager.events.TouchEventCoalescingKeyHelper import com.facebook.react.uimanager.events.TouchEventType import com.facebook.testutils.shadows.ShadowArguments +import com.facebook.testutils.shadows.ShadowNativeLoader +import com.facebook.testutils.shadows.ShadowNativeMap import com.facebook.testutils.shadows.ShadowSoLoader +import com.facebook.testutils.shadows.ShadowTouchesHelper +import com.facebook.testutils.shadows.ShadowWritableNativeMap import org.assertj.core.api.Assertions.assertThat import org.junit.After import org.junit.Before @@ -41,9 +45,19 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import org.robolectric.shadow.api.Shadow @RunWith(RobolectricTestRunner::class) -@Config(shadows = [ShadowSoLoader::class, ShadowArguments::class]) +@Config( + shadows = + [ + ShadowSoLoader::class, + ShadowNativeLoader::class, + ShadowArguments::class, + ShadowWritableNativeMap::class, + ShadowTouchesHelper::class, + ] +) class TouchEventDispatchTest { private val touchEventCoalescingKeyHelper = TouchEventCoalescingKeyHelper() @@ -558,7 +572,7 @@ class TouchEventDispatchTest { anyInt(), anyLong(), ) - assertThat(startMoveEndExpectedSequence).isEqualTo(argument.allValues) + assertThat(startMoveEndExpectedSequence).isEqualTo(argument.payloads()) } @Test @@ -578,7 +592,7 @@ class TouchEventDispatchTest { anyInt(), anyLong(), ) - assertThat(startMoveCancelExpectedSequence).isEqualTo(argument.allValues) + assertThat(startMoveCancelExpectedSequence).isEqualTo(argument.payloads()) } @Test @@ -598,7 +612,7 @@ class TouchEventDispatchTest { anyInt(), anyLong(), ) - assertThat(startPointerMoveUpExpectedSequence).isEqualTo(argument.allValues) + assertThat(startPointerMoveUpExpectedSequence).isEqualTo(argument.payloads()) } private fun createTouchEvent( @@ -696,5 +710,9 @@ class TouchEventDispatchTest { } } +/** The contents of the captured payloads, which are shadowed native maps. */ +private fun ArgumentCaptor.payloads(): List = + allValues.map { Shadow.extract(it).backingMap } + private fun IntArray.toPointerProperties(): Array = this.map { PointerProperties().apply { id = it } }.toTypedArray() diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/testutils/shadows/ShadowTouchesHelper.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/testutils/shadows/ShadowTouchesHelper.kt new file mode 100644 index 000000000000..adacabf276dc --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/testutils/shadows/ShadowTouchesHelper.kt @@ -0,0 +1,63 @@ +/* + * 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.testutils.shadows + +import com.facebook.react.bridge.JavaOnlyArray +import com.facebook.react.bridge.JavaOnlyMap +import com.facebook.react.bridge.WritableMap +import com.facebook.react.bridge.WritableNativeMap +import com.facebook.react.uimanager.events.TouchesHelper +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements + +@Implements(TouchesHelper::class) +class ShadowTouchesHelper { + + companion object { + private const val POINTER_FIELDS = 8 + + /** Builds the same payload as the native TouchesHelper::createTouchPayload. */ + @JvmStatic + @Implementation + fun nativeCreateTouchPayload( + pointerData: DoubleArray, + eventPointer: Int, + changedPointers: Int, + touchPointers: Int, + ): WritableNativeMap { + val pointerCount = pointerData.size / POINTER_FIELDS + val changedTouches = JavaOnlyArray() + val touches = JavaOnlyArray() + for (index in 0 until pointerCount) { + if (changedPointers and (1 shl index) != 0) { + changedTouches.pushMap(JavaOnlyMap().also { putTouch(it, pointerData, index) }) + } + if (touchPointers and (1 shl index) != 0) { + touches.pushMap(JavaOnlyMap().also { putTouch(it, pointerData, index) }) + } + } + val payload = WritableNativeMap() + putTouch(payload, pointerData, eventPointer) + payload.putArray("changedTouches", changedTouches) + payload.putArray("touches", touches) + return payload + } + + private fun putTouch(touch: WritableMap, pointerData: DoubleArray, index: Int) { + val field = index * POINTER_FIELDS + touch.putDouble("pageX", pointerData[field]) + touch.putDouble("pageY", pointerData[field + 1]) + touch.putDouble("locationX", pointerData[field + 2]) + touch.putDouble("locationY", pointerData[field + 3]) + touch.putInt("targetSurface", pointerData[field + 4].toInt()) + touch.putInt("target", pointerData[field + 5].toInt()) + touch.putDouble("timestamp", pointerData[field + 6]) + touch.putDouble("identifier", pointerData[field + 7]) + } + } +} diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api index ca72d3985fc9..97a6a1f652cc 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api @@ -2426,6 +2426,11 @@ class facebook::react::TouchEventEmitter : public facebook::react::EventEmitter public void onTouchStart(facebook::react::TouchEvent event) const; } +class facebook::react::TouchesHelper : public facebook::jni::JavaClass { + public static constexpr auto * kJavaDescriptor; + public static void registerNatives(); +} + class facebook::react::TracingDelegate : public facebook::react::jsinspector_modern::HostTargetTracingDelegate { public TracingState getTracingState(); public size_t registerTracingStateListener(facebook::react::TracingStateListener listener); diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api index 2c501ad938e5..93e5f0cdf12b 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api @@ -4079,6 +4079,11 @@ class facebook::react::TouchEventEmitter : public facebook::react::EventEmitter public void onTouchStart(facebook::react::TouchEvent event) const; } +class facebook::react::TouchesHelper : public facebook::jni::JavaClass { + public static constexpr auto * kJavaDescriptor; + public static void registerNatives(); +} + class facebook::react::TracingDelegate : public facebook::react::jsinspector_modern::HostTargetTracingDelegate { public TracingState getTracingState(); public size_t registerTracingStateListener(facebook::react::TracingStateListener listener); diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api index 1a105ab8080c..05dc03891252 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api @@ -2419,6 +2419,11 @@ class facebook::react::TouchEventEmitter : public facebook::react::EventEmitter public void onTouchStart(facebook::react::TouchEvent event) const; } +class facebook::react::TouchesHelper : public facebook::jni::JavaClass { + public static constexpr auto * kJavaDescriptor; + public static void registerNatives(); +} + class facebook::react::TracingDelegate : public facebook::react::jsinspector_modern::HostTargetTracingDelegate { public TracingState getTracingState(); public size_t registerTracingStateListener(facebook::react::TracingStateListener listener); diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api index 1a105ab8080c..05dc03891252 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api @@ -2419,6 +2419,11 @@ class facebook::react::TouchEventEmitter : public facebook::react::EventEmitter public void onTouchStart(facebook::react::TouchEvent event) const; } +class facebook::react::TouchesHelper : public facebook::jni::JavaClass { + public static constexpr auto * kJavaDescriptor; + public static void registerNatives(); +} + class facebook::react::TracingDelegate : public facebook::react::jsinspector_modern::HostTargetTracingDelegate { public TracingState getTracingState(); public size_t registerTracingStateListener(facebook::react::TracingStateListener listener);