diff --git a/Examples/UIExplorer/UIExplorer.xcodeproj/project.pbxproj b/Examples/UIExplorer/UIExplorer.xcodeproj/project.pbxproj index c7e038c9c..8dea9bc79 100644 --- a/Examples/UIExplorer/UIExplorer.xcodeproj/project.pbxproj +++ b/Examples/UIExplorer/UIExplorer.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 1341802C1AA9178B003F314A /* libRCTNetwork.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 1341802B1AA91779003F314A /* libRCTNetwork.a */; }; 134454601AAFCABD003F0779 /* libRCTAdSupport.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 1344545A1AAFCAAE003F0779 /* libRCTAdSupport.a */; }; 134A8A2A1AACED7A00945AAE /* libRCTGeolocation.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 134A8A251AACED6A00945AAE /* libRCTGeolocation.a */; }; + 1353F5461B0E64F9009B4FAC /* ClippingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 1353F5451B0E64F9009B4FAC /* ClippingTests.m */; }; 139FDEDB1B0651FB00C62182 /* libRCTWebSocket.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 139FDED91B0651EA00C62182 /* libRCTWebSocket.a */; }; 13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.m */; }; 13B07FBD1A68108700A75B9A /* LaunchScreen.xib in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB11A68108700A75B9A /* LaunchScreen.xib */; }; @@ -129,6 +130,7 @@ 134180261AA91779003F314A /* RCTNetwork.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RCTNetwork.xcodeproj; path = ../../Libraries/Network/RCTNetwork.xcodeproj; sourceTree = ""; }; 134454551AAFCAAE003F0779 /* RCTAdSupport.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RCTAdSupport.xcodeproj; path = ../../Libraries/AdSupport/RCTAdSupport.xcodeproj; sourceTree = ""; }; 134A8A201AACED6A00945AAE /* RCTGeolocation.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RCTGeolocation.xcodeproj; path = ../../Libraries/Geolocation/RCTGeolocation.xcodeproj; sourceTree = ""; }; + 1353F5451B0E64F9009B4FAC /* ClippingTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ClippingTests.m; sourceTree = ""; }; 139FDECA1B0651EA00C62182 /* RCTWebSocket.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RCTWebSocket.xcodeproj; path = ../../Libraries/WebSocket/RCTWebSocket.xcodeproj; sourceTree = ""; }; 13B07F961A680F5B00A75B9A /* UIExplorer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = UIExplorer.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = UIExplorer/AppDelegate.h; sourceTree = ""; }; @@ -179,6 +181,7 @@ isa = PBXGroup; children = ( 004D28A21AAF61C70097A701 /* UIExplorerTests.m */, + 1353F5451B0E64F9009B4FAC /* ClippingTests.m */, 004D28A01AAF61C70097A701 /* Supporting Files */, ); path = UIExplorerTests; @@ -575,6 +578,7 @@ buildActionMask = 2147483647; files = ( 004D28A31AAF61C70097A701 /* UIExplorerTests.m in Sources */, + 1353F5461B0E64F9009B4FAC /* ClippingTests.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Examples/UIExplorer/UIExplorerTests/ClippingTests.m b/Examples/UIExplorer/UIExplorerTests/ClippingTests.m new file mode 100644 index 000000000..1f94a80c1 --- /dev/null +++ b/Examples/UIExplorer/UIExplorerTests/ClippingTests.m @@ -0,0 +1,127 @@ +/** + * The examples provided by Facebook are for non-commercial testing and + * evaluation purposes only. + * + * Facebook reserves all rights not expressly granted. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL + * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +#import +#import +#import +#import + +extern CGRect RCTClipRect(CGSize contentSize, CGFloat contentScale, + CGSize targetSize, CGFloat targetScale, + UIViewContentMode resizeMode); + +#define RCTAssertEqualPoints(a, b) { \ +XCTAssertEqual(a.x, b.x); \ +XCTAssertEqual(a.y, b.y); \ +} + +#define RCTAssertEqualSizes(a, b) { \ +XCTAssertEqual(a.width, b.width); \ +XCTAssertEqual(a.height, b.height); \ +} + +#define RCTAssertEqualRects(a, b) { \ +RCTAssertEqualPoints(a.origin, b.origin); \ +RCTAssertEqualSizes(a.size, b.size); \ +} + +@interface ClippingTests : XCTestCase + +@end + +@implementation ClippingTests + +- (void)testLandscapeSourceLandscapeTarget +{ + CGSize content = {1000, 100}; + CGSize target = {100, 20}; + + { + CGRect expected = {CGPointZero, {100, 20}}; + CGRect result = RCTClipRect(content, 1, target, 1, UIViewContentModeScaleToFill); + RCTAssertEqualRects(expected, result); + } + + { + CGRect expected = {CGPointZero, {100, 10}}; + CGRect result = RCTClipRect(content, 1, target, 1, UIViewContentModeScaleAspectFit); + RCTAssertEqualRects(expected, result); + } + + { + CGRect expected = {{-50, 0}, {200, 20}}; + CGRect result = RCTClipRect(content, 1, target, 1, UIViewContentModeScaleAspectFill); + RCTAssertEqualRects(expected, result); + } +} + +- (void)testPortraitSourceLandscapeTarget +{ + CGSize content = {10, 100}; + CGSize target = {100, 20}; + + { + CGRect expected = {CGPointZero, {10, 20}}; + CGRect result = RCTClipRect(content, 1, target, 1, UIViewContentModeScaleToFill); + RCTAssertEqualRects(expected, result); + } + + { + CGRect expected = {CGPointZero, {2, 20}}; + CGRect result = RCTClipRect(content, 1, target, 1, UIViewContentModeScaleAspectFit); + RCTAssertEqualRects(expected, result); + } + + { + CGRect expected = {{0, -49}, {10, 100}}; + CGRect result = RCTClipRect(content, 1, target, 1, UIViewContentModeScaleAspectFill); + RCTAssertEqualRects(expected, result); + } +} + +- (void)testPortraitSourcePortraitTarget +{ + CGSize content = {10, 100}; + CGSize target = {20, 50}; + + { + CGRect expected = {CGPointZero, {10, 50}}; + CGRect result = RCTClipRect(content, 1, target, 1, UIViewContentModeScaleToFill); + RCTAssertEqualRects(expected, result); + } + + { + CGRect expected = {CGPointZero, {5, 50}}; + CGRect result = RCTClipRect(content, 1, target, 1, UIViewContentModeScaleAspectFit); + RCTAssertEqualRects(expected, result); + } + + { + CGRect expected = {{0, -37.5}, {10, 100}}; + CGRect result = RCTClipRect(content, 1, target, 1, UIViewContentModeScaleAspectFill); + RCTAssertEqualRects(expected, result); + } +} + +- (void)testScaling +{ + CGSize content = {2, 2}; + CGSize target = {3, 3}; + + CGRect expected = {CGPointZero, {3, 3}}; + CGRect result = RCTClipRect(content, 2, target, 1, UIViewContentModeScaleToFill); + RCTAssertEqualRects(expected, result); +} + +@end diff --git a/Libraries/Image/RCTImageDownloader.h b/Libraries/Image/RCTImageDownloader.h index 178bc734c..89a77975f 100644 --- a/Libraries/Image/RCTImageDownloader.h +++ b/Libraries/Image/RCTImageDownloader.h @@ -33,6 +33,8 @@ typedef void (^RCTImageDownloadBlock)(UIImage *image, NSError *error); - (id)downloadImageForURL:(NSURL *)url size:(CGSize)size scale:(CGFloat)scale + resizeMode:(UIViewContentMode)resizeMode + backgroundColor:(UIColor *)backgroundColor block:(RCTImageDownloadBlock)block; /** diff --git a/Libraries/Image/RCTImageDownloader.m b/Libraries/Image/RCTImageDownloader.m index 9577ce4e5..aa524ef56 100644 --- a/Libraries/Image/RCTImageDownloader.m +++ b/Libraries/Image/RCTImageDownloader.m @@ -10,6 +10,7 @@ #import "RCTImageDownloader.h" #import "RCTCache.h" +#import "RCTLog.h" #import "RCTUtils.h" typedef void (^RCTCachedDataDownloadBlock)(BOOL cached, NSData *data, NSError *error); @@ -121,34 +122,134 @@ static NSString *RCTCacheKeyForURL(NSURL *url) }]; } -- (id)downloadImageForURL:(NSURL *)url size:(CGSize)size - scale:(CGFloat)scale block:(RCTImageDownloadBlock)block +/** + * Returns the optimal context size for an image drawn using the clip rect + * returned by RCTClipRect. + */ +CGSize RCTTargetSizeForClipRect(CGRect); +CGSize RCTTargetSizeForClipRect(CGRect clipRect) +{ + return (CGSize){ + clipRect.size.width + clipRect.origin.x * 2, + clipRect.size.height + clipRect.origin.y * 2 + }; +} + +/** + * This function takes an input content size & scale (typically from an image), + * a target size & scale that it will be drawn into (typically a CGContext) and + * then calculates the optimal rectangle to draw the image into so that it will + * be sized and positioned correctly if drawn using the specified content mode. + */ +CGRect RCTClipRect(CGSize, CGFloat, CGSize, CGFloat, UIViewContentMode); +CGRect RCTClipRect(CGSize sourceSize, CGFloat sourceScale, + CGSize destSize, CGFloat destScale, + UIViewContentMode resizeMode) +{ + // Precompensate for scale + CGFloat scale = sourceScale / destScale; + sourceSize.width *= scale; + sourceSize.height *= scale; + + // Calculate aspect ratios if needed (don't bother is resizeMode == stretch) + CGFloat aspect = 0.0, targetAspect = 0.0; + if (resizeMode != UIViewContentModeScaleToFill) { + aspect = sourceSize.width / sourceSize.height; + targetAspect = destSize.width / destSize.height; + if (aspect == targetAspect) { + resizeMode = UIViewContentModeScaleToFill; + } + } + + switch (resizeMode) { + case UIViewContentModeScaleToFill: // stretch + + sourceSize.width = MIN(destSize.width, sourceSize.width); + sourceSize.height = MIN(destSize.height, sourceSize.height); + return (CGRect){CGPointZero, sourceSize}; + + case UIViewContentModeScaleAspectFit: // contain + + if (targetAspect <= aspect) { // target is taller than content + sourceSize.width = destSize.width = MIN(sourceSize.width, destSize.width); + sourceSize.height = sourceSize.width / aspect; + } else { // target is wider than content + sourceSize.height = destSize.height = MIN(sourceSize.height, destSize.height); + sourceSize.width = sourceSize.height * aspect; + } + return (CGRect){CGPointZero, sourceSize}; + + case UIViewContentModeScaleAspectFill: // cover + + if (targetAspect <= aspect) { // target is taller than content + + sourceSize.height = destSize.height = MIN(sourceSize.height, destSize.height); + sourceSize.width = sourceSize.height * aspect; + destSize.width = destSize.height * targetAspect; + return (CGRect){{(destSize.width - sourceSize.width) / 2, 0}, sourceSize}; + + } else { // target is wider than content + + sourceSize.width = destSize.width = MIN(sourceSize.width, destSize.width); + sourceSize.height = sourceSize.width / aspect; + destSize.height = destSize.width / targetAspect; + return (CGRect){{0, (destSize.height - sourceSize.height) / 2}, sourceSize}; + } + + default: + + RCTLogError(@"A resizeMode value of %zd is not supported", resizeMode); + return (CGRect){CGPointZero, destSize}; + } +} + +- (id)downloadImageForURL:(NSURL *)url + size:(CGSize)size + scale:(CGFloat)scale + resizeMode:(UIViewContentMode)resizeMode + backgroundColor:(UIColor *)backgroundColor + block:(RCTImageDownloadBlock)block { return [self downloadDataForURL:url block:^(NSData *data, NSError *error) { + if (!data || error) { + block(nil, error); + return; + } + + if (CGSizeEqualToSize(size, CGSizeZero)) { + // Target size wasn't available yet, so abort image drawing + block(nil, nil); + return; + } + UIImage *image = [UIImage imageWithData:data scale:scale]; if (image) { - // Resize (TODO: should we take aspect ratio into account?) - CGSize imageSize = size; - if (CGSizeEqualToSize(imageSize, CGSizeZero)) { - imageSize = image.size; - } else { - imageSize = (CGSize){ - MIN(size.width, image.size.width), - MIN(size.height, image.size.height) - }; - } + // Get scale and size + CGFloat destScale = scale ?: RCTScreenScale(); + CGRect imageRect = RCTClipRect(image.size, image.scale, size, destScale, resizeMode); + CGSize destSize = RCTTargetSizeForClipRect(imageRect); - // Rescale image if required size is smaller - CGFloat imageScale = scale; - if (imageScale == 0 || imageScale < image.scale) { - imageScale = image.scale; + // Opacity optimizations + UIColor *blendColor = nil; + BOOL opaque = !RCTImageHasAlpha(image.CGImage); + if (!opaque && backgroundColor) { + CGFloat alpha; + [backgroundColor getRed:NULL green:NULL blue:NULL alpha:&alpha]; + if (alpha > 0.999) { // no benefit to blending if background is translucent + opaque = YES; + blendColor = backgroundColor; + } } // Decompress image at required size - UIGraphicsBeginImageContextWithOptions(imageSize, NO, imageScale); - [image drawInRect:(CGRect){{0, 0}, imageSize}]; + UIGraphicsBeginImageContextWithOptions(destSize, opaque, destScale); + if (blendColor) { + [blendColor setFill]; + UIRectFill((CGRect){CGPointZero, destSize}); + } + [image drawInRect:imageRect]; image = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); } diff --git a/Libraries/Image/RCTImageLoader.m b/Libraries/Image/RCTImageLoader.m index 948503cce..7525b37d0 100644 --- a/Libraries/Image/RCTImageLoader.m +++ b/Libraries/Image/RCTImageLoader.m @@ -26,8 +26,6 @@ static dispatch_queue_t RCTImageLoaderQueue(void) static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ queue = dispatch_queue_create("com.facebook.rctImageLoader", DISPATCH_QUEUE_SERIAL); - dispatch_set_target_queue(queue, - dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)); }); return queue; diff --git a/Libraries/Image/RCTNetworkImageView.m b/Libraries/Image/RCTNetworkImageView.m index 6ad6a4c80..89cddef59 100644 --- a/Libraries/Image/RCTNetworkImageView.m +++ b/Libraries/Image/RCTNetworkImageView.m @@ -13,6 +13,7 @@ #import "RCTGIFImage.h" #import "RCTImageDownloader.h" #import "RCTUtils.h" +#import "UIView+React.h" @implementation RCTNetworkImageView { @@ -26,8 +27,7 @@ - (instancetype)initWithFrame:(CGRect)frame imageDownloader:(RCTImageDownloader *)imageDownloader { - self = [super initWithFrame:frame]; - if (self) { + if ((self = [super initWithFrame:frame])) { _deferSentinel = 0; _imageDownloader = imageDownloader; self.userInteractionEnabled = NO; @@ -37,20 +37,44 @@ - (NSURL *)imageURL { - // We clear our backing layer's imageURL when we are not in a window for a while, + // We clear our imageURL when we are not in a window for a while, // to make sure we don't consume network resources while offscreen. // However we don't want to expose this hackery externally. return _deferred ? _deferredImageURL : _imageURL; } +- (void)setBackgroundColor:(UIColor *)backgroundColor +{ + super.backgroundColor = backgroundColor; + [self _updateImage]; +} + +- (void)reactSetFrame:(CGRect)frame +{ + [super reactSetFrame:frame]; + [self _updateImage]; +} + +- (void)_updateImage +{ + [self setImageURL:_imageURL resetToDefaultImageWhileLoading:NO]; +} + - (void)setImageURL:(NSURL *)imageURL resetToDefaultImageWhileLoading:(BOOL)reset { + if (![_imageURL isEqual:imageURL] && _downloadToken) { + [_imageDownloader cancelDownload:_downloadToken]; + _downloadToken = nil; + } + + _imageURL = imageURL; + if (_deferred) { _deferredImageURL = imageURL; } else { - if (_downloadToken) { - [_imageDownloader cancelDownload:_downloadToken]; - _downloadToken = nil; + if (!imageURL) { + self.layer.contents = nil; + return; } if (reset) { self.layer.contentsScale = _defaultImage.scale; @@ -62,25 +86,35 @@ _downloadToken = [_imageDownloader downloadDataForURL:imageURL block:^(NSData *data, NSError *error) { if (data) { dispatch_async(dispatch_get_main_queue(), ^{ + if (imageURL != self.imageURL) { + // Image has changed + return; + } CAKeyframeAnimation *animation = RCTGIFImageWithData(data); self.layer.contentsScale = 1.0; self.layer.minificationFilter = kCAFilterLinear; self.layer.magnificationFilter = kCAFilterLinear; [self.layer addAnimation:animation forKey:@"contents"]; }); + } else if (error) { + RCTLogWarn(@"Unable to download image data. Error: %@", error); } - // TODO: handle errors }]; } else { - _downloadToken = [_imageDownloader downloadImageForURL:imageURL size:self.bounds.size scale:RCTScreenScale() block:^(UIImage *image, NSError *error) { + _downloadToken = [_imageDownloader downloadImageForURL:imageURL size:self.bounds.size scale:RCTScreenScale() resizeMode:self.contentMode backgroundColor:self.backgroundColor block:^(UIImage *image, NSError *error) { if (image) { dispatch_async(dispatch_get_main_queue(), ^{ + if (imageURL != self.imageURL) { + // Image has changed + return; + } [self.layer removeAnimationForKey:@"contents"]; self.layer.contentsScale = image.scale; self.layer.contents = (__bridge id)image.CGImage; }); + } else if (error) { + RCTLogWarn(@"Unable to download image. Error: %@", error); } - // TODO: handle errors }]; } } diff --git a/React/Base/RCTUtils.h b/React/Base/RCTUtils.h index 6c4b91464..5c34d0e0a 100644 --- a/React/Base/RCTUtils.h +++ b/React/Base/RCTUtils.h @@ -52,3 +52,6 @@ RCT_EXTERN NSDictionary *RCTMakeAndLogError(NSString *message, id toStringify, N // Returns YES if React is running in a test environment RCT_EXTERN BOOL RCTRunningInTestEnvironment(void); + +// Return YES if image has an alpha component +RCT_EXTERN BOOL RCTImageHasAlpha(CGImageRef image); diff --git a/React/Base/RCTUtils.m b/React/Base/RCTUtils.m index ff11b5764..712e9724e 100644 --- a/React/Base/RCTUtils.m +++ b/React/Base/RCTUtils.m @@ -261,3 +261,15 @@ BOOL RCTRunningInTestEnvironment(void) }); return _isTestEnvironment; } + +BOOL RCTImageHasAlpha(CGImageRef image) +{ + switch (CGImageGetAlphaInfo(image)) { + case kCGImageAlphaNone: + case kCGImageAlphaNoneSkipLast: + case kCGImageAlphaNoneSkipFirst: + return NO; + default: + return YES; + } +}