Skip to content

Commit 7c405e5

Browse files
Saadnajmiclaude
andcommitted
fix(macOS): add transform3D property to RCTUIView with anchor point and hit testing fixes
On macOS, AppKit resets layer.transform to identity during its display cycle because NSView has no built-in transform property. This adds a transform3D property to RCTUIView that persists the transform and re-applies it in updateLayer. Also fixes two related issues in the compat layer: - Compensates for macOS layer.anchorPoint defaulting to {0,0} instead of {0.5, 0.5}, so transforms apply from the view's center. - Uses CALayer coordinate conversion in hitTest: and RCTUIViewHitTestWithEvent, which correctly accounts for layer.transform (NSView's convertPoint:fromView: does not). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2a222b2 commit 7c405e5

2 files changed

Lines changed: 54 additions & 14 deletions

File tree

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
}

0 commit comments

Comments
 (0)