Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
});
15 changes: 15 additions & 0 deletions packages/react-native/React/Fabric/RCTScheduler.h
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

#import <UIKit/UIKit.h>
#import <cstdint>
#import <memory>

#import <react/renderer/componentregistry/ComponentDescriptorFactory.h>
Expand Down Expand Up @@ -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

/**
Expand Down Expand Up @@ -75,6 +82,14 @@ NS_ASSUME_NONNULL_BEGIN

- (void)removeEventListener:(const std::shared_ptr<facebook::react::EventListener> &)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
110 changes: 110 additions & 0 deletions packages/react-native/React/Fabric/RCTScheduler.mm
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
#import <react/renderer/scheduler/SchedulerDelegate.h>
#import <react/utils/RunLoopObserver.h>

#include <atomic>
#include <mutex>
#include <unordered_map>

#import "PlatformRunLoopObserver.h"
#import "RCTConversions.h"

Expand Down Expand Up @@ -78,8 +82,101 @@ void schedulerDidUpdateShadowTree(const std::unordered_map<Tag, folly::dynamic>
// This delegate method is not currently used on iOS.
}

void schedulerMeasure(SurfaceId surfaceId, Tag tag, MeasureCallback callback) override
{
RCTScheduler *scheduler = (__bridge RCTScheduler *)scheduler_;
id<RCTSchedulerDelegate> delegate = scheduler.delegate;
if (delegate == nil) {
callback(std::nullopt);
return;
}

auto callbackId = nextMeasureCallbackId_.fetch_add(1);
{
std::lock_guard<std::mutex> 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<RCTSchedulerDelegate> delegate = scheduler.delegate;
if (delegate == nil) {
callback(std::nullopt);
return;
}

auto callbackId = nextMeasureCallbackId_.fetch_add(1);
{
std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> 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<int64_t> nextMeasureCallbackId_{1};
std::mutex pendingMeasureMutex_;
std::unordered_map<int64_t, MeasureCallback> pendingMeasureCallbacks_;
std::unordered_map<int64_t, MeasureInWindowCallback> pendingMeasureInWindowCallbacks_;
};

class LayoutAnimationDelegateProxy : public LayoutAnimationStatusDelegate, public RunLoopObserver::Delegate {
Expand Down Expand Up @@ -212,4 +309,17 @@ - (void)removeEventListener:(const std::shared_ptr<EventListener> &)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
53 changes: 53 additions & 0 deletions packages/react-native/React/Fabric/RCTSurfacePresenter.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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<RCTSurfacePresenterObserver>)observer
{
std::unique_lock lock(_observerListMutex);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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);
Expand Down
Loading