Map Annotation Clustering in iOS 17

Emre Degirmenci,SwiftUIMapiOS 17Clustering

High Performance Map Annotation Clustering in iOS 17 with SwiftUI Map()

Map!

"Photo by Tamas Tuzes-Katai on Unsplash

Introduction:

Map annotation clustering is a technique used to group nearby annotations on a map into a single cluster, which is represented by a single annotation. This is important for improving performance and user experience, especially when dealing with large datasets. Without clustering, a map with many annotations can become cluttered and difficult to read, leading to a poor user experience. Clustering reduces the number of annotations displayed on the map, making it easier to read and improving performance by reducing the amount of data that needs to be rendered.

Overview of Map Annotation Clustering

First of all, I got a lot of help from this open-source project (https://github.com/vospennikov/ClusterMap (opens in a new tab)) in order to implement map annotation clustering in iOS 17 SwiftUI Map(). I highly recommend checking it out if you want to learn more about how it works.

ClusterMap!

Difference between working with UIKit Map and SwiftUI Map

In terms of data handling and performance, ClusterMap library provides a solution for implementing map annotation clustering in SwiftUI Map() by changing the data structure from an array or dictionary to a tree.

The most critical difference from the native implementation in UIKit is that the ClusterMap library handles adding data asynchronously, avoiding lags when adding significant quantities of data. In contrast, Apple handles adding data to the MainThread, which can cause lags when scrolling or zooming. The library requires the dimensions of the grid used in clustering to be provided through a custom configuration object. This allows for custom configuration of the grid size used in clustering, improving performance during scrolling or zooming. While Apple's implementation is more straightforward, the performance issue could be critical (e.g. Memory usage reduced from ~500 MB to ~230 MB).

Integration with SwiftUI Map

When working with iOS 17, the library can be placed in an object marked with the @Observable macro and added to the hierarchy via the .environment modifier. The map will automatically reload when needed, such as when the camera position changes, and annotations can be added or removed in any other views. In my case I was trying to MKLocalSearch to search for places and display them on the map through a tabbar button.

An asynchronous local search is started using the MKLocalSearch class and the initialized request. The result of the search is stored in an async let variable called searchResult. After the search is completed, the clusterManager object is used to remove all existing annotations from the map. The mapItems property of the searchResult is then added to the clusterManager using the add method. Finally, the reloadAnnotations method is called to update the map view with the new annotations.

import ClusterMap
import Foundation
import MapKit
 
struct ExampleClusterAnnotation: Identifiable {
    var id = UUID()
    var coordinate: CLLocationCoordinate2D
    var count: Int
}
    
@Observable
class LocalSearchCompleter: NSObject {
var mapSize: CGSize = .zero
var currentRegion: MKCoordinateRegion = .userLocation
var annotations = [MKMapItem]() // #1 element of the tree
var clusters = [ExampleClusterAnnotation]() // #2 element of the tree
 
let customConfig = ClusterManager<MKMapItem>.Configuration(
    cellSizeForZoomLevel: { (zoom: Int) -> CGSize in
        switch zoom {
        case 13...15: return CGSize(width: 64, height: 64) // grid size used in clustering
        case 16...18: return CGSize(width: 32, height: 32)
        case 19...: return CGSize(width: 16, height: 16)
        default: return CGSize(width: 88, height: 88)
        }
    }
)
 
var clusterManager: ClusterManager<MKMapItem>
 
override init() {
    clusterManager = ClusterManager<MKMapItem>(configuration: customConfig)
}
 
func search(for query: String) async {
        let request = MKLocalSearch.Request()
        request.naturalLanguageQuery = query
        request.region = currentRegion
        do {
            async let searchResult = MKLocalSearch(request: request).start()
            await clusterManager.removeAll()
            try await clusterManager.add(searchResult.mapItems)
            await reloadAnnotations()
        } catch {
            assertionFailure("Error: \(error.localizedDescription)")
        }
    }
 
    func reloadAnnotations() async {
        async let changes = clusterManager.reload(mapViewSize: mapSize, coordinateRegion: currentRegion)
        await applyChanges(changes)
    }
 
    @MainActor
    private func applyChanges(_ difference: ClusterManager<MKMapItem>.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
                ))
            }
        }
    }
}
 
extension MKMapItem: CoordinateIdentifiable, Identifiable {
public var id: String {
    placemark.region?.identifier ?? UUID().uuidString
}
 
public var coordinate: CLLocationCoordinate2D {
    get { placemark.coordinate }
    set(newValue) { }
}
}

The customConfig object is defined using the ClusterManager class, which allows for custom configuration of the grid size used in clustering. Finally, the clusterManager property is initialized with the custom configuration.

Usage of the LocalSearchCompleter() in SwiftUI View:

import SwiftUI
import MapKit
import ClusterMap
 
struct LocationRequestIsOnView: View {
    
    @State private var searchClient = LocalSearchCompleter()
 
    var body: some View {
        NavigationStack {
            ZStack {
                mapView
            }
        }
    }
    
    var mapView: some View {
        Map(
            initialPosition: .region(searchClient.currentRegion),
            selection: $selectedResult
        ) {
            if !showSettingsView {
                ForEach(searchClient.annotations) { result in
                    if systemImageName == "bolt.car.circle.fill" {
                        Marker(result.placemark.name ?? "", systemImage: "bolt.car.circle.fill", coordinate: result.coordinate)
                            .tag(result)
                    } else {
                        Marker(result.placemark.name ?? "", systemImage: "fuelpump.circle.fill", coordinate: result.coordinate)
                            .tag(result)
                    }
                }
 
                ForEach(searchClient.clusters) { result in
                    if systemImageName == "bolt.car.circle.fill" {
                        Marker("\(result.count)", systemImage: "bolt.car.fill", coordinate: result.coordinate)
                    } else {
                        Marker("\(result.count)", systemImage: "fuelpump.circle.fill", coordinate: result.coordinate)
                    }
                }
                
                UserAnnotation()
                
                if let route {
                    MapPolyline(route)
                        .stroke(.blue, lineWidth: 5)
                }
            }
        }
        .readSize(onChange: { newValue in
            searchClient.mapSize = newValue
        })
        .onChange(of: selectedResult) {
            if selectedResult != nil {
                getDirections()
                isShowingBottomSheet = true
            } else {
                isShowingBottomSheet = false
            }                
        }
        .sheet(isPresented: $isShowingBottomSheet) {
            ItemInfoView(
                isShowingBottomSheet: $isShowingBottomSheet,
                route: $route,
                selectedResult: $selectedResult, selectedTabBarButton: selectedTabBarButton,
                url: self.shareLocation()
            )
            .presentationDetents([.medium, .fraction(0.12), .large])
            .presentationBackgroundInteraction(.enabled(upThrough: .medium))
            .presentationDragIndicator(.visible)
        }
        .onMapCameraChange { context in
            searchClient.currentRegion = context.region
        }
        .onMapCameraChange(frequency: .onEnd) { context in
            Task.detached {
                await searchClient.reloadAnnotations()
            }
        }
        .mapControls {
            MapUserLocationButton()
            MapCompass()
            MapScaleView()
        }
        .environment(searchClient)
    }
}

There is a ForEach loop that iterates over the searchClient.annotations array (which are the first element of the tree). For each annotation, a Marker view is created and displayed on the map. The Marker represents a location on the map and includes a title, a system image, and a coordinate. Similarly, there is another ForEach loop that iterates over the searchClient.clusters array (which are the second elements of the tree). For each cluster, a Marker view is created and displayed on the map. The Marker represents a cluster of locations and includes the count of locations in the cluster, a system image, and a coordinate. The mapView also includes various modifiers and event handlers. For example, the readSize modifier is used to update the searchClient's mapSize property when the size of the map view changes. The onChange event handler is used to perform actions when the selectedResult changes, such as getting directions and showing or hiding a bottom sheet.

Performance Benefits of ClusterMap

Memory! Memory!

...and so many other hangs, hitchs, and lags that I was experiencing with the native MapKit implementation which I couldn't add the Xcode Instruments screenshots.

Overall, the project demonstrates how the combination of SwiftUI Map, ClusterMap, and other technologies can be used to create a performant and customizable map view in iOS 17. "Thanks Apple 🤣" By following best practices for implementing map annotation clustering, such as using a third-party library and providing custom configuration for the grid size, you can create a better user experience and improve the performance of your MapKit projects.

See you ✌🏼

Sign up for my newsletter