diff --git a/Examples/UIExplorer/CameraRollExample.ios.js b/Examples/UIExplorer/CameraRollExample.ios.js new file mode 100644 index 000000000..8037f536f --- /dev/null +++ b/Examples/UIExplorer/CameraRollExample.ios.js @@ -0,0 +1,115 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule CameraRollExample + */ +'use strict'; + +var React = require('react-native'); +var { + CameraRoll, + Image, + Slider, + StyleSheet, + SwitchIOS, + Text, + View, +} = React; + +var CameraRollView = require('./CameraRollView.ios'); + +var CAMERA_ROLL_VIEW = 'camera_roll_view'; + +var CameraRollExample = React.createClass({ + + getInitialState() { + return { + groupTypes: 'SavedPhotos', + sliderValue: 1, + bigImages: true, + }; + }, + + render() { + return ( + + + {(this.state.bigImages ? 'Big' : 'Small') + ' Images'} + + {'Group Type: ' + this.state.groupTypes} + + + ); + }, + + _renderImage(asset) { + var imageSize = this.state.bigImages ? 150 : 75; + var imageStyle = [styles.image, {width: imageSize, height: imageSize}]; + 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()} + + + ); + }, + + _onSliderChange(value) { + var options = CameraRoll.GroupTypesOptions; + var index = Math.floor(value * options.length * 0.99); + var groupTypes = options[index]; + if (groupTypes !== this.state.groupTypes) { + this.setState({groupTypes: groupTypes}); + } + }, + + _onSwitchChange(value) { + this.refs[CAMERA_ROLL_VIEW].rendererChanged(); + this.setState({ bigImages: value }); + } +}); + +var styles = StyleSheet.create({ + row: { + flexDirection: 'row', + flex: 1, + }, + url: { + fontSize: 9, + marginBottom: 14, + }, + image: { + margin: 4, + }, + info: { + flex: 1, + }, +}); + +exports.title = ''; +exports.description = 'Example component that uses CameraRoll to list user\'s photos'; +exports.examples = [ + { + title: 'Photos', + render() { return ; } + } +]; diff --git a/Examples/UIExplorer/CameraRollView.ios.js b/Examples/UIExplorer/CameraRollView.ios.js new file mode 100644 index 000000000..f0ee92afc --- /dev/null +++ b/Examples/UIExplorer/CameraRollView.ios.js @@ -0,0 +1,231 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule CameraRollView + */ +'use strict'; + +var React = require('react-native'); +var { + ActivityIndicatorIOS, + CameraRoll, + Image, + ListView, + ListViewDataSource, + StyleSheet, + View, +} = React; + +var groupByEveryN = require('groupByEveryN'); +var logError = require('logError'); + +var propTypes = { + /** + * The group where the photos will be fetched from. Possible + * values are 'Album', 'All', 'Event', 'Faces', 'Library', 'PhotoStream' + * and SavedPhotos. + */ + groupTypes: React.PropTypes.oneOf([ + 'Album', + 'All', + 'Event', + 'Faces', + 'Library', + 'PhotoStream', + 'SavedPhotos', + ]), + + /** + * Number of images that will be fetched in one page. + */ + batchSize: React.PropTypes.number, + + /** + * A function that takes a single image as a parameter and renders it. + */ + renderImage: React.PropTypes.func, + + /** + * imagesPerRow: Number of images to be shown in each row. + */ + imagesPerRow: React.PropTypes.number, +}; + +var CameraRollView = React.createClass({ + propTypes: propTypes, + + getDefaultProps: function() { + return { + groupTypes: 'SavedPhotos', + batchSize: 5, + imagesPerRow: 1, + renderImage: function(asset) { + var imageSize = 150; + var imageStyle = [styles.image, {width: imageSize, height: imageSize}]; + return ( + + ); + }, + }; + }, + + getInitialState: function() { + var ds = new ListViewDataSource({rowHasChanged: this._rowHasChanged}); + + return { + assets: [], + groupTypes: this.props.groupTypes, + lastCursor: null, + noMore: false, + loadingMore: false, + dataSource: ds, + }; + }, + + /** + * This should be called when the image renderer is changed to tell the + * component to re-render its assets. + */ + rendererChanged: function() { + var ds = new ListViewDataSource({rowHasChanged: this._rowHasChanged}); + this.state.dataSource = ds.cloneWithRows( + groupByEveryN(this.state.assets, this.props.imagesPerRow) + ); + }, + + componentDidMount: function() { + this.fetch(); + }, + + componentWillReceiveProps: function(nextProps) { + if (this.props.groupTypes !== nextProps.groupTypes) { + this.fetch(true); + } + }, + + _fetch: function(clear) { + if (clear) { + this.setState(this.getInitialState(), this.fetch); + return; + } + + var fetchParams = { + first: this.props.batchSize, + groupTypes: this.props.groupTypes, + }; + if (this.state.lastCursor) { + fetchParams.after = this.state.lastCursor; + } + + CameraRoll.getPhotos(fetchParams, this._appendAssets, logError); + }, + + /** + * Fetches more images from the camera roll. If clear is set to true, it will + * set the component to its initial state and re-fetch the images. + */ + fetch: function(clear) { + if (!this.state.loadingMore) { + this.setState({loadingMore: true}, () => { this._fetch(clear); }); + } + }, + + render: function() { + return ( + + ); + }, + + _rowHasChanged: function(r1, r2) { + if (r1.length !== r2.length) { + return true; + } + + for (var i = 0; i < r1.length; i++) { + if (r1[i] !== r2[i]) { + return true; + } + } + + return false; + }, + + _renderFooterSpinner: function() { + if (!this.state.noMore) { + return ; + } + return null; + }, + + // rowData is an array of images + _renderRow: function(rowData, sectionID, rowID) { + var images = rowData.map((image) => { + if (image === null) { + return null; + } + return this.props.renderImage(image); + }); + + return ( + + {images} + + ); + }, + + _appendAssets: function(data) { + var assets = data.edges; + var newState = { loadingMore: false }; + + if (!data.page_info.has_next_page) { + newState.noMore = true; + } + + if (assets.length > 0) { + newState.lastCursor = data.page_info.end_cursor; + newState.assets = this.state.assets.concat(assets); + newState.dataSource = this.state.dataSource.cloneWithRows( + groupByEveryN(newState.assets, this.props.imagesPerRow) + ); + } + + this.setState(newState); + }, + + _onEndReached: function() { + if (!this.state.noMore) { + this.fetch(); + } + }, +}); + +var styles = StyleSheet.create({ + row: { + flexDirection: 'row', + flex: 1, + }, + url: { + fontSize: 9, + marginBottom: 14, + }, + image: { + margin: 4, + }, + info: { + flex: 1, + }, + container: { + flex: 1, + }, +}); + +module.exports = CameraRollView; diff --git a/Examples/UIExplorer/UIExplorerList.js b/Examples/UIExplorer/UIExplorerList.js index e04edcab6..85627ee79 100644 --- a/Examples/UIExplorer/UIExplorerList.js +++ b/Examples/UIExplorer/UIExplorerList.js @@ -36,6 +36,7 @@ var EXAMPLES = [ require('./TabBarExample'), require('./SwitchExample'), require('./SliderExample'), + require('./CameraRollExample.ios'), ]; var UIExplorerList = React.createClass({ diff --git a/Libraries/CameraRoll/CameraRoll.js b/Libraries/CameraRoll/CameraRoll.js new file mode 100644 index 000000000..54295fc52 --- /dev/null +++ b/Libraries/CameraRoll/CameraRoll.js @@ -0,0 +1,149 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule CameraRoll + */ +'use strict'; + +var ReactPropTypes = require('ReactPropTypes'); +var RKCameraRollManager = require('NativeModules').RKCameraRollManager; + +var createStrictShapeTypeChecker = require('createStrictShapeTypeChecker'); +var deepFreezeAndThrowOnMutationInDev = + require('deepFreezeAndThrowOnMutationInDev'); +var invariant = require('invariant'); + +var GROUP_TYPES_OPTIONS = [ + 'Album', + 'All', + 'Event', + 'Faces', + 'Library', + 'PhotoStream', + 'SavedPhotos', // default +]; + +deepFreezeAndThrowOnMutationInDev(GROUP_TYPES_OPTIONS); + +/** + * Shape of the param arg for the `getPhotos` function. + */ +var getPhotosParamChecker = createStrictShapeTypeChecker({ + /** + * The number of photos wanted in reverse order of the photo application + * (i.e. most recent first for SavedPhotos). + */ + first: ReactPropTypes.number.isRequired, + + /** + * A cursor that matches `page_info { end_cursor }` returned from a previous + * call to `getPhotos` + */ + after: ReactPropTypes.string, + + /** + * Specifies which group types to filter the results to. + */ + groupTypes: ReactPropTypes.oneOf(GROUP_TYPES_OPTIONS), + + /** + * Specifies filter on group names, like 'Recent Photos' or custom album + * titles. + */ + groupName: ReactPropTypes.string, +}); + +/** + * Shape of the return value of the `getPhotos` function. + */ +var getPhotosReturnChecker = createStrictShapeTypeChecker({ + edges: ReactPropTypes.arrayOf(createStrictShapeTypeChecker({ + node: createStrictShapeTypeChecker({ + type: ReactPropTypes.string.isRequired, + group_name: ReactPropTypes.string.isRequired, + image: createStrictShapeTypeChecker({ + uri: ReactPropTypes.string.isRequired, + height: ReactPropTypes.number.isRequired, + width: ReactPropTypes.number.isRequired, + isStored: ReactPropTypes.bool, + }).isRequired, + timestamp: ReactPropTypes.number.isRequired, + location: createStrictShapeTypeChecker({ + latitude: ReactPropTypes.number, + longitude: ReactPropTypes.number, + altitude: ReactPropTypes.number, + heading: ReactPropTypes.number, + speed: ReactPropTypes.number, + }), + }).isRequired, + })).isRequired, + page_info: createStrictShapeTypeChecker({ + has_next_page: ReactPropTypes.bool.isRequired, + start_cursor: ReactPropTypes.string, + end_cursor: ReactPropTypes.string, + }).isRequired, +}); + +class CameraRoll { + /** + * Saves the image with tag `tag` to the camera roll. + * + * @param {string} tag - Can be any of the three kinds of tags we accept: + * 1. URL + * 2. assets-library tag + * 3. tag returned from storing an image in memory + */ + static saveImageWithTag(tag, successCallback, errorCallback) { + invariant( + typeof tag === 'string', + 'CameraRoll.saveImageWithTag tag must be a valid string.' + ); + RKCameraRollManager.saveImageWithTag( + tag, + (imageTag) => { + successCallback && successCallback(imageTag); + }, + (errorMessage) => { + errorCallback && errorCallback(errorMessage); + }); + } + + /** + * Invokes `callback` with photo identifier objects from the local camera + * roll of the device matching shape defined by `getPhotosReturnChecker`. + * + * @param {object} params - See `getPhotosParamChecker`. + * @param {function} callback - Invoked with arg of shape defined by + * `getPhotosReturnChecker` on success. + * @param {function} errorCallback - Invoked with error message on error. + */ + static getPhotos(params, callback, errorCallback) { + var metaCallback = callback; + if (__DEV__) { + getPhotosParamChecker({params}, 'params', 'CameraRoll.getPhotos'); + invariant( + typeof callback === 'function', + 'CameraRoll.getPhotos callback must be a valid function.' + ); + invariant( + typeof errorCallback === 'function', + 'CameraRoll.getPhotos errorCallback must be a valid function.' + ); + } + if (__DEV__) { + metaCallback = (response) => { + getPhotosReturnChecker( + {response}, + 'response', + 'CameraRoll.getPhotos callback' + ); + callback(response); + }; + } + RKCameraRollManager.getPhotos(params, metaCallback, errorCallback); + } +} + +CameraRoll.GroupTypesOptions = GROUP_TYPES_OPTIONS; + +module.exports = CameraRoll; diff --git a/Libraries/Image/RCTCameraRollManager.h b/Libraries/Image/RCTCameraRollManager.h new file mode 100644 index 000000000..4a957d6a2 --- /dev/null +++ b/Libraries/Image/RCTCameraRollManager.h @@ -0,0 +1,7 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTBridgeModule.h" + +@interface RCTCameraRollManager : NSObject + +@end diff --git a/Libraries/Image/RCTCameraRollManager.m b/Libraries/Image/RCTCameraRollManager.m new file mode 100644 index 000000000..9f86ffb69 --- /dev/null +++ b/Libraries/Image/RCTCameraRollManager.m @@ -0,0 +1,148 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTCameraRollManager.h" + +#import +#import +#import +#import + + #import "RCTImageLoader.h" +#import "RCTLog.h" + +@implementation RCTCameraRollManager + +- (void)saveImageWithTag:(NSString *)imageTag successCallback:(RCTResponseSenderBlock)successCallback errorCallback:(RCTResponseSenderBlock)errorCallback +{ + RCT_EXPORT(); + + [RCTImageLoader loadImageWithTag:imageTag callback:^(NSError *loadError, UIImage *loadedImage) { + if (loadError) { + errorCallback(@[[loadError localizedDescription]]); + return; + } + [[RCTImageLoader assetsLibrary] writeImageToSavedPhotosAlbum:[loadedImage CGImage] metadata:nil completionBlock:^(NSURL *assetURL, NSError *saveError) { + if (saveError) { + NSString *errorMessage = [NSString stringWithFormat:@"Error saving cropped image: %@", saveError]; + RCTLogWarn(@"%@", errorMessage); + errorCallback(@[errorMessage]); + return; + } + successCallback(@[[assetURL absoluteString]]); + }]; + }]; +} + +- (void)callCallback:(RCTResponseSenderBlock)callback withAssets:(NSArray *)assets hasNextPage:(BOOL)hasNextPage +{ + if (![assets count]) { + callback(@[@{ + @"edges": assets, + @"page_info": @{ + @"has_next_page": @NO} + }]); + return; + } + callback(@[@{ + @"edges": assets, + @"page_info": @{ + @"start_cursor": assets[0][@"node"][@"image"][@"uri"], + @"end_cursor": assets[assets.count - 1][@"node"][@"image"][@"uri"], + @"has_next_page": @(hasNextPage)} + }]); +} + +- (void)getPhotos:(NSDictionary *)params callback:(RCTResponseSenderBlock)callback errorCallback:(RCTResponseSenderBlock)errorCallback +{ + RCT_EXPORT(); + + NSUInteger first = [params[@"first"] integerValue]; + NSString *afterCursor = params[@"after"]; + NSString *groupTypesStr = params[@"groupTypes"]; + NSString *groupName = params[@"groupName"]; + ALAssetsGroupType groupTypes; + if ([groupTypesStr isEqualToString:@"Album"]) { + groupTypes = ALAssetsGroupAlbum; + } else if ([groupTypesStr isEqualToString:@"All"]) { + groupTypes = ALAssetsGroupAll; + } else if ([groupTypesStr isEqualToString:@"Event"]) { + groupTypes = ALAssetsGroupEvent; + } else if ([groupTypesStr isEqualToString:@"Faces"]) { + groupTypes = ALAssetsGroupFaces; + } else if ([groupTypesStr isEqualToString:@"Library"]) { + groupTypes = ALAssetsGroupLibrary; + } else if ([groupTypesStr isEqualToString:@"PhotoStream"]) { + groupTypes = ALAssetsGroupPhotoStream; + } else { + groupTypes = ALAssetsGroupSavedPhotos; + } + + BOOL __block foundAfter = NO; + BOOL __block hasNextPage = NO; + BOOL __block calledCallback = NO; + NSMutableArray *assets = [[NSMutableArray alloc] init]; + + [[RCTImageLoader assetsLibrary] enumerateGroupsWithTypes:groupTypes usingBlock:^(ALAssetsGroup *group, BOOL *stopGroups) { + if (group && (groupName == nil || [groupName isEqualToString:[group valueForProperty:ALAssetsGroupPropertyName]])) { + [group setAssetsFilter:ALAssetsFilter.allPhotos]; + [group enumerateAssetsWithOptions:NSEnumerationReverse usingBlock:^(ALAsset *result, NSUInteger index, BOOL *stopAssets) { + if (result) { + NSString *uri = [(NSURL *)[result valueForProperty:ALAssetPropertyAssetURL] absoluteString]; + if (afterCursor && !foundAfter) { + if ([afterCursor isEqualToString:uri]) { + foundAfter = YES; + } + return; // Skip until we get to the first one + } + if (first == [assets count]) { + *stopAssets = YES; + *stopGroups = YES; + hasNextPage = YES; + RCTAssert(calledCallback == NO, @"Called the callback before we finished processing the results."); + [self callCallback:callback withAssets:assets hasNextPage:hasNextPage]; + calledCallback = YES; + return; + } + CGSize dimensions = [result defaultRepresentation].dimensions; + CLLocation *loc = [result valueForProperty:ALAssetPropertyLocation]; + NSDate *date = [result valueForProperty:ALAssetPropertyDate]; + [assets addObject:@{ + @"node": @{ + @"type": [result valueForProperty:ALAssetPropertyType], + @"group_name": [group valueForProperty:ALAssetsGroupPropertyName], + @"image": @{ + @"uri": uri, + @"height": @(dimensions.height), + @"width": @(dimensions.width), + @"isStored": @YES, + }, + @"timestamp": @([date timeIntervalSince1970]), + @"location": loc ? + @{ + @"latitude": @(loc.coordinate.latitude), + @"longitude": @(loc.coordinate.longitude), + @"altitude": @(loc.altitude), + @"heading": @(loc.course), + @"speed": @(loc.speed), + } : @{}, + } + }]; + } + }]; + } else { + // Sometimes the enumeration continues even if we set stop above, so we guard against calling the callback + // multiple times here. + if (!calledCallback) { + [self callCallback:callback withAssets:assets hasNextPage:hasNextPage]; + calledCallback = YES; + } + } + } failureBlock:^(NSError *error) { + if (error.code != ALAssetsLibraryAccessUserDeniedError) { + RCTLogError(@"Failure while iterating through asset groups %@", error); + } + errorCallback(@[error.description]); + }]; +} + +@end diff --git a/Libraries/Image/RCTImage.xcodeproj/project.pbxproj b/Libraries/Image/RCTImage.xcodeproj/project.pbxproj index 409d61d32..dea9cb419 100644 --- a/Libraries/Image/RCTImage.xcodeproj/project.pbxproj +++ b/Libraries/Image/RCTImage.xcodeproj/project.pbxproj @@ -10,6 +10,8 @@ 1304D5AB1AA8C4A30002E2BE /* RCTStaticImage.m in Sources */ = {isa = PBXBuildFile; fileRef = 1304D5A81AA8C4A30002E2BE /* RCTStaticImage.m */; }; 1304D5AC1AA8C4A30002E2BE /* RCTStaticImageManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 1304D5AA1AA8C4A30002E2BE /* RCTStaticImageManager.m */; }; 1304D5B21AA8C50D0002E2BE /* RCTGIFImage.m in Sources */ = {isa = PBXBuildFile; fileRef = 1304D5B11AA8C50D0002E2BE /* RCTGIFImage.m */; }; + 143879351AAD238D00F088A5 /* RCTCameraRollManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 143879341AAD238D00F088A5 /* RCTCameraRollManager.m */; }; + 143879381AAD32A300F088A5 /* RCTImageLoader.m in Sources */ = {isa = PBXBuildFile; fileRef = 143879371AAD32A300F088A5 /* RCTImageLoader.m */; }; 58B5118F1A9E6BD600147676 /* RCTImageDownloader.m in Sources */ = {isa = PBXBuildFile; fileRef = 58B5118A1A9E6BD600147676 /* RCTImageDownloader.m */; }; 58B511901A9E6BD600147676 /* RCTNetworkImageView.m in Sources */ = {isa = PBXBuildFile; fileRef = 58B5118C1A9E6BD600147676 /* RCTNetworkImageView.m */; }; 58B511911A9E6BD600147676 /* RCTNetworkImageViewManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 58B5118E1A9E6BD600147676 /* RCTNetworkImageViewManager.m */; }; @@ -34,6 +36,10 @@ 1304D5AA1AA8C4A30002E2BE /* RCTStaticImageManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTStaticImageManager.m; sourceTree = ""; }; 1304D5B01AA8C50D0002E2BE /* RCTGIFImage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTGIFImage.h; sourceTree = ""; }; 1304D5B11AA8C50D0002E2BE /* RCTGIFImage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTGIFImage.m; sourceTree = ""; }; + 143879331AAD238D00F088A5 /* RCTCameraRollManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTCameraRollManager.h; sourceTree = ""; }; + 143879341AAD238D00F088A5 /* RCTCameraRollManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTCameraRollManager.m; sourceTree = ""; }; + 143879361AAD32A300F088A5 /* RCTImageLoader.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTImageLoader.h; sourceTree = ""; }; + 143879371AAD32A300F088A5 /* RCTImageLoader.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTImageLoader.m; sourceTree = ""; }; 58B5115D1A9E6B3D00147676 /* libRCTImage.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libRCTImage.a; sourceTree = BUILT_PRODUCTS_DIR; }; 58B511891A9E6BD600147676 /* RCTImageDownloader.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTImageDownloader.h; sourceTree = ""; }; 58B5118A1A9E6BD600147676 /* RCTImageDownloader.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTImageDownloader.m; sourceTree = ""; }; @@ -57,6 +63,10 @@ 58B511541A9E6B3D00147676 = { isa = PBXGroup; children = ( + 143879361AAD32A300F088A5 /* RCTImageLoader.h */, + 143879371AAD32A300F088A5 /* RCTImageLoader.m */, + 143879331AAD238D00F088A5 /* RCTCameraRollManager.h */, + 143879341AAD238D00F088A5 /* RCTCameraRollManager.m */, 1304D5B01AA8C50D0002E2BE /* RCTGIFImage.h */, 1304D5B11AA8C50D0002E2BE /* RCTGIFImage.m */, 58B511891A9E6BD600147676 /* RCTImageDownloader.h */, @@ -142,6 +152,8 @@ 1304D5AC1AA8C4A30002E2BE /* RCTStaticImageManager.m in Sources */, 58B511901A9E6BD600147676 /* RCTNetworkImageView.m in Sources */, 1304D5B21AA8C50D0002E2BE /* RCTGIFImage.m in Sources */, + 143879351AAD238D00F088A5 /* RCTCameraRollManager.m in Sources */, + 143879381AAD32A300F088A5 /* RCTImageLoader.m in Sources */, 1304D5AB1AA8C4A30002E2BE /* RCTStaticImage.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Libraries/Image/RCTImageLoader.h b/Libraries/Image/RCTImageLoader.h new file mode 100644 index 000000000..3554f4b46 --- /dev/null +++ b/Libraries/Image/RCTImageLoader.h @@ -0,0 +1,13 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import + +@class ALAssetsLibrary; +@class UIImage; + +@interface RCTImageLoader : NSObject + ++ (ALAssetsLibrary *)assetsLibrary; ++ (void)loadImageWithTag:(NSString *)tag callback:(void (^)(NSError *error, UIImage *image))callback; + +@end diff --git a/Libraries/Image/RCTImageLoader.m b/Libraries/Image/RCTImageLoader.m new file mode 100644 index 000000000..ec3e1dda2 --- /dev/null +++ b/Libraries/Image/RCTImageLoader.m @@ -0,0 +1,98 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTImageLoader.h" + +#import +#import +#import +#import +#import + +#import "RCTConvert.h" +#import "RCTImageDownloader.h" +#import "RCTLog.h" + +NSError *errorWithMessage(NSString *message) { + NSDictionary *errorInfo = @{NSLocalizedDescriptionKey: message}; + NSError *error = [[NSError alloc] initWithDomain:RCTErrorDomain code:0 userInfo:errorInfo]; + return error; +} + +@implementation RCTImageLoader + ++ (ALAssetsLibrary *)assetsLibrary +{ + static ALAssetsLibrary *assetsLibrary = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + assetsLibrary = [[ALAssetsLibrary alloc] init]; + }); + return assetsLibrary; +} + ++ (void)loadImageWithTag:(NSString *)imageTag callback:(void (^)(NSError *error, UIImage *image))callback +{ + if ([imageTag hasPrefix:@"assets-library"]) { + [[RCTImageLoader assetsLibrary] assetForURL:[NSURL URLWithString:imageTag] resultBlock:^(ALAsset *asset) { + if (asset) { + ALAssetRepresentation *representation = [asset defaultRepresentation]; + ALAssetOrientation orientation = [representation orientation]; + UIImage *image = [UIImage imageWithCGImage:[representation fullResolutionImage] scale:1.0f orientation:(UIImageOrientation)orientation]; + callback(nil, image); + } else { + NSString *errorText = [NSString stringWithFormat:@"Failed to load asset at URL %@ with no error message.", imageTag]; + NSError *error = errorWithMessage(errorText); + callback(error, nil); + } + } failureBlock:^(NSError *loadError) { + NSString *errorText = [NSString stringWithFormat:@"Failed to load asset at URL %@.\niOS Error: %@", imageTag, loadError]; + NSError *error = errorWithMessage(errorText); + callback(error, nil); + }]; + } else if ([imageTag hasPrefix:@"ph://"]) { + // Using PhotoKit for iOS 8+ + // 'ph://' prefix is used by FBMediaKit to differentiate between assets-library. It is prepended to the local ID so that it + // is in the form of NSURL which is what assets-library is based on. + // This means if we use any FB standard photo picker, we will get this prefix =( + NSString *phAssetID = [imageTag substringFromIndex:[@"ph://" length]]; + PHFetchResult *results = [PHAsset fetchAssetsWithLocalIdentifiers:@[phAssetID] options:nil]; + if (results.count == 0) { + NSString *errorText = [NSString stringWithFormat:@"Failed to fetch PHAsset with local identifier %@ with no error message.", phAssetID]; + NSError *error = errorWithMessage(errorText); + callback(error, nil); + return; + } + + PHAsset *asset = [results firstObject]; + [[PHImageManager defaultManager] requestImageForAsset:asset targetSize:PHImageManagerMaximumSize contentMode:PHImageContentModeDefault options:nil resultHandler:^(UIImage *result, NSDictionary *info) { + if (result) { + callback(nil, result); + } else { + NSString *errorText = [NSString stringWithFormat:@"Failed to load PHAsset with local identifier %@ with no error message.", phAssetID]; + NSError *error = errorWithMessage(errorText); + callback(error, nil); + return; + } + }]; + } else if ([imageTag hasPrefix:@"http"]) { + NSURL *url = [NSURL URLWithString:imageTag]; + if (!url) { + NSString *errorMessage = [NSString stringWithFormat:@"Invalid URL: %@", imageTag]; + callback(errorWithMessage(errorMessage), nil); + return; + } + [[RCTImageDownloader sharedInstance] downloadDataForURL:url block:^(NSData *data, NSError *error) { + if (error) { + callback(error, nil); + } else { + callback(nil, [UIImage imageWithData:data]); + } + }]; + } else { + NSString *errorMessage = [NSString stringWithFormat:@"Unrecognized tag protocol: %@", imageTag]; + NSError *error = errorWithMessage(errorMessage); + callback(error, nil); + } +} + +@end diff --git a/Libraries/Image/RCTStaticImage.m b/Libraries/Image/RCTStaticImage.m index b57b763ed..e8378fc72 100644 --- a/Libraries/Image/RCTStaticImage.m +++ b/Libraries/Image/RCTStaticImage.m @@ -24,7 +24,7 @@ // Apply trilinear filtering to smooth out mis-sized images self.layer.minificationFilter = kCAFilterTrilinear; self.layer.magnificationFilter = kCAFilterTrilinear; - + super.image = image; } diff --git a/Libraries/Image/RCTStaticImageManager.m b/Libraries/Image/RCTStaticImageManager.m index b83d8c42b..ef60247f2 100644 --- a/Libraries/Image/RCTStaticImageManager.m +++ b/Libraries/Image/RCTStaticImageManager.m @@ -6,6 +6,7 @@ #import "RCTConvert.h" #import "RCTGIFImage.h" +#import "RCTImageLoader.h" #import "RCTStaticImage.h" @implementation RCTStaticImageManager @@ -39,5 +40,19 @@ RCT_CUSTOM_VIEW_PROPERTY(tintColor, RCTStaticImage *) view.tintColor = defaultView.tintColor; } } +RCT_CUSTOM_VIEW_PROPERTY(imageTag, RCTStaticImage *) +{ + if (json) { + [RCTImageLoader loadImageWithTag:[RCTConvert NSString:json] callback:^(NSError *error, UIImage *image) { + if (error) { + RCTLogWarn(@"%@", error.localizedDescription); + } else { + view.image = image; + } + }]; + } else { + view.image = defaultView.image; + } +} @end diff --git a/Libraries/Utilities/groupByEveryN.js b/Libraries/Utilities/groupByEveryN.js new file mode 100644 index 000000000..e85e58ed0 --- /dev/null +++ b/Libraries/Utilities/groupByEveryN.js @@ -0,0 +1,46 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule groupByEveryN + */ + +/** + * Useful method to split an array into groups of the same number of elements. + * You can use it to generate grids, rows, pages... + * + * If the input length is not a multiple of the count, it'll fill the last + * array with null so you can display a placeholder. + * + * Example: + * groupByEveryN([1, 2, 3, 4, 5], 3) + * => [[1, 2, 3], [4, 5, null]] + * + * groupByEveryN([1, 2, 3], 2).map(elems => { + * return {elems.map(elem => {elem})}; + * }) + */ +'use strict'; + +function groupByEveryN(array, n) { + var result = []; + var temp = []; + + for (var i = 0; i < array.length; ++i) { + if (i > 0 && i % n === 0) { + result.push(temp); + temp = []; + } + temp.push(array[i]); + } + + if (temp.length > 0) { + while (temp.length !== n) { + temp.push(null); + } + result.push(temp); + } + + return result; +} + +module.exports = groupByEveryN; diff --git a/Libraries/react-native/react-native.js b/Libraries/react-native/react-native.js index 7f7edaf72..4b2de062a 100644 --- a/Libraries/react-native/react-native.js +++ b/Libraries/react-native/react-native.js @@ -8,6 +8,7 @@ var ReactNative = { ...require('React'), AppRegistry: require('AppRegistry'), + CameraRoll: require('CameraRoll'), DatePickerIOS: require('DatePickerIOS'), ExpandingText: require('ExpandingText'), Image: require('Image'),