diff --git a/CHANGELOG.md b/CHANGELOG.md index f793020..cd95b24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # 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 + 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..4a4dfbd 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! } @@ -87,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) { @@ -136,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) } } @@ -177,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) { @@ -194,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) } @@ -225,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/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/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.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/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 8fb876c..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.4.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 fb80e12..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; }); @@ -204,29 +204,107 @@ void main() { debugDefaultTargetPlatformOverride = null; }); - 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("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 { + 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; + }); } 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), ); }