Skip to content

Commit 70cdc6a

Browse files
Saadnajmiclaude
andauthored
fix(transforms): enable transforms on new arch and fix hit testing on both arches (#2866)
## Summary CSS transforms (`rotate`, `scale`, `translateX`, etc.) were not rendering on macOS with the New Architecture (Fabric). This PR fixes the root cause and related issues. ### Root Cause On macOS, `NSView` has no built-in `transform` property (unlike `UIView` on iOS). AppKit's layer-backed view system resets `layer.transform` to identity during its layout/display cycle. This means any transform set on the layer is silently discarded. ### Changes **1. Add `transform3D` property to `RCTUIView` with anchor point and hit testing fixes** - Stores the transform in an ivar so it survives AppKit's reset - Re-applies the transform in `updateLayer` (called during AppKit's display phase) - `wantsUpdateLayer` is conditional on having a custom transform or `displayLayer:` delegate, so `drawRect:` still works for text views - Compensates for macOS `layer.anchorPoint` defaulting to `{0, 0}` instead of `{0.5, 0.5}`, so transforms apply from the view's center - `hitTest:` and `RCTUIViewHitTestWithEvent` use `CALayer` coordinate conversion, which correctly accounts for `layer.transform` (`NSView`'s `convertPoint:fromView:` does not) **2. Use `transform3D` in Fabric component views** - `RCTViewComponentView` uses `self.transform3D` instead of `self.layer.transform` on macOS - Removes duplicated anchor point compensation and `hitTest:` override now handled by `RCTUIView` **3. Update Paper hit testing callers for transform-aware coordinate conversion** - Updates `RCTUIViewHitTestWithEvent` callers in Paper architecture to use the new `fromView` parameter, enabling transform-aware hit testing in old architecture as well ## Test Plan - [x] Verified transforms render correctly on macOS Fabric: rotate, scale, translateX, opacity, combined rotate+scale - [x] Verified text still renders (conditional `wantsUpdateLayer` prevents skipping `drawRect:`) - [x] Verified hit testing works on transformed views (Pressable with click counters) - [x] Verified no regression on non-transformed views 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 85ab8c2 commit 70cdc6a

File tree

7 files changed

+67
-29
lines changed

7 files changed

+67
-29
lines changed

packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -536,7 +536,7 @@ - (RCTPlatformView *)betterHitTest:(CGPoint)point withEvent:(UIEvent *)event //
536536
}
537537

538538
for (RCTPlatformView *subview in [_containerView.subviews reverseObjectEnumerator]) { // [macOS]
539-
RCTPlatformView *hitView = RCTUIViewHitTestWithEvent(subview, [subview convertPoint:point fromView:self], event); // [macOS]
539+
RCTPlatformView *hitView = RCTUIViewHitTestWithEvent(subview, point, self, event); // [macOS]
540540
if (hitView) {
541541
return hitView;
542542
}

packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -362,17 +362,12 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
362362
![_propKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN containsObject:@"transform"]) {
363363
auto newTransform = newViewProps.resolveTransform(_layoutMetrics);
364364
CATransform3D caTransform = RCTCATransform3DFromTransformMatrix(newTransform);
365-
#if TARGET_OS_OSX // [macOS
366-
CGPoint anchorPoint = self.layer.anchorPoint;
367-
if (CGPointEqualToPoint(anchorPoint, CGPointZero) && !CATransform3DEqualToTransform(caTransform, CATransform3DIdentity)) {
368-
// https://developer.apple.com/documentation/quartzcore/calayer/1410817-anchorpoint
369-
// This compensates for the fact that layer.anchorPoint is {0, 0} instead of {0.5, 0.5} on macOS for some reason.
370-
CATransform3D originAdjust = CATransform3DTranslate(CATransform3DIdentity, self.frame.size.width / 2, self.frame.size.height / 2, 0);
371-
caTransform = CATransform3DConcat(CATransform3DConcat(CATransform3DInvert(originAdjust), caTransform), originAdjust);
372-
}
365+
#if !TARGET_OS_OSX // [macOS]
366+
self.layer.transform = caTransform;
367+
#else // [macOS
368+
self.transform3D = caTransform;
373369
#endif // macOS]
374370

375-
self.layer.transform = caTransform;
376371
// Enable edge antialiasing in rotation, skew, or perspective transforms
377372
self.layer.allowsEdgeAntialiasing = caTransform.m12 != 0.0f || caTransform.m21 != 0.0f || caTransform.m34 != 0.0f;
378373
}
@@ -713,7 +708,11 @@ - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics
713708
if ((_props->transformOrigin.isSet() || _props->transform.operations.size() > 0) &&
714709
layoutMetrics.frame.size != oldLayoutMetrics.frame.size) {
715710
auto newTransform = _props->resolveTransform(layoutMetrics);
711+
#if !TARGET_OS_OSX // [macOS]
716712
self.layer.transform = RCTCATransform3DFromTransformMatrix(newTransform);
713+
#else // [macOS
714+
self.transform3D = RCTCATransform3DFromTransformMatrix(newTransform);
715+
#endif // macOS]
717716
}
718717
}
719718

@@ -807,7 +806,7 @@ - (RCTPlatformView *)betterHitTest:(CGPoint)point withEvent:(UIEvent *)event //
807806
}
808807

809808
for (RCTPlatformView *subview in [self.subviews reverseObjectEnumerator]) { // [macOS]
810-
RCTPlatformView *hitView = RCTUIViewHitTestWithEvent(subview, [subview convertPoint:point fromView:self], event); // [macOS]
809+
RCTPlatformView *hitView = RCTUIViewHitTestWithEvent(subview, point, self, event); // [macOS]
811810
if (hitView) {
812811
return hitView;
813812
}

packages/react-native/React/Modules/RCTUIManager.mm

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1186,7 +1186,7 @@ - (void)synchronouslyUpdateViewOnUIThread:(NSNumber *)reactTag viewName:(NSStrin
11861186
{
11871187
[self addUIBlock:^(__unused RCTUIManager *uiManager, NSDictionary<NSNumber *, RCTPlatformView *> *viewRegistry) { // [macOS]
11881188
RCTPlatformView *view = viewRegistry[reactTag]; // [macOS]
1189-
RCTPlatformView *target = RCTUIViewHitTestWithEvent(view, point, nil); // [macOS]
1189+
RCTPlatformView *target = RCTUIViewHitTestWithEvent(view, point, view, nil); // [macOS]
11901190
CGRect frame = [target convertRect:target.bounds toView:view];
11911191

11921192
while (target.reactTag == nil && target.superview != nil) {

packages/react-native/React/RCTUIKit/RCTUIView.h

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ NS_ASSUME_NONNULL_BEGIN
5656

5757
@property (nonatomic, copy) NSColor *backgroundColor;
5858
@property (nonatomic) CGAffineTransform transform;
59+
@property (nonatomic) CATransform3D transform3D;
5960

6061
/**
6162
* Specifies whether the view should receive the mouse down event when the
@@ -75,7 +76,6 @@ NS_ASSUME_NONNULL_BEGIN
7576
*/
7677
@property (nonatomic, assign) BOOL enableFocusRing;
7778

78-
// [macOS
7979
/**
8080
* iOS compatibility shim. On macOS, this forwards to accessibilityChildren.
8181
*/
@@ -90,9 +90,9 @@ NS_ASSUME_NONNULL_BEGIN
9090

9191
#if !TARGET_OS_OSX
9292

93-
UIKIT_STATIC_INLINE RCTPlatformView *RCTUIViewHitTestWithEvent(RCTPlatformView *view, CGPoint point, __unused UIEvent *__nullable event)
93+
UIKIT_STATIC_INLINE RCTPlatformView *RCTUIViewHitTestWithEvent(RCTPlatformView *view, CGPoint point, RCTPlatformView *fromView, __unused UIEvent *__nullable event)
9494
{
95-
return [view hitTest:point withEvent:event];
95+
return [view hitTest:[view convertPoint:point fromView:fromView] withEvent:event];
9696
}
9797

9898
UIKIT_STATIC_INLINE void RCTUIViewSetContentModeRedraw(UIView *view)
@@ -107,11 +107,15 @@ UIKIT_STATIC_INLINE BOOL RCTUIViewIsDescendantOfView(RCTPlatformView *view, RCTP
107107

108108
#else // TARGET_OS_OSX
109109

110-
NS_INLINE RCTPlatformView *RCTUIViewHitTestWithEvent(RCTPlatformView *view, CGPoint point, __unused UIEvent *__nullable event)
110+
// Use CALayer coordinate conversion which correctly accounts for layer.transform.
111+
// NSView's convertPoint:fromView: does not account for layer transforms on macOS.
112+
// IMPORTANT -- NSView's hitTest: expects a point in the superview's coordinate space,
113+
// so we convert from fromView → superview using CALayer, which handles layer transforms correctly.
114+
// This allows hit testing to work correctly between nested RCTUIViews and plain NSViews.
115+
NS_INLINE RCTPlatformView *RCTUIViewHitTestWithEvent(RCTPlatformView *view, CGPoint point, RCTPlatformView *fromView, __unused UIEvent *__nullable event)
111116
{
112-
// [macOS IMPORTANT -- point is in local coordinate space, but OSX expects super coordinate space for hitTest:
113117
NSView *superview = [view superview];
114-
NSPoint pointInSuperview = superview != nil ? [view convertPoint:point toView:superview] : point;
118+
NSPoint pointInSuperview = superview != nil ? [superview.layer convertPoint:point fromLayer:fromView.layer] : point;
115119
return [view hitTest:pointInSuperview];
116120
}
117121

packages/react-native/React/RCTUIKit/RCTUIView.m

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
#if TARGET_OS_OSX
1111

12+
#import <QuartzCore/QuartzCore.h>
1213
#import <React/RCTUIView.h>
1314

1415
// UIView
@@ -21,6 +22,8 @@ @implementation RCTUIView
2122
BOOL _userInteractionEnabled;
2223
BOOL _mouseDownCanMoveWindow;
2324
BOOL _respondsToDisplayLayer;
25+
CATransform3D _transform3D;
26+
BOOL _hasCustomTransform3D;
2427
}
2528

2629
+ (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key
@@ -51,6 +54,8 @@ @implementation RCTUIView
5154
self->_enableFocusRing = YES;
5255
self->_mouseDownCanMoveWindow = YES;
5356
self->_respondsToDisplayLayer = [self respondsToSelector:@selector(displayLayer:)];
57+
self->_transform3D = CATransform3DIdentity;
58+
self->_hasCustomTransform3D = NO;
5459
}
5560
return self;
5661
}
@@ -127,20 +132,46 @@ - (CGAffineTransform)transform
127132

128133
- (void)setTransform:(CGAffineTransform)transform
129134
{
130-
self.layer.affineTransform = transform;
135+
self.transform3D = CATransform3DMakeAffineTransform(transform);
136+
}
137+
138+
- (CATransform3D)transform3D
139+
{
140+
return _transform3D;
141+
}
142+
143+
- (void)setTransform3D:(CATransform3D)transform3D
144+
{
145+
// On macOS, layer.anchorPoint defaults to {0, 0} instead of {0.5, 0.5} on iOS.
146+
// Compensate so transforms are applied from the view's center as expected.
147+
CGPoint anchorPoint = self.layer.anchorPoint;
148+
if (CGPointEqualToPoint(anchorPoint, CGPointZero) && !CATransform3DEqualToTransform(transform3D, CATransform3DIdentity)) {
149+
CATransform3D originAdjust = CATransform3DTranslate(CATransform3DIdentity, self.frame.size.width / 2, self.frame.size.height / 2, 0);
150+
transform3D = CATransform3DConcat(CATransform3DConcat(CATransform3DInvert(originAdjust), transform3D), originAdjust);
151+
}
152+
153+
_transform3D = transform3D;
154+
_hasCustomTransform3D = !CATransform3DEqualToTransform(transform3D, CATransform3DIdentity);
155+
self.layer.transform = transform3D;
131156
}
132157

133158
- (NSView *)hitTest:(NSPoint)point
134159
{
135-
// IMPORTANT point is passed in super coordinates by OSX, but expected to be passed in local coordinates
136-
NSView *superview = [self superview];
137-
NSPoint pointInSelf = superview != nil ? [self convertPoint:point fromView:superview] : point;
138-
return [self hitTest:pointInSelf withEvent:nil];
160+
// NSView's hitTest: receives a point in superview coordinates. Convert to local
161+
// coordinates using CALayer, which correctly accounts for layer.transform.
162+
// NSView's convertPoint:fromView: does NOT account for layer transforms.
163+
CGPoint localPoint;
164+
if (self.layer.superlayer) {
165+
localPoint = [self.layer convertPoint:point fromLayer:self.layer.superlayer];
166+
} else {
167+
localPoint = point;
168+
}
169+
return [self hitTest:localPoint withEvent:nil];
139170
}
140171

141172
- (BOOL)wantsUpdateLayer
142173
{
143-
return [self respondsToSelector:@selector(displayLayer:)];
174+
return _respondsToDisplayLayer || _hasCustomTransform3D;
144175
}
145176

146177
- (void)updateLayer
@@ -153,8 +184,13 @@ - (void)updateLayer
153184
[layer setBackgroundColor:[_backgroundColor CGColor]];
154185
}
155186

156-
// In Fabric, wantsUpdateLayer is always enabled and doesn't guarantee that
157-
// the instance has a displayLayer method.
187+
// On macOS, AppKit's layer-backed view system resets layer.transform to identity
188+
// during its layout/display cycle because NSView has no built-in transform property
189+
// (unlike UIView on iOS). We must re-apply the stored transform after each cycle.
190+
if (_hasCustomTransform3D && !CATransform3DEqualToTransform(layer.transform, _transform3D)) {
191+
layer.transform = _transform3D;
192+
}
193+
158194
if (_respondsToDisplayLayer) {
159195
[(id<CALayerDelegate>)self displayLayer:layer];
160196
}

packages/react-native/React/Views/RCTView.m

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,8 +257,7 @@ - (RCTPlatformView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event // [macOS
257257
// of the hit view will return YES from -pointInside:withEvent:). See:
258258
// - https://developer.apple.com/library/ios/qa/qa2013/qa1812.html
259259
for (RCTUIView *subview in [sortedSubviews reverseObjectEnumerator]) { // [macOS]
260-
CGPoint pointForHitTest = [subview convertPoint:point fromView:self];
261-
hitSubview = RCTUIViewHitTestWithEvent(subview, pointForHitTest, event); // macOS]
260+
hitSubview = RCTUIViewHitTestWithEvent(subview, point, self, event); // [macOS]
262261
if (hitSubview != nil) {
263262
break;
264263
}

packages/react-native/React/Views/UIView+React.m

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ - (BOOL)isReactRootView
6868

6969
- (NSNumber *)reactTagAtPoint:(CGPoint)point
7070
{
71-
RCTPlatformView *view = RCTUIViewHitTestWithEvent(self, point, nil); // [macOS]
71+
RCTPlatformView *view = RCTUIViewHitTestWithEvent(self, point, self, nil); // [macOS]
7272
while (view && !view.reactTag) {
7373
view = view.superview;
7474
}

0 commit comments

Comments
 (0)