From 16818841ae39f2e7bfda2be769797125574988ee Mon Sep 17 00:00:00 2001 From: Julian Bissekkou <36447137+JulianBissekkou@users.noreply.github.com> Date: Thu, 22 Aug 2024 07:08:37 +0200 Subject: [PATCH] image-export (#77) * implement image-export * add layer status observation * remove map status observeration * implement image export on android * update readme * update docs --- arcgis_map_sdk/README.md | 1 + .../lib/src/arcgis_map_controller.dart | 6 + .../arcgis_map_sdk_android/ArcgisMapView.kt | 36 ++++- .../ios/Classes/ArcgisMapView.swift | 17 +++ .../src/method_channel_arcgis_map_plugin.dart | 7 + .../arcgis_map_sdk_platform_interface.dart | 4 + example/lib/export_image_example_page.dart | 128 ++++++++++++++++++ example/lib/main.dart | 11 ++ 8 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 example/lib/export_image_example_page.dart diff --git a/arcgis_map_sdk/README.md b/arcgis_map_sdk/README.md index 730dc5b7..613f8c94 100644 --- a/arcgis_map_sdk/README.md +++ b/arcgis_map_sdk/README.md @@ -109,6 +109,7 @@ Checkout the example app `example/lib/main.dart` for more details. | toggleBaseMap | ✅ | ✅ | ✅ | | moveCamera | ✅ | ✅ | ✅ | | moveCameraToPoints | | ✅ | ✅ | +| exportImage | | ✅ | ✅ | | zoomIn | ✅ | ✅ | ✅ | | zoomOut | ✅ | ✅ | ✅ | | getZoom | ✅ | ✅ | ✅ | diff --git a/arcgis_map_sdk/lib/src/arcgis_map_controller.dart b/arcgis_map_sdk/lib/src/arcgis_map_controller.dart index 5ed62cf8..fbfee84a 100644 --- a/arcgis_map_sdk/lib/src/arcgis_map_controller.dart +++ b/arcgis_map_sdk/lib/src/arcgis_map_controller.dart @@ -52,6 +52,12 @@ class ArcgisMapController { ); } + /// Exports an image of the currently visible map view containing all + /// layers of that view. + Future exportImage() { + return ArcgisMapPlatform.instance.exportImage(mapId); + } + Future addGraphicsLayer({ required String layerId, required GraphicsLayerOptions options, diff --git a/arcgis_map_sdk_android/android/src/main/kotlin/dev/fluttercommunity/arcgis_map_sdk_android/ArcgisMapView.kt b/arcgis_map_sdk_android/android/src/main/kotlin/dev/fluttercommunity/arcgis_map_sdk_android/ArcgisMapView.kt index b6cc4036..f8a23270 100644 --- a/arcgis_map_sdk_android/android/src/main/kotlin/dev/fluttercommunity/arcgis_map_sdk_android/ArcgisMapView.kt +++ b/arcgis_map_sdk_android/android/src/main/kotlin/dev/fluttercommunity/arcgis_map_sdk_android/ArcgisMapView.kt @@ -1,6 +1,7 @@ package dev.fluttercommunity.arcgis_map_sdk_android import android.content.Context +import android.graphics.Bitmap import android.view.LayoutInflater import android.view.View import com.esri.arcgisruntime.ArcGISRuntimeEnvironment @@ -39,6 +40,7 @@ import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.platform.PlatformView +import java.io.ByteArrayOutputStream import kotlin.math.exp import kotlin.math.ln import kotlin.math.roundToInt @@ -188,6 +190,8 @@ internal class ArcgisMapView( result ) + "export_image" -> onExportImage(result) + else -> result.notImplemented() } } @@ -517,6 +521,20 @@ internal class ArcgisMapView( } } + private fun onExportImage(result: MethodChannel.Result) { + result.finishWithFuture( + mapResult = { bitmap -> + val stream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) + val byteArray = stream.toByteArray() + bitmap.recycle() + byteArray + }, + getFuture = { mapView.exportImageAsync() } + ) + + } + /** * Convert map scale to zoom level * https://developers.arcgis.com/documentation/mapping-apis-and-services/reference/zoom-levels-and-scale/#conversion-tool @@ -561,13 +579,23 @@ internal class ArcgisMapView( // region helper methods - private fun MethodChannel.Result.finishWithFuture(function: () -> ListenableFuture<*>) { + /** + * Safely awaits the provide future and respond to the MethodChannel with the result + * or an error. + * + * @param mapResult optional transformation of the returned value of the future. If null will default to Boolean true. + * @param getFuture A callback that returns the future that will be awaited. This invocation is also caught. + */ + private fun MethodChannel.Result.finishWithFuture( + mapResult: (T) -> Any = { _ -> true }, + getFuture: () -> ListenableFuture + ) { try { - val future = function() + val future = getFuture() future.addDoneListener { try { - future.get() - success(true) + val result = future.get() + success(mapResult(result)) } catch (e: Throwable) { finishWithError(e) } diff --git a/arcgis_map_sdk_ios/ios/Classes/ArcgisMapView.swift b/arcgis_map_sdk_ios/ios/Classes/ArcgisMapView.swift index 44b0ea7c..4e116db9 100644 --- a/arcgis_map_sdk_ios/ios/Classes/ArcgisMapView.swift +++ b/arcgis_map_sdk_ios/ios/Classes/ArcgisMapView.swift @@ -88,6 +88,7 @@ class ArcgisMapView: NSObject, FlutterPlatformView { } map.basemap = AGSBasemap(baseLayers: layers, referenceLayers: nil) } + map.minScale = convertZoomLevelToMapScale(mapOptions.minZoom) map.maxScale = convertZoomLevelToMapScale(mapOptions.maxZoom) @@ -158,6 +159,7 @@ class ArcgisMapView: NSObject, FlutterPlatformView { case "location_display_update_display_source_position_manually" : onUpdateLocationDisplaySourcePositionManually(call, result) case "location_display_set_data_source_type" : onSetLocationDisplayDataSourceType(call, result) case "update_is_attribution_text_visible": onUpdateIsAttributionTextVisible(call, result) + case "export_image" : onExportImage(result) default: result(FlutterError(code: "Unimplemented", message: "No method matching the name \(call.method)", details: nil)) } @@ -516,6 +518,21 @@ class ArcgisMapView: NSObject, FlutterPlatformView { mapView.isAttributionTextVisible = isVisible result(true) } + + private func onExportImage(_ result: @escaping FlutterResult) { + mapView.exportImage { image, error in + if let error = error { + result(FlutterError(code: "export_error", message: error.localizedDescription, details: nil)) + return + } + + if let image = image, let imageData = image.pngData() { + result(FlutterStandardTypedData(bytes: imageData)) + } else { + result(FlutterError(code: "conversion_error", message: "Failed to convert image to PNG data", details: nil)) + } + } + } private func operationWithSymbol(_ call: FlutterMethodCall, _ result: @escaping FlutterResult, handler: (AGSSymbol) -> Void) { do { diff --git a/arcgis_map_sdk_method_channel/lib/src/method_channel_arcgis_map_plugin.dart b/arcgis_map_sdk_method_channel/lib/src/method_channel_arcgis_map_plugin.dart index 0eb07479..1f12134f 100644 --- a/arcgis_map_sdk_method_channel/lib/src/method_channel_arcgis_map_plugin.dart +++ b/arcgis_map_sdk_method_channel/lib/src/method_channel_arcgis_map_plugin.dart @@ -29,6 +29,13 @@ class MethodChannelArcgisMapPlugin extends ArcgisMapPlatform { throw UnimplementedError('addFeatureLayer() has not been implemented.'); } + @override + Future exportImage(int mapId) { + return _methodChannelBuilder(mapId) + .invokeMethod("export_image") + .then((value) => value!); + } + @override void setMouseCursor(SystemMouseCursor cursor, int mapId) { throw UnimplementedError('setMouseCursor() has not been implemented'); diff --git a/arcgis_map_sdk_platform_interface/lib/src/arcgis_map_sdk_platform_interface.dart b/arcgis_map_sdk_platform_interface/lib/src/arcgis_map_sdk_platform_interface.dart index 7a8a9092..f78f7bff 100644 --- a/arcgis_map_sdk_platform_interface/lib/src/arcgis_map_sdk_platform_interface.dart +++ b/arcgis_map_sdk_platform_interface/lib/src/arcgis_map_sdk_platform_interface.dart @@ -22,6 +22,10 @@ class ArcgisMapPlatform extends PlatformInterface { throw UnimplementedError('init() has not been implemented.'); } + Future exportImage(int mapId) { + throw UnimplementedError('exportImage() has not been implemented.'); + } + Future addFeatureLayer( FeatureLayerOptions options, List? data, diff --git a/example/lib/export_image_example_page.dart b/example/lib/export_image_example_page.dart new file mode 100644 index 00000000..968adf40 --- /dev/null +++ b/example/lib/export_image_example_page.dart @@ -0,0 +1,128 @@ +import 'dart:typed_data'; + +import 'package:arcgis_example/main.dart'; +import 'package:arcgis_map_sdk/arcgis_map_sdk.dart'; +import 'package:flutter/material.dart'; + +class ExportImageExamplePage extends StatefulWidget { + const ExportImageExamplePage({super.key}); + + @override + State createState() => _ExportImageExamplePageState(); +} + +class _ExportImageExamplePageState extends State { + final _snackBarKey = GlobalKey(); + ArcgisMapController? _controller; + + Uint8List? _imageBytes; + final initialCenter = const LatLng(51.16, 10.45); + late final start = initialCenter; + final end = const LatLng(51.16551, 10.45221); + + @override + Widget build(BuildContext context) { + return Scaffold( + key: _snackBarKey, + appBar: AppBar(), + floatingActionButton: FloatingActionButton( + child: Icon(Icons.refresh), + onPressed: () async { + try { + final image = await _controller!.exportImage(); + if (!mounted) return; + setState(() => _imageBytes = image); + } catch (e, stack) { + if (!mounted) return; + ScaffoldMessenger.of(_snackBarKey.currentContext!) + .showSnackBar(SnackBar(content: Text("$e"))); + debugPrint("$e"); + debugPrintStack(stackTrace: stack); + } + }, + ), + body: Column( + children: [ + Expanded( + child: ArcgisMap( + apiKey: arcGisApiKey, + initialCenter: initialCenter, + zoom: 12, + basemap: BaseMap.arcgisNavigationNight, + mapStyle: MapStyle.twoD, + onMapCreated: (controller) { + _controller = controller; + + controller.addGraphic( + layerId: "pin", + graphic: PointGraphic( + longitude: initialCenter.longitude, + latitude: initialCenter.latitude, + height: 20, + attributes: Attributes({ + 'id': "pin1", + 'name': "pin1", + 'family': 'Pins', + }), + symbol: PictureMarkerSymbol( + assetUri: 'assets/navPointer.png', + width: 56, + height: 56, + ), + ), + ); + + controller.addGraphic( + layerId: "line", + graphic: PolylineGraphic( + paths: [ + [ + [start.longitude, start.latitude, 10.0], + [end.longitude, end.latitude, 10.0], + ] + ], + symbol: const SimpleLineSymbol( + color: Colors.purple, + style: PolylineStyle.shortDashDotDot, + width: 3, + marker: LineSymbolMarker( + color: Colors.green, + colorOpacity: 1, + style: MarkerStyle.circle, + ), + ), + attributes: Attributes({'id': "line-1", 'name': "line-1"}), + ), + ); + }, + ), + ), + const Divider(), + Text( + _imageBytes == null + ? "Press the button to generate an image" + : "This image is a screenshot of the mapview! ⬇️", + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 8), + Expanded( + child: _imageBytes == null + ? SizedBox() + : Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor, + borderRadius: BorderRadius.circular(12), + ), + child: ClipRRect( + child: Image.memory(_imageBytes!), + borderRadius: BorderRadius.circular(12), + ), + ), + ), + SizedBox(height: MediaQuery.paddingOf(context).bottom), + ], + ), + ); + } +} diff --git a/example/lib/main.dart b/example/lib/main.dart index 974d7627..741444ee 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:core'; +import 'package:arcgis_example/export_image_example_page.dart'; import 'package:arcgis_example/location_indicator_example_page.dart'; import 'package:arcgis_example/map_elements.dart'; import 'package:arcgis_example/vector_layer_example_page.dart'; @@ -515,6 +516,10 @@ class _ExampleMapState extends State { onPressed: _routeToVectorLayerMap, child: const Text("Show Vector layer example"), ), + ElevatedButton( + onPressed: _routeToExportImageExample, + child: const Text("Show export image example"), + ), ElevatedButton( onPressed: _routeToLocationIndicatorExample, child: const Text("Location indicator example"), @@ -808,4 +813,10 @@ class _ExampleMapState extends State { MaterialPageRoute(builder: (_) => const LocationIndicatorExamplePage()), ); } + + void _routeToExportImageExample() { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ExportImageExamplePage()), + ); + } }