From ac12f986899d8520527684438f76299675dc0daa Mon Sep 17 00:00:00 2001 From: Nick Lockwood Date: Tue, 23 Feb 2016 02:26:11 -0800 Subject: [PATCH] Added support for taking snapshots of the screen, window or individual views Summary:This adds a `takeSnapshot` method to UIManager that can be used to capture screenshots as an image. The takeSnapshot method accepts either 'screen', 'window' or a view ref as an argument. You can also specify the size, format and quality of the captured image. I've added an example of capturing a screenshot at UIExplorer > Snapshot / Screenshot. I've also added an example of sharing a screenshot to the UIExplorer > ActionSheetIOS demo. Reviewed By: javache Differential Revision: D2958351 fb-gh-sync-id: d2eb93fea3297ec5aaa312854dd6add724a7f4f8 shipit-source-id: d2eb93fea3297ec5aaa312854dd6add724a7f4f8 --- Examples/UIExplorer/ActionSheetIOSExample.js | 57 +++++++++++++-- Examples/UIExplorer/SnapshotExample.js | 73 ++++++++++++++++++++ Examples/UIExplorer/UIExplorerList.ios.js | 4 ++ Libraries/Utilities/UIManager.js | 41 ++++++++++- React/Base/RCTUtils.h | 3 + React/Base/RCTUtils.m | 45 ++++++++++++ React/Modules/RCTUIManager.m | 69 ++++++++++++++++++ website/server/extractDocs.js | 1 + 8 files changed, 288 insertions(+), 5 deletions(-) create mode 100644 Examples/UIExplorer/SnapshotExample.js diff --git a/Examples/UIExplorer/ActionSheetIOSExample.js b/Examples/UIExplorer/ActionSheetIOSExample.js index 31fd79c38..b749400c0 100644 --- a/Examples/UIExplorer/ActionSheetIOSExample.js +++ b/Examples/UIExplorer/ActionSheetIOSExample.js @@ -20,6 +20,7 @@ var { ActionSheetIOS, StyleSheet, Text, + UIManager, View, } = React; @@ -127,9 +128,7 @@ var ShareActionSheetExample = React.createClass({ 'com.apple.UIKit.activity.PostToTwitter' ] }, - (error) => { - console.error(error); - }, + (error) => alert(error), (success, method) => { var text; if (success) { @@ -142,6 +141,50 @@ var ShareActionSheetExample = React.createClass({ } }); +var ShareScreenshotExample = React.createClass({ + getInitialState() { + return { + text: '' + }; + }, + + render() { + return ( + + + Click to show the Share ActionSheet + + + {this.state.text} + + + ); + }, + + showShareActionSheet() { + // Take the snapshot (returns a temp file uri) + UIManager.takeSnapshot('window').then((uri) => { + // Share image data + ActionSheetIOS.showShareActionSheetWithOptions({ + url: uri, + excludedActivityTypes: [ + 'com.apple.UIKit.activity.PostToTwitter' + ] + }, + (error) => alert(error), + (success, method) => { + var text; + if (success) { + text = `Shared via ${method}`; + } else { + text = 'You didn\'t share'; + } + this.setState({text}); + }); + }).catch((error) => alert(error)); + } +}); + var style = StyleSheet.create({ button: { marginBottom: 10, @@ -166,10 +209,16 @@ exports.examples = [ return ; } }, - { + { title: 'Share Local Image', render(): ReactElement { return ; } + }, + { + title: 'Share Screenshot', + render(): ReactElement { + return ; + } } ]; diff --git a/Examples/UIExplorer/SnapshotExample.js b/Examples/UIExplorer/SnapshotExample.js new file mode 100644 index 000000000..f5384c666 --- /dev/null +++ b/Examples/UIExplorer/SnapshotExample.js @@ -0,0 +1,73 @@ +/** + * 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, + UIManager, + View, +} = React; + +var ScreenshotExample = React.createClass({ + getInitialState() { + return { + uri: undefined, + }; + }, + + render() { + return ( + + + Click to take a screenshot + + + + ); + }, + + takeScreenshot() { + UIManager + .takeSnapshot('screen', {format: 'jpeg', quality: 0.8}) // See UIManager.js for options + .then((uri) => this.setState({uri})) + .catch((error) => alert(error)); + } +}); + +var style = StyleSheet.create({ + button: { + marginBottom: 10, + fontWeight: '500', + }, + image: { + flex: 1, + height: 300, + resizeMode: 'contain', + backgroundColor: 'black', + }, +}); + +exports.title = 'Snapshot / Screenshot'; +exports.description = 'API to capture images from the screen.'; +exports.examples = [ + { + title: 'Take screenshot', + render(): ReactElement { return ; } + }, +]; diff --git a/Examples/UIExplorer/UIExplorerList.ios.js b/Examples/UIExplorer/UIExplorerList.ios.js index 70a6dbf4c..65781609d 100644 --- a/Examples/UIExplorer/UIExplorerList.ios.js +++ b/Examples/UIExplorer/UIExplorerList.ios.js @@ -220,6 +220,10 @@ var APIExamples: Array = [ key: 'RCTRootViewIOSExample', module: require('./RCTRootViewIOSExample'), }, + { + key: 'SnapshotExample', + module: require('./SnapshotExample'), + }, { key: 'StatusBarIOSExample', module: require('./StatusBarIOSExample'), diff --git a/Libraries/Utilities/UIManager.js b/Libraries/Utilities/UIManager.js index ff2fc2354..068087a8f 100644 --- a/Libraries/Utilities/UIManager.js +++ b/Libraries/Utilities/UIManager.js @@ -12,6 +12,7 @@ 'use strict'; var UIManager = require('NativeModules').UIManager; +var findNodeHandle = require('findNodeHandle'); if (!UIManager.setChildren) { @@ -39,7 +40,45 @@ if (!UIManager.setChildren) { UIManager.setChildren = function(containerTag, createdTags) { var indexes = this._cachedIndexArray(createdTags.length); UIManager.manageChildren(containerTag, null, null, createdTags, indexes, null); - } + }; } +const _takeSnapshot = UIManager.takeSnapshot; + +/** + * Capture an image of the screen, window or an individual view. The image + * will be stored in a temporary file that will only exist for as long as the + * app is running. + * + * The `view` argument can be the literal string `screen` or `window` if you + * want to capture the entire screen, or it can be a reference to a specific + * React Native component. + * + * The `options` argument may include: + * - width/height (number) - the width and height of the image to capture. + * - format (string) - either 'png' or 'jpeg'. Defaults to 'png'. + * - quality (number) - the quality when using jpeg. 0.0 - 1.0 (default). + * + * Returns a Promise. + * @platform ios + */ +UIManager.takeSnapshot = async function( + view ?: 'screen' | 'window' | ReactElement | number, + options ?: { + width ?: number; + height ?: number; + format ?: 'png' | 'jpeg'; + quality ?: number; + }, +) { + if (!_takeSnapshot) { + console.warn('UIManager.takeSnapshot is not available on this platform'); + return; + } + if (typeof view !== 'number' && view !== 'screen' && view !== 'window') { + view = findNodeHandle(view) || 'screen'; + } + return _takeSnapshot(view, options); +}; + module.exports = UIManager; diff --git a/React/Base/RCTUtils.h b/React/Base/RCTUtils.h index 9d63bdbf3..ea3198145 100644 --- a/React/Base/RCTUtils.h +++ b/React/Base/RCTUtils.h @@ -109,6 +109,9 @@ RCT_EXTERN NSString *__nullable RCTBundlePathForURL(NSURL *__nullable URL); // Determines if a given image URL actually refers to an XCAsset RCT_EXTERN BOOL RCTIsXCAssetURL(NSURL *__nullable imageURL); +// Creates a new, unique temporary file path with the specified extension +RCT_EXTERN NSString *__nullable RCTTempFilePath(NSString *__nullable extension, NSError **error); + // Converts a CGColor to a hex string RCT_EXTERN NSString *RCTColorToHexString(CGColorRef color); diff --git a/React/Base/RCTUtils.m b/React/Base/RCTUtils.m index 68e3a15e5..c693ea1a7 100644 --- a/React/Base/RCTUtils.m +++ b/React/Base/RCTUtils.m @@ -589,6 +589,51 @@ BOOL RCTIsXCAssetURL(NSURL *__nullable imageURL) return YES; } +RCT_EXTERN NSString *__nullable RCTTempFilePath(NSString *extension, NSError **error) +{ + static NSError *setupError = nil; + static NSString *directory; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + directory = [NSTemporaryDirectory() stringByAppendingPathComponent:@"ReactNative"]; + // If the temporary directory already exists, we'll delete it to ensure + // that temp files from the previous run have all been deleted. This is not + // a security measure, it simply prevents the temp directory from using too + // much space, as the circumstances under which iOS clears it automatically + // are not well-defined. + NSFileManager *fileManager = [NSFileManager new]; + if ([fileManager fileExistsAtPath:directory]) { + [fileManager removeItemAtPath:directory error:NULL]; + } + if (![fileManager fileExistsAtPath:directory]) { + NSError *localError = nil; + if (![fileManager createDirectoryAtPath:directory + withIntermediateDirectories:YES + attributes:nil + error:&localError]) { + // This is bad + RCTLogError(@"Failed to create temporary directory: %@", localError); + setupError = localError; + directory = nil; + } + } + }); + + if (!directory || setupError) { + if (error) { + *error = setupError; + } + return nil; + } + + // Append a unique filename + NSString *filename = [NSUUID new].UUIDString; + if (extension) { + filename = [filename stringByAppendingPathExtension:extension]; + } + return [directory stringByAppendingPathComponent:filename]; +} + static void RCTGetRGBAColorComponents(CGColorRef color, CGFloat rgba[4]) { CGColorSpaceModel model = CGColorSpaceGetModel(CGColorGetColorSpace(color)); diff --git a/React/Modules/RCTUIManager.m b/React/Modules/RCTUIManager.m index cc0821013..1186ac027 100644 --- a/React/Modules/RCTUIManager.m +++ b/React/Modules/RCTUIManager.m @@ -1182,6 +1182,75 @@ RCT_EXPORT_METHOD(measureViewsInRect:(CGRect)rect callback(@[results]); } +RCT_EXPORT_METHOD(takeSnapshot:(id /* NSString or NSNumber */)target + withOptions:(NSDictionary *)options + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + [self addUIBlock:^(__unused RCTUIManager *uiManager, NSDictionary *viewRegistry) { + + // Get view + UIView *view; + if (target == nil || [target isEqual:@"screen"]) { + view = [[UIScreen mainScreen] snapshotViewAfterScreenUpdates:YES]; + } else if ([target isEqual:@"window"]) { + view = RCTKeyWindow(); + } else if ([target isKindOfClass:[NSNumber class]]) { + view = viewRegistry[target]; + if (!view) { + RCTLogError(@"No view found with reactTag: %@", target); + return; + } + } + + // Get options + CGSize size = [RCTConvert CGSize:options]; + NSString *format = [RCTConvert NSString:options[@"format"] ?: @"png"]; + + // Capture image + if (size.width < 0.1 || size.height < 0.1) { + size = view.bounds.size; + } + UIGraphicsBeginImageContextWithOptions(size, NO, 0); + BOOL success = [view drawViewHierarchyInRect:(CGRect){CGPointZero, size} afterScreenUpdates:YES]; + UIImage *image = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + + if (!success || !image) { + reject(RCTErrorUnspecified, @"Failed to capture view snapshot", nil); + return; + } + + // Convert image to data (on a background thread) + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + + NSData *data; + if ([format isEqualToString:@"png"]) { + data = UIImagePNGRepresentation(image); + } else if ([format isEqualToString:@"jpeg"]) { + CGFloat quality = [RCTConvert CGFloat:options[@"quality"] ?: @1]; + data = UIImageJPEGRepresentation(image, quality); + } else { + RCTLogError(@"Unsupported image format: %@", format); + return; + } + + // Save to a temp file + NSError *error = nil; + NSString *tempFilePath = RCTTempFilePath(format, &error); + if (tempFilePath) { + if ([data writeToFile:tempFilePath options:(NSDataWritingOptions)0 error:&error]) { + resolve(tempFilePath); + return; + } + } + + // If we reached here, something went wrong + reject(RCTErrorUnspecified, error.localizedDescription, error); + }); + }]; +} + /** * JS sets what *it* considers to be the responder. Later, scroll views can use * this in order to determine if scrolling is appropriate. diff --git a/website/server/extractDocs.js b/website/server/extractDocs.js index b74c029b7..b263a94a8 100644 --- a/website/server/extractDocs.js +++ b/website/server/extractDocs.js @@ -250,6 +250,7 @@ var apis = [ '../Libraries/StyleSheet/StyleSheet.js', '../Libraries/Components/TimePickerAndroid/TimePickerAndroid.android.js', '../Libraries/Components/ToastAndroid/ToastAndroid.android.js', + '../Libraries/Utilities/UIManager.js', '../Libraries/Vibration/VibrationIOS.ios.js', ];