diff --git a/packages/react-native/Libraries/Pressability/__tests__/Pressability-test.js b/packages/react-native/Libraries/Pressability/__tests__/Pressability-test.js index be84bbdac94728..971aef13a820ec 100644 --- a/packages/react-native/Libraries/Pressability/__tests__/Pressability-test.js +++ b/packages/react-native/Libraries/Pressability/__tests__/Pressability-test.js @@ -880,5 +880,104 @@ describe('Pressability', () => { expect(config.onPressOut).toBeCalled(); }); }); + + describe('when measured responder region does not match touch target (e.g. stale during animation)', () => { + it('`onPress` is cancelled even if the finger does not move', () => { + // Measure reports a stale region at x=0..50, while touch events are at x=100. + getMock(UIManager.measure).mockImplementation((id, fn) => { + fn( + mockRegion.left, + mockRegion.top, + mockRegion.width, + mockRegion.height, + mockRegion.pageX, + mockRegion.pageY, + ); + }); + + const {config, handlers} = createMockPressability({ + delayPressIn: 0, + }); + + handlers.onStartShouldSetResponder(); + handlers.onResponderGrant( + createMockPressEvent({ + registrationName: 'onResponderGrant', + pageX: mockRegion.width * 2, + pageY: mockRegion.height / 2, + }), + ); + + expect(UIManager.measure).toBeCalled(); + + // Finger doesn't move; a MOVE event is still observed (e.g. due to jitter). + handlers.onResponderMove( + createMockPressEvent({ + registrationName: 'onResponderMove', + pageX: mockRegion.width * 2, + pageY: mockRegion.height / 2, + }), + ); + jest.runOnlyPendingTimers(); + + handlers.onResponderRelease( + createMockPressEvent({ + registrationName: 'onResponderRelease', + pageX: mockRegion.width * 2, + pageY: mockRegion.height / 2, + }), + ); + + expect(config.onPress).not.toBeCalled(); + }); + + it('`onPress` is called when measured region matches touch coordinates', () => { + // Measure reports a region that matches the touch coordinates. + getMock(UIManager.measure).mockImplementation((id, fn) => { + fn( + mockRegion.left, + mockRegion.top, + mockRegion.width, + mockRegion.height, + mockRegion.width * 2, + mockRegion.pageY, + ); + }); + + const {config, handlers} = createMockPressability({ + delayPressIn: 0, + }); + + handlers.onStartShouldSetResponder(); + handlers.onResponderGrant( + createMockPressEvent({ + registrationName: 'onResponderGrant', + pageX: mockRegion.width * 2 + 1, + pageY: mockRegion.height / 2, + }), + ); + + expect(UIManager.measure).toBeCalled(); + + handlers.onResponderMove( + createMockPressEvent({ + registrationName: 'onResponderMove', + pageX: mockRegion.width * 2 + 1, + pageY: mockRegion.height / 2, + }), + ); + jest.runOnlyPendingTimers(); + + handlers.onResponderRelease( + createMockPressEvent({ + registrationName: 'onResponderRelease', + pageX: mockRegion.width * 2 + 1, + pageY: mockRegion.height / 2, + }), + ); + + expect(config.onPress).toBeCalled(); + }); + }); }); }); diff --git a/packages/react-native/React/Fabric/RCTScheduler.h b/packages/react-native/React/Fabric/RCTScheduler.h index ed585390890b1d..555589ee26b78c 100644 --- a/packages/react-native/React/Fabric/RCTScheduler.h +++ b/packages/react-native/React/Fabric/RCTScheduler.h @@ -6,6 +6,7 @@ */ #import +#import #import #import @@ -43,6 +44,12 @@ NS_ASSUME_NONNULL_BEGIN forShadowView:(const facebook::react::ShadowView &)shadowView; - (void)schedulerDidSynchronouslyUpdateViewOnUIThread:(facebook::react::Tag)reactTag props:(folly::dynamic)props; + +// Measure APIs (used by Fabric `measure` / `measureInWindow`). +- (void)schedulerMeasure:(ReactTag)surfaceId + reactTag:(ReactTag)reactTag + inWindow:(BOOL)inWindow + callbackId:(int64_t)callbackId; @end /** @@ -75,6 +82,14 @@ NS_ASSUME_NONNULL_BEGIN - (void)removeEventListener:(const std::shared_ptr &)listener; +- (void)onMeasureResultWithCallbackId:(int64_t)callbackId + inWindow:(BOOL)inWindow + success:(BOOL)success + x:(double)x + y:(double)y + width:(double)width + height:(double)height; + @end NS_ASSUME_NONNULL_END diff --git a/packages/react-native/React/Fabric/RCTScheduler.mm b/packages/react-native/React/Fabric/RCTScheduler.mm index 6cc4e5b6feb730..e45ff6fb798acf 100644 --- a/packages/react-native/React/Fabric/RCTScheduler.mm +++ b/packages/react-native/React/Fabric/RCTScheduler.mm @@ -15,6 +15,10 @@ #import #import +#include +#include +#include + #import "PlatformRunLoopObserver.h" #import "RCTConversions.h" @@ -78,8 +82,101 @@ void schedulerDidUpdateShadowTree(const std::unordered_map // This delegate method is not currently used on iOS. } + void schedulerMeasure(SurfaceId surfaceId, Tag tag, MeasureCallback callback) override + { + RCTScheduler *scheduler = (__bridge RCTScheduler *)scheduler_; + id delegate = scheduler.delegate; + if (delegate == nil) { + callback(std::nullopt); + return; + } + + auto callbackId = nextMeasureCallbackId_.fetch_add(1); + { + std::lock_guard lock(pendingMeasureMutex_); + pendingMeasureCallbacks_.emplace(callbackId, std::move(callback)); + } + + [delegate schedulerMeasure:surfaceId reactTag:tag inWindow:NO callbackId:callbackId]; + } + + void schedulerMeasureInWindow(SurfaceId surfaceId, Tag tag, MeasureInWindowCallback callback) override + { + RCTScheduler *scheduler = (__bridge RCTScheduler *)scheduler_; + id delegate = scheduler.delegate; + if (delegate == nil) { + callback(std::nullopt); + return; + } + + auto callbackId = nextMeasureCallbackId_.fetch_add(1); + { + std::lock_guard lock(pendingMeasureMutex_); + pendingMeasureInWindowCallbacks_.emplace(callbackId, std::move(callback)); + } + + [delegate schedulerMeasure:surfaceId reactTag:tag inWindow:YES callbackId:callbackId]; + } + + void onMeasureResult(int64_t callbackId, bool inWindow, bool success, double x, double y, double width, double height) + { + if (inWindow) { + MeasureInWindowCallback callback; + { + std::lock_guard lock(pendingMeasureMutex_); + auto it = pendingMeasureInWindowCallbacks_.find(callbackId); + if (it == pendingMeasureInWindowCallbacks_.end()) { + return; + } + callback = std::move(it->second); + pendingMeasureInWindowCallbacks_.erase(it); + } + + if (!success) { + callback(std::nullopt); + return; + } + + MeasureInWindowResult result; + result.x = x; + result.y = y; + result.width = width; + result.height = height; + callback(result); + return; + } + + MeasureCallback callback; + { + std::lock_guard lock(pendingMeasureMutex_); + auto it = pendingMeasureCallbacks_.find(callbackId); + if (it == pendingMeasureCallbacks_.end()) { + return; + } + callback = std::move(it->second); + pendingMeasureCallbacks_.erase(it); + } + + if (!success) { + callback(std::nullopt); + return; + } + + MeasureResult result; + result.pageX = x; + result.pageY = y; + result.width = width; + result.height = height; + callback(result); + } + private: void *scheduler_; + + std::atomic nextMeasureCallbackId_{1}; + std::mutex pendingMeasureMutex_; + std::unordered_map pendingMeasureCallbacks_; + std::unordered_map pendingMeasureInWindowCallbacks_; }; class LayoutAnimationDelegateProxy : public LayoutAnimationStatusDelegate, public RunLoopObserver::Delegate { @@ -212,4 +309,17 @@ - (void)removeEventListener:(const std::shared_ptr &)listener return _scheduler->getUIManager(); } +- (void)onMeasureResultWithCallbackId:(int64_t)callbackId + inWindow:(BOOL)inWindow + success:(BOOL)success + x:(double)x + y:(double)y + width:(double)width + height:(double)height +{ + if (_delegateProxy) { + _delegateProxy->onMeasureResult(callbackId, inWindow, success, x, y, width, height); + } +} + @end diff --git a/packages/react-native/React/Fabric/RCTSurfacePresenter.mm b/packages/react-native/React/Fabric/RCTSurfacePresenter.mm index 9a5b94696ccf5d..a48856b4d5e847 100644 --- a/packages/react-native/React/Fabric/RCTSurfacePresenter.mm +++ b/packages/react-native/React/Fabric/RCTSurfacePresenter.mm @@ -334,6 +334,59 @@ - (void)schedulerDidSetIsJSResponder:(BOOL)isJSResponder [_mountingManager setIsJSResponder:isJSResponder blockNativeResponder:blockNativeResponder forShadowView:shadowView]; } +- (void)schedulerMeasure:(ReactTag)surfaceId + reactTag:(ReactTag)reactTag + inWindow:(BOOL)inWindow + callbackId:(int64_t)callbackId +{ + RCTScheduler *scheduler = [self scheduler]; + if (!scheduler) { + return; + } + + // The component view registry and UIKit measurements must be accessed on the main thread. + RCTExecuteOnMainQueue(^{ + UIView *targetView = [self->_mountingManager.componentViewRegistry findComponentViewWithTag:reactTag]; + if (!targetView) { + [scheduler onMeasureResultWithCallbackId:callbackId + inWindow:inWindow + success:NO + x:0 + y:0 + width:0 + height:0]; + return; + } + + CGRect rect = CGRectZero; + if (inWindow) { + rect = [targetView convertRect:targetView.bounds toView:nil]; + } else { + RCTFabricSurface *surface = [self surfaceForRootTag:surfaceId]; + UIView *rootView = surface.view; + if (!rootView) { + [scheduler onMeasureResultWithCallbackId:callbackId + inWindow:inWindow + success:NO + x:0 + y:0 + width:0 + height:0]; + return; + } + rect = [targetView convertRect:targetView.bounds toView:rootView]; + } + + [scheduler onMeasureResultWithCallbackId:callbackId + inWindow:inWindow + success:YES + x:rect.origin.x + y:rect.origin.y + width:rect.size.width + height:rect.size.height]; + }); +} + - (void)addObserver:(id)observer { std::unique_lock lock(_observerListMutex); diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java index a4cb1c2dd5ed47..96330d5562a6ef 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java @@ -1038,6 +1038,51 @@ public void updateRootLayoutSpecs( return surfaceManager == null ? null : surfaceManager.getView(reactTag); } + /** + * Measures a mounted view by tag on the UI thread and reports the result back to the C++ binding. + * + *

This method is invoked from C++ and must not touch view hierarchy off the UI thread. + */ + @SuppressWarnings("unused") + public void measure(int reactTag, long callbackId, boolean inWindow) { + Runnable measureRunnable = + new Runnable() { + @Override + public void run() { + UiThreadUtil.assertOnUiThread(); + + int[] buffer = new int[] {0, 0, 0, 0}; + boolean success = false; + + try { + SurfaceMountingManager surfaceManager = mMountingManager.getSurfaceManagerForView(reactTag); + if (surfaceManager != null) { + if (inWindow) { + surfaceManager.measureInWindow(reactTag, buffer); + } else { + surfaceManager.measure(reactTag, buffer); + } + success = true; + } + } catch (Exception e) { + success = false; + } + + FabricUIManagerBinding binding = mBinding; + if (binding != null) { + binding.onMeasureResult( + callbackId, inWindow, success, buffer[0], buffer[1], buffer[2], buffer[3]); + } + } + }; + + if (UiThreadUtil.isOnUiThread()) { + measureRunnable.run(); + } else { + UiThreadUtil.runOnUiThread(measureRunnable); + } + } + @Override public void receiveEvent(int reactTag, String eventName, @Nullable WritableMap params) { receiveEvent(View.NO_ID, reactTag, eventName, false, params, EventCategoryDef.UNSPECIFIED); diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManagerBinding.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManagerBinding.kt index 55818f21103314..054e1ed5b8d32d 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManagerBinding.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManagerBinding.kt @@ -83,6 +83,17 @@ internal class FabricUIManagerBinding : HybridClassBase() { external fun reportMount(surfaceId: Int) + @DoNotStrip + external fun onMeasureResult( + callbackId: Long, + inWindow: Boolean, + success: Boolean, + x: Int, + y: Int, + width: Int, + height: Int, + ) + fun register( runtimeExecutor: RuntimeExecutor, runtimeScheduler: RuntimeScheduler, diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/SurfaceMountingManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/SurfaceMountingManager.java index 23e9e5efb116f7..ca47abae5587f2 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/SurfaceMountingManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/SurfaceMountingManager.java @@ -52,6 +52,7 @@ import com.facebook.react.uimanager.RootViewManager; import com.facebook.react.uimanager.StateWrapper; import com.facebook.react.uimanager.ThemedReactContext; +import com.facebook.react.uimanager.ViewMeasureUtil; import com.facebook.react.uimanager.ViewManager; import com.facebook.react.uimanager.ViewManagerRegistry; import com.facebook.react.uimanager.events.EventCategoryDef; @@ -1243,6 +1244,18 @@ public View getView(int reactTag) { return view; } + @UiThread + public void measure(int reactTag, int[] outputBuffer) { + UiThreadUtil.assertOnUiThread(); + ViewMeasureUtil.measureViewRelativeToRoot(getView(reactTag), outputBuffer); + } + + @UiThread + public void measureInWindow(int reactTag, int[] outputBuffer) { + UiThreadUtil.assertOnUiThread(); + ViewMeasureUtil.measureViewInWindow(getView(reactTag), outputBuffer); + } + private @NonNull ViewState getViewState(int tag) { ViewState viewState = mTagToViewState.get(tag); if (viewState == null) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewMeasureUtil.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewMeasureUtil.kt new file mode 100644 index 00000000000000..6388d615356de3 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewMeasureUtil.kt @@ -0,0 +1,95 @@ +/* + * 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.graphics.Matrix +import android.graphics.Rect +import android.graphics.RectF +import android.view.View +import android.view.ViewParent +import androidx.annotation.UiThread +import com.facebook.react.bridge.UiThreadUtil + +/** Utility for measuring a View's bounding box accounting for view transforms. */ +public object ViewMeasureUtil { + /** + * Populates outputBuffer with `[x, y, width, height]` measured relative to the surface RootView. + * + * The resulting `x` and `y` are relative to the RootView's coordinate space (same space as + * `pageX/pageY` in touch events). + */ + @UiThread + @JvmStatic + public fun measureViewRelativeToRoot(view: View, outputBuffer: IntArray) { + UiThreadUtil.assertOnUiThread() + + val rootView = RootViewUtil.getRootView(view) as? View + ?: throw IllegalViewOperationException("Native view ${view.id} is no longer on screen") + + val rootBuffer = IntArray(4) + computeBoundingBox(rootView, rootBuffer) + val rootX = rootBuffer[0] + val rootY = rootBuffer[1] + + computeBoundingBox(view, outputBuffer) + outputBuffer[0] -= rootX + outputBuffer[1] -= rootY + } + + /** + * Populates outputBuffer with `[x, y, width, height]` measured relative to the visible window. + */ + @UiThread + @JvmStatic + public fun measureViewInWindow(view: View, outputBuffer: IntArray) { + UiThreadUtil.assertOnUiThread() + + computeBoundingBox(view, outputBuffer) + + // Subtract window insets / split-screen offsets, matching Paper semantics. + val visibleWindowFrame = Rect() + view.getWindowVisibleDisplayFrame(visibleWindowFrame) + outputBuffer[0] -= visibleWindowFrame.left + outputBuffer[1] -= visibleWindowFrame.top + } + + private fun computeBoundingBox(view: View, outputBuffer: IntArray) { + val rect = RectF(0f, 0f, view.width.toFloat(), view.height.toFloat()) + mapRectFromViewToWindowCoords(view, rect) + + outputBuffer[0] = Math.round(rect.left) + outputBuffer[1] = Math.round(rect.top) + outputBuffer[2] = Math.round(rect.right - rect.left) + outputBuffer[3] = Math.round(rect.bottom - rect.top) + } + + private fun mapRectFromViewToWindowCoords(view: View, rect: RectF) { + var matrix: Matrix = view.matrix + if (!matrix.isIdentity) { + matrix.mapRect(rect) + } + + rect.offset(view.left.toFloat(), view.top.toFloat()) + + var parent: ViewParent? = view.parent + while (parent is View) { + val parentView = parent + + rect.offset(-parentView.scrollX.toFloat(), -parentView.scrollY.toFloat()) + + matrix = parentView.matrix + if (!matrix.isIdentity) { + matrix.mapRect(rect) + } + + rect.offset(parentView.left.toFloat(), parentView.top.toFloat()) + + parent = parentView.parent + } + } +} + diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricMountingManager.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricMountingManager.cpp index 8b966236899cae..a0e3cd109a9900 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricMountingManager.cpp +++ b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricMountingManager.cpp @@ -1206,4 +1206,16 @@ void FabricMountingManager::synchronouslyUpdateViewOnUIThread( synchronouslyUpdateViewOnUIThreadJNI(javaUIManager_, viewTag, propsMap); } +void FabricMountingManager::measure(Tag viewTag, int64_t callbackId, bool inWindow) { + static auto measureJNI = + JFabricUIManager::javaClassStatic()->getMethod( + "measure"); + + measureJNI( + javaUIManager_, + static_cast(viewTag), + static_cast(callbackId), + static_cast(inWindow)); +} + } // namespace facebook::react diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricMountingManager.h b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricMountingManager.h index 9fd87538d165f5..84a44f5130112a 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricMountingManager.h +++ b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricMountingManager.h @@ -54,6 +54,8 @@ class FabricMountingManager final { void synchronouslyUpdateViewOnUIThread(Tag viewTag, const folly::dynamic &props); + void measure(Tag viewTag, int64_t callbackId, bool inWindow); + private: bool isOnMainThread(); diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricUIManagerBinding.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricUIManagerBinding.cpp index 3b2be76a229812..851f6c27e3a899 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricUIManagerBinding.cpp +++ b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricUIManagerBinding.cpp @@ -589,6 +589,12 @@ void FabricUIManagerBinding::uninstallFabricUIManager() { animationDriver_ = nullptr; scheduler_ = nullptr; mountingManager_ = nullptr; + + { + std::lock_guard measureLock(pendingMeasureMutex_); + pendingMeasureCallbacks_.clear(); + pendingMeasureInWindowCallbacks_.clear(); + } } std::shared_ptr @@ -764,6 +770,104 @@ void FabricUIManagerBinding::schedulerDidUpdateShadowTree( // no-op } +void FabricUIManagerBinding::schedulerMeasure( + SurfaceId /*surfaceId*/, + Tag tag, + MeasureCallback callback) { + auto mountingManager = getMountingManager("schedulerMeasure"); + if (!mountingManager) { + callback(std::nullopt); + return; + } + + auto callbackId = nextMeasureCallbackId_.fetch_add(1); + { + std::lock_guard lock(pendingMeasureMutex_); + pendingMeasureCallbacks_.emplace(callbackId, std::move(callback)); + } + + mountingManager->measure(tag, callbackId, /*inWindow*/ false); +} + +void FabricUIManagerBinding::schedulerMeasureInWindow( + SurfaceId /*surfaceId*/, + Tag tag, + MeasureInWindowCallback callback) { + auto mountingManager = getMountingManager("schedulerMeasureInWindow"); + if (!mountingManager) { + callback(std::nullopt); + return; + } + + auto callbackId = nextMeasureCallbackId_.fetch_add(1); + { + std::lock_guard lock(pendingMeasureMutex_); + pendingMeasureInWindowCallbacks_.emplace(callbackId, std::move(callback)); + } + + mountingManager->measure(tag, callbackId, /*inWindow*/ true); +} + +void FabricUIManagerBinding::onMeasureResult( + jlong callbackId, + jboolean inWindow, + jboolean success, + jint x, + jint y, + jint width, + jint height) { + if (inWindow) { + MeasureInWindowCallback callback; + { + std::lock_guard lock(pendingMeasureMutex_); + auto it = pendingMeasureInWindowCallbacks_.find(callbackId); + if (it == pendingMeasureInWindowCallbacks_.end()) { + return; + } + callback = std::move(it->second); + pendingMeasureInWindowCallbacks_.erase(it); + } + + if (!success) { + callback(std::nullopt); + return; + } + + auto result = MeasureInWindowResult{ + .x = static_cast(x) / pointScaleFactor_, + .y = static_cast(y) / pointScaleFactor_, + .width = static_cast(width) / pointScaleFactor_, + .height = static_cast(height) / pointScaleFactor_, + }; + callback(result); + return; + } + + MeasureCallback callback; + { + std::lock_guard lock(pendingMeasureMutex_); + auto it = pendingMeasureCallbacks_.find(callbackId); + if (it == pendingMeasureCallbacks_.end()) { + return; + } + callback = std::move(it->second); + pendingMeasureCallbacks_.erase(it); + } + + if (!success) { + callback(std::nullopt); + return; + } + + auto result = MeasureResult{ + .pageX = static_cast(x) / pointScaleFactor_, + .pageY = static_cast(y) / pointScaleFactor_, + .width = static_cast(width) / pointScaleFactor_, + .height = static_cast(height) / pointScaleFactor_, + }; + callback(result); +} + void FabricUIManagerBinding::onAnimationStarted() { auto mountingManager = getMountingManager("onAnimationStarted"); if (!mountingManager) { @@ -804,6 +908,7 @@ void FabricUIManagerBinding::registerNatives() { makeNativeMethod( "uninstallFabricUIManager", FabricUIManagerBinding::uninstallFabricUIManager), + makeNativeMethod("onMeasureResult", FabricUIManagerBinding::onMeasureResult), makeNativeMethod( "startSurfaceWithSurfaceHandler", FabricUIManagerBinding::startSurfaceWithSurfaceHandler), diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricUIManagerBinding.h b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricUIManagerBinding.h index 29adfde41cebb6..4b62a3e856765f 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricUIManagerBinding.h +++ b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricUIManagerBinding.h @@ -7,6 +7,7 @@ #pragma once +#include #include #include #include @@ -112,6 +113,13 @@ class FabricUIManagerBinding : public jni::HybridClass, void schedulerDidUpdateShadowTree(const std::unordered_map &tagToProps) override; + void schedulerMeasure(SurfaceId surfaceId, Tag tag, MeasureCallback callback) override; + + void schedulerMeasureInWindow( + SurfaceId surfaceId, + Tag tag, + MeasureInWindowCallback callback) override; + void setPixelDensity(float pointScaleFactor); void driveCxxAnimations(); @@ -126,6 +134,15 @@ class FabricUIManagerBinding : public jni::HybridClass, void uninstallFabricUIManager(); + void onMeasureResult( + jlong callbackId, + jboolean inWindow, + jboolean success, + jint x, + jint y, + jint width, + jint height); + // Private member variables std::shared_mutex installMutex_; std::shared_ptr mountingManager_; @@ -150,6 +167,11 @@ class FabricUIManagerBinding : public jni::HybridClass, std::mutex pendingTransactionsMutex_; std::vector pendingTransactions_; + std::atomic nextMeasureCallbackId_{1}; + std::mutex pendingMeasureMutex_; + std::unordered_map pendingMeasureCallbacks_; + std::unordered_map pendingMeasureInWindowCallbacks_; + float pointScaleFactor_ = 1; bool enableFabricLogs_{false}; diff --git a/packages/react-native/ReactCommon/react/renderer/scheduler/SchedulerDelegate.h b/packages/react-native/ReactCommon/react/renderer/scheduler/SchedulerDelegate.h index afa2d57de6574d..766337ca9f22b8 100644 --- a/packages/react-native/ReactCommon/react/renderer/scheduler/SchedulerDelegate.h +++ b/packages/react-native/ReactCommon/react/renderer/scheduler/SchedulerDelegate.h @@ -7,7 +7,9 @@ #pragma once +#include #include +#include #include #include @@ -20,6 +22,24 @@ namespace facebook::react { */ class SchedulerDelegate { public: + struct MeasureResult { + double pageX{0}; + double pageY{0}; + double width{0}; + double height{0}; + }; + + struct MeasureInWindowResult { + double x{0}; + double y{0}; + double width{0}; + double height{0}; + }; + + using MeasureCallback = std::function)>; + using MeasureInWindowCallback = + std::function)>; + /* * Called right after Scheduler computed (and laid out) a new updated version * of the tree and calculated a set of mutations which are sufficient @@ -60,6 +80,29 @@ class SchedulerDelegate { virtual void schedulerDidUpdateShadowTree(const std::unordered_map &tagToProps) = 0; + /* + * Request measuring a mounted native view (if present). Implementations may + * call `callback` on any thread. Default implementation reports failure. + */ + virtual void schedulerMeasure( + SurfaceId /*surfaceId*/, + Tag /*tag*/, + MeasureCallback callback) { + callback(std::nullopt); + } + + /* + * Request measuring a mounted native view (if present) in window coordinates. + * Implementations may call `callback` on any thread. Default implementation + * reports failure. + */ + virtual void schedulerMeasureInWindow( + SurfaceId /*surfaceId*/, + Tag /*tag*/, + MeasureInWindowCallback callback) { + callback(std::nullopt); + } + virtual ~SchedulerDelegate() noexcept = default; }; diff --git a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp index 5f661273a67834..9156ebad36ab18 100644 --- a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp +++ b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp @@ -11,10 +11,13 @@ #include #include #include +#include #include #include #include #include +#include +#include #include #include @@ -650,6 +653,52 @@ jsi::Value UIManagerBinding::get( auto measureRect = dom::measure(currentRevision, *shadowNode); +#if defined(__ANDROID__) || defined(__APPLE__) + auto runtimeSchedulerBinding = RuntimeSchedulerBinding::getBinding(runtime); + auto runtimeScheduler = runtimeSchedulerBinding != nullptr + ? runtimeSchedulerBinding->getRuntimeScheduler() + : nullptr; + + auto* scheduler = dynamic_cast(uiManager->getDelegate()); + auto* schedulerDelegate = + scheduler != nullptr ? scheduler->getDelegate() : nullptr; + + if (schedulerDelegate != nullptr && runtimeScheduler != nullptr) { + auto jsInvoker = + std::make_shared(runtimeScheduler); + + auto asyncCallback = + AsyncCallback( + runtime, std::move(callbackFunction), jsInvoker); + + schedulerDelegate->schedulerMeasure( + shadowNode->getSurfaceId(), + shadowNode->getTag(), + [asyncCallback, measureRect]( + std::optional nativeResult) + mutable { + auto width = + nativeResult ? nativeResult->width : measureRect.width; + auto height = nativeResult ? nativeResult->height + : measureRect.height; + auto pageX = nativeResult ? nativeResult->pageX + : measureRect.pageX; + auto pageY = nativeResult ? nativeResult->pageY + : measureRect.pageY; + + asyncCallback.call( + measureRect.x, + measureRect.y, + width, + height, + pageX, + pageY); + }); + + return jsi::Value::undefined(); + } +#endif + callbackFunction.call( runtime, {jsi::Value{runtime, measureRect.x}, @@ -690,6 +739,44 @@ jsi::Value UIManagerBinding::get( } auto rect = dom::measureInWindow(currentRevision, *shadowNode); + +#if defined(__ANDROID__) || defined(__APPLE__) + auto runtimeSchedulerBinding = RuntimeSchedulerBinding::getBinding(runtime); + auto runtimeScheduler = runtimeSchedulerBinding != nullptr + ? runtimeSchedulerBinding->getRuntimeScheduler() + : nullptr; + + auto* scheduler = dynamic_cast(uiManager->getDelegate()); + auto* schedulerDelegate = + scheduler != nullptr ? scheduler->getDelegate() : nullptr; + + if (schedulerDelegate != nullptr && runtimeScheduler != nullptr) { + auto jsInvoker = + std::make_shared(runtimeScheduler); + + auto asyncCallback = AsyncCallback( + runtime, std::move(callbackFunction), jsInvoker); + + schedulerDelegate->schedulerMeasureInWindow( + shadowNode->getSurfaceId(), + shadowNode->getTag(), + [asyncCallback, rect]( + std::optional + nativeResult) mutable { + auto x = nativeResult ? nativeResult->x : rect.x; + auto y = nativeResult ? nativeResult->y : rect.y; + auto width = + nativeResult ? nativeResult->width : rect.width; + auto height = + nativeResult ? nativeResult->height : rect.height; + + asyncCallback.call(x, y, width, height); + }); + + return jsi::Value::undefined(); + } +#endif + callbackFunction.call( runtime, {jsi::Value{runtime, rect.x}, diff --git a/packages/rn-tester/js/examples/Pressable/PressableExample.js b/packages/rn-tester/js/examples/Pressable/PressableExample.js index 648fe4d5e6b49a..ef66fdd9f2e11a 100644 --- a/packages/rn-tester/js/examples/Pressable/PressableExample.js +++ b/packages/rn-tester/js/examples/Pressable/PressableExample.js @@ -173,6 +173,66 @@ function PressableDelayEvents() { ); } +function PressableDuringNativeDriverAnimation() { + const [pressCount, setPressCount] = useState(0); + const translateX = useRef(new Animated.Value(0)).current; + + useEffect(() => { + const animation = Animated.loop( + Animated.sequence([ + Animated.timing(translateX, { + toValue: 1, + duration: 1200, + useNativeDriver: true, + }), + Animated.timing(translateX, { + toValue: 0, + duration: 1200, + useNativeDriver: true, + }), + ]), + ); + animation.start(); + + return () => { + animation.stop(); + }; + }, [translateX]); + + const tx = translateX.interpolate({ + inputRange: [0, 1], + outputRange: [0, 160], + }); + + return ( + + + + Try pressing the moving button while it animates (native driver). If + hit testing is broken, the press count won’t increment reliably. + + + Press count: {pressCount} + + + + + + setPressCount(c => c + 1)} + style={({pressed}) => [ + styles.wrapperCustom, + {backgroundColor: pressed ? 'rgb(210, 230, 255)' : 'white'}, + ]}> + Tap me while moving + + + + + ); +} + function ForceTouchExample() { const [force, setForce] = useState(0); @@ -405,6 +465,15 @@ const examples = [ return ; }, }, + { + title: 'Press during native-driver transform animation (Android)', + description: + ('Repro for pressability regressions during native-driven transforms. Try tapping while it moves.': string), + platform: 'android', + render(): React.Node { + return ; + }, + }, { title: 'Pressable with Ripple and Animated child', description: