ClusterMap is an open-source library for high-performance map clustering.
Creating clusters is a highly effective way to improve user experience and performance when displaying multiple points on a map. Native clustering mechanisms offer a straightforward, code-free solution with visually appealing animations. However, it's worth noting that this implementation does have one major drawback: all points are added to the map in the MainThread, which can cause issues when dealing with large numbers of points. Unfortunately, this bottleneck cannot be avoided. Another problem is that SwiftUI still lacks this feature.
ClusterMap provides high-performance computations based on the QuadTree algorithm. This library is UI framework-independent and performs computations in any thread.
Comparison ClusterMap and Native implementation with 20,000 annotations. For a detailed comparison, look at Example-UIKit.
- Features
- What's in the next releases?
- Demo projects
- Installation
- Usage
- Documentation
- Credits
- License
- UI framework independent.
- Thread independent.
- Integration with AppKit, UIKit, and SwiftUI.
- MapKit integration.
- Swift concurrency.
- Stateless implementation.
- Improve difference logic.
- Simplify API.
The repository contains three main examples, which is a good starting point for exploring the basic functionality of the library.
Add the ClusterMap dependency to an Xcode project as a package dependency.
- From the File menu, select Add Packages...
- Enter "https://github.com/vospennikov/ClusterMap.git" into the package repository URL text field.
Add the ClusterMap dependency to your Package.swift
manifest.
- Add the following dependency to your
dependencies
argument:.package(url: "https://github.com/vospennikov/ClusterMap.git", from: "2.1.0")
- Add the dependency to any targets you've declared in your manifest:
.target( name: "MyTarget", dependencies: [ .product(name: "ClusterMap", package: "ClusterMap"), ] )
The ClusterManager
stores and cluster map points. It's an actor and safe thread for usage.
-
You need to confirm your map points to the protocols
CoordinateIdentifiable, Identifiable, Hashable, Sendable
:// SwiftUI annotation struct ExampleAnnotation: CoordinateIdentifiable, Identifiable, Hashable { let id = UUID() var coordinate: CLLocationCoordinate2D } // MapKit (MKMapItem) integration extension MKMapItem: CoordinateIdentifiable, Identifiable, Hashable { let id = UUID() var coordinate: CLLocationCoordinate2D { get { placemark.coordinate } set(newValue) { } } } // MapKit (MKPointAnnotation) integration class ExampleAnnotation: MKPointAnnotation, CoordinateIdentifiable, Identifiable, Hashable { let id = UUID() }
-
You need to create an instance of
ClusterManager
and set your annotation type insteadExampleAnnotation
let clusterManager = ClusterManager<ExampleAnnotation>()
-
Next, you can add and remove your points.
let reykjavik = ExampleAnnotation(coordinates: CLLocationCoordinate2D(latitude: 64.1466, longitude: -21.9426)) let akureyri = ExampleAnnotation(coordinates: CLLocationCoordinate2D(latitude: 65.6835, longitude: -18.1002)) let husavik = ExampleAnnotation(coordinates: CLLocationCoordinate2D(latitude: 66.0449, longitude: -17.3389)) let cities = [reykjavik, akureyri, husavik] await clusterManager.add(cities) await clusterManager.remove(husavik) await clusterManager.removeAll()
-
To calculate clusters correctly, the ClusterMap needs to know the size of the map. Unfortunately, Apple doesn't provide this information. For reading map size, you can use GeometryReader and pass the size to your model object.
@State private var mapSize: CGSize = .zero var body: some View { GeometryReader(content: { geometryProxy in Map() .onAppear(perform: { mapSize = geometryProxy.size }) .onChange(of: geometryProxy.size) { oldValue, newValue in mapSize = newValue } }) }
Or you can use the prebuilt method
.readSize
fromClusterMapSwiftUI
package.import ClusterMapSwiftUI @State private var mapSize: CGSize = .zero var body: some View { GeometryReader(content: { geometryProxy in Map() .readSize(onChange: { newValue in mapSize = newValue }) }) }
-
Next, you need to pass this size to the reload method of ClusterMap each time. Let's reload our map each time the camera changes position. In the example, we use the modern (iOS 17) method onMapCameraChange. Integrate with Map before iOS 17 a little bit tricky, for more information look at Example-SwiftUI/App/LegacyMap
Map() .onMapCameraChange(frequency: .onEnd) { context in Task.detached { await clusterManager.reload(mapViewSize: mapSize, coordinateRegion: context.region) } }
As a result of this method, ClusterMap returns struct
ClusterManager<ExampleAnnotation>.Difference
. You need to apply this difference to your view.private var annotations: [ExampleAnnotation] = [] private var clusters: [ExampleClusterAnnotation] = [] func applyChanges(_ difference: ClusterManager<ExampleAnnotation>.Difference) { for removal in difference.removals { switch removal { case .annotation(let annotation): annotations.removeAll { $0 == annotation } case .cluster(let clusterAnnotation): clusters.removeAll { $0.id == clusterAnnotation.id } } } for insertion in difference.insertions { switch insertion { case .annotation(let newItem): annotations.append(newItem) case .cluster(let newItem): clusters.append(ExampleClusterAnnotation( id: newItem.id, coordinate: newItem.coordinate, count: newItem.memberAnnotations.count )) } } }
For additional information on integration, look at Example-SwiftUI
-
To calculate clusters correctly, the ClusterMap needs to know the size of the map. We can read the size from
MKMapView
directly.var mapView = MKMapView(frame: .zero) func reloadMap() async { await clusterManager.reload(mapViewSize: mapView.bounds.size, coordinateRegion: mapView.region) }
-
As a result of this method, ClusterMap returns struct
ClusterManager<ExampleAnnotation>.Difference
. You need to apply this difference to your view. It's tricky because your MKMapView keepsMKAnnotation
and erases type. You can cast annotations to type check or keep them in an additional variable. I keep them in variables for clear examples.var annotations: [ExampleAnnotation] = [] private func applyChanges(_ difference: ClusterManager<ExampleAnnotation>.Difference) { for annotationType in difference.removals { switch annotationType { case .annotation(let annotation): annotations.removeAll(where: { $0 == annotation }) mapView.removeAnnotation(annotation) case .cluster(let clusterAnnotation): if let result = annotations.enumerated().first(where: { $0.element.id == clusterAnnotation.id }) { annotations.remove(at: result.offset) mapView.removeAnnotation(result.element) } } } for annotationType in difference.insertions { switch annotationType { case .annotation(let annotation): annotations.append(annotation) mapView.addAnnotation(annotation) case .cluster(let clusterAnnotation): let cluster = ClusterAnnotation() cluster.id = clusterAnnotation.id cluster.coordinate = clusterAnnotation.coordinate cluster.memberAnnotations = clusterAnnotation.memberAnnotations annotations.append(cluster) mapView.addAnnotation(cluster) } } }
For additional information on integration, look at Example-UIKit
ClusterMap is a UI framework independent. You can integrate this library into any application layer. Let's look at integrating this library as a TCA dependency. Finally, you can save results in the database and reuse items anytime without additional heavyweight computations.
struct ClusterMapClient {
struct ClusterClientResult {
let objects: [ExampleAnnotation]
let clusters: [ExampleClusterAnnotation]
}
var clusterObjects: @Sendable ([ExampleAnnotation], MKCoordinateRegion, CGSize) async -> ClusterClientResult
}
extension DependencyValues {
var clusterMapClient: ClusterMapClient {
get { self[ClusterMapClient.self] }
set { self[ClusterMapClient.self] = newValue }
}
}
extension ClusterMapClient: DependencyKey {
static var liveValue = ClusterMapClient(
clusterObjects: { inputObjects, mapRegion, mapSize in
let clusterManager = ClusterManager<ExampleAnnotations>()
await clusterManager.add(inputObjects)
await clusterManager.reload(mapViewSize: mapSize, coordinateRegion: mapRegion)
var objects: [ExampleAnnotation] = []
var clusters: [ExampleClusterAnnotation] = []
await clusterManager.visibleAnnotations.forEach { annotationType in
switch annotationType {
case .annotation(let annotation):
objects.append(annotation)
case .cluster(let cluster):
clusters.append(.init(coordinate: cluster.coordinate))
}
}
return .init(objects: objects, clusters: clusters)
}
)
}
The ClusterManager
has configuration, that help you improve perfomance and control clustering logic, for addition information, look at ClusterManager.Configuration
The documentation for releases and main
are available here:
This project is based on the work of Lasha Efremidze, who created the Cluster.
This library is released under the MIT license. See LICENSE for details.