diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm index 57746520d786..798855eca16f 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -536,7 +536,7 @@ - (RCTPlatformView *)betterHitTest:(CGPoint)point withEvent:(UIEvent *)event // } for (RCTPlatformView *subview in [_containerView.subviews reverseObjectEnumerator]) { // [macOS] - RCTPlatformView *hitView = RCTUIViewHitTestWithEvent(subview, [subview convertPoint:point fromView:self], event); // [macOS] + RCTPlatformView *hitView = RCTUIViewHitTestWithEvent(subview, point, self, event); // [macOS] if (hitView) { return hitView; } diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm index 78b616a03a7a..2290babfdbd3 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -367,17 +367,12 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & ![_propKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN containsObject:@"transform"]) { auto newTransform = newViewProps.resolveTransform(_layoutMetrics); CATransform3D caTransform = RCTCATransform3DFromTransformMatrix(newTransform); -#if TARGET_OS_OSX // [macOS - CGPoint anchorPoint = self.layer.anchorPoint; - if (CGPointEqualToPoint(anchorPoint, CGPointZero) && !CATransform3DEqualToTransform(caTransform, CATransform3DIdentity)) { - // https://developer.apple.com/documentation/quartzcore/calayer/1410817-anchorpoint - // This compensates for the fact that layer.anchorPoint is {0, 0} instead of {0.5, 0.5} on macOS for some reason. - CATransform3D originAdjust = CATransform3DTranslate(CATransform3DIdentity, self.frame.size.width / 2, self.frame.size.height / 2, 0); - caTransform = CATransform3DConcat(CATransform3DConcat(CATransform3DInvert(originAdjust), caTransform), originAdjust); - } +#if !TARGET_OS_OSX // [macOS] + self.layer.transform = caTransform; +#else // [macOS + self.transform3D = caTransform; #endif // macOS] - self.layer.transform = caTransform; // Enable edge antialiasing in rotation, skew, or perspective transforms self.layer.allowsEdgeAntialiasing = caTransform.m12 != 0.0f || caTransform.m21 != 0.0f || caTransform.m34 != 0.0f; } @@ -718,7 +713,11 @@ - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics if ((_props->transformOrigin.isSet() || _props->transform.operations.size() > 0) && layoutMetrics.frame.size != oldLayoutMetrics.frame.size) { auto newTransform = _props->resolveTransform(layoutMetrics); +#if !TARGET_OS_OSX // [macOS] self.layer.transform = RCTCATransform3DFromTransformMatrix(newTransform); +#else // [macOS + self.transform3D = RCTCATransform3DFromTransformMatrix(newTransform); +#endif // macOS] } } @@ -812,7 +811,7 @@ - (RCTPlatformView *)betterHitTest:(CGPoint)point withEvent:(UIEvent *)event // } for (RCTPlatformView *subview in [self.subviews reverseObjectEnumerator]) { // [macOS] - RCTPlatformView *hitView = RCTUIViewHitTestWithEvent(subview, [subview convertPoint:point fromView:self], event); // [macOS] + RCTPlatformView *hitView = RCTUIViewHitTestWithEvent(subview, point, self, event); // [macOS] if (hitView) { return hitView; } diff --git a/packages/react-native/React/Modules/RCTUIManager.mm b/packages/react-native/React/Modules/RCTUIManager.mm index 45e7ea7d1275..7f1aeff436b8 100644 --- a/packages/react-native/React/Modules/RCTUIManager.mm +++ b/packages/react-native/React/Modules/RCTUIManager.mm @@ -1186,7 +1186,7 @@ - (void)synchronouslyUpdateViewOnUIThread:(NSNumber *)reactTag viewName:(NSStrin { [self addUIBlock:^(__unused RCTUIManager *uiManager, NSDictionary *viewRegistry) { // [macOS] RCTPlatformView *view = viewRegistry[reactTag]; // [macOS] - RCTPlatformView *target = RCTUIViewHitTestWithEvent(view, point, nil); // [macOS] + RCTPlatformView *target = RCTUIViewHitTestWithEvent(view, point, view, nil); // [macOS] CGRect frame = [target convertRect:target.bounds toView:view]; while (target.reactTag == nil && target.superview != nil) { diff --git a/packages/react-native/React/RCTUIKit/RCTUIView.h b/packages/react-native/React/RCTUIKit/RCTUIView.h index 991018bc4fb9..c87faaa94497 100644 --- a/packages/react-native/React/RCTUIKit/RCTUIView.h +++ b/packages/react-native/React/RCTUIKit/RCTUIView.h @@ -56,6 +56,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, copy) NSColor *backgroundColor; @property (nonatomic) CGAffineTransform transform; +@property (nonatomic) CATransform3D transform3D; /** * Specifies whether the view should receive the mouse down event when the @@ -75,7 +76,6 @@ NS_ASSUME_NONNULL_BEGIN */ @property (nonatomic, assign) BOOL enableFocusRing; -// [macOS /** * iOS compatibility shim. On macOS, this forwards to accessibilityChildren. */ @@ -90,9 +90,9 @@ NS_ASSUME_NONNULL_BEGIN #if !TARGET_OS_OSX -UIKIT_STATIC_INLINE RCTPlatformView *RCTUIViewHitTestWithEvent(RCTPlatformView *view, CGPoint point, __unused UIEvent *__nullable event) +UIKIT_STATIC_INLINE RCTPlatformView *RCTUIViewHitTestWithEvent(RCTPlatformView *view, CGPoint point, RCTPlatformView *fromView, __unused UIEvent *__nullable event) { - return [view hitTest:point withEvent:event]; + return [view hitTest:[view convertPoint:point fromView:fromView] withEvent:event]; } UIKIT_STATIC_INLINE void RCTUIViewSetContentModeRedraw(UIView *view) @@ -107,11 +107,15 @@ UIKIT_STATIC_INLINE BOOL RCTUIViewIsDescendantOfView(RCTPlatformView *view, RCTP #else // TARGET_OS_OSX -NS_INLINE RCTPlatformView *RCTUIViewHitTestWithEvent(RCTPlatformView *view, CGPoint point, __unused UIEvent *__nullable event) +// Use CALayer coordinate conversion which correctly accounts for layer.transform. +// NSView's convertPoint:fromView: does not account for layer transforms on macOS. +// IMPORTANT -- NSView's hitTest: expects a point in the superview's coordinate space, +// so we convert from fromView → superview using CALayer, which handles layer transforms correctly. +// This allows hit testing to work correctly between nested RCTUIViews and plain NSViews. +NS_INLINE RCTPlatformView *RCTUIViewHitTestWithEvent(RCTPlatformView *view, CGPoint point, RCTPlatformView *fromView, __unused UIEvent *__nullable event) { - // [macOS IMPORTANT -- point is in local coordinate space, but OSX expects super coordinate space for hitTest: NSView *superview = [view superview]; - NSPoint pointInSuperview = superview != nil ? [view convertPoint:point toView:superview] : point; + NSPoint pointInSuperview = superview != nil ? [superview.layer convertPoint:point fromLayer:fromView.layer] : point; return [view hitTest:pointInSuperview]; } diff --git a/packages/react-native/React/RCTUIKit/RCTUIView.m b/packages/react-native/React/RCTUIKit/RCTUIView.m index 152f7b31c007..0976c309ff90 100644 --- a/packages/react-native/React/RCTUIKit/RCTUIView.m +++ b/packages/react-native/React/RCTUIKit/RCTUIView.m @@ -9,6 +9,7 @@ #if TARGET_OS_OSX +#import #import // UIView @@ -21,6 +22,8 @@ @implementation RCTUIView BOOL _userInteractionEnabled; BOOL _mouseDownCanMoveWindow; BOOL _respondsToDisplayLayer; + CATransform3D _transform3D; + BOOL _hasCustomTransform3D; } + (NSSet *)keyPathsForValuesAffectingValueForKey:(NSString *)key @@ -51,6 +54,8 @@ @implementation RCTUIView self->_enableFocusRing = YES; self->_mouseDownCanMoveWindow = YES; self->_respondsToDisplayLayer = [self respondsToSelector:@selector(displayLayer:)]; + self->_transform3D = CATransform3DIdentity; + self->_hasCustomTransform3D = NO; } return self; } @@ -127,20 +132,46 @@ - (CGAffineTransform)transform - (void)setTransform:(CGAffineTransform)transform { - self.layer.affineTransform = transform; + self.transform3D = CATransform3DMakeAffineTransform(transform); +} + +- (CATransform3D)transform3D +{ + return _transform3D; +} + +- (void)setTransform3D:(CATransform3D)transform3D +{ + // On macOS, layer.anchorPoint defaults to {0, 0} instead of {0.5, 0.5} on iOS. + // Compensate so transforms are applied from the view's center as expected. + CGPoint anchorPoint = self.layer.anchorPoint; + if (CGPointEqualToPoint(anchorPoint, CGPointZero) && !CATransform3DEqualToTransform(transform3D, CATransform3DIdentity)) { + CATransform3D originAdjust = CATransform3DTranslate(CATransform3DIdentity, self.frame.size.width / 2, self.frame.size.height / 2, 0); + transform3D = CATransform3DConcat(CATransform3DConcat(CATransform3DInvert(originAdjust), transform3D), originAdjust); + } + + _transform3D = transform3D; + _hasCustomTransform3D = !CATransform3DEqualToTransform(transform3D, CATransform3DIdentity); + self.layer.transform = transform3D; } - (NSView *)hitTest:(NSPoint)point { - // IMPORTANT point is passed in super coordinates by OSX, but expected to be passed in local coordinates - NSView *superview = [self superview]; - NSPoint pointInSelf = superview != nil ? [self convertPoint:point fromView:superview] : point; - return [self hitTest:pointInSelf withEvent:nil]; + // NSView's hitTest: receives a point in superview coordinates. Convert to local + // coordinates using CALayer, which correctly accounts for layer.transform. + // NSView's convertPoint:fromView: does NOT account for layer transforms. + CGPoint localPoint; + if (self.layer.superlayer) { + localPoint = [self.layer convertPoint:point fromLayer:self.layer.superlayer]; + } else { + localPoint = point; + } + return [self hitTest:localPoint withEvent:nil]; } - (BOOL)wantsUpdateLayer { - return [self respondsToSelector:@selector(displayLayer:)]; + return _respondsToDisplayLayer || _hasCustomTransform3D; } - (void)updateLayer @@ -153,8 +184,13 @@ - (void)updateLayer [layer setBackgroundColor:[_backgroundColor CGColor]]; } - // In Fabric, wantsUpdateLayer is always enabled and doesn't guarantee that - // the instance has a displayLayer method. + // On macOS, AppKit's layer-backed view system resets layer.transform to identity + // during its layout/display cycle because NSView has no built-in transform property + // (unlike UIView on iOS). We must re-apply the stored transform after each cycle. + if (_hasCustomTransform3D && !CATransform3DEqualToTransform(layer.transform, _transform3D)) { + layer.transform = _transform3D; + } + if (_respondsToDisplayLayer) { [(id)self displayLayer:layer]; } diff --git a/packages/react-native/React/Views/RCTView.m b/packages/react-native/React/Views/RCTView.m index 5562d9b4e20b..e21035af2c47 100644 --- a/packages/react-native/React/Views/RCTView.m +++ b/packages/react-native/React/Views/RCTView.m @@ -257,8 +257,7 @@ - (RCTPlatformView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event // [macOS // of the hit view will return YES from -pointInside:withEvent:). See: // - https://developer.apple.com/library/ios/qa/qa2013/qa1812.html for (RCTUIView *subview in [sortedSubviews reverseObjectEnumerator]) { // [macOS] - CGPoint pointForHitTest = [subview convertPoint:point fromView:self]; - hitSubview = RCTUIViewHitTestWithEvent(subview, pointForHitTest, event); // macOS] + hitSubview = RCTUIViewHitTestWithEvent(subview, point, self, event); // [macOS] if (hitSubview != nil) { break; } diff --git a/packages/react-native/React/Views/UIView+React.m b/packages/react-native/React/Views/UIView+React.m index 9af40b67b8de..30c4f1e53e7b 100644 --- a/packages/react-native/React/Views/UIView+React.m +++ b/packages/react-native/React/Views/UIView+React.m @@ -68,7 +68,7 @@ - (BOOL)isReactRootView - (NSNumber *)reactTagAtPoint:(CGPoint)point { - RCTPlatformView *view = RCTUIViewHitTestWithEvent(self, point, nil); // [macOS] + RCTPlatformView *view = RCTUIViewHitTestWithEvent(self, point, self, nil); // [macOS] while (view && !view.reactTag) { view = view.superview; }