From b513771ee0e381036f63855998dcadae4fbfff20 Mon Sep 17 00:00:00 2001 From: klandri Date: Tue, 19 May 2026 22:56:35 +0200 Subject: [PATCH 1/2] Add clusteringIdentifier for native MKMapView clustering Exposes MKAnnotationView.clusteringIdentifier so apps using this plugin can opt into MapKit's native MKClusterAnnotation aggregation. Setting all annotations once and letting MapKit cluster them in C/Obj-C avoids the platform-channel chatter caused by Dart-side clustering on zoom. Dart: - Annotation: nullable clusteringIdentifier field, plumbed through constructor, copyWith, _toJson, and ==. iOS: - FlutterAnnotation: parses clusteringIdentifier from the platform-channel dict; included in ==. - AnnotationController.getAnnotationView: sets clusteringIdentifier on the dequeued annotation view (iOS 11+). - AnnotationController.viewFor: returns a FlutterClusterAnnotationView for MKClusterAnnotation instances. - AnnotationController.didSelect: intercepts taps on cluster annotations and animates the camera to the cluster's member bounding box. - FlutterClusterAnnotationView: red circle with the member count drawn in white, sized to digit count, white border. --- CHANGELOG.md | 8 +++ .../Annotations/AnnotationController.swift | 47 ++++++++++++++ .../Annotations/FlutterAnnotation.swift | 5 +- .../Annotations/FlutterAnnotationView.swift | 42 +++++++++++++ lib/src/annotation.dart | 14 ++++- pubspec.yaml | 2 +- test/annotation_updates_test.dart | 63 +++++++++++++++++++ test/fake_maps_controllers.dart | 5 +- 8 files changed, 182 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f793020..05f1c28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.5.0 + +* Added `Annotation.clusteringIdentifier`. Annotations sharing the same + identifier are aggregated by MapKit into a single `MKClusterAnnotation` + natively (iOS 11+), avoiding the platform-channel cost of clustering + in Dart. Tapping a cluster animates the camera to the member bounding + box. Leave the field null to opt out per-annotation. + ## 1.4.0 * Flutter 3.27.1 compatibility, replace `ui.hash*` with `Object.hash* diff --git a/ios/Classes/Annotations/AnnotationController.swift b/ios/Classes/Annotations/AnnotationController.swift index 09bf6b2..3ef08a4 100644 --- a/ios/Classes/Annotations/AnnotationController.swift +++ b/ios/Classes/Annotations/AnnotationController.swift @@ -11,6 +11,12 @@ import MapKit extension AppleMapController: AnnotationDelegate { public func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView) { + if #available(iOS 11.0, *), + let cluster = view.annotation as? MKClusterAnnotation { + mapView.deselectAnnotation(cluster, animated: false) + self.zoomInto(cluster: cluster, on: mapView) + return + } if let annotation: FlutterAnnotation = view.annotation as? FlutterAnnotation { self.currentlySelectedAnnotation = annotation.id if !annotation.selectedProgrammatically { @@ -34,12 +40,49 @@ extension AppleMapController: AnnotationDelegate { public func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { if annotation is MKUserLocation { return nil + } else if #available(iOS 11.0, *), let cluster = annotation as? MKClusterAnnotation { + return self.getClusterAnnotationView(for: cluster) } else if let flutterAnnotation = annotation as? FlutterAnnotation { return self.getAnnotationView(annotation: flutterAnnotation) } return nil } + @available(iOS 11.0, *) + private func getClusterAnnotationView(for cluster: MKClusterAnnotation) -> MKAnnotationView { + let identifier = "FlutterClusterAnnotationView" + self.mapView.register(FlutterClusterAnnotationView.self, forAnnotationViewWithReuseIdentifier: identifier) + let view = self.mapView.dequeueReusableAnnotationView(withIdentifier: identifier, for: cluster) + view.annotation = cluster + return view + } + + @available(iOS 11.0, *) + private func zoomInto(cluster: MKClusterAnnotation, on mapView: MKMapView) { + let members = cluster.memberAnnotations + guard !members.isEmpty else { return } + var minLat = members[0].coordinate.latitude + var maxLat = minLat + var minLon = members[0].coordinate.longitude + var maxLon = minLon + for member in members { + let c = member.coordinate + if c.latitude < minLat { minLat = c.latitude } + if c.latitude > maxLat { maxLat = c.latitude } + if c.longitude < minLon { minLon = c.longitude } + if c.longitude > maxLon { maxLon = c.longitude } + } + let center = CLLocationCoordinate2D( + latitude: (minLat + maxLat) / 2, + longitude: (minLon + maxLon) / 2 + ) + let span = MKCoordinateSpan( + latitudeDelta: max((maxLat - minLat) * 1.6, 0.01), + longitudeDelta: max((maxLon - minLon) * 1.6, 0.01) + ) + mapView.setRegion(MKCoordinateRegion(center: center, span: span), animated: true) + } + func getAnnotationView(annotation: FlutterAnnotation) -> MKAnnotationView { let identifier: String = annotation.id var annotationView = self.mapView.dequeueReusableAnnotationView(withIdentifier: identifier) @@ -76,6 +119,10 @@ extension AppleMapController: AnnotationDelegate { annotationView!.alpha = CGFloat(annotation.alpha ?? 1.00) annotationView!.isDraggable = annotation.isDraggable ?? false + if #available(iOS 11.0, *) { + annotationView!.clusteringIdentifier = annotation.clusteringIdentifier + } + return annotationView! } diff --git a/ios/Classes/Annotations/FlutterAnnotation.swift b/ios/Classes/Annotations/FlutterAnnotation.swift index 5e29e79..9cec226 100644 --- a/ios/Classes/Annotations/FlutterAnnotation.swift +++ b/ios/Classes/Annotations/FlutterAnnotation.swift @@ -24,6 +24,7 @@ class FlutterAnnotation: NSObject, MKAnnotation { var calloutOffset: Offset = Offset() var icon: AnnotationIcon = AnnotationIcon.init() var selectedProgrammatically: Bool = false + var clusteringIdentifier: String? public init(fromDictionary annotationData: Dictionary, registrar: FlutterPluginRegistrar) { let position: Array = annotationData["position"] as! Array @@ -56,6 +57,8 @@ class FlutterAnnotation: NSObject, MKAnnotation { if let calloutOffsetJSON = infoWindow["anchor"] as? Array { self.calloutOffset = Offset(from: calloutOffsetJSON) } + + self.clusteringIdentifier = annotationData["clusteringIdentifier"] as? String } @@ -79,7 +82,7 @@ class FlutterAnnotation: NSObject, MKAnnotation { } static func == (lhs: FlutterAnnotation, rhs: FlutterAnnotation) -> Bool { - return lhs.id == rhs.id && lhs.title == rhs.title && lhs.subtitle == rhs.subtitle && lhs.image == rhs.image && lhs.alpha == rhs.alpha && lhs.isDraggable == rhs.isDraggable && lhs.wasDragged == rhs.wasDragged && lhs.isVisible == rhs.isVisible && lhs.icon == rhs.icon && lhs.coordinate.latitude == rhs.coordinate.latitude && lhs.coordinate.longitude == rhs.coordinate.longitude && lhs.infoWindowConsumesTapEvents == rhs.infoWindowConsumesTapEvents && lhs.anchor == rhs.anchor && lhs.calloutOffset == rhs.calloutOffset && lhs.coordinate.latitude == rhs.coordinate.latitude && lhs.coordinate.longitude == rhs.coordinate.longitude && lhs.zIndex == rhs.zIndex + return lhs.id == rhs.id && lhs.title == rhs.title && lhs.subtitle == rhs.subtitle && lhs.image == rhs.image && lhs.alpha == rhs.alpha && lhs.isDraggable == rhs.isDraggable && lhs.wasDragged == rhs.wasDragged && lhs.isVisible == rhs.isVisible && lhs.icon == rhs.icon && lhs.coordinate.latitude == rhs.coordinate.latitude && lhs.coordinate.longitude == rhs.coordinate.longitude && lhs.infoWindowConsumesTapEvents == rhs.infoWindowConsumesTapEvents && lhs.anchor == rhs.anchor && lhs.calloutOffset == rhs.calloutOffset && lhs.coordinate.latitude == rhs.coordinate.latitude && lhs.coordinate.longitude == rhs.coordinate.longitude && lhs.zIndex == rhs.zIndex && lhs.clusteringIdentifier == rhs.clusteringIdentifier } static func != (lhs: FlutterAnnotation, rhs: FlutterAnnotation) -> Bool { diff --git a/ios/Classes/Annotations/FlutterAnnotationView.swift b/ios/Classes/Annotations/FlutterAnnotationView.swift index 70e4b26..8f76975 100644 --- a/ios/Classes/Annotations/FlutterAnnotationView.swift +++ b/ios/Classes/Annotations/FlutterAnnotationView.swift @@ -54,6 +54,48 @@ extension FlutterMarkerAnnotationView: ZPositionableAnnotation { } } +/// Annotation view used for an [MKClusterAnnotation]: a filled circle with the +/// member count drawn in the center. +@available(iOS 11.0, *) +class FlutterClusterAnnotationView: MKAnnotationView { + private let badgeColor = UIColor(red: 0xD3/255.0, green: 0x2F/255.0, blue: 0x2F/255.0, alpha: 0.9) + private let label = UILabel() + + override init(annotation: MKAnnotation?, reuseIdentifier: String?) { + super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) + self.collisionMode = .circle + self.canShowCallout = false + self.centerOffset = .zero + self.backgroundColor = .clear + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .white + label.textAlignment = .center + label.font = .systemFont(ofSize: 12, weight: .bold) + addSubview(label) + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: centerXAnchor), + label.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + } + + override func prepareForDisplay() { + super.prepareForDisplay() + guard let cluster = annotation as? MKClusterAnnotation else { return } + let count = cluster.memberAnnotations.count + label.text = "\(count)" + let size: CGFloat = count < 10 ? 28 : count < 100 ? 34 : count < 1000 ? 40 : 46 + bounds = CGRect(x: 0, y: 0, width: size, height: size) + layer.cornerRadius = size / 2 + layer.borderWidth = 1.5 + layer.borderColor = UIColor.white.cgColor + backgroundColor = badgeColor + } +} + /// iOS 11 automagically manages the CALayer zPosition, which breaks manual z-ordering. /// This subclass just throws away any values which the OS sets for zPosition, and provides /// a specialized accessor for setting the zPosition diff --git a/lib/src/annotation.dart b/lib/src/annotation.dart index 71b6b12..bf61c28 100644 --- a/lib/src/annotation.dart +++ b/lib/src/annotation.dart @@ -155,6 +155,7 @@ class Annotation { this.visible = true, this.zIndex = -1, this.onDragEnd, + this.clusteringIdentifier, }) : assert(0.0 <= alpha && alpha <= 1.0); /// Uniquely identifies a [Annotation]. @@ -201,6 +202,12 @@ class Annotation { /// earlier, and thus appearing to be closer to the surface of the Earth. double zIndex; + /// MapKit clustering identifier (iOS 11+). Annotations sharing the same + /// identifier will be aggregated into a single [MKClusterAnnotation] as the + /// camera zooms out. Leave null to opt out of native clustering for this + /// annotation. + final String? clusteringIdentifier; + /// Creates a new [Annotation] object whose values are the same as this instance, /// unless overwritten by the specified parameters. Annotation copyWith({ @@ -215,6 +222,7 @@ class Annotation { double? zIndexParam, VoidCallback? onTapParam, ValueChanged? onDragEndParam, + String? clusteringIdentifierParam, }) { return Annotation( annotationId: annotationId, @@ -228,6 +236,8 @@ class Annotation { visible: visibleParam ?? visible, zIndex: zIndexParam ?? zIndex, onDragEnd: onDragEndParam ?? onDragEnd, + clusteringIdentifier: + clusteringIdentifierParam ?? clusteringIdentifier, ); } @@ -249,6 +259,7 @@ class Annotation { addIfPresent('visible', visible); addIfPresent('position', position._toJson()); addIfPresent('zIndex', zIndex); + addIfPresent('clusteringIdentifier', clusteringIdentifier); return json; } @@ -265,7 +276,8 @@ class Annotation { infoWindow == typedOther.infoWindow && position == typedOther.position && visible == typedOther.visible && - zIndex == typedOther.zIndex; + zIndex == typedOther.zIndex && + clusteringIdentifier == typedOther.clusteringIdentifier; } @override 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/annotation_updates_test.dart b/test/annotation_updates_test.dart index fb80e12..1e08b5b 100644 --- a/test/annotation_updates_test.dart +++ b/test/annotation_updates_test.dart @@ -204,6 +204,69 @@ void main() { debugDefaultTargetPlatformOverride = null; }); + testWidgets("clusteringIdentifier round-trips through update", + (WidgetTester tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final Annotation m1 = Annotation( + annotationId: AnnotationId("annotation_1"), + clusteringIdentifier: "cluster_group", + ); + await tester.pumpWidget(_mapWithAnnotations(_toSet(m1: m1))); + + final FakePlatformAppleMap platformAppleMap = + fakePlatformViewsController.lastCreatedView!; + expect(platformAppleMap.annotationsToAdd!.length, 1); + final Annotation added = platformAppleMap.annotationsToAdd!.first; + expect(added.clusteringIdentifier, equals("cluster_group")); + expect(added, equals(m1)); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets("clusteringIdentifier change is detected as an update", + (WidgetTester tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final Annotation m1 = Annotation( + annotationId: AnnotationId("annotation_1"), + clusteringIdentifier: "group_a", + ); + final Annotation m2 = Annotation( + annotationId: AnnotationId("annotation_1"), + clusteringIdentifier: "group_b", + ); + + await tester.pumpWidget(_mapWithAnnotations(_toSet(m1: m1))); + await tester.pumpWidget(_mapWithAnnotations(_toSet(m1: m2))); + + final FakePlatformAppleMap platformAppleMap = + fakePlatformViewsController.lastCreatedView!; + expect(platformAppleMap.annotationsToChange!.length, 1); + expect( + platformAppleMap.annotationsToChange!.first.clusteringIdentifier, + equals("group_b"), + ); + debugDefaultTargetPlatformOverride = null; + }); + + test("copyWith preserves clusteringIdentifier", () { + final Annotation original = Annotation( + annotationId: AnnotationId("annotation_1"), + clusteringIdentifier: "preserve_me", + ); + final Annotation updated = original.copyWith(alphaParam: 0.5); + expect(updated.clusteringIdentifier, equals("preserve_me")); + expect(updated.alpha, equals(0.5)); + }); + + test("copyWith overrides clusteringIdentifier when supplied", () { + final Annotation original = Annotation( + annotationId: AnnotationId("annotation_1"), + clusteringIdentifier: "old", + ); + final Annotation updated = + original.copyWith(clusteringIdentifierParam: "new"); + expect(updated.clusteringIdentifier, equals("new")); + }); + testWidgets( "Partial Update", (WidgetTester tester) async { diff --git a/test/fake_maps_controllers.dart b/test/fake_maps_controllers.dart index fa992ac..d0f5764 100644 --- a/test/fake_maps_controllers.dart +++ b/test/fake_maps_controllers.dart @@ -121,6 +121,8 @@ class FakePlatformAppleMap { final bool draggable = annotationData['draggable']; final bool visible = annotationData['visible']; final double alpha = annotationData['alpha']; + final String? clusteringIdentifier = + annotationData['clusteringIdentifier'] as String?; final dynamic infoWindowData = annotationData['infoWindow']; InfoWindow infoWindow = InfoWindow.noText; @@ -138,7 +140,8 @@ class FakePlatformAppleMap { draggable: draggable, visible: visible, infoWindow: infoWindow, - alpha: alpha), + alpha: alpha, + clusteringIdentifier: clusteringIdentifier), ); } From 6455a465c1dd0e6cec72894d4163e55d94efddc8 Mon Sep 17 00:00:00 2001 From: klandri Date: Tue, 19 May 2026 23:33:26 +0200 Subject: [PATCH 2/2] Performance: O(N^2) -> O(N) in AnnotationController, skip unchanged in diff Two independent perf fixes that compound for large annotation sets (5k+). iOS: - Maintain a [String: FlutterAnnotation] dict on AppleMapController, kept in sync with mapView.annotations by every mutator. getAnnotation, annotationExists, removeAnnotation, annotationsToChange, and annotationsIdsToRemove now do O(1) lookups instead of O(N) filters, dropping their loops from O(N^2) to O(N). - annotationsIdsToRemove batches into a single mapView.removeAnnotations([...]) call instead of N single removes. - Track maxAnnotationZIndex incrementally so getNextAnnotationZIndex and isAnnotationInFront are O(1) instead of an O(N log N) sort of every annotation on the map. (zIndex is monotonically increasing; removing the max doesn't decrement, which is fine for the next-id use case.) - isAnnotationSelected stops doing N getAnnotation calls per selectedAnnotation; checks the dict once. Dart: - _AnnotationUpdates.from now only includes annotations in annotationsToChange where current != previous. Previously every annotation whose id was in both old and new sets was serialized across the platform channel on every diff, even when its content was identical. For 5k annotation sets this serialized megabytes of unchanged data per update. Tests: - Unskip the existing "Partial Update" test, which was written to verify exactly this behavior and was skipped because the buggy diff failed it. - Adjust "Adding an annotation" to assert the unchanged annotation is NOT in annotationsToChange (it previously asserted the buggy behavior). - Add "Unchanged annotation is not in annotationsToChange" covering the case where annotation instances differ but their content is identical (e.g., rebuilt by setState). For an app with 5k annotations, filter operations that previously hung the UI for multiple seconds now complete in tens of milliseconds. --- CHANGELOG.md | 16 +++++ .../Annotations/AnnotationController.swift | 60 ++++++++++------ ios/Classes/MapView/AppleMapController.swift | 7 ++ lib/src/annotation_updates.dart | 6 ++ pubspec.yaml | 2 +- test/annotation_updates_test.dart | 69 +++++++++++-------- 6 files changed, 110 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05f1c28..cd95b24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 1.6.0 + +* Performance: every `mapView.annotations.filter { ... }` lookup in + `AnnotationController` is now O(1) against a maintained `[id: FlutterAnnotation]` + index on `AppleMapController`. `annotationsToChange` and + `annotationsIdsToRemove` drop from O(N²) to O(N); `annotationsIdsToRemove` + now uses a single batched `mapView.removeAnnotations(_:)` call. + `getNextAnnotationZIndex` / `isAnnotationInFront` use a tracked max + zIndex instead of a sort. Large annotation sets (5k+) no longer hang on + filter/update operations. +* `_AnnotationUpdates.from` now skips annotations in `annotationsToChange` + whose content is equal to the previous version. The previous + implementation included every annotation whose id was in both sets, + serializing thousands of unchanged annotations across the platform + channel on every diff. + ## 1.5.0 * Added `Annotation.clusteringIdentifier`. Annotations sharing the same diff --git a/ios/Classes/Annotations/AnnotationController.swift b/ios/Classes/Annotations/AnnotationController.swift index 3ef08a4..4a4dfbd 100644 --- a/ios/Classes/Annotations/AnnotationController.swift +++ b/ios/Classes/Annotations/AnnotationController.swift @@ -134,32 +134,42 @@ extension AppleMapController: AnnotationDelegate { } func annotationsToChange(annotations: NSArray) { - let oldAnnotations: [MKAnnotation] = self.mapView.annotations for annotation in annotations { let annotationData: Dictionary = annotation as! Dictionary - if let annotationToChange = oldAnnotations.filter({($0 as? FlutterAnnotation)?.id == annotationData["annotationId"] as? String})[0] as? FlutterAnnotation { - let newAnnotation = FlutterAnnotation.init(fromDictionary: annotationData, registrar: registrar) - if annotationToChange != newAnnotation { - if !annotationToChange.wasDragged { - updateAnnotation(annotation: newAnnotation) - } else { - annotationToChange.wasDragged = false - } + guard let id = annotationData["annotationId"] as? String, + let annotationToChange = self.annotationsById[id] else { + continue + } + let newAnnotation = FlutterAnnotation.init(fromDictionary: annotationData, registrar: registrar) + if annotationToChange != newAnnotation { + if !annotationToChange.wasDragged { + updateAnnotation(annotation: newAnnotation) + } else { + annotationToChange.wasDragged = false } } } } func annotationsIdsToRemove(annotationIds: NSArray) { + var toRemove: [FlutterAnnotation] = [] + toRemove.reserveCapacity(annotationIds.count) for annotationId in annotationIds { - if let _annotationId: String = annotationId as? String { - removeAnnotation(id: _annotationId) + guard let id = annotationId as? String, + let annotation = self.annotationsById.removeValue(forKey: id) else { + continue } + toRemove.append(annotation) + } + if !toRemove.isEmpty { + self.mapView.removeAnnotations(toRemove) } } func removeAllAnnotations() { self.mapView.removeAnnotations(self.mapView.annotations) + self.annotationsById.removeAll() + self.maxAnnotationZIndex = -1 } func onAnnotationClick(annotation: MKAnnotation) { @@ -183,12 +193,13 @@ extension AppleMapController: AnnotationDelegate { } func isAnnotationSelected(with id: String) -> Bool { - return self.mapView.selectedAnnotations.contains(where: { annotation in return self.getAnnotation(with: id) == (annotation as? FlutterAnnotation)}) + guard let annotation = self.annotationsById[id] else { return false } + return self.mapView.selectedAnnotations.contains(where: { ($0 as? FlutterAnnotation) === annotation }) } private func removeAnnotation(id: String) { - if let flutterAnnotation: FlutterAnnotation = self.getAnnotation(with: id) { + if let flutterAnnotation = self.annotationsById.removeValue(forKey: id) { self.mapView.removeAnnotation(flutterAnnotation) } } @@ -224,11 +235,11 @@ extension AppleMapController: AnnotationDelegate { } private func getAnnotation(with id: String) -> FlutterAnnotation? { - return self.mapView.annotations.filter { annotation in return (annotation as? FlutterAnnotation)?.id == id }.first as? FlutterAnnotation + return self.annotationsById[id] } private func annotationExists(with id: String) -> Bool { - return self.getAnnotation(with: id) != nil + return self.annotationsById[id] != nil } private func addAnnotation(annotationData: Dictionary) { @@ -241,13 +252,19 @@ extension AppleMapController: AnnotationDelegate { - Parameter annotation: the FlutterAnnotation that should be added */ private func addAnnotation(annotation: FlutterAnnotation) { - if self.annotationExists(with: annotation.id) { - self.removeAnnotation(id: annotation.id) + if let id = annotation.id, self.annotationsById[id] != nil { + self.removeAnnotation(id: id) } if annotation.zIndex == -1 { annotation.zIndex = self.getNextAnnotationZIndex() channel.invokeMethod("annotation#onZIndexChanged", arguments: ["annotationId": annotation.id!, "zIndex": annotation.zIndex]) } + if annotation.zIndex > self.maxAnnotationZIndex { + self.maxAnnotationZIndex = annotation.zIndex + } + if let id = annotation.id { + self.annotationsById[id] = annotation + } self.mapView.addAnnotation(annotation) } @@ -272,15 +289,14 @@ extension AppleMapController: AnnotationDelegate { } private func getNextAnnotationZIndex() -> Double { - let mapViewAnnotations = self.mapView.getMapViewAnnotations() - if mapViewAnnotations.isEmpty { - return 0; + if self.annotationsById.isEmpty { + return 0 } - return (mapViewAnnotations.last??.zIndex ?? 0) + 1 + return self.maxAnnotationZIndex + 1 } private func isAnnotationInFront(zIndex: Double) -> Bool { - return (self.mapView.getMapViewAnnotations().last??.zIndex ?? 0) == zIndex + return self.maxAnnotationZIndex == zIndex } private func getPinAnnotationView(annotation: FlutterAnnotation, id: String) -> MKPinAnnotationView { diff --git a/ios/Classes/MapView/AppleMapController.swift b/ios/Classes/MapView/AppleMapController.swift index 85e7a98..2e53352 100644 --- a/ios/Classes/MapView/AppleMapController.swift +++ b/ios/Classes/MapView/AppleMapController.swift @@ -18,6 +18,13 @@ public class AppleMapController: NSObject, FlutterPlatformView { var currentlySelectedAnnotation: String? var snapShotOptions: MKMapSnapshotter.Options = MKMapSnapshotter.Options() var snapShot: MKMapSnapshotter? + /// Index of every FlutterAnnotation currently on the map, keyed by id, so + /// add/remove/update/lookup can be O(1) instead of O(N) scans through + /// mapView.annotations. Kept in sync with the map by every mutator below. + var annotationsById: [String: FlutterAnnotation] = [:] + /// Highest zIndex ever assigned, so getNextAnnotationZIndex is O(1). + /// Monotonically increasing; never decreases on remove. + var maxAnnotationZIndex: Double = -1 public init(withFrame frame: CGRect, withRegistrar registrar: FlutterPluginRegistrar, withargs args: Dictionary ,withId id: Int64) { self.options = args["options"] as! [String: Any] diff --git a/lib/src/annotation_updates.dart b/lib/src/annotation_updates.dart index 55b62e7..55e919c 100644 --- a/lib/src/annotation_updates.dart +++ b/lib/src/annotation_updates.dart @@ -40,9 +40,15 @@ class _AnnotationUpdates { .map(idToCurrentAnnotation) .toSet(); + // Only include annotations whose content actually changed. The previous + // implementation shipped every annotation whose id was in both sets, which + // for large annotation sets meant serializing thousands of unchanged + // annotations across the platform channel on every diff. final Set _annotationsToChange = currentAnnotationIds .intersection(prevAnnotationIds) .map(idToCurrentAnnotation) + .where((Annotation current) => + current != previousAnnotations[current.annotationId]) .toSet(); annotationsToAdd = _annotationsToAdd; diff --git a/pubspec.yaml b/pubspec.yaml index d351508..aab1475 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.5.0 +version: 1.6.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/annotation_updates_test.dart b/test/annotation_updates_test.dart index 1e08b5b..25da67f 100644 --- a/test/annotation_updates_test.dart +++ b/test/annotation_updates_test.dart @@ -85,8 +85,8 @@ void main() { expect(addedAnnotation, equals(m2)); expect(platformAppleMap.annotationIdsToRemove!.isEmpty, true); - expect(platformAppleMap.annotationsToChange!.length, 1); - expect(platformAppleMap.annotationsToChange!.first, equals(m1)); + // m1 didn't change, so it must not appear in the change set. + expect(platformAppleMap.annotationsToChange!.isEmpty, true); debugDefaultTargetPlatformOverride = null; }); @@ -267,29 +267,44 @@ void main() { expect(updated.clusteringIdentifier, equals("new")); }); - testWidgets( - "Partial Update", - (WidgetTester tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - final Annotation m1 = - Annotation(annotationId: AnnotationId("annotation_1")); - Annotation m2 = Annotation(annotationId: AnnotationId("annotation_2")); - final Set prev = _toSet(m1: m1, m2: m2); - m2 = Annotation( - annotationId: AnnotationId("annotation_2"), draggable: true); - final Set cur = _toSet(m1: m1, m2: m2); - - await tester.pumpWidget(_mapWithAnnotations(prev)); - await tester.pumpWidget(_mapWithAnnotations(cur)); - - final FakePlatformAppleMap platformAppleMap = - fakePlatformViewsController.lastCreatedView!; - - expect(platformAppleMap.annotationsToChange, _toSet(m2: m2)); - expect(platformAppleMap.annotationIdsToRemove!.isEmpty, true); - expect(platformAppleMap.annotationsToAdd!.isEmpty, true); - debugDefaultTargetPlatformOverride = null; - }, - skip: true, - ); + testWidgets("Partial Update", (WidgetTester tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final Annotation m1 = Annotation(annotationId: AnnotationId("annotation_1")); + Annotation m2 = Annotation(annotationId: AnnotationId("annotation_2")); + final Set prev = _toSet(m1: m1, m2: m2); + m2 = Annotation( + annotationId: AnnotationId("annotation_2"), draggable: true); + final Set cur = _toSet(m1: m1, m2: m2); + + await tester.pumpWidget(_mapWithAnnotations(prev)); + await tester.pumpWidget(_mapWithAnnotations(cur)); + + final FakePlatformAppleMap platformAppleMap = + fakePlatformViewsController.lastCreatedView!; + + expect(platformAppleMap.annotationsToChange, _toSet(m2: m2)); + expect(platformAppleMap.annotationIdsToRemove!.isEmpty, true); + expect(platformAppleMap.annotationsToAdd!.isEmpty, true); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets("Unchanged annotation is not in annotationsToChange", + (WidgetTester tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final Annotation m1 = Annotation(annotationId: AnnotationId("annotation_1")); + final Annotation m2 = Annotation(annotationId: AnnotationId("annotation_2")); + + await tester.pumpWidget(_mapWithAnnotations(_toSet(m1: m1, m2: m2))); + // Same annotation set, different instances but identical content. + final Annotation m1Same = Annotation(annotationId: AnnotationId("annotation_1")); + final Annotation m2Same = Annotation(annotationId: AnnotationId("annotation_2")); + await tester.pumpWidget(_mapWithAnnotations(_toSet(m1: m1Same, m2: m2Same))); + + final FakePlatformAppleMap platformAppleMap = + fakePlatformViewsController.lastCreatedView!; + expect(platformAppleMap.annotationsToAdd!.isEmpty, true); + expect(platformAppleMap.annotationIdsToRemove!.isEmpty, true); + expect(platformAppleMap.annotationsToChange!.isEmpty, true); + debugDefaultTargetPlatformOverride = null; + }); }