Skip to content

Commit

Permalink
Merge pull request #625 from IjzerenHein/feature-takesnapshot-android
Browse files Browse the repository at this point in the history
Added support for taking snapshots on Android
  • Loading branch information
Spike Brehm committed Dec 8, 2016
2 parents 7869dfd + b12503c commit bf676e3
Show file tree
Hide file tree
Showing 5 changed files with 272 additions and 29 deletions.
19 changes: 13 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,6 @@ render() {
```

### Take Snapshot of map
currently only for ios, android implementation WIP

```jsx
getInitialState() {
Expand All @@ -386,11 +385,19 @@ getInitialState() {
}

takeSnapshot () {
// arguments to 'takeSnapshot' are width, height, coordinates and callback
this.refs.map.takeSnapshot(300, 300, this.state.coordinate, (err, snapshot) => {
// snapshot contains image 'uri' - full path to image and 'data' - base64 encoded image
this.setState({ mapSnapshot: snapshot })
})
// 'takeSnapshot' takes a config object with the
// following options
const snapshot = this.refs.map.takeSnapshot({
width: 300, // optional, when omitted the view-width is used
height: 300, // optional, when omitted the view-height is used
region: {..}, // iOS only, optional region to render
format: 'png', // image formats: 'png', 'jpg' (default: 'png')
quality: 0.8, // image quality: 0..1 (only relevant for jpg, default: 1)
result: 'file' // result types: 'file', 'base64' (default: 'file')
});
snapshot.then((uri) => {
this.setState({ mapSnapshot: uri });
});
}

render() {
Expand Down
128 changes: 128 additions & 0 deletions android/src/main/java/com/airbnb/android/react/maps/AirMapModule.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package com.airbnb.android.react.maps;

import android.app.Activity;
import android.util.DisplayMetrics;
import android.util.Base64;
import android.graphics.Bitmap;
import android.net.Uri;
import android.view.View;

import java.io.File;
import java.io.FileOutputStream;
import java.io.OutputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.Closeable;

import javax.annotation.Nullable;

import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.uimanager.UIManagerModule;
import com.facebook.react.uimanager.UIBlock;
import com.facebook.react.uimanager.NativeViewHierarchyManager;

import com.google.android.gms.maps.GoogleMap;

public class AirMapModule extends ReactContextBaseJavaModule {

private static final String SNAPSHOT_RESULT_FILE = "file";
private static final String SNAPSHOT_RESULT_BASE64 = "base64";
private static final String SNAPSHOT_FORMAT_PNG = "png";
private static final String SNAPSHOT_FORMAT_JPG = "jpg";

public AirMapModule(ReactApplicationContext reactContext) {
super(reactContext);
}

@Override
public String getName() {
return "AirMapModule";
}

public Activity getActivity() {
return getCurrentActivity();
}

public static void closeQuietly(Closeable closeable) {
if (closeable == null) return;
try {
closeable.close();
} catch (IOException ignored) {
}
}

@ReactMethod
public void takeSnapshot(final int tag, final ReadableMap options, final Promise promise) {

// Parse and verity options
final ReactApplicationContext context = getReactApplicationContext();
final String format = options.hasKey("format") ? options.getString("format") : "png";
final Bitmap.CompressFormat compressFormat =
format.equals(SNAPSHOT_FORMAT_PNG) ? Bitmap.CompressFormat.PNG :
format.equals(SNAPSHOT_FORMAT_JPG) ? Bitmap.CompressFormat.JPEG : null;
final double quality = options.hasKey("quality") ? options.getDouble("quality") : 1.0;
final DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics();
final Integer width = options.hasKey("width") ? (int)(displayMetrics.density * options.getDouble("width")) : 0;
final Integer height = options.hasKey("height") ? (int)(displayMetrics.density * options.getDouble("height")) : 0;
final String result = options.hasKey("result") ? options.getString("result") : "file";

// Add UI-block so we can get a valid reference to the map-view
UIManagerModule uiManager = context.getNativeModule(UIManagerModule.class);
uiManager.addUIBlock(new UIBlock() {
public void execute (NativeViewHierarchyManager nvhm) {
AirMapView view = (AirMapView) nvhm.resolveView(tag);
if (view == null) {
promise.reject("AirMapView not found");
return;
}
if (view.map == null) {
promise.reject("AirMapView.map is not valid");
return;
}
view.map.snapshot(new GoogleMap.SnapshotReadyCallback() {
public void onSnapshotReady(@Nullable Bitmap snapshot) {

// Convert image to requested width/height if neccesary
if (snapshot == null) {
promise.reject("Failed to generate bitmap, snapshot = null");
return;
}
if ((width != 0) && (height != 0) && (width != snapshot.getWidth() || height != snapshot.getHeight())) {
snapshot = Bitmap.createScaledBitmap(snapshot, width, height, true);
}

// Save the snapshot to disk
if (result.equals(SNAPSHOT_RESULT_FILE)) {
File tempFile;
FileOutputStream outputStream;
try {
tempFile = File.createTempFile("AirMapSnapshot", "." + format, context.getCacheDir());
outputStream = new FileOutputStream(tempFile);
}
catch (Exception e) {
promise.reject(e);
return;
}
snapshot.compress(compressFormat, (int)(100.0 * quality), outputStream);
closeQuietly(outputStream);
String uri = Uri.fromFile(tempFile).toString();
promise.resolve(uri);
}
else if (result.equals(SNAPSHOT_RESULT_BASE64)) {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
snapshot.compress(compressFormat, (int)(100.0 * quality), outputStream);
closeQuietly(outputStream);
byte[] bytes = outputStream.toByteArray();
String data = Base64.encodeToString(bytes, Base64.NO_WRAP);
promise.resolve(data);
}
}
});
}
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public MapsPackage() {

@Override
public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
return Collections.emptyList();
return Arrays.<NativeModule>asList(new AirMapModule(reactContext));
}

@Override
Expand Down
76 changes: 73 additions & 3 deletions components/MapView.js
Original file line number Diff line number Diff line change
Expand Up @@ -476,9 +476,79 @@ class MapView extends React.Component {
this._runCommand('fitToCoordinates', [coordinates, edgePadding, animated]);
}

takeSnapshot(width, height, region, callback) {
const finalRegion = region || this.props.region || this.props.initialRegion;
this._runCommand('takeSnapshot', [width, height, finalRegion, callback]);
/**
* Takes a snapshot of the map and saves it to a picture
* file or returns the image as a base64 encoded string.
*
* @param config Configuration options
* @param [config.width] Width of the rendered map-view (when omitted actual view width is used).
* @param [config.height] Height of the rendered map-view (when omitted actual height is used).
* @param [config.region] Region to render (Only supported on iOS).
* @param [config.format] Encoding format ('png', 'jpg') (default: 'png').
* @param [config.quality] Compression quality (only used for jpg) (default: 1.0).
* @param [config.result] Result format ('file', 'base64') (default: 'file').
*
* @return Promise Promise with either the file-uri or base64 encoded string
*/
takeSnapshot(args) {
// For the time being we support the legacy API on iOS.
// This will be removed in a future release and only the
// new Promise style API shall be supported.
if (Platform.OS === 'ios' && (arguments.length === 4)) {
console.warn('Old takeSnapshot API has been deprecated; will be removed in the near future'); //eslint-disable-line
const width = arguments[0]; // eslint-disable-line
const height = arguments[1]; // eslint-disable-line
const region = arguments[2]; // eslint-disable-line
const callback = arguments[3]; // eslint-disable-line
this._runCommand('takeSnapshot', [
width || 0,
height || 0,
region || {},
'png',
1,
'legacy',
callback,
]);
return undefined;
}

// Sanitize inputs
const config = {
width: args.width || 0,
height: args.height || 0,
region: args.region || {},
format: args.format || 'png',
quality: args.quality || 1.0,
result: args.result || 'file',
};
if ((config.format !== 'png') &&
(config.format !== 'jpg')) throw new Error('Invalid format specified');
if ((config.result !== 'file') &&
(config.result !== 'base64')) throw new Error('Invalid result specified');

// Call native function
if (Platform.OS === 'android') {
return NativeModules.AirMapModule.takeSnapshot(this._getHandle(), config);
} else if (Platform.OS === 'ios') {
return new Promise((resolve, reject) => {
this._runCommand('takeSnapshot', [
config.width,
config.height,
config.region,
config.format,
config.quality,
config.result,
(err, snapshot) => {
if (err) {
reject(err);
} else {
resolve(snapshot);
}
},
]);
});
}
return Promise.reject('takeSnapshot not supported on this platform');
}

_uiManagerCommand(name) {
Expand Down
76 changes: 57 additions & 19 deletions ios/AirMaps/AIRMapManager.m
Original file line number Diff line number Diff line change
Expand Up @@ -221,10 +221,13 @@ - (UIView *)view
}

RCT_EXPORT_METHOD(takeSnapshot:(nonnull NSNumber *)reactTag
withWidth:(nonnull NSNumber *)width
withHeight:(nonnull NSNumber *)height
withRegion:(MKCoordinateRegion)region
withCallback:(RCTResponseSenderBlock)callback)
width:(nonnull NSNumber *)width
height:(nonnull NSNumber *)height
region:(MKCoordinateRegion)region
format:(nonnull NSString *)format
quality:(nonnull NSNumber *)quality
result:(nonnull NSString *)result
callback:(RCTResponseSenderBlock)callback)
{
[self.bridge.uiManager addUIBlock:^(__unused RCTUIManager *uiManager, NSDictionary<NSNumber *, UIView *> *viewRegistry) {
id view = viewRegistry[reactTag];
Expand All @@ -234,25 +237,34 @@ - (UIView *)view
AIRMap *mapView = (AIRMap *)view;
MKMapSnapshotOptions *options = [[MKMapSnapshotOptions alloc] init];

options.region = region;
options.size = CGSizeMake([width floatValue], [height floatValue]);
options.region = (region.center.latitude && region.center.longitude) ? region : mapView.region;
options.size = CGSizeMake(
([width floatValue] == 0) ? mapView.bounds.size.width : [width floatValue],
([height floatValue] == 0) ? mapView.bounds.size.height : [height floatValue]
);
options.scale = [[UIScreen mainScreen] scale];

MKMapSnapshotter *snapshotter = [[MKMapSnapshotter alloc] initWithOptions:options];


[self takeMapSnapshot:mapView withSnapshotter:snapshotter withCallback:callback];

[self takeMapSnapshot:mapView
snapshotter:snapshotter
format:format
quality:quality.floatValue
result:result
callback:callback];
}
}];
}

#pragma mark Take Snapshot
- (void)takeMapSnapshot:(AIRMap *)mapView
withSnapshotter:(MKMapSnapshotter *) snapshotter
withCallback:(RCTResponseSenderBlock) callback {
snapshotter:(MKMapSnapshotter *) snapshotter
format:(NSString *)format
quality:(CGFloat) quality
result:(NSString *)result
callback:(RCTResponseSenderBlock) callback {
NSTimeInterval timeStamp = [[NSDate date] timeIntervalSince1970];
NSString *pathComponent = [NSString stringWithFormat:@"Documents/snapshot-%.20lf.png", timeStamp];
NSString *pathComponent = [NSString stringWithFormat:@"Documents/snapshot-%.20lf.%@", timeStamp, format];
NSString *filePath = [NSHomeDirectory() stringByAppendingPathComponent: pathComponent];

[snapshotter startWithQueue:dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)
Expand Down Expand Up @@ -294,13 +306,39 @@ - (void)takeMapSnapshot:(AIRMap *)mapView

UIImage *compositeImage = UIGraphicsGetImageFromCurrentImageContext();

NSData *data = UIImagePNGRepresentation(compositeImage);
[data writeToFile:filePath atomically:YES];
NSDictionary *snapshotData = @{
@"uri": filePath,
@"data": [data base64EncodedStringWithOptions:NSDataBase64EncodingEndLineWithCarriageReturn]
};
callback(@[[NSNull null], snapshotData]);
NSData *data;
if ([format isEqualToString:@"png"]) {
data = UIImagePNGRepresentation(compositeImage);
}
else if([format isEqualToString:@"jpg"]) {
data = UIImageJPEGRepresentation(compositeImage, quality);
}

if ([result isEqualToString:@"file"]) {
[data writeToFile:filePath atomically:YES];
callback(@[[NSNull null], filePath]);
}
else if ([result isEqualToString:@"base64"]) {
callback(@[[NSNull null], [data base64EncodedStringWithOptions:NSDataBase64EncodingEndLineWithCarriageReturn]]);
}
else if ([result isEqualToString:@"legacy"]) {

// In the initial (iOS only) implementation of takeSnapshot,
// both the uri and the base64 encoded string were returned.
// Returning both is rarely useful and in fact causes a
// performance penalty when only the file URI is desired.
// In that case the base64 encoded string was always marshalled
// over the JS-bridge (which is quite slow).
// A new more flexible API was created to cover this.
// This code should be removed in a future release when the
// old API is fully deprecated.
[data writeToFile:filePath atomically:YES];
NSDictionary *snapshotData = @{
@"uri": filePath,
@"data": [data base64EncodedStringWithOptions:NSDataBase64EncodingEndLineWithCarriageReturn]
};
callback(@[[NSNull null], snapshotData]);
}
}
UIGraphicsEndImageContext();
}];
Expand Down

0 comments on commit bf676e3

Please sign in to comment.