Skip to content

Commit d2b8e80

Browse files
authored
feat: Introduce RCTUIImage (2/2) (microsoft#2766)
## Summary: Needs microsoft#2783 to merge first. Resolves microsoft#2738 There were a couple of issues with our macOS image rendering code that caused borders and shadows to not render properly in Fabric: 1. Our shim for `UIGraphicsImageRenderer` on macOS (`RCTUIGraphicsImageRenderer`) called `[NSImage imageWithActions:flipped:drawingHandler]`, which would not render the imade right away but instead when it's about to be displayed (presumably so that it can generate a proper `CGContext` for the screen the image is on). This meant that CGImage bitmap would sometimes just return as `0x0` 1. `NSImage` unlike `UIImage` does not have a `CGImage` property, so we used the method `CGImageForProposedRect:` to get one. Unfortunately, that returns an autoreleased CGImage, and when we later set that to RCTViewComponentViews' `layer.contents`, it would not be retained. This manifested as borders and shadows not rendering, or rendering and then dissapearing on a resize. To fix the first issue, apparently we can just call `[image lockFocus]` and `[image unlockFocus]` in succession to force it to render a bitmap. To fix the second issue, we need some way to cache the CGImage. I propose a new class: `RCTUIImage`. `RCTUIImage`, like `RCTUIView` is a subclass of `NSImage` with some extra properties and methods to make it more compatible with iOS code. In our case, we add the extra code to cache a CGImage, with proper retain and release calls. We can then have `RCTUIGraphicsImageRenderer` return our new subclass, and update all callers appropiately. The end result it we can actually remove some diffs, and have our macOS image handling behave closer to iOS. While doing this work, I realized our `NSImage` / `UIImage` shims were kinda messy, and with the new subclass to introduce weird runtime edge cases. I split out microsoft#2783 to help clean up and clarify what image class we're using in the rest of the codebase. While here, I also fixed an issue with clipsToBounds on macOS which was the final piece to get shadows and borders working. ## Test Plan: RNTester seems better :) https://github.com/user-attachments/assets/a5afe150-15ae-4c92-9219-8843aa5362ac
1 parent 5406504 commit d2b8e80

12 files changed

Lines changed: 101 additions & 49 deletions

File tree

packages/react-native/Libraries/Image/RCTImageBlurUtils.mm

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,13 @@
3030
RCTUIGraphicsImageRenderer *const renderer = [[RCTUIGraphicsImageRenderer alloc] initWithSize:inputImage.size // [macOS]
3131
format:rendererFormat];
3232

33+
imageRef = [renderer imageWithActions:^(RCTUIGraphicsImageRendererContext *_Nonnull context) { // [macOS]
3334
#if !TARGET_OS_OSX // [macOS]
34-
imageRef = [renderer imageWithActions:^(UIGraphicsImageRendererContext *_Nonnull context) {
3535
[inputImage drawAtPoint:CGPointZero];
36-
}].CGImage;
3736
#else // [macOS
38-
NSImage *image = [renderer imageWithActions:^(RCTUIGraphicsImageRendererContext *_Nonnull context) {
39-
[inputImage drawAtPoint:CGPointZero fromRect:NSZeroRect operation:NSCompositingOperationSourceOver fraction:1.0];
40-
}];
41-
imageRef = UIImageGetCGImageRef(image);
37+
[inputImage drawAtPoint:CGPointZero fromRect:NSZeroRect operation:NSCompositingOperationSourceOver fraction:1.0];
4238
#endif // macOS]
39+
}].CGImage;
4340
}
4441
vImage_Buffer buffer1, buffer2;
4542
buffer1.width = buffer2.width = CGImageGetWidth(imageRef);

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

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ UIKIT_STATIC_INLINE void UIBezierPathAppendPath(UIBezierPath *path, UIBezierPath
6060
#define RCTUIView UIView
6161
#define RCTUIScrollView UIScrollView
6262
#define RCTPlatformImage UIImage
63-
63+
#define RCTUIImage UIImage
6464

6565
UIKIT_STATIC_INLINE RCTPlatformView *RCTUIViewHitTestWithEvent(RCTPlatformView *view, CGPoint point, __unused UIEvent *__nullable event)
6666
{
@@ -268,7 +268,6 @@ extern "C" {
268268

269269
// UIGraphics.h
270270
CGContextRef UIGraphicsGetCurrentContext(void);
271-
CGImageRef UIImageGetCGImageRef(NSImage *image);
272271

273272
#ifdef __cplusplus
274273
}
@@ -333,19 +332,41 @@ NS_INLINE NSEdgeInsets UIEdgeInsetsMake(CGFloat top, CGFloat left, CGFloat botto
333332
// UIApplication
334333
#define UIApplication NSApplication
335334

336-
// UIImage
335+
/**
336+
* An NSImage subclass that caches its CGImage representation.
337+
*
338+
* RCTUIImage solves an issue where NSImage's `CGImageForProposedRect:` returns a new
339+
* autoreleased CGImage each time it's called. When assigned to `CALayer.contents`, these
340+
* autoreleased CGImages get deallocated when the autorelease pool drains, causing rendering
341+
* issues (e.g., blank borders and shadows).
342+
*
343+
* @warning Treat RCTUIImage instances as immutable after creation. Do not modify the image's
344+
* representations or properties after accessing the CGImage property.
345+
*/
346+
@interface RCTUIImage : NSImage
347+
348+
@property (nonatomic, readonly, nullable) CGImageRef CGImage;
349+
350+
@property (nonatomic, readonly) CGFloat scale;
351+
352+
@end
353+
337354
typedef NS_ENUM(NSInteger, UIImageRenderingMode) {
338355
UIImageRenderingModeAlwaysOriginal,
339356
UIImageRenderingModeAlwaysTemplate,
340357
};
341358

342359
#ifdef __cplusplus
343-
extern "C"
360+
extern "C" {
344361
#endif
345-
CGFloat UIImageGetScale(NSImage *image);
346362

363+
CGFloat UIImageGetScale(NSImage *image);
347364
CGImageRef UIImageGetCGImageRef(NSImage *image);
348365

366+
#ifdef __cplusplus
367+
}
368+
#endif
369+
349370
NS_INLINE NSImage *UIImageWithContentsOfFile(NSString *filePath)
350371
{
351372
return [[NSImage alloc] initWithContentsOfFile:filePath];
@@ -626,7 +647,7 @@ typedef void (^RCTUIGraphicsImageDrawingActions)(RCTUIGraphicsImageRendererConte
626647

627648
- (instancetype)initWithSize:(CGSize)size;
628649
- (instancetype)initWithSize:(CGSize)size format:(RCTUIGraphicsImageRendererFormat *)format;
629-
- (NSImage *)imageWithActions:(NS_NOESCAPE RCTUIGraphicsImageDrawingActions)actions;
650+
- (RCTUIImage *)imageWithActions:(NS_NOESCAPE RCTUIGraphicsImageDrawingActions)actions;
630651

631652
@end
632653
NS_ASSUME_NONNULL_END

packages/react-native/React/Base/macOS/RCTUIKit.m

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,43 @@ CGFloat UIImageGetScale(NSImage *image)
7676
return 1.0;
7777
}
7878

79+
// RCTUIImage - NSImage subclass with cached CGImage
80+
81+
@implementation RCTUIImage {
82+
CGImageRef _cachedCGImage;
83+
}
84+
85+
- (void)dealloc {
86+
if (_cachedCGImage != NULL) {
87+
CGImageRelease(_cachedCGImage);
88+
}
89+
}
90+
91+
- (CGImageRef)CGImage {
92+
if (_cachedCGImage == NULL) {
93+
CGImageRef cgImage = [self CGImageForProposedRect:NULL context:NULL hints:NULL];
94+
if (cgImage != NULL) {
95+
_cachedCGImage = CGImageRetain(cgImage);
96+
}
97+
}
98+
return _cachedCGImage;
99+
}
100+
101+
- (CGFloat)scale {
102+
return UIImageGetScale(self);
103+
}
104+
105+
@end
106+
79107
CGImageRef __nullable UIImageGetCGImageRef(NSImage *image)
80108
{
109+
// If it's an RCTUIImage, use the cached CGImage property
110+
if ([image isKindOfClass:[RCTUIImage class]]) {
111+
return ((RCTUIImage *)image).CGImage;
112+
}
113+
114+
// Otherwise, fall back to the standard NSImage method
115+
// Note: This returns an autoreleased CGImageRef
81116
return [image CGImageForProposedRect:NULL context:NULL hints:NULL];
82117
}
83118

@@ -825,11 +860,10 @@ - (nonnull instancetype)initWithSize:(CGSize)size format:(nonnull RCTUIGraphicsI
825860
return self;
826861
}
827862

828-
- (nonnull NSImage *)imageWithActions:(NS_NOESCAPE RCTUIGraphicsImageDrawingActions)actions {
829-
830-
NSImage *image = [NSImage imageWithSize:_size
831-
flipped:YES
832-
drawingHandler:^BOOL(NSRect dstRect) {
863+
- (nonnull RCTUIImage *)imageWithActions:(NS_NOESCAPE RCTUIGraphicsImageDrawingActions)actions {
864+
RCTUIImage *image = [RCTUIImage imageWithSize:_size
865+
flipped:YES
866+
drawingHandler:^BOOL(NSRect dstRect) {
833867

834868
RCTUIGraphicsImageRendererContext *context = [NSGraphicsContext currentContext];
835869
if (self->_format.opaque) {
@@ -838,6 +872,12 @@ - (nonnull NSImage *)imageWithActions:(NS_NOESCAPE RCTUIGraphicsImageDrawingActi
838872
actions(context);
839873
return YES;
840874
}];
875+
876+
// Calling these in succession forces the image to render its contents immediately,
877+
// rather than deferring until later.
878+
[image lockFocus];
879+
[image unlockFocus];
880+
841881
return image;
842882
}
843883

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

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -839,7 +839,7 @@ static void RCTAddContourEffectToLayer(
839839
const UIEdgeInsets &contourInsets,
840840
const RCTBorderStyle &contourStyle)
841841
{
842-
RCTPlatformImage *image = RCTGetBorderImage( // [macOS]
842+
RCTUIImage *image = RCTGetBorderImage( // [macOS]
843843
contourStyle, layer.bounds.size, cornerRadii, contourInsets, contourColors, [RCTUIColor clearColor], NO); // [macOS]
844844

845845
if (image == nil) {
@@ -850,13 +850,8 @@ static void RCTAddContourEffectToLayer(
850850
CGRect contentsCenter = CGRect{
851851
CGPoint{imageCapInsets.left / imageSize.width, imageCapInsets.top / imageSize.height},
852852
CGSize{(CGFloat)1.0 / imageSize.width, (CGFloat)1.0 / imageSize.height}};
853-
#if !TARGET_OS_OSX // [macOS]
854853
layer.contents = (id)image.CGImage;
855854
layer.contentsScale = image.scale;
856-
#else // [macOS
857-
layer.contents = (__bridge id) UIImageGetCGImageRef(image);
858-
layer.contentsScale = UIImageGetScale(image);
859-
#endif // macOS]
860855

861856
BOOL isResizable = !UIEdgeInsetsEqualToEdgeInsets(image.capInsets, UIEdgeInsetsZero);
862857
if (isResizable) {
@@ -1050,8 +1045,15 @@ - (void)invalidateLayer
10501045
}
10511046

10521047
#if TARGET_OS_OSX // [macOS
1053-
// clipsToBounds is stubbed out on macOS because it's not part of NSView
1054-
layer.masksToBounds = self.clipsToBounds;
1048+
// On macOS, clipsToBounds doesn't automatically set layer.masksToBounds like iOS does.
1049+
// When _useCustomContainerView is true (boxShadow + overflow:hidden), the container
1050+
// view handles clipping children while the main layer stays unclipped for the shadow.
1051+
// The container view's masksToBounds is set in currentContainerView getter.
1052+
if (_useCustomContainerView) {
1053+
layer.masksToBounds = NO;
1054+
} else {
1055+
layer.masksToBounds = _props->getClipsContentToBounds();
1056+
}
10551057
#endif // macOS]
10561058

10571059
const auto borderMetrics = _props->resolveBorderMetrics(_layoutMetrics);
@@ -1292,17 +1294,13 @@ - (void)invalidateLayer
12921294
_boxShadowLayer.zPosition = _borderLayer.zPosition;
12931295
_boxShadowLayer.frame = RCTGetBoundingRect(_props->boxShadow, self.layer.bounds.size);
12941296

1295-
RCTPlatformImage *boxShadowImage = RCTGetBoxShadowImage( // [macOS]
1297+
RCTUIImage *boxShadowImage = RCTGetBoxShadowImage( // [macOS]
12961298
_props->boxShadow,
12971299
RCTCornerRadiiFromBorderRadii(borderMetrics.borderRadii),
12981300
RCTUIEdgeInsetsFromEdgeInsets(borderMetrics.borderWidths),
12991301
self.layer.bounds.size);
13001302

1301-
#if !TARGET_OS_OSX // [macOS]
13021303
_boxShadowLayer.contents = (id)boxShadowImage.CGImage;
1303-
#else // [macOS
1304-
_boxShadowLayer.contents = (__bridge id)UIImageGetCGImageRef(boxShadowImage);
1305-
#endif // macOS]
13061304
}
13071305

13081306
// clipping

packages/react-native/React/Fabric/Utils/RCTBoxShadow.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
#import <React/RCTUIKit.h>
1313
#import <react/renderer/graphics/BoxShadow.h>
1414

15-
RCT_EXTERN RCTPlatformImage *RCTGetBoxShadowImage( // [macOS]
15+
RCT_EXTERN RCTUIImage *RCTGetBoxShadowImage( // [macOS]
1616
const std::vector<facebook::react::BoxShadow> &shadows,
1717
RCTCornerRadii cornerRadii,
1818
UIEdgeInsets edgeInsets,

packages/react-native/React/Fabric/Utils/RCTBoxShadow.mm

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ static void renderInsetShadows(
281281
CGContextRestoreGState(context);
282282
}
283283

284-
RCTPlatformImage *RCTGetBoxShadowImage( // [macOS]
284+
RCTUIImage *RCTGetBoxShadowImage( // [macOS]
285285
const std::vector<BoxShadow> &shadows,
286286
RCTCornerRadii cornerRadii,
287287
UIEdgeInsets edgeInsets,
@@ -293,7 +293,7 @@ static void renderInsetShadows(
293293
RCTUIGraphicsImageRenderer *const renderer = [[RCTUIGraphicsImageRenderer alloc] initWithSize:boundingRect.size
294294
format:rendererFormat];
295295
// macOS]
296-
RCTPlatformImage *const boxShadowImage = // [macOS]
296+
RCTUIImage *const boxShadowImage = // [macOS]
297297
[renderer imageWithActions:^(RCTUIGraphicsImageRendererContext *_Nonnull rendererContext) { // [macOS]
298298
auto [outsetShadows, insetShadows] = splitBoxShadowsByInset(shadows);
299299
const CGContextRef context = rendererContext.CGContext;

packages/react-native/React/Fabric/Utils/RCTLinearGradient.mm

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ + (CALayer *)gradientLayerWithSize:(CGSize)size gradient:(const LinearGradient &
2020
{
2121
RCTUIGraphicsImageRenderer *renderer = [[RCTUIGraphicsImageRenderer alloc] initWithSize:size]; // [macOS]
2222
const auto &direction = gradient.direction;
23-
RCTPlatformImage *gradientImage = [renderer imageWithActions:^(RCTUIGraphicsImageRendererContext *_Nonnull rendererContext) { // [macOS]
23+
RCTUIImage *gradientImage = [renderer imageWithActions:^(RCTUIGraphicsImageRendererContext *_Nonnull rendererContext) { // [macOS]
2424
CGPoint startPoint;
2525
CGPoint endPoint;
2626

@@ -65,11 +65,7 @@ + (CALayer *)gradientLayerWithSize:(CGSize)size gradient:(const LinearGradient &
6565
}];
6666

6767
CALayer *gradientLayer = [CALayer layer];
68-
#if !TARGET_OS_OSX // [macOS]
69-
gradientLayer.contents = (__bridge id)gradientImage.CGImage;
70-
#else // [macOS
71-
gradientLayer.contents = (__bridge id)UIImageGetCGImageRef(gradientImage);
72-
#endif // macOS]
68+
gradientLayer.contents = (id)gradientImage.CGImage;
7369

7470
return gradientLayer;
7571
}

packages/react-native/React/Views/RCTBorderDrawing.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ RCTPathCreateWithRoundedRect(CGRect bounds, RCTCornerInsets cornerInsets, const
6262
* `borderInsets` defines the border widths for each edge.
6363
* `scaleFactor` defines the backing scale factor of the device for supporting high-resolution drawing. // [macOS]
6464
*/
65-
RCT_EXTERN RCTPlatformImage *RCTGetBorderImage( // [macOS]
65+
RCT_EXTERN RCTUIImage *RCTGetBorderImage( // [macOS]
6666
RCTBorderStyle borderStyle,
6767
CGSize viewSize,
6868
RCTCornerRadii cornerRadii,

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ static CGPathRef RCTPathCreateOuterOutline(BOOL drawToEdge, CGRect rect, RCTCorn
191191
return renderer;
192192
}
193193

194-
static RCTPlatformImage *RCTGetSolidBorderImage( // [macOS]
194+
static RCTUIImage *RCTGetSolidBorderImage( // [macOS]
195195
RCTCornerRadii cornerRadii,
196196
CGSize viewSize,
197197
UIEdgeInsets borderInsets,
@@ -231,7 +231,7 @@ static CGPathRef RCTPathCreateOuterOutline(BOOL drawToEdge, CGRect rect, RCTCorn
231231

232232
RCTUIGraphicsImageRenderer *const imageRenderer =
233233
RCTMakeUIGraphicsImageRenderer(size, backgroundColor, hasCornerRadii, drawToEdge);
234-
RCTPlatformImage *image = [imageRenderer imageWithActions:^(RCTUIGraphicsImageRendererContext *_Nonnull rendererContext) { // [macOS]
234+
RCTUIImage *image = [imageRenderer imageWithActions:^(RCTUIGraphicsImageRendererContext *_Nonnull rendererContext) { // [macOS]
235235
const CGContextRef context = rendererContext.CGContext;
236236
const CGRect rect = {.size = size};
237237
CGPathRef path = RCTPathCreateOuterOutline(drawToEdge, rect, cornerRadii);
@@ -461,7 +461,7 @@ static CGPathRef RCTPathCreateOuterOutline(BOOL drawToEdge, CGRect rect, RCTCorn
461461
// of gradients _along_ a path (NB: clipping a path and drawing a linear gradient
462462
// is _not_ equivalent).
463463

464-
static RCTPlatformImage *RCTGetDashedOrDottedBorderImage( // [macOS]
464+
static RCTUIImage *RCTGetDashedOrDottedBorderImage( // [macOS]
465465
RCTBorderStyle borderStyle,
466466
RCTCornerRadii cornerRadii,
467467
CGSize viewSize,
@@ -525,7 +525,7 @@ static CGPathRef RCTPathCreateOuterOutline(BOOL drawToEdge, CGRect rect, RCTCorn
525525
}];
526526
}
527527

528-
RCTPlatformImage *RCTGetBorderImage( // [macOS]
528+
RCTUIImage *RCTGetBorderImage( // [macOS]
529529
RCTBorderStyle borderStyle,
530530
CGSize viewSize,
531531
RCTCornerRadii cornerRadii,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1269,7 +1269,7 @@ - (void)displayLayer:(CALayer *)layer
12691269
return;
12701270
}
12711271

1272-
RCTPlatformImage *image = RCTGetBorderImage( // [macOS]
1272+
RCTUIImage *image = RCTGetBorderImage( // [macOS]
12731273
_borderStyle, layer.bounds.size, cornerRadii, borderInsets, borderColors, backgroundColor, self.clipsToBounds);
12741274

12751275
layer.backgroundColor = NULL;

0 commit comments

Comments
 (0)