diff --git a/CHANGELOG.md b/CHANGELOG.md index f793020..54787ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 1.5.0 + +* Added `MapElevationStyle` to support realistic 3D elevation on iOS 16+. +* Allow zoom level 0 so maps can zoom farther out toward globe-style views. + ## 1.4.0 * Flutter 3.27.1 compatibility, replace `ui.hash*` with `Object.hash* diff --git a/ios/Classes/MapView/FlutterMapView.swift b/ios/Classes/MapView/FlutterMapView.swift index 1e733ed..1cf5955 100644 --- a/ios/Classes/MapView/FlutterMapView.swift +++ b/ios/Classes/MapView/FlutterMapView.swift @@ -20,6 +20,9 @@ class FlutterMapView: MKMapView, UIGestureRecognizerDelegate { var oldBounds: CGRect? var options: Dictionary? var isMyLocationButtonShowing: Bool? = false + var currentMapType: Int = 0 + var currentMapElevationStyle: Int = 0 + var currentTrafficEnabled: Bool = false fileprivate let locationManager: CLLocationManager = CLLocationManager() @@ -141,16 +144,24 @@ class FlutterMapView: MKMapView, UIGestureRecognizerDelegate { self.layoutMargins = margins } + var shouldUpdateMapConfiguration = false if let mapType: Int = options["mapType"] as? Int { - self.mapType = self.mapTypes[mapType] + self.currentMapType = mapType + shouldUpdateMapConfiguration = true + } + + if let mapElevationStyle: Int = options["mapElevationStyle"] as? Int { + self.currentMapElevationStyle = mapElevationStyle + shouldUpdateMapConfiguration = true } if let trafficEnabled: Bool = options["trafficEnabled"] as? Bool { - if #available(iOS 9.0, *) { - self.showsTraffic = trafficEnabled - } else { - // do nothing - } + self.currentTrafficEnabled = trafficEnabled + shouldUpdateMapConfiguration = true + } + + if shouldUpdateMapConfiguration { + self.applyMapConfiguration() } if let rotateGesturesEnabled: Bool = options["rotateGesturesEnabled"] as? Bool { @@ -203,6 +214,36 @@ class FlutterMapView: MKMapView, UIGestureRecognizerDelegate { } + private func applyMapConfiguration() { + guard self.currentMapType >= 0 && self.currentMapType < self.mapTypes.count else { + return + } + + if #available(iOS 16.0, *) { + let elevationStyle: MKMapConfiguration.ElevationStyle = self.currentMapElevationStyle == 1 ? .realistic : .flat + + switch self.currentMapType { + case 0: + let configuration = MKStandardMapConfiguration(elevationStyle: elevationStyle) + configuration.showsTraffic = self.currentTrafficEnabled + self.preferredConfiguration = configuration + case 1: + self.preferredConfiguration = MKImageryMapConfiguration(elevationStyle: elevationStyle) + case 2: + let configuration = MKHybridMapConfiguration(elevationStyle: elevationStyle) + configuration.showsTraffic = self.currentTrafficEnabled + self.preferredConfiguration = configuration + default: + self.mapType = self.mapTypes[self.currentMapType] + } + } else { + self.mapType = self.mapTypes[self.currentMapType] + if #available(iOS 9.0, *) { + self.showsTraffic = self.currentTrafficEnabled + } + } + } + func setUserLocation() { let authorizationStatus = CLLocationManager.authorizationStatus() diff --git a/ios/Classes/MapView/MapViewExtension.swift b/ios/Classes/MapView/MapViewExtension.swift index 1105a3b..e3f4b7f 100644 --- a/ios/Classes/MapView/MapViewExtension.swift +++ b/ios/Classes/MapView/MapViewExtension.swift @@ -16,7 +16,7 @@ public extension MKMapView { static var _pitch: CGFloat = CGFloat(0) static var _heading: CLLocationDirection = CLLocationDirection(0) static var _maxZoomLevel: Double = Double(21) - static var _minZoomLevel: Double = Double(2) + static var _minZoomLevel: Double = Double(0) } var maxZoomLevel: Double { @@ -239,12 +239,8 @@ public extension MKMapView { } func zoomOut(animated: Bool) { - if Holder._zoomLevel - 1 >= Holder._minZoomLevel { - Holder._zoomLevel -= 1 - if round(Holder._zoomLevel) <= 2 { - Holder._zoomLevel = 0 - } - + if Holder._zoomLevel > Holder._minZoomLevel { + Holder._zoomLevel = Swift.max(Holder._minZoomLevel, Holder._zoomLevel - 1) if #available(iOS 9.0, *) { self.setCenterCoordinateWithAltitude(centerCoordinate: centerCoordinate, zoomLevel: Holder._zoomLevel, animated: animated) } else { diff --git a/lib/src/apple_map.dart b/lib/src/apple_map.dart index 793a269..71c8b1e 100644 --- a/lib/src/apple_map.dart +++ b/lib/src/apple_map.dart @@ -23,6 +23,7 @@ class AppleMap extends StatefulWidget { this.compassEnabled = true, this.trafficEnabled = false, this.mapType = MapType.standard, + this.mapElevationStyle = MapElevationStyle.flat, this.minMaxZoomPreference = MinMaxZoomPreference.unbounded, this.trackingMode = TrackingMode.none, this.rotateGesturesEnabled = true, @@ -59,6 +60,9 @@ class AppleMap extends StatefulWidget { /// Type of map tiles to be rendered. final MapType mapType; + /// The elevation style used by the native Apple map. + final MapElevationStyle mapElevationStyle; + /// The mode used to track the user location. final TrackingMode trackingMode; @@ -204,7 +208,8 @@ class _AppleMapState extends State { ); } return Text( - '$defaultTargetPlatform is not yet supported by the apple maps plugin'); + '$defaultTargetPlatform is not yet supported by the apple maps plugin', + ); } @override @@ -229,8 +234,9 @@ class _AppleMapState extends State { void _updateOptions() async { final _AppleMapOptions newOptions = _AppleMapOptions.fromWidget(widget); - final Map updates = - _appleMapOptions.updatesMap(newOptions); + final Map updates = _appleMapOptions.updatesMap( + newOptions, + ); if (updates.isEmpty) { return; } @@ -241,15 +247,17 @@ class _AppleMapState extends State { void _updateAnnotations() async { final AppleMapController controller = await _controller.future; - controller._updateAnnotations(_AnnotationUpdates.from( - _annotations.values.toSet(), widget.annotations)); + controller._updateAnnotations( + _AnnotationUpdates.from(_annotations.values.toSet(), widget.annotations), + ); _annotations = _keyByAnnotationId(widget.annotations); } void _updatePolylines() async { final AppleMapController controller = await _controller.future; controller._updatePolylines( - _PolylineUpdates.from(_polylines.values.toSet(), widget.polylines)); + _PolylineUpdates.from(_polylines.values.toSet(), widget.polylines), + ); _polylines = _keyByPolylineId(widget.polylines); } @@ -257,7 +265,8 @@ class _AppleMapState extends State { final AppleMapController controller = await _controller.future; // ignore: unawaited_futures controller._updatePolygons( - _PolygonUpdates.from(_polygons.values.toSet(), widget.polygons)); + _PolygonUpdates.from(_polygons.values.toSet(), widget.polygons), + ); _polygons = _keyByPolygonId(widget.polygons); } @@ -265,7 +274,8 @@ class _AppleMapState extends State { final AppleMapController controller = await _controller.future; // ignore: unawaited_futures controller._updateCircles( - _CircleUpdates.from(_circles.values.toSet(), widget.circles)); + _CircleUpdates.from(_circles.values.toSet(), widget.circles), + ); _circles = _keyByCircleId(widget.circles); } @@ -332,6 +342,7 @@ class _AppleMapOptions { this.compassEnabled, this.trafficEnabled, this.mapType, + this.mapElevationStyle, this.minMaxZoomPreference, this.rotateGesturesEnabled, this.scrollGesturesEnabled, @@ -349,6 +360,7 @@ class _AppleMapOptions { compassEnabled: map.compassEnabled, trafficEnabled: map.trafficEnabled, mapType: map.mapType, + mapElevationStyle: map.mapElevationStyle, minMaxZoomPreference: map.minMaxZoomPreference, rotateGesturesEnabled: map.rotateGesturesEnabled, scrollGesturesEnabled: map.scrollGesturesEnabled, @@ -368,6 +380,8 @@ class _AppleMapOptions { final MapType? mapType; + final MapElevationStyle? mapElevationStyle; + final MinMaxZoomPreference? minMaxZoomPreference; final bool? rotateGesturesEnabled; @@ -400,6 +414,7 @@ class _AppleMapOptions { addIfNonNull('compassEnabled', compassEnabled); addIfNonNull('trafficEnabled', trafficEnabled); addIfNonNull('mapType', mapType?.index); + addIfNonNull('mapElevationStyle', mapElevationStyle?.index); addIfNonNull('minMaxZoomPreference', minMaxZoomPreference?._toJson()); addIfNonNull('rotateGesturesEnabled', rotateGesturesEnabled); addIfNonNull('scrollGesturesEnabled', scrollGesturesEnabled); @@ -410,16 +425,18 @@ class _AppleMapOptions { addIfNonNull('myLocationButtonEnabled', myLocationButtonEnabled); addIfNonNull('padding', _serializePadding(padding)); addIfNonNull( - 'insetsLayoutMarginsFromSafeArea', insetsLayoutMarginsFromSafeArea); + 'insetsLayoutMarginsFromSafeArea', + insetsLayoutMarginsFromSafeArea, + ); return optionsMap; } Map updatesMap(_AppleMapOptions newOptions) { final Map prevOptionsMap = toMap(); - return newOptions.toMap() - ..removeWhere( - (String key, dynamic value) => prevOptionsMap[key] == value); + return newOptions.toMap()..removeWhere( + (String key, dynamic value) => prevOptionsMap[key] == value, + ); } List? _serializePadding(EdgeInsets? insets) { diff --git a/lib/src/ui.dart b/lib/src/ui.dart index 71c00a5..6e4ed19 100644 --- a/lib/src/ui.dart +++ b/lib/src/ui.dart @@ -16,6 +16,19 @@ enum MapType { hybrid, } +/// Controls whether MapKit renders the map on flat terrain or with realistic +/// 3D elevation where the device and map data support it. +enum MapElevationStyle { + /// Flat, 2D terrain. + flat, + + /// Realistic, 3D terrain. + /// + /// This maps to MapKit's iOS 16+ realistic elevation configuration on iOS. + /// Unsupported devices or areas may fall back to flat terrain. + realistic, +} + enum TrackingMode { // the user's location is not followed none, @@ -63,7 +76,7 @@ class CameraTargetBounds { class MinMaxZoomPreference { const MinMaxZoomPreference(this.minZoom, this.maxZoom) - : assert(minZoom == null || maxZoom == null || minZoom <= maxZoom); + : assert(minZoom == null || maxZoom == null || minZoom <= maxZoom); /// The preferred minimum zoom level or null, if unbounded from below. final double? minZoom; @@ -72,8 +85,10 @@ class MinMaxZoomPreference { final double? maxZoom; /// Unbounded zooming. - static const MinMaxZoomPreference unbounded = - MinMaxZoomPreference(null, null); + static const MinMaxZoomPreference unbounded = MinMaxZoomPreference( + null, + null, + ); dynamic _toJson() => [minZoom, maxZoom]; diff --git a/pubspec.yaml b/pubspec.yaml index 8fb876c..d351508 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: apple_maps_flutter description: This plugin uses the Flutter platform view to display an Apple Maps widget. -version: 1.4.0 +version: 1.5.0 homepage: https://luisthein.de repository: https://github.com/LuisThein/apple_maps_flutter issue_tracker: https://github.com/LuisThein/apple_maps_flutter/issues diff --git a/test/apple_map_test.dart b/test/apple_map_test.dart index 9e7222a..8d0ccbc 100644 --- a/test/apple_map_test.dart +++ b/test/apple_map_test.dart @@ -17,7 +17,8 @@ void main() { setUpAll(() { SystemChannels.platform_views.setMockMethodCallHandler( - fakePlatformViewsController.fakePlatformViewsMethodHandler); + fakePlatformViewsController.fakePlatformViewsMethodHandler, + ); }); setUp(() { @@ -38,13 +39,16 @@ void main() { final FakePlatformAppleMap platformAppleMap = fakePlatformViewsController.lastCreatedView!; - expect(platformAppleMap.cameraPosition, - const CameraPosition(target: LatLng(10.0, 15.0))); + expect( + platformAppleMap.cameraPosition, + const CameraPosition(target: LatLng(10.0, 15.0)), + ); debugDefaultTargetPlatformOverride = null; }); - testWidgets('Initial camera position change is a no-op', - (WidgetTester tester) async { + testWidgets('Initial camera position change is a no-op', ( + WidgetTester tester, + ) async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; await tester.pumpWidget( const Directionality( @@ -67,8 +71,10 @@ void main() { final FakePlatformAppleMap platformAppleMap = fakePlatformViewsController.lastCreatedView!; - expect(platformAppleMap.cameraPosition, - const CameraPosition(target: LatLng(10.0, 15.0))); + expect( + platformAppleMap.cameraPosition, + const CameraPosition(target: LatLng(10.0, 15.0)), + ); debugDefaultTargetPlatformOverride = null; }); @@ -134,6 +140,37 @@ void main() { debugDefaultTargetPlatformOverride = null; }); + testWidgets('Can update mapElevationStyle', (WidgetTester tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: AppleMap( + initialCameraPosition: CameraPosition(target: LatLng(10.0, 15.0)), + mapElevationStyle: MapElevationStyle.realistic, + ), + ), + ); + + final FakePlatformAppleMap platformAppleMap = + fakePlatformViewsController.lastCreatedView!; + + expect(platformAppleMap.mapElevationStyle, MapElevationStyle.realistic); + + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: AppleMap( + initialCameraPosition: CameraPosition(target: LatLng(10.0, 15.0)), + mapElevationStyle: MapElevationStyle.flat, + ), + ), + ); + + expect(platformAppleMap.mapElevationStyle, MapElevationStyle.flat); + debugDefaultTargetPlatformOverride = null; + }); + testWidgets('Can update minMaxZoom', (WidgetTester tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; await tester.pumpWidget( @@ -149,8 +186,10 @@ void main() { final FakePlatformAppleMap platformAppleMap = fakePlatformViewsController.lastCreatedView!; - expect(platformAppleMap.minMaxZoomPreference, - const MinMaxZoomPreference(1.0, 3.0)); + expect( + platformAppleMap.minMaxZoomPreference, + const MinMaxZoomPreference(1.0, 3.0), + ); await tester.pumpWidget( const Directionality( @@ -163,7 +202,9 @@ void main() { ); expect( - platformAppleMap.minMaxZoomPreference, MinMaxZoomPreference.unbounded); + platformAppleMap.minMaxZoomPreference, + MinMaxZoomPreference.unbounded, + ); debugDefaultTargetPlatformOverride = null; }); @@ -322,8 +363,9 @@ void main() { debugDefaultTargetPlatformOverride = null; }); - testWidgets('Can update myLocationButtonEnabled', - (WidgetTester tester) async { + testWidgets('Can update myLocationButtonEnabled', ( + WidgetTester tester, + ) async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; await tester.pumpWidget( const Directionality( diff --git a/test/fake_maps_controllers.dart b/test/fake_maps_controllers.dart index fa992ac..af36146 100644 --- a/test/fake_maps_controllers.dart +++ b/test/fake_maps_controllers.dart @@ -11,8 +11,10 @@ import 'package:flutter_test/flutter_test.dart'; class FakePlatformAppleMap { FakePlatformAppleMap(int id, Map params) { cameraPosition = CameraPosition.fromMap(params['initialCameraPosition']); - channel = MethodChannel('apple_maps_plugin.luisthein.de/apple_maps_$id', - const StandardMethodCodec()); + channel = MethodChannel( + 'apple_maps_plugin.luisthein.de/apple_maps_$id', + const StandardMethodCodec(), + ); channel.setMockMethodCallHandler(onMethodCall); updateOptions(params['options']); updatePolylines(params); @@ -29,6 +31,8 @@ class FakePlatformAppleMap { MapType? mapType; + MapElevationStyle? mapElevationStyle; + MinMaxZoomPreference? minMaxZoomPreference; bool? rotateGesturesEnabled; @@ -93,12 +97,15 @@ class FakePlatformAppleMap { if (annotationUpdates == null) { return; } - annotationsToAdd = - _deserializeAnnotations(annotationUpdates['annotationsToAdd']); - annotationIdsToRemove = - _deserializeAnnotationIds(annotationUpdates['annotationIdsToRemove']); - annotationsToChange = - _deserializeAnnotations(annotationUpdates['annotationsToChange']); + annotationsToAdd = _deserializeAnnotations( + annotationUpdates['annotationsToAdd'], + ); + annotationIdsToRemove = _deserializeAnnotationIds( + annotationUpdates['annotationIdsToRemove'], + ); + annotationsToChange = _deserializeAnnotations( + annotationUpdates['annotationsToChange'], + ); } Set _deserializeAnnotationIds(List? annotationIds) { @@ -134,11 +141,12 @@ class FakePlatformAppleMap { result.add( Annotation( - annotationId: AnnotationId(annotationId), - draggable: draggable, - visible: visible, - infoWindow: infoWindow, - alpha: alpha), + annotationId: AnnotationId(annotationId), + draggable: draggable, + visible: visible, + infoWindow: infoWindow, + alpha: alpha, + ), ); } @@ -150,10 +158,12 @@ class FakePlatformAppleMap { return; } polylinesToAdd = _deserializePolylines(polylineUpdates['polylinesToAdd']); - polylineIdsToRemove = - _deserializePolylineIds(polylineUpdates['polylineIdsToRemove']); - polylinesToChange = - _deserializePolylines(polylineUpdates['polylinesToChange']); + polylineIdsToRemove = _deserializePolylineIds( + polylineUpdates['polylineIdsToRemove'], + ); + polylinesToChange = _deserializePolylines( + polylineUpdates['polylinesToChange'], + ); } Set _deserializePolylineIds(List? polylineIds) { @@ -176,11 +186,13 @@ class FakePlatformAppleMap { final bool visible = polylineData['visible']; // final bool geodesic = polylineData['geodesic']; - result.add(Polyline( - polylineId: PolylineId(polylineId), - visible: visible, - // geodesic: geodesic, - )); + result.add( + Polyline( + polylineId: PolylineId(polylineId), + visible: visible, + // geodesic: geodesic, + ), + ); } return result; @@ -191,8 +203,9 @@ class FakePlatformAppleMap { return; } polygonsToAdd = _deserializePolygons(polygonUpdates['polygonsToAdd']); - polygonIdsToRemove = - _deserializePolygonIds(polygonUpdates['polygonIdsToRemove']); + polygonIdsToRemove = _deserializePolygonIds( + polygonUpdates['polygonIdsToRemove'], + ); polygonsToChange = _deserializePolygons(polygonUpdates['polygonsToChange']); } @@ -239,8 +252,9 @@ class FakePlatformAppleMap { return; } circlesToAdd = _deserializeCircles(circleUpdates['circlesToAdd']); - circleIdsToRemove = - _deserializeCircleIds(circleUpdates['circleIdsToRemove']); + circleIdsToRemove = _deserializeCircleIds( + circleUpdates['circleIdsToRemove'], + ); circlesToChange = _deserializeCircles(circleUpdates['circlesToChange']); } @@ -262,11 +276,9 @@ class FakePlatformAppleMap { final bool visible = circleData['visible']; final double radius = circleData['radius']; - result.add(Circle( - circleId: CircleId(circleId), - visible: visible, - radius: radius, - )); + result.add( + Circle(circleId: CircleId(circleId), visible: visible, radius: radius), + ); } return result; @@ -279,10 +291,16 @@ class FakePlatformAppleMap { if (options.containsKey('mapType')) { mapType = MapType.values[options['mapType']]; } + if (options.containsKey('mapElevationStyle')) { + mapElevationStyle = + MapElevationStyle.values[options['mapElevationStyle']]; + } if (options.containsKey('minMaxZoomPreference')) { final List minMaxZoomList = options['minMaxZoomPreference']; - minMaxZoomPreference = - MinMaxZoomPreference(minMaxZoomList[0], minMaxZoomList[1]); + minMaxZoomPreference = MinMaxZoomPreference( + minMaxZoomList[0], + minMaxZoomList[1], + ); } if (options.containsKey('rotateGesturesEnabled')) { rotateGesturesEnabled = options['rotateGesturesEnabled']; @@ -313,10 +331,7 @@ class FakePlatformViewsController { case 'create': final Map args = call.arguments; final Map params = _decodeParams(args['params']); - lastCreatedView = FakePlatformAppleMap( - args['id'], - params, - ); + lastCreatedView = FakePlatformAppleMap(args['id'], params); return Future.sync(() => 1); default: return Future.sync(() {});