From 967c105ed178062ee78beb7322f1b8c7bdf2d0f1 Mon Sep 17 00:00:00 2001 From: Adam Roth Date: Mon, 13 Jul 2015 02:20:37 -0400 Subject: [PATCH] Automatically scale iOS assets based on the target dimensions specified in the style property of the tag. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently, documentation under "Image > Best Camera Roll Image" is misleading: “iOS saves multiple sizes for the same image in your Camera Roll, it is very important to pick the one that's as close as possible for performance reasons. You wouldn't want to use the full quality 3264x2448 image as source when displaying a 200x200 thumbnail. If there's an exact match, React Native will pick it, otherwise it's going to use the first one that's at least 50% bigger in order to avoid blur when resizing from a close size. All of this is done by default so you don't have to worry about writing the tedious (and error prone) code to do it yourself.” https://facebook.github.io/react-native/docs/image.html This does not occur. Instead, React Native loads the full resolution image no matter the desired size. This means an 8MP photo will require 32MB of memory to display even if displayed in a 100x100 thumbnail. Loading a series of large images will spike memory and likely crash your app. This commit resolves the discrepancy and brings the codebase inline with current documentation. Example: Consider a 3264x2448 image loaded on an iPhone 6 with the following tag: The image will automatically be scaled to 640x320 (320x240 target dimensions * a Retina scale of 2.0). This uses considerably less memory than rendering the full resolution asset. It also happens automatically. To force the loading of full resolution, the assetUseMaximumSize option can be passed in: Additionally, RCTImageLoader has been updated to handle automatic scaling for images from both the Assets Library and Photos Framework. Added an example to UIExplorer. Tap an image in the . Issues touched: https://github.com/facebook/react-native/issues/1567 https://github.com/facebook/react-native/issues/1845 https://github.com/facebook/react-native/issues/1579 https://github.com/facebook/react-native/issues/930 Note: Pull Request #1704 (https://github.com/facebook/react-native/pull/1704) takes an alternate approach by allowing an assetThumbnail prop (boolean) to be passed in to the Image tag's source hash. IE: If true, the asset's thumbnail representation will be used. The resolution of this thumbnail is non-configurable and does not scale well. --- .../UIExplorer/AssetThumbnailExample.ios.js | 132 ++++++++++++++++++ Examples/UIExplorer/CameraRollExample.ios.js | 35 +++-- Examples/UIExplorer/createExamplePage.js | 1 + Libraries/Image/Image.ios.js | 52 ++++++- Libraries/Image/RCTCameraRollManager.m | 2 +- Libraries/Image/RCTImageLoader.h | 1 + Libraries/Image/RCTImageLoader.m | 106 +++++++++++++- Libraries/Image/RCTImageRequestHandler.m | 2 +- Libraries/Image/RCTStaticImageManager.m | 4 +- 9 files changed, 315 insertions(+), 20 deletions(-) create mode 100644 Examples/UIExplorer/AssetThumbnailExample.ios.js diff --git a/Examples/UIExplorer/AssetThumbnailExample.ios.js b/Examples/UIExplorer/AssetThumbnailExample.ios.js new file mode 100644 index 00000000000000..f561978f98ca80 --- /dev/null +++ b/Examples/UIExplorer/AssetThumbnailExample.ios.js @@ -0,0 +1,132 @@ +/** + * 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. + * + * @flow + */ +'use strict'; + +var React = require('react-native'); +var { + Image, + StyleSheet, + Text, + View, + ScrollView +} = React; + +var AssetThumbnailExampleView = React.createClass({ + + getInitialState() { + return { + asset: this.props.asset + }; + }, + + render() { + var asset = this.state.asset; + return ( + + + + + + + + + + + + + + + + + ); + }, + + +}); + +var styles = StyleSheet.create({ + row: { + padding: 10, + flex: 1, + flexDirection: 'row', + }, + + details: { + margin: 5 + }, + + textColumn: { + flex: 1, + flexDirection: 'column' + }, + + image1: { + borderWidth: 1, + borderColor: 'black', + width: 240, + height: 320, + margin: 5, + }, + + image2: { + borderWidth: 1, + borderColor: 'black', + width: 320, + height: 240 + }, + + image3: { + borderWidth: 1, + borderColor: 'black', + width: 100, + height: 100 + }, + + image4: { + borderWidth: 1, + borderColor: 'black', + width: 200, + height: 200 + }, + + image5: { + borderWidth: 1, + borderColor: 'black', + width: 355, + height: 100 + }, + + image6: { + borderWidth: 1, + borderColor: 'black', + width: 355, + height: 355 + }, + +}); + +exports.title = ''; +exports.description = 'Example component that displays the thumbnail capabilities of the tag'; +module.exports = AssetThumbnailExampleView; \ No newline at end of file diff --git a/Examples/UIExplorer/CameraRollExample.ios.js b/Examples/UIExplorer/CameraRollExample.ios.js index 73678407213224..41cb692c927495 100644 --- a/Examples/UIExplorer/CameraRollExample.ios.js +++ b/Examples/UIExplorer/CameraRollExample.ios.js @@ -22,11 +22,13 @@ var { SliderIOS, StyleSheet, SwitchIOS, + TouchableOpacity, Text, View, } = React; var CameraRollView = require('./CameraRollView.ios'); +var AssetThumbnailExampleView = require('./AssetThumbnailExample.ios'); var CAMERA_ROLL_VIEW = 'camera_roll_view'; @@ -61,6 +63,15 @@ var CameraRollExample = React.createClass({ ); }, + + loadAsset(asset){ + this.props.navigator.push({ + title: "Thumbnails", + component: AssetThumbnailExampleView, + backButtonTitle: 'Back', + passProps: { asset: asset }, + }); + }, _renderImage(asset) { var imageSize = this.state.bigImages ? 150 : 75; @@ -68,18 +79,20 @@ var CameraRollExample = React.createClass({ var location = asset.node.location.longitude ? JSON.stringify(asset.node.location) : 'Unknown location'; return ( - - - - {asset.node.image.uri} - {location} - {asset.node.group_name} - {new Date(asset.node.timestamp).toString()} + + + + + {asset.node.image.uri} + {location} + {asset.node.group_name} + {new Date(asset.node.timestamp).toString()} + - + ); }, diff --git a/Examples/UIExplorer/createExamplePage.js b/Examples/UIExplorer/createExamplePage.js index 3d5a1ac88c4c98..352f84a4c4adec 100644 --- a/Examples/UIExplorer/createExamplePage.js +++ b/Examples/UIExplorer/createExamplePage.js @@ -55,6 +55,7 @@ var createExamplePage = function(title: ?string, exampleModule: ExampleModule) var result = example.render(null); if (result) { renderedComponent = result; + result.props.navigator = this.props.navigator; } (React: Object).render = originalRender; (React: Object).renderComponent = originalRenderComponent; diff --git a/Libraries/Image/Image.ios.js b/Libraries/Image/Image.ios.js index 7a629ce9a60c48..20ceed1c030108 100644 --- a/Libraries/Image/Image.ios.js +++ b/Libraries/Image/Image.ios.js @@ -49,6 +49,12 @@ var warning = require('warning'); * style={styles.logo} * source={{uri: 'http://facebook.github.io/react/img/logo_og.png'}} * /> + * * * ); * }, @@ -61,9 +67,41 @@ var Image = React.createClass({ * `uri` is a string representing the resource identifier for the image, which * could be an http address, a local file path, or the name of a static image * resource (which should be wrapped in the `require('image!name')` function). + * + * `options` supports the following optional properties: + * + * Photos Framework and Asset Library: + * ----------------------------------- + * assetUseMaximumSize (boolean): Boolean value indicating whether to use the full + * resolution asset. Defaults to false. + * + * If false, the image will be automatically sized based + * on the target dimensions specified in the style property of the + * tag (width, height). + * + * If true, the full resolution asset will be used. + * This can result in substantial memory usage and potential crashes, + * especially when rendering many images in sequence. Consider that + * an 8MP photo taken with an iPhone6 will require 32MB of memory to + * display in full resolution (3264x2448). + * + * + * Photos Framework only (assets with uri matching ph://...): + * ---------------------------------------------------------- + * contentMode (string): Content mode used when requesting images using the Photos Framework. + * `fit` (PHImageContentModeAspectFit) default + * `fill` (PHImageContentModeAspectFill) + * + * renderMode (string): Render mode used when reqeusting images using the Photos Framework. + * `fast` (PHImageRequestOptionsResizeModeFast) default + * `exact` (PHImageRequestOptionsResizeModeFast) + * `none` (PHImageRequestOptionsResizeModeNone) + * + * */ source: PropTypes.shape({ uri: PropTypes.string, + assetOptions: PropTypes.object, }), /** * A static image to display while downloading the final image off the @@ -154,7 +192,19 @@ var Image = React.createClass({ tintColor: style.tintColor, }); if (isStored) { - nativeProps.imageTag = source.uri; + var options = { + // iOS specific asset options + assetResizeMode: 'fast', + assetContentMode: 'fill', + assetTargetSize: { width: style.width, height: style.height }, + assetUseMaximumSize: false + }; + + Object.assign( options, this.props.source.options ); + + nativeProps.imageTag = { uri: source.uri, + options: options }; + } else { nativeProps.src = source.uri; } diff --git a/Libraries/Image/RCTCameraRollManager.m b/Libraries/Image/RCTCameraRollManager.m index d7b42f88560aba..5fd7148de93193 100644 --- a/Libraries/Image/RCTCameraRollManager.m +++ b/Libraries/Image/RCTCameraRollManager.m @@ -25,7 +25,7 @@ @implementation RCTCameraRollManager successCallback:(RCTResponseSenderBlock)successCallback errorCallback:(RCTResponseSenderBlock)errorCallback) { - [RCTImageLoader loadImageWithTag:imageTag callback:^(NSError *loadError, UIImage *loadedImage) { + [RCTImageLoader loadImageWithTag:imageTag options:@{} callback:^(NSError *loadError, UIImage *loadedImage) { if (loadError) { errorCallback(@[[loadError localizedDescription]]); return; diff --git a/Libraries/Image/RCTImageLoader.h b/Libraries/Image/RCTImageLoader.h index 186a53cd1046b0..a7ecb0208c837b 100644 --- a/Libraries/Image/RCTImageLoader.h +++ b/Libraries/Image/RCTImageLoader.h @@ -21,6 +21,7 @@ * Will always call callback on main thread. */ + (void)loadImageWithTag:(NSString *)tag + options:(NSDictionary *)options callback:(void (^)(NSError *error, id /* UIImage or CAAnimation */ image))callback; @end diff --git a/Libraries/Image/RCTImageLoader.m b/Libraries/Image/RCTImageLoader.m index 04fa17f5d4b3d1..aeef3e2bc223e5 100644 --- a/Libraries/Image/RCTImageLoader.m +++ b/Libraries/Image/RCTImageLoader.m @@ -56,11 +56,44 @@ + (ALAssetsLibrary *)assetsLibrary return assetsLibrary; } ++(CGImageRef )scaledImageRefForAssetRepresentation:(ALAssetRepresentation *)assetRepresentation maxPixelSize:(float)maxPixelSize +{ + NSData *data = nil; + CGImageRef imageRef = nil; + + uint8_t *buffer = (uint8_t *)malloc(sizeof(uint8_t)*[assetRepresentation size]); + if (buffer != NULL) { + NSError *error = nil; + NSUInteger bytesRead = [assetRepresentation getBytes:buffer fromOffset:0 length:[assetRepresentation size] error:&error]; + data = [NSData dataWithBytes:buffer length:bytesRead]; + + free(buffer); + } + + if ([data length]){ + CGImageSourceRef sourceRef = CGImageSourceCreateWithData((__bridge CFDataRef)data, nil); + + NSMutableDictionary *options = [NSMutableDictionary dictionary]; + + [options setObject:(id)kCFBooleanTrue forKey:(id)kCGImageSourceShouldAllowFloat]; + [options setObject:(id)kCFBooleanTrue forKey:(id)kCGImageSourceCreateThumbnailWithTransform]; + [options setObject:(id)kCFBooleanTrue forKey:(id)kCGImageSourceCreateThumbnailFromImageAlways]; + [options setObject:(id)[NSNumber numberWithFloat:maxPixelSize] forKey:(id)kCGImageSourceThumbnailMaxPixelSize]; + imageRef = CGImageSourceCreateThumbnailAtIndex(sourceRef, 0, (__bridge CFDictionaryRef)options); + + if (sourceRef){ + CFRelease(sourceRef); + } + } + + return imageRef; +} + /** * Can be called from any thread. * Will always call callback on main thread. */ -+ (void)loadImageWithTag:(NSString *)imageTag callback:(void (^)(NSError *error, id image))callback ++ (void)loadImageWithTag:(NSString *)imageTag options:(NSDictionary *)options callback:(void (^)(NSError *error, id image))callback { if ([imageTag hasPrefix:@"assets-library"]) { [[RCTImageLoader assetsLibrary] assetForURL:[NSURL URLWithString:imageTag] resultBlock:^(ALAsset *asset) { @@ -75,7 +108,38 @@ + (void)loadImageWithTag:(NSString *)imageTag callback:(void (^)(NSError *error, @autoreleasepool { ALAssetRepresentation *representation = [asset defaultRepresentation]; ALAssetOrientation orientation = [representation orientation]; - UIImage *image = [UIImage imageWithCGImage:[representation fullResolutionImage] scale:1.0f orientation:(UIImageOrientation)orientation]; + + CGImageRef ref = nil; + UIImage *image = nil; + + if ([options[@"assetUseMaximumSize"] boolValue]) { // Full resolution + ref = [[asset defaultRepresentation] fullResolutionImage]; + image = [UIImage imageWithCGImage:ref scale:[representation scale] orientation:(UIImageOrientation)orientation]; + + } else { + CGFloat retinaScale = [UIScreen mainScreen].scale; + + CGFloat targetWidth = [options[@"assetTargetSize"][@"width"] floatValue]; + CGFloat targetHeight = [options[@"assetTargetSize"][@"height"] floatValue]; + + CGFloat fullWidth = [representation dimensions].width; + CGFloat fullHeight = [representation dimensions].height; + CGFloat fullAspectRatio = fullWidth / fullHeight; + + CGFloat maxPixelSize; + + if (fullWidth > fullHeight) { + maxPixelSize = ceil((fullAspectRatio * targetHeight) * retinaScale); + } else { + maxPixelSize = ceil((targetWidth / fullAspectRatio) * retinaScale); + } + + ref = [self scaledImageRefForAssetRepresentation:representation maxPixelSize:maxPixelSize]; + image = [UIImage imageWithCGImage:ref]; + } + + //RCTLogInfo(@"[%@] Full size: (%f, %f) Container: (%f, %f), Scale: %f, UIImage: (%f, %f), Memory=%.2fkb", [options[@"assetUseMaximumSize"] boolValue] ? @"Maximum" : @"Scaled", [representation dimensions].width, [representation dimensions].height, [options[@"assetTargetSize"][@"width"] floatValue], [options[@"assetTargetSize"][@"height"] floatValue], [UIScreen mainScreen].scale, image.size.width, image.size.height, (CGImageGetHeight(image.CGImage) * CGImageGetBytesPerRow(image.CGImage)) / 1024.0 ); + RCTDispatchCallbackOnMainQueue(callback, nil, image); } }); @@ -102,9 +166,42 @@ + (void)loadImageWithTag:(NSString *)imageTag callback:(void (^)(NSError *error, RCTDispatchCallbackOnMainQueue(callback, error, nil); return; } - + PHAsset *asset = [results firstObject]; - [[PHImageManager defaultManager] requestImageForAsset:asset targetSize:PHImageManagerMaximumSize contentMode:PHImageContentModeDefault options:nil resultHandler:^(UIImage *result, NSDictionary *info) { + PHImageRequestOptions *imageOptions = [[PHImageRequestOptions alloc] init]; + + // Resize Mode (default: PHImageRequestOptionsResizeModeFast) + if ([options[@"resizeMode"] isEqualToString:@"fast"]) { + imageOptions.resizeMode = PHImageRequestOptionsResizeModeFast; + } else if ([options[@"resizeMode"] isEqualToString:@"exact"]) { + imageOptions.resizeMode = PHImageRequestOptionsResizeModeExact; + } else if ([options[@"resizeMode"] isEqualToString:@"none"]) { + imageOptions.resizeMode = PHImageRequestOptionsResizeModeNone; + } else { + imageOptions.resizeMode = PHImageRequestOptionsResizeModeNone; + } + + // Content Mode (default: PHImageContentModeAspectFill) + PHImageContentMode contentMode; + if ([options[@"contentMode"] isEqualToString:@"fill"]) { + contentMode = PHImageContentModeAspectFill; + } else if ([options[@"contentMode"] isEqualToString:@"fit"]) { + contentMode = PHImageContentModeAspectFit; + } else { + contentMode = PHImageContentModeAspectFit; + } + + float retinaScale = [UIScreen mainScreen].scale; + CGSize targetSize; + + if ([options[@"assetUseMaximumSize"] boolValue]) { + targetSize = PHImageManagerMaximumSize; + } else { + targetSize = CGSizeMake([options[@"targetSize"][@"width"] floatValue] * retinaScale, + [options[@"targetSize"][@"height"] floatValue] * retinaScale); + } + + [[PHImageManager defaultManager] requestImageForAsset:asset targetSize:targetSize contentMode:contentMode options:imageOptions resultHandler:^(UIImage *result, NSDictionary *info) { if (result) { RCTDispatchCallbackOnMainQueue(callback, nil, result); } else { @@ -114,6 +211,7 @@ + (void)loadImageWithTag:(NSString *)imageTag callback:(void (^)(NSError *error, return; } }]; + } else if ([imageTag hasPrefix:@"http"]) { NSURL *url = [NSURL URLWithString:imageTag]; if (!url) { diff --git a/Libraries/Image/RCTImageRequestHandler.m b/Libraries/Image/RCTImageRequestHandler.m index e5eb3bfd4f1d2c..450c722a1e89d0 100644 --- a/Libraries/Image/RCTImageRequestHandler.m +++ b/Libraries/Image/RCTImageRequestHandler.m @@ -30,7 +30,7 @@ - (id)sendRequest:(NSURLRequest *)request { NSNumber *requestToken = @(++_currentToken); NSString *URLString = [request.URL absoluteString]; - [RCTImageLoader loadImageWithTag:URLString callback:^(NSError *error, UIImage *image) { + [RCTImageLoader loadImageWithTag:URLString options:@{} callback:^(NSError *error, UIImage *image) { if (error) { [delegate URLRequest:requestToken didCompleteWithError:error]; return; diff --git a/Libraries/Image/RCTStaticImageManager.m b/Libraries/Image/RCTStaticImageManager.m index bdc6f0596a673d..864cde226c5556 100644 --- a/Libraries/Image/RCTStaticImageManager.m +++ b/Libraries/Image/RCTStaticImageManager.m @@ -51,10 +51,10 @@ - (UIView *)view view.tintColor = defaultView.tintColor; } } -RCT_CUSTOM_VIEW_PROPERTY(imageTag, NSString, RCTStaticImage) +RCT_CUSTOM_VIEW_PROPERTY(imageTag, NSDictionary, RCTStaticImage) { if (json) { - [RCTImageLoader loadImageWithTag:[RCTConvert NSString:json] callback:^(NSError *error, id image) { + [RCTImageLoader loadImageWithTag:[RCTConvert NSString:json[@"uri"]] options:[RCTConvert NSDictionary:json[@"options"]] callback:^(NSError *error, id image) { if (error) { RCTLogWarn(@"%@", error.localizedDescription); }