Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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*
Expand Down
107 changes: 85 additions & 22 deletions ios/Classes/Annotations/AnnotationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -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!
}

Expand All @@ -87,32 +134,42 @@ extension AppleMapController: AnnotationDelegate {
}

func annotationsToChange(annotations: NSArray) {
let oldAnnotations: [MKAnnotation] = self.mapView.annotations
for annotation in annotations {
let annotationData: Dictionary<String, Any> = annotation as! Dictionary<String, Any>
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) {
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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<String, Any>) {
Expand All @@ -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)
}

Expand All @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion ios/Classes/Annotations/FlutterAnnotation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Any>, registrar: FlutterPluginRegistrar) {
let position: Array<Double> = annotationData["position"] as! Array<Double>
Expand Down Expand Up @@ -56,6 +57,8 @@ class FlutterAnnotation: NSObject, MKAnnotation {
if let calloutOffsetJSON = infoWindow["anchor"] as? Array<Double> {
self.calloutOffset = Offset(from: calloutOffsetJSON)
}

self.clusteringIdentifier = annotationData["clusteringIdentifier"] as? String
}


Expand All @@ -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 {
Expand Down
42 changes: 42 additions & 0 deletions ios/Classes/Annotations/FlutterAnnotationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions ios/Classes/MapView/AppleMapController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Any> ,withId id: Int64) {
self.options = args["options"] as! [String: Any]
Expand Down
14 changes: 13 additions & 1 deletion lib/src/annotation.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand Down Expand Up @@ -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({
Expand All @@ -215,6 +222,7 @@ class Annotation {
double? zIndexParam,
VoidCallback? onTapParam,
ValueChanged<LatLng>? onDragEndParam,
String? clusteringIdentifierParam,
}) {
return Annotation(
annotationId: annotationId,
Expand All @@ -228,6 +236,8 @@ class Annotation {
visible: visibleParam ?? visible,
zIndex: zIndexParam ?? zIndex,
onDragEnd: onDragEndParam ?? onDragEnd,
clusteringIdentifier:
clusteringIdentifierParam ?? clusteringIdentifier,
);
}

Expand All @@ -249,6 +259,7 @@ class Annotation {
addIfPresent('visible', visible);
addIfPresent('position', position._toJson());
addIfPresent('zIndex', zIndex);
addIfPresent('clusteringIdentifier', clusteringIdentifier);
return json;
}

Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions lib/src/annotation_updates.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<Annotation> _annotationsToChange = currentAnnotationIds
.intersection(prevAnnotationIds)
.map(idToCurrentAnnotation)
.where((Annotation current) =>
current != previousAnnotations[current.annotationId])
.toSet();

annotationsToAdd = _annotationsToAdd;
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading