A SwiftUI-native package for clustering MapKit map points — no UIViewRepresentable required. Keeps your maps fast, clean, and easy to navigate no matter how many points you're displaying. Because clustering is density-based, groups form naturally around real geographic concentrations — not arbitrary grid boundaries (I'm looking at you quad-tree).
Powered by DBSCAN (Density-Based Spatial Clustering of Applications with Noise) paired with a KD-Tree for fast spatial lookups. This combination means clustering is both accurate and efficient — DBSCAN naturally handles clusters of varying shapes and sizes, while the KD-Tree keeps nearest-neighbor searches fast even with thousands of points.
- iOS 17.0+ / macOS 14.0+
- Swift 6.0+
- Xcode 16.0+
In Xcode, go to File → Add Package Dependencies and enter the repository URL:
https://github.com/tomhoag/Clusterables
Follow these steps to add clustering to your SwiftUI map. The general flow is: wrap your data in a Clusterable type, add a ClusterManager to your view, then drive your map annotations from the manager's output.
Any point type that you want to cluster on the map must conform to the Clusterable protocol by exposing a CLLocationCoordinate2D:
struct City: Clusterable {
var name: String
var coordinate: CLLocationCoordinate2D
}Add a ClusterManager as a @State property, specifying the Clusterable type it will manage:
struct ContentView: View {
@State var clusterManager = ClusterManager<City>()
// ...
}MapReader gives you a mapProxy, which you can use to convert screen-space pixel spacing to geographic degrees for clustering:
MapReader { mapProxy in
Map(position: $cameraPosition, interactionModes: .all) {
// ...
}
}Iterate over clusterManager.clusters to build your map annotations. When a cluster contains a single item (size == 1), it is rendered using a red circle. When it contains multiple items, it is rendered using a ClusterAnnotationView (found in the Example project).
If you use a minimumPoints value greater than 1 (see Step 5), points that don't meet the density threshold are placed in clusterManager.outliers instead of forming single-item clusters. Render them separately:
Map(position: $cameraPosition, interactionModes: .all) {
ForEach(clusterManager.clusters) { cluster in
if cluster.size == 1, let city = cluster.items.first {
// Single point — show a regular annotation
Annotation(city.name, coordinate: city.coordinate) {
Circle()
.foregroundColor(.red)
.frame(width: 7)
}
} else {
// Multiple points — show a cluster annotation
Annotation("", coordinate: cluster.center) {
ClusterAnnotationView(size: cluster.size)
}
}
}
// Outliers — points that didn't meet the minimumPoints threshold
ForEach(clusterManager.outliers, id: \.self) { city in
Annotation(city.name, coordinate: city.coordinate) {
Circle()
.foregroundColor(.gray)
.frame(width: 7)
}
}
}Call clusterManager.update whenever the map appears, the camera position changes, or whenever you want to update the clusters.
Parameters:
epsilon— The clustering distance in degrees. Items closer than this are grouped together. UseMapProxy.degrees(fromPixels:)to convert screen-space pixel spacing to degrees at the current zoom level.minimumPoints(optional, default:1) — The minimum number of neighbors required for a point to be a core point. With the default of1, every point belongs to a cluster. Increase this to require denser groupings — isolated points that don't meet the threshold are placed inclusterManager.outliers.
.onAppear {
Task {
cameraPosition = .region(mapRegion)
if let epsilon = mapProxy.degrees(fromPixels: spacing) {
await clusterManager.update(items, epsilon: epsilon)
}
}
}
.onMapCameraChange(frequency: .onEnd) { _ in
Task {
if let epsilon = mapProxy.degrees(fromPixels: spacing) {
await clusterManager.update(items, epsilon: epsilon, minimumPoints: 3)
}
}
}The update method can be called from any context. Clustering runs on a background thread, and results are published on the main actor.
When update is called while a previous update is still running, the earlier update is automatically discarded — its results are never returned. This happens at multiple levels: before DBSCAN starts, during the main clustering loop, during cluster expansion, and before results are written to the UI. Only the most recent call's results are ever returned.
This is not debouncing. The library does not delay or throttle calls to update. Every call starts immediately. The library's responsibility is ensuring that only the latest results are returned to the caller — it does not decide when or how often update should be called. That is left to the caller.
If your UI triggers updates rapidly (for example, during continuous map panning), you should debounce or throttle on your side before calling update. The Example project demonstrates this with an UpdateCoordinator actor that cancels and restarts a delayed task on each camera change. The library's internal cancellation is a safety net that prevents stale results from briefly flashing on screen, not a substitute for call-site throttling.
Clone this repo and open Example/Example.xcodeproj. The project contains two targets:
A minimal app that demonstrates Clusterables in under 100 lines of code. It loads 1,813 US cities from bundled JSON, displays them on a map, and clusters them in real time as you pan and zoom. This is the best place to start if you want to understand the basics:
ClusterManageras a@StatepropertyMapProxy.degrees(fromPixels:)to compute epsilonclusterManager.update(_:epsilon:)on every camera changeclusterManager.clustersdrivingForEachannotations
No view model, no debouncing, no settings UI — just the core clustering workflow.
A full-featured app that builds on the same foundation with production-oriented extras:
- Debounced updates via an
UpdateCoordinatoractor that cancels and restarts a delayed task on each camera change - Visible-only filtering to cluster only the points currently on screen
- Multiple data sets (938 / 1,813 / 33K US cities) switchable at runtime
- Adjustable cluster spacing via a slider
- Draggable statistics overlay showing cluster count, point count, and timing
- Settings sheet for toggling clustering, choosing data sources, and controlling the overlay
- Loading indicator during data loading and cluster computation
Clusterables uses Euclidean distance on raw latitude/longitude values when computing point proximity. This treats one degree of longitude as the same length as one degree of latitude, which is only true at the equator. At higher latitudes, a degree of longitude shrinks by cos(latitude) — for example, at 60°N it's half the actual ground distance.
For typical map clustering (city or regional scale, interactive zoom levels), this has no meaningful effect on cluster quality. At continental or global scales, east-west distances are overestimated at high latitudes, which can cause clusters to split along the longitude axis when they shouldn't.
Points on opposite sides of the international date line (e.g., 179°E and 179°W) are geographically 2° apart but appear 358° apart in Euclidean space. The clusterer will not group these points together, even with a large epsilon.
This also affects the antimeridian more generally — any cluster that would span the ±180° longitude boundary will be split into two.
Both limitations stem from treating latitude/longitude as a flat Cartesian plane. See this KDTree discussion for approaches including latitude-adjusted distance formulas and projected coordinate systems.
This package was adapted from a Medium post by @stevenkish.
