diff --git a/packages/react-native/React/Base/RCTUIKit.h b/packages/react-native/React/Base/RCTUIKit.h index d01c4029f8b371..edffb66e4aefd3 100644 --- a/packages/react-native/React/Base/RCTUIKit.h +++ b/packages/react-native/React/Base/RCTUIKit.h @@ -264,7 +264,6 @@ extern "C" { // UIGraphics.h CGContextRef UIGraphicsGetCurrentContext(void); -CGImageRef UIImageGetCGImageRef(NSImage *image); #ifdef __cplusplus } @@ -330,7 +329,14 @@ NS_INLINE NSEdgeInsets UIEdgeInsetsMake(CGFloat top, CGFloat left, CGFloat botto #define UIApplication NSApplication // UIImage -@compatibility_alias UIImage NSImage; +// RCTUIImage is a subclass of NSImage that caches its CGImage representation. +// This is needed because NSImage's CGImageForProposedRect: returns a new autoreleased +// CGImage each time, which causes issues when used with CALayer.contents. +@interface RCTUIImage : NSImage +@property (nonatomic, readonly, nullable) CGImageRef CGImage; +@end + +@compatibility_alias UIImage RCTUIImage; typedef NS_ENUM(NSInteger, UIImageRenderingMode) { UIImageRenderingModeAlwaysOriginal, @@ -338,20 +344,24 @@ typedef NS_ENUM(NSInteger, UIImageRenderingMode) { }; #ifdef __cplusplus -extern "C" +extern "C" { #endif -CGFloat UIImageGetScale(NSImage *image); -CGImageRef UIImageGetCGImageRef(NSImage *image); +CGFloat UIImageGetScale(RCTUIImage *image); +CGImageRef UIImageGetCGImageRef(RCTUIImage *image); + +#ifdef __cplusplus +} +#endif -NS_INLINE UIImage *UIImageWithContentsOfFile(NSString *filePath) +NS_INLINE RCTUIImage *UIImageWithContentsOfFile(NSString *filePath) { - return [[NSImage alloc] initWithContentsOfFile:filePath]; + return [[RCTUIImage alloc] initWithContentsOfFile:filePath]; } -NS_INLINE UIImage *UIImageWithData(NSData *imageData) +NS_INLINE RCTUIImage *UIImageWithData(NSData *imageData) { - return [[NSImage alloc] initWithData:imageData]; + return [[RCTUIImage alloc] initWithData:imageData]; } NSData *UIImagePNGRepresentation(NSImage *image); @@ -624,7 +634,7 @@ typedef void (^RCTUIGraphicsImageDrawingActions)(RCTUIGraphicsImageRendererConte - (instancetype)initWithSize:(CGSize)size; - (instancetype)initWithSize:(CGSize)size format:(RCTUIGraphicsImageRendererFormat *)format; -- (NSImage *)imageWithActions:(NS_NOESCAPE RCTUIGraphicsImageDrawingActions)actions; +- (RCTUIImage *)imageWithActions:(NS_NOESCAPE RCTUIGraphicsImageDrawingActions)actions; @end NS_ASSUME_NONNULL_END diff --git a/packages/react-native/React/Base/macOS/RCTUIKit.m b/packages/react-native/React/Base/macOS/RCTUIKit.m index 5ab447832ed5a9..79351305dbf064 100644 --- a/packages/react-native/React/Base/macOS/RCTUIKit.m +++ b/packages/react-native/React/Base/macOS/RCTUIKit.m @@ -57,7 +57,7 @@ CGContextRef UIGraphicsGetCurrentContext(void) // UIImage -CGFloat UIImageGetScale(NSImage *image) +CGFloat UIImageGetScale(RCTUIImage *image) { if (image == nil) { return 0.0; @@ -76,9 +76,33 @@ CGFloat UIImageGetScale(NSImage *image) return 1.0; } -CGImageRef __nullable UIImageGetCGImageRef(NSImage *image) +// RCTUIImage - NSImage subclass with cached CGImage + +@implementation RCTUIImage { + CGImageRef _cachedCGImage; +} + +- (void)dealloc { + if (_cachedCGImage != NULL) { + CGImageRelease(_cachedCGImage); + } +} + +- (CGImageRef)CGImage { + if (_cachedCGImage == NULL) { + CGImageRef cgImage = [self CGImageForProposedRect:NULL context:NULL hints:NULL]; + if (cgImage != NULL) { + _cachedCGImage = CGImageRetain(cgImage); + } + } + return _cachedCGImage; +} + +@end + +CGImageRef __nullable UIImageGetCGImageRef(RCTUIImage *image) { - return [image CGImageForProposedRect:NULL context:NULL hints:NULL]; + return image.CGImage; } static NSData *NSImageDataForFileType(NSImage *image, NSBitmapImageFileType fileType, NSDictionary *properties) @@ -825,19 +849,55 @@ - (nonnull instancetype)initWithSize:(CGSize)size format:(nonnull RCTUIGraphicsI return self; } -- (nonnull NSImage *)imageWithActions:(NS_NOESCAPE RCTUIGraphicsImageDrawingActions)actions { - - NSImage *image = [NSImage imageWithSize:_size - flipped:YES - drawingHandler:^BOOL(NSRect dstRect) { - - RCTUIGraphicsImageRendererContext *context = [NSGraphicsContext currentContext]; - if (self->_format.opaque) { - CGContextSetAlpha([context CGContext], 1.0); - } - actions(context); - return YES; - }]; +- (nonnull RCTUIImage *)imageWithActions:(NS_NOESCAPE RCTUIGraphicsImageDrawingActions)actions { + // Create an RCTUIImage which caches its CGImage for efficient layer.contents usage. + // We draw into a bitmap context and create the image from that. + + CGFloat scale = _format.scale > 0 ? _format.scale : [[NSScreen mainScreen] backingScaleFactor]; + NSInteger pixelWidth = (NSInteger)(_size.width * scale); + NSInteger pixelHeight = (NSInteger)(_size.height * scale); + + if (pixelWidth <= 0 || pixelHeight <= 0) { + return [[RCTUIImage alloc] initWithSize:_size]; + } + + // Create a bitmap context + NSBitmapImageRep *bitmapRep = [[NSBitmapImageRep alloc] + initWithBitmapDataPlanes:NULL + pixelsWide:pixelWidth + pixelsHigh:pixelHeight + bitsPerSample:8 + samplesPerPixel:4 + hasAlpha:YES + isPlanar:NO + colorSpaceName:NSCalibratedRGBColorSpace + bytesPerRow:0 + bitsPerPixel:0]; + + bitmapRep.size = _size; + + NSGraphicsContext *context = [NSGraphicsContext graphicsContextWithBitmapImageRep:bitmapRep]; + [NSGraphicsContext saveGraphicsState]; + [NSGraphicsContext setCurrentContext:context]; + + // Flip the context to match iOS coordinate system (origin at top-left) + CGContextRef cgContext = [context CGContext]; + CGContextTranslateCTM(cgContext, 0, _size.height); + CGContextScaleCTM(cgContext, 1.0, -1.0); + + if (_format.opaque) { + CGContextSetAlpha(cgContext, 1.0); + } + + // Execute the drawing actions + actions(context); + + [NSGraphicsContext restoreGraphicsState]; + + // Create an RCTUIImage from the bitmap representation + RCTUIImage *image = [[RCTUIImage alloc] initWithSize:_size]; + [image addRepresentation:bitmapRep]; + return image; } 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 0086fc4ce33536..17387e7aeef598 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -854,7 +854,9 @@ static void RCTAddContourEffectToLayer( layer.contents = (id)image.CGImage; layer.contentsScale = image.scale; #else // [macOS - layer.contents = (__bridge id) UIImageGetCGImageRef(image); + // RCTUIImage caches its CGImage, so it stays valid as long as the image is alive. + // The image is retained by the layer.contents assignment. + layer.contents = (__bridge id)image.CGImage; layer.contentsScale = UIImageGetScale(image); #endif // macOS] @@ -1015,10 +1017,20 @@ - (RCTUIView *)currentContainerView // [macOS] if (_useCustomContainerView) { if (!_containerView) { _containerView = [[RCTPlatformView alloc] initWithFrame:CGRectMake(0, 0, self.frame.size.width, self.frame.size.height)]; // [macOS] +#if TARGET_OS_OSX // [macOS + _containerView.wantsLayer = YES; +#endif // macOS] for (RCTPlatformView *subview in self.subviews) { // [macOS] [_containerView addSubview:subview]; } +#if !TARGET_OS_OSX // [macOS] _containerView.clipsToBounds = self.clipsToBounds; +#else // [macOS + // On macOS, clipsToBounds doesn't automatically set layer.masksToBounds + // like it does on iOS, so we need to set it directly. + _containerView.clipsToBounds = _props->getClipsContentToBounds(); + _containerView.layer.masksToBounds = _props->getClipsContentToBounds(); +#endif // macOS] self.clipsToBounds = NO; _containerView.layer.mask = self.layer.mask; self.layer.mask = nil; @@ -1050,8 +1062,15 @@ - (void)invalidateLayer } #if TARGET_OS_OSX // [macOS - // clipsToBounds is stubbed out on macOS because it's not part of NSView - layer.masksToBounds = self.clipsToBounds; + // On macOS, clipsToBounds doesn't automatically set layer.masksToBounds like iOS does. + // When _useCustomContainerView is true (boxShadow + overflow:hidden), the container + // view handles clipping children while the main layer stays unclipped for the shadow. + // The container view's masksToBounds is set in currentContainerView getter. + if (_useCustomContainerView) { + layer.masksToBounds = NO; + } else { + layer.masksToBounds = _props->getClipsContentToBounds(); + } #endif // macOS] const auto borderMetrics = _props->resolveBorderMetrics(_layoutMetrics); @@ -1189,6 +1208,10 @@ - (void)invalidateLayer [layer addSublayer:borderLayer]; _borderLayer = borderLayer; } +#if TARGET_OS_OSX // [macOS + // Update frame on every call in case view was resized + _borderLayer.frame = layer.bounds; +#endif // macOS] layer.borderWidth = 0; layer.borderColor = nil; @@ -1289,7 +1312,8 @@ - (void)invalidateLayer if (!_props->boxShadow.empty()) { _boxShadowLayer = [CALayer layer]; [self.layer addSublayer:_boxShadowLayer]; - _boxShadowLayer.zPosition = _borderLayer.zPosition; + // Box shadow should be behind all content but still visible + _boxShadowLayer.zPosition = BACKGROUND_COLOR_ZPOSITION - 1; _boxShadowLayer.frame = RCTGetBoundingRect(_props->boxShadow, self.layer.bounds.size); UIImage *boxShadowImage = RCTGetBoxShadowImage( @@ -1301,7 +1325,8 @@ - (void)invalidateLayer #if !TARGET_OS_OSX // [macOS] _boxShadowLayer.contents = (id)boxShadowImage.CGImage; #else // [macOS - _boxShadowLayer.contents = (__bridge id)UIImageGetCGImageRef(boxShadowImage); + // RCTUIImage caches its CGImage, so it stays valid as long as the image is alive. + _boxShadowLayer.contents = (__bridge id)boxShadowImage.CGImage; #endif // macOS] }