From 77d0b29482164765842b1dddb6ec96510d13bbd9 Mon Sep 17 00:00:00 2001 From: Mantas Kantautas Date: Fri, 23 Oct 2026 19:02:35 +0300 Subject: [PATCH] fix(ios): resize useRenderInContext captures to width/height -[CALayer renderInContext:] draws the layer at its own size, so with useRenderInContext a width/height smaller than the view cropped the capture and a larger one padded it with transparent pixels. Scale the context to the requested size, as drawViewHierarchyInRect: does. --- .../ViewShotExample.xcodeproj/project.pbxproj | 4 + .../RNViewShotRenderInContextTests.mm | 230 ++++++++++++++++++ ios/RNViewShot.mm | 8 +- 3 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 example/ios/ViewShotExampleTests/RNViewShotRenderInContextTests.mm diff --git a/example/ios/ViewShotExample.xcodeproj/project.pbxproj b/example/ios/ViewShotExample.xcodeproj/project.pbxproj index 902c7dac..a7e65260 100644 --- a/example/ios/ViewShotExample.xcodeproj/project.pbxproj +++ b/example/ios/ViewShotExample.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 2A8A27A6FB5DE73C673F3249 /* libPods-ViewShotExampleTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = FC04D99A75BA31580C9E0E75 /* libPods-ViewShotExampleTests.a */; }; 36A755D11A5AFE0015B0DE4C /* RNViewShotEncodingTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = E8B24C6C71854D7AAFDF405D /* RNViewShotEncodingTests.mm */; }; + 4A59BD3D8D582F30A4EEF472 /* RNViewShotRenderInContextTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 672E61412E0BF41EBF749852 /* RNViewShotRenderInContextTests.mm */; }; 58A23CD2F827398406615B76 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; }; 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; }; 7A13F0082931444BD8DE800F /* RNViewShotReleaseCaptureTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 5C182E8F18E761A148F92B74 /* RNViewShotReleaseCaptureTests.mm */; }; @@ -39,6 +40,7 @@ 18FD9AAD8492A83D10EE439B /* Pods-ViewShotExampleTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ViewShotExampleTests.debug.xcconfig"; path = "Target Support Files/Pods-ViewShotExampleTests/Pods-ViewShotExampleTests.debug.xcconfig"; sourceTree = ""; }; 2257FE4D308A78193EE13AA2 /* Pods-ViewShotExampleTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ViewShotExampleTests.release.xcconfig"; path = "Target Support Files/Pods-ViewShotExampleTests/Pods-ViewShotExampleTests.release.xcconfig"; sourceTree = ""; }; 5C182E8F18E761A148F92B74 /* RNViewShotReleaseCaptureTests.mm */ = {isa = PBXFileReference; includeInIndex = 1; path = RNViewShotReleaseCaptureTests.mm; sourceTree = ""; }; + 672E61412E0BF41EBF749852 /* RNViewShotRenderInContextTests.mm */ = {isa = PBXFileReference; includeInIndex = 1; path = RNViewShotRenderInContextTests.mm; sourceTree = ""; }; 761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = ViewShotExample/AppDelegate.swift; sourceTree = ""; }; 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = ViewShotExample/LaunchScreen.storyboard; sourceTree = ""; }; 861C42BC0C7660F41F61AD83 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -106,6 +108,7 @@ children = ( 5C182E8F18E761A148F92B74 /* RNViewShotReleaseCaptureTests.mm */, E8B24C6C71854D7AAFDF405D /* RNViewShotEncodingTests.mm */, + 672E61412E0BF41EBF749852 /* RNViewShotRenderInContextTests.mm */, 861C42BC0C7660F41F61AD83 /* Info.plist */, ); name = ViewShotExampleTests; @@ -361,6 +364,7 @@ files = ( 7A13F0082931444BD8DE800F /* RNViewShotReleaseCaptureTests.mm in Sources */, 36A755D11A5AFE0015B0DE4C /* RNViewShotEncodingTests.mm in Sources */, + 4A59BD3D8D582F30A4EEF472 /* RNViewShotRenderInContextTests.mm in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/example/ios/ViewShotExampleTests/RNViewShotRenderInContextTests.mm b/example/ios/ViewShotExampleTests/RNViewShotRenderInContextTests.mm new file mode 100644 index 00000000..7934f817 --- /dev/null +++ b/example/ios/ViewShotExampleTests/RNViewShotRenderInContextTests.mm @@ -0,0 +1,230 @@ +// RNViewShotRenderInContextTests.mm +// +// `width` / `height` must resize the capture with `useRenderInContext` too, +// as they do with the default `drawViewHierarchyInRect:` strategy. +// `-[CALayer renderInContext:]` draws the layer at its own size, so without +// scaling the result is cropped (smaller size) or padded with transparent +// pixels (larger size). +// +// These tests call the exported `captureRef` method with a stub UI manager that +// runs the UI block against a local view registry. `renderInContext:` draws +// views that are not attached to a window, so the pixels can be asserted. + +#import +#import +#import "RNViewShot.h" + +@interface RNViewShot (TestingOnly) +- (void)captureRef:(nonnull NSNumber *)target + withOptions:(NSDictionary *)options + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; +@end + +typedef void (^RNViewShotTestUIBlock)(id uiManager, NSDictionary *viewRegistry); + +// Responds to the two messages captureRef sends: `bridge.uiManager` and +// `addUIBlock:`. +@interface RNViewShotTestUIManager : NSObject +@property (nonatomic, copy) NSDictionary *viewRegistry; +@end + +@implementation RNViewShotTestUIManager +- (void)addUIBlock:(RNViewShotTestUIBlock)block +{ + block(nil, self.viewRegistry); +} +@end + +@interface RNViewShotTestBridge : NSObject +@property (nonatomic, strong) RNViewShotTestUIManager *uiManager; +@end + +@implementation RNViewShotTestBridge +@end + +@interface RNViewShotRenderInContextTests : XCTestCase +@property (nonatomic, strong) RNViewShot *module; +// RNViewShot holds its bridge weakly. +@property (nonatomic, strong) RNViewShotTestBridge *bridge; +@end + +@implementation RNViewShotRenderInContextTests + +- (void)setUp +{ + [super setUp]; + self.bridge = [RNViewShotTestBridge new]; + self.bridge.uiManager = [RNViewShotTestUIManager new]; + self.module = [RNViewShot new]; + [self.module setValue:self.bridge forKey:@"bridge"]; +} + +// Red, blue, green and yellow quadrants (top-left, top-right, bottom-left, +// bottom-right). +static UIView *QuadrantView(CGSize size) +{ + UIView *view = [[UIView alloc] initWithFrame:(CGRect){CGPointZero, size}]; + NSArray *colors = @[ UIColor.redColor, UIColor.blueColor, UIColor.greenColor, UIColor.yellowColor ]; + for (NSUInteger i = 0; i < colors.count; i++) { + UIView *quadrant = [[UIView alloc] initWithFrame:CGRectMake((i % 2) * size.width / 2, + (i / 2) * size.height / 2, + size.width / 2, + size.height / 2)]; + quadrant.backgroundColor = colors[i]; + [view addSubview:quadrant]; + } + return view; +} + +- (UIImage *)captureView:(UIView *)view options:(NSDictionary *)extraOptions +{ + self.bridge.uiManager.viewRegistry = @{ @1 : view }; + NSMutableDictionary *options = [@{ + @"format" : @"png", + @"quality" : @1, + @"result" : @"base64", + @"snapshotContentContainer" : @NO, + @"useRenderInContext" : @YES, + } mutableCopy]; + [options addEntriesFromDictionary:extraOptions]; + + XCTestExpectation *done = [self expectationWithDescription:@"capture settles"]; + __block NSString *base64 = nil; + __block NSString *failure = nil; + [self.module captureRef:@1 + withOptions:options + resolve:^(id result) { + base64 = result; + [done fulfill]; + } + reject:^(NSString *code, NSString *message, NSError *error) { + failure = message; + [done fulfill]; + }]; + [self waitForExpectations:@[ done ] timeout:10]; + + XCTAssertNil(failure, @"capture rejected: %@", failure); + NSData *data = [[NSData alloc] initWithBase64EncodedString:base64 options:0]; + UIImage *image = data ? [UIImage imageWithData:data] : nil; + XCTAssertNotNil(image, @"capture did not resolve to a decodable PNG"); + return image; +} + +static NSString *ColorName(const uint8_t *rgba) +{ + if (rgba[3] < 32) return @"transparent"; + BOOL r = rgba[0] > 192, g = rgba[1] > 192, b = rgba[2] > 192; + BOOL rLow = rgba[0] < 64, gLow = rgba[1] < 64, bLow = rgba[2] < 64; + if (r && gLow && bLow) return @"red"; + if (rLow && gLow && b) return @"blue"; + if (rLow && g && bLow) return @"green"; + if (r && g && bLow) return @"yellow"; + return [NSString stringWithFormat:@"rgba(%d,%d,%d,%d)", rgba[0], rgba[1], rgba[2], rgba[3]]; +} + +// Colors at the centers of the image's four quadrants. +static NSArray *QuadrantColors(UIImage *image) +{ + CGImageRef cgImage = image.CGImage; + size_t width = CGImageGetWidth(cgImage); + size_t height = CGImageGetHeight(cgImage); + CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB); + CGContextRef context = CGBitmapContextCreate(NULL, width, height, 8, width * 4, space, + (CGBitmapInfo)kCGImageAlphaPremultipliedLast); + CGColorSpaceRelease(space); + CGContextDrawImage(context, CGRectMake(0, 0, width, height), cgImage); + const uint8_t *pixels = (const uint8_t *)CGBitmapContextGetData(context); + + NSMutableArray *names = [NSMutableArray array]; + const CGFloat points[4][2] = { { 0.25, 0.25 }, { 0.75, 0.25 }, { 0.25, 0.75 }, { 0.75, 0.75 } }; + for (int i = 0; i < 4; i++) { + size_t x = (size_t)(points[i][0] * width); + size_t y = (size_t)(points[i][1] * height); + [names addObject:ColorName(pixels + (y * width + x) * 4)]; + } + CGContextRelease(context); + return names; +} + +static NSArray *ExpectedQuadrants(void) +{ + return @[ @"red", @"blue", @"green", @"yellow" ]; +} + +- (void)assertImage:(UIImage *)image hasPointSize:(CGSize)size +{ + CGFloat scale = UIScreen.mainScreen.scale; + XCTAssertEqual(CGImageGetWidth(image.CGImage), (size_t)round(size.width * scale)); + XCTAssertEqual(CGImageGetHeight(image.CGImage), (size_t)round(size.height * scale)); +} + +- (void)testWithoutWidthHeight_capturesTheViewAtItsOwnSize +{ + UIImage *image = [self captureView:QuadrantView(CGSizeMake(200, 100)) options:@{}]; + + [self assertImage:image hasPointSize:CGSizeMake(200, 100)]; + XCTAssertEqualObjects(QuadrantColors(image), ExpectedQuadrants()); +} + +- (void)testSmallerWidthHeight_resizesTheWholeView +{ + UIImage *image = [self captureView:QuadrantView(CGSizeMake(200, 100)) + options:@{ @"width" : @100, @"height" : @50 }]; + + [self assertImage:image hasPointSize:CGSizeMake(100, 50)]; + XCTAssertEqualObjects(QuadrantColors(image), ExpectedQuadrants()); +} + +- (void)testLargerWidthHeight_resizesTheWholeView +{ + UIImage *image = [self captureView:QuadrantView(CGSizeMake(200, 100)) + options:@{ @"width" : @400, @"height" : @200 }]; + + [self assertImage:image hasPointSize:CGSizeMake(400, 200)]; + XCTAssertEqualObjects(QuadrantColors(image), ExpectedQuadrants()); +} + +- (void)testSnapshotContentContainer_resizesTheWholeContent +{ + UIScrollView *scrollView = [[UIScrollView alloc] initWithFrame:CGRectMake(0, 0, 200, 100)]; + [scrollView addSubview:QuadrantView(CGSizeMake(200, 200))]; + scrollView.contentSize = CGSizeMake(200, 200); + + UIImage *image = [self captureView:scrollView + options:@{ @"width" : @100, @"height" : @100, @"snapshotContentContainer" : @YES }]; + + [self assertImage:image hasPointSize:CGSizeMake(100, 100)]; + XCTAssertEqualObjects(QuadrantColors(image), ExpectedQuadrants()); + XCTAssertTrue(CGRectEqualToRect(scrollView.frame, CGRectMake(0, 0, 200, 100))); +} + +// A transformed scroll view's temporarily expanded bounds differ from its +// content size, so they must not be used as the resize reference. +static UIScrollView *HalfScaleScrollView(void) +{ + UIScrollView *scrollView = [[UIScrollView alloc] initWithFrame:CGRectMake(0, 0, 200, 100)]; + [scrollView addSubview:QuadrantView(CGSizeMake(200, 200))]; + scrollView.contentSize = CGSizeMake(200, 200); + scrollView.transform = CGAffineTransformMakeScale(0.5, 0.5); + return scrollView; +} + +- (void)testSnapshotContentContainer_transformedScrollViewWithoutWidthHeight_keepsTheContentSize +{ + UIImage *image = [self captureView:HalfScaleScrollView() options:@{ @"snapshotContentContainer" : @YES }]; + + [self assertImage:image hasPointSize:CGSizeMake(200, 200)]; + XCTAssertEqualObjects(QuadrantColors(image), ExpectedQuadrants()); +} + +- (void)testSnapshotContentContainer_transformedScrollView_resizesTheWholeContent +{ + UIImage *image = [self captureView:HalfScaleScrollView() + options:@{ @"width" : @100, @"height" : @100, @"snapshotContentContainer" : @YES }]; + + [self assertImage:image hasPointSize:CGSizeMake(100, 100)]; + XCTAssertEqualObjects(QuadrantColors(image), ExpectedQuadrants()); +} + +@end diff --git a/ios/RNViewShot.mm b/ios/RNViewShot.mm index 820b7f33..26728944 100644 --- a/ios/RNViewShot.mm +++ b/ios/RNViewShot.mm @@ -128,8 +128,10 @@ - (dispatch_queue_t)methodQueue rendered = view; } + // Size of the capture when width/height are not given + CGSize naturalSize = snapshotContentContainer ? scrollView.contentSize : view.bounds.size; if (size.width < 0.1 || size.height < 0.1) { - size = snapshotContentContainer ? scrollView.contentSize : view.bounds.size; + size = naturalSize; } if (size.width < 0.1 || size.height < 0.1) { reject(RCTErrorUnspecified, [NSString stringWithFormat:@"The content size must not be zero or negative. Got: (%g, %g)", size.width, size.height], nil); @@ -155,6 +157,10 @@ - (dispatch_queue_t)methodQueue UIImage *image = [renderer imageWithActions:^(UIGraphicsImageRendererContext * _Nonnull rendererContext) { if (renderInContext) { // this comes with some trade-offs such as inability to capture gradients or scrollview's content in full but it works for large views + // renderInContext: draws the layer unscaled; scale the natural-size capture to the requested width/height like drawViewHierarchyInRect: does + if (!CGSizeEqualToSize(size, naturalSize) && naturalSize.width > 0 && naturalSize.height > 0) { + CGContextScaleCTM(rendererContext.CGContext, size.width / naturalSize.width, size.height / naturalSize.height); + } [rendered.layer renderInContext:rendererContext.CGContext]; success = YES; }