Skip to content

Commit bf676e3

Browse files
author
Spike Brehm
authored
Merge pull request #625 from IjzerenHein/feature-takesnapshot-android
Added support for taking snapshots on Android
2 parents 7869dfd + b12503c commit bf676e3

File tree

5 files changed

+272
-29
lines changed

5 files changed

+272
-29
lines changed

README.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,6 @@ render() {
373373
```
374374

375375
### Take Snapshot of map
376-
currently only for ios, android implementation WIP
377376

378377
```jsx
379378
getInitialState() {
@@ -386,11 +385,19 @@ getInitialState() {
386385
}
387386

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

396403
render() {
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package com.airbnb.android.react.maps;
2+
3+
import android.app.Activity;
4+
import android.util.DisplayMetrics;
5+
import android.util.Base64;
6+
import android.graphics.Bitmap;
7+
import android.net.Uri;
8+
import android.view.View;
9+
10+
import java.io.File;
11+
import java.io.FileOutputStream;
12+
import java.io.OutputStream;
13+
import java.io.ByteArrayOutputStream;
14+
import java.io.IOException;
15+
import java.io.Closeable;
16+
17+
import javax.annotation.Nullable;
18+
19+
import com.facebook.react.bridge.ReactApplicationContext;
20+
import com.facebook.react.bridge.ReactContextBaseJavaModule;
21+
import com.facebook.react.bridge.ReadableMap;
22+
import com.facebook.react.bridge.Promise;
23+
import com.facebook.react.bridge.ReactMethod;
24+
import com.facebook.react.uimanager.UIManagerModule;
25+
import com.facebook.react.uimanager.UIBlock;
26+
import com.facebook.react.uimanager.NativeViewHierarchyManager;
27+
28+
import com.google.android.gms.maps.GoogleMap;
29+
30+
public class AirMapModule extends ReactContextBaseJavaModule {
31+
32+
private static final String SNAPSHOT_RESULT_FILE = "file";
33+
private static final String SNAPSHOT_RESULT_BASE64 = "base64";
34+
private static final String SNAPSHOT_FORMAT_PNG = "png";
35+
private static final String SNAPSHOT_FORMAT_JPG = "jpg";
36+
37+
public AirMapModule(ReactApplicationContext reactContext) {
38+
super(reactContext);
39+
}
40+
41+
@Override
42+
public String getName() {
43+
return "AirMapModule";
44+
}
45+
46+
public Activity getActivity() {
47+
return getCurrentActivity();
48+
}
49+
50+
public static void closeQuietly(Closeable closeable) {
51+
if (closeable == null) return;
52+
try {
53+
closeable.close();
54+
} catch (IOException ignored) {
55+
}
56+
}
57+
58+
@ReactMethod
59+
public void takeSnapshot(final int tag, final ReadableMap options, final Promise promise) {
60+
61+
// Parse and verity options
62+
final ReactApplicationContext context = getReactApplicationContext();
63+
final String format = options.hasKey("format") ? options.getString("format") : "png";
64+
final Bitmap.CompressFormat compressFormat =
65+
format.equals(SNAPSHOT_FORMAT_PNG) ? Bitmap.CompressFormat.PNG :
66+
format.equals(SNAPSHOT_FORMAT_JPG) ? Bitmap.CompressFormat.JPEG : null;
67+
final double quality = options.hasKey("quality") ? options.getDouble("quality") : 1.0;
68+
final DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics();
69+
final Integer width = options.hasKey("width") ? (int)(displayMetrics.density * options.getDouble("width")) : 0;
70+
final Integer height = options.hasKey("height") ? (int)(displayMetrics.density * options.getDouble("height")) : 0;
71+
final String result = options.hasKey("result") ? options.getString("result") : "file";
72+
73+
// Add UI-block so we can get a valid reference to the map-view
74+
UIManagerModule uiManager = context.getNativeModule(UIManagerModule.class);
75+
uiManager.addUIBlock(new UIBlock() {
76+
public void execute (NativeViewHierarchyManager nvhm) {
77+
AirMapView view = (AirMapView) nvhm.resolveView(tag);
78+
if (view == null) {
79+
promise.reject("AirMapView not found");
80+
return;
81+
}
82+
if (view.map == null) {
83+
promise.reject("AirMapView.map is not valid");
84+
return;
85+
}
86+
view.map.snapshot(new GoogleMap.SnapshotReadyCallback() {
87+
public void onSnapshotReady(@Nullable Bitmap snapshot) {
88+
89+
// Convert image to requested width/height if neccesary
90+
if (snapshot == null) {
91+
promise.reject("Failed to generate bitmap, snapshot = null");
92+
return;
93+
}
94+
if ((width != 0) && (height != 0) && (width != snapshot.getWidth() || height != snapshot.getHeight())) {
95+
snapshot = Bitmap.createScaledBitmap(snapshot, width, height, true);
96+
}
97+
98+
// Save the snapshot to disk
99+
if (result.equals(SNAPSHOT_RESULT_FILE)) {
100+
File tempFile;
101+
FileOutputStream outputStream;
102+
try {
103+
tempFile = File.createTempFile("AirMapSnapshot", "." + format, context.getCacheDir());
104+
outputStream = new FileOutputStream(tempFile);
105+
}
106+
catch (Exception e) {
107+
promise.reject(e);
108+
return;
109+
}
110+
snapshot.compress(compressFormat, (int)(100.0 * quality), outputStream);
111+
closeQuietly(outputStream);
112+
String uri = Uri.fromFile(tempFile).toString();
113+
promise.resolve(uri);
114+
}
115+
else if (result.equals(SNAPSHOT_RESULT_BASE64)) {
116+
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
117+
snapshot.compress(compressFormat, (int)(100.0 * quality), outputStream);
118+
closeQuietly(outputStream);
119+
byte[] bytes = outputStream.toByteArray();
120+
String data = Base64.encodeToString(bytes, Base64.NO_WRAP);
121+
promise.resolve(data);
122+
}
123+
}
124+
});
125+
}
126+
});
127+
}
128+
}

android/src/main/java/com/airbnb/android/react/maps/MapsPackage.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public MapsPackage() {
2121

2222
@Override
2323
public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
24-
return Collections.emptyList();
24+
return Arrays.<NativeModule>asList(new AirMapModule(reactContext));
2525
}
2626

2727
@Override

components/MapView.js

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -476,9 +476,79 @@ class MapView extends React.Component {
476476
this._runCommand('fitToCoordinates', [coordinates, edgePadding, animated]);
477477
}
478478

479-
takeSnapshot(width, height, region, callback) {
480-
const finalRegion = region || this.props.region || this.props.initialRegion;
481-
this._runCommand('takeSnapshot', [width, height, finalRegion, callback]);
479+
/**
480+
* Takes a snapshot of the map and saves it to a picture
481+
* file or returns the image as a base64 encoded string.
482+
*
483+
* @param config Configuration options
484+
* @param [config.width] Width of the rendered map-view (when omitted actual view width is used).
485+
* @param [config.height] Height of the rendered map-view (when omitted actual height is used).
486+
* @param [config.region] Region to render (Only supported on iOS).
487+
* @param [config.format] Encoding format ('png', 'jpg') (default: 'png').
488+
* @param [config.quality] Compression quality (only used for jpg) (default: 1.0).
489+
* @param [config.result] Result format ('file', 'base64') (default: 'file').
490+
*
491+
* @return Promise Promise with either the file-uri or base64 encoded string
492+
*/
493+
takeSnapshot(args) {
494+
// For the time being we support the legacy API on iOS.
495+
// This will be removed in a future release and only the
496+
// new Promise style API shall be supported.
497+
if (Platform.OS === 'ios' && (arguments.length === 4)) {
498+
console.warn('Old takeSnapshot API has been deprecated; will be removed in the near future'); //eslint-disable-line
499+
const width = arguments[0]; // eslint-disable-line
500+
const height = arguments[1]; // eslint-disable-line
501+
const region = arguments[2]; // eslint-disable-line
502+
const callback = arguments[3]; // eslint-disable-line
503+
this._runCommand('takeSnapshot', [
504+
width || 0,
505+
height || 0,
506+
region || {},
507+
'png',
508+
1,
509+
'legacy',
510+
callback,
511+
]);
512+
return undefined;
513+
}
514+
515+
// Sanitize inputs
516+
const config = {
517+
width: args.width || 0,
518+
height: args.height || 0,
519+
region: args.region || {},
520+
format: args.format || 'png',
521+
quality: args.quality || 1.0,
522+
result: args.result || 'file',
523+
};
524+
if ((config.format !== 'png') &&
525+
(config.format !== 'jpg')) throw new Error('Invalid format specified');
526+
if ((config.result !== 'file') &&
527+
(config.result !== 'base64')) throw new Error('Invalid result specified');
528+
529+
// Call native function
530+
if (Platform.OS === 'android') {
531+
return NativeModules.AirMapModule.takeSnapshot(this._getHandle(), config);
532+
} else if (Platform.OS === 'ios') {
533+
return new Promise((resolve, reject) => {
534+
this._runCommand('takeSnapshot', [
535+
config.width,
536+
config.height,
537+
config.region,
538+
config.format,
539+
config.quality,
540+
config.result,
541+
(err, snapshot) => {
542+
if (err) {
543+
reject(err);
544+
} else {
545+
resolve(snapshot);
546+
}
547+
},
548+
]);
549+
});
550+
}
551+
return Promise.reject('takeSnapshot not supported on this platform');
482552
}
483553

484554
_uiManagerCommand(name) {

ios/AirMaps/AIRMapManager.m

Lines changed: 57 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -221,10 +221,13 @@ - (UIView *)view
221221
}
222222

223223
RCT_EXPORT_METHOD(takeSnapshot:(nonnull NSNumber *)reactTag
224-
withWidth:(nonnull NSNumber *)width
225-
withHeight:(nonnull NSNumber *)height
226-
withRegion:(MKCoordinateRegion)region
227-
withCallback:(RCTResponseSenderBlock)callback)
224+
width:(nonnull NSNumber *)width
225+
height:(nonnull NSNumber *)height
226+
region:(MKCoordinateRegion)region
227+
format:(nonnull NSString *)format
228+
quality:(nonnull NSNumber *)quality
229+
result:(nonnull NSString *)result
230+
callback:(RCTResponseSenderBlock)callback)
228231
{
229232
[self.bridge.uiManager addUIBlock:^(__unused RCTUIManager *uiManager, NSDictionary<NSNumber *, UIView *> *viewRegistry) {
230233
id view = viewRegistry[reactTag];
@@ -234,25 +237,34 @@ - (UIView *)view
234237
AIRMap *mapView = (AIRMap *)view;
235238
MKMapSnapshotOptions *options = [[MKMapSnapshotOptions alloc] init];
236239

237-
options.region = region;
238-
options.size = CGSizeMake([width floatValue], [height floatValue]);
240+
options.region = (region.center.latitude && region.center.longitude) ? region : mapView.region;
241+
options.size = CGSizeMake(
242+
([width floatValue] == 0) ? mapView.bounds.size.width : [width floatValue],
243+
([height floatValue] == 0) ? mapView.bounds.size.height : [height floatValue]
244+
);
239245
options.scale = [[UIScreen mainScreen] scale];
240246

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

243-
244-
[self takeMapSnapshot:mapView withSnapshotter:snapshotter withCallback:callback];
245-
249+
[self takeMapSnapshot:mapView
250+
snapshotter:snapshotter
251+
format:format
252+
quality:quality.floatValue
253+
result:result
254+
callback:callback];
246255
}
247256
}];
248257
}
249258

250259
#pragma mark Take Snapshot
251260
- (void)takeMapSnapshot:(AIRMap *)mapView
252-
withSnapshotter:(MKMapSnapshotter *) snapshotter
253-
withCallback:(RCTResponseSenderBlock) callback {
261+
snapshotter:(MKMapSnapshotter *) snapshotter
262+
format:(NSString *)format
263+
quality:(CGFloat) quality
264+
result:(NSString *)result
265+
callback:(RCTResponseSenderBlock) callback {
254266
NSTimeInterval timeStamp = [[NSDate date] timeIntervalSince1970];
255-
NSString *pathComponent = [NSString stringWithFormat:@"Documents/snapshot-%.20lf.png", timeStamp];
267+
NSString *pathComponent = [NSString stringWithFormat:@"Documents/snapshot-%.20lf.%@", timeStamp, format];
256268
NSString *filePath = [NSHomeDirectory() stringByAppendingPathComponent: pathComponent];
257269

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

295307
UIImage *compositeImage = UIGraphicsGetImageFromCurrentImageContext();
296308

297-
NSData *data = UIImagePNGRepresentation(compositeImage);
298-
[data writeToFile:filePath atomically:YES];
299-
NSDictionary *snapshotData = @{
300-
@"uri": filePath,
301-
@"data": [data base64EncodedStringWithOptions:NSDataBase64EncodingEndLineWithCarriageReturn]
302-
};
303-
callback(@[[NSNull null], snapshotData]);
309+
NSData *data;
310+
if ([format isEqualToString:@"png"]) {
311+
data = UIImagePNGRepresentation(compositeImage);
312+
}
313+
else if([format isEqualToString:@"jpg"]) {
314+
data = UIImageJPEGRepresentation(compositeImage, quality);
315+
}
316+
317+
if ([result isEqualToString:@"file"]) {
318+
[data writeToFile:filePath atomically:YES];
319+
callback(@[[NSNull null], filePath]);
320+
}
321+
else if ([result isEqualToString:@"base64"]) {
322+
callback(@[[NSNull null], [data base64EncodedStringWithOptions:NSDataBase64EncodingEndLineWithCarriageReturn]]);
323+
}
324+
else if ([result isEqualToString:@"legacy"]) {
325+
326+
// In the initial (iOS only) implementation of takeSnapshot,
327+
// both the uri and the base64 encoded string were returned.
328+
// Returning both is rarely useful and in fact causes a
329+
// performance penalty when only the file URI is desired.
330+
// In that case the base64 encoded string was always marshalled
331+
// over the JS-bridge (which is quite slow).
332+
// A new more flexible API was created to cover this.
333+
// This code should be removed in a future release when the
334+
// old API is fully deprecated.
335+
[data writeToFile:filePath atomically:YES];
336+
NSDictionary *snapshotData = @{
337+
@"uri": filePath,
338+
@"data": [data base64EncodedStringWithOptions:NSDataBase64EncodingEndLineWithCarriageReturn]
339+
};
340+
callback(@[[NSNull null], snapshotData]);
341+
}
304342
}
305343
UIGraphicsEndImageContext();
306344
}];

0 commit comments

Comments
 (0)