Coalescing Geographic Locations with SwiftUI & MapKit

Designing information-dense maps

Steven Kish
6 min readOct 15, 2023

--

Want to save time time building your map interface with SwiftUI? While it’s certainly come a long way since it’s launch, SwiftUI is still constantly improving and adding features. How exactly to get the most out of your code can be a mystery at times. Getting up and running with a basic map in your iOS app is quicker than ever. However, the core frameworks need a bit of help as the amount of information we want to display increases.

Consider a fully interactive map that displays several dozen geographic locations simultaneously on a mobile device. Information-dense interfaces quickly appear cluttered on smaller screens. A common approach in map design is to coalesce overlapping map annotations into a single annotation, and then to present the locations individually at zoom levels where they no longer overlap. Although coalescing map annotations is possible and relatively straightforward with SwiftUI, an separate clustering algorithm is needed (as far as I can see).

It’s easy for the human eye to group similar objects together but not as straightforward for a computer to do the same

Read on to find the Swift code and background you need to get started with SwiftUI & MapKit, and to learn about extending these frameworks. I hope to save others a bit of time and possible frustration, and to provide an intro into clustering algorithms.

SwiftUI Tutorial

First Steps

The first code to write defines a map view and displays a group of geographic locations. If you’re familiar with SwiftUI, this might be review. Let’s assume we already have the geo locations we want to display using a MapKit or CoreLocation class e.g. MKMapItem, MKPlacemark, CLLocation, CLLocationCoordinate2D. Here MKMapItem is used for convenience.

import Foundation
import SwiftUI
import MapKit

struct HikingMap : View {

@State var mapItems : [MKMapItem] // <--- populate with valid map items

var body: some View {
Map(){
ForEach(mapItems, id:\.self) { mapItem in
Marker( mapItem.name ?? "Hiking Trail",
systemImage: "figure.hiking",
coordinate: mapItem.placemark.coordinate)
}
}
}
}

#Preview {
HikingMap()
}

The foundation code creates a SwiftUI Map instance and populates it with Marker instances using ForEach. When the locations state is updated with your data (look for my other article on mapping data soon) a Marker instance is created for every MKMapItem. Since the Map position is set to automatic, the map camera will ensure all map items are visible when the Markers update. Below is the XCode preview of HikingMap when populated with about twenty hiking locations near Yosemite Valley, CA.

Overlapping map markers

As you can see many of the map items overlap and obscure information. It’s apparent that many locations existing in the denser areas but it’s unclear how many before zooming closer. A method is needed to increase legibility and retain the full breadth of information. Applying a concept called clustering can help neatly organize information-dense interfaces like the map above.

Geodetic Clustering Implementation

Clustering geographic locations on the screen involves both geo coordinates (degrees as latitude, longitude) and screen coordinates (pixels as x, y). Even though their geo coordinates never change, their place on the screen will and visual overlap occurs when the distance in pixels between map markers is too little. One important variable for the clustering algorithm will be the minimum pixel distance between map markers. A handy MapKit class named MapProxy defines two functions for conversion between pixels and degrees. Below is Apple’s documentations for these functions:

    /// Converts a point in the specified coordinate space to a map coordinate.
///
/// If `point` is outside of the MapReader's associated Map, returns nil.
///
/// struct ContentView: View {
/// @State private var markerCoordinate: CLLocationCoordinate2D = .office
///
/// var body: some View {
/// MapReader { proxy in
/// Map {
/// Marker("Marker", coordinate: markerCoordinate)
/// }
/// .onTapGesture { location in
/// if let coordinate = proxy.convert(location, from: .local) {
/// markerCoordinate = coordinate
/// }
/// }
/// }
/// }
/// }
///
/// - Parameters:
/// - point: The point you want to convert.
/// - space: The reference coordinate space for the point parameter.
public func convert(_ point: CGPoint, from space: some CoordinateSpaceProtocol) -> CLLocationCoordinate2D?

/// Converts a map coordinate to a point in the specified coordinate space.
///
/// If `coordinate` is not represented by a point in the MapReader's
/// associated Map, returns nil.
///
/// - Parameters:
/// - coordinate: The map coordinate that you want to find the
/// corresponding point for.
/// - space: The reference coordinate space for the returned point.
public func convert(_ coordinate: CLLocationCoordinate2D, to space: some CoordinateSpaceProtocol) -> CGPoint?

Although more accurate methods exist, the below extension is simple and reliable. This code determines map scale by converting two CGPoint objects to two CLLocationCoordinate2D objects. It simply takes the latitude in the upper left corner of the map and finds the difference in latitude some pixels to the right. Really, any two points would work as long as we know their screen coordinates as well as their geographic coordinates.

import CoreLocation

extension MapProxy {
func degrees( fromPixels pixels : Int ) -> Double? {
let c1 = self.convert(CGPoint.zero, from: .global)
let p2 = CGPoint(x: Double(pixels), y: 0.0 )
let c2 = self.convert(p2, from: .global)
if let lon1 = c1?.longitude, let lon2 = c2?.longitude {
return abs(lon1 - lon2)
}
return nil
}
}

After defining the extension, the Map instance can be wrapped with MapReader to provide a MapProxy. Our new extension can be used when mapItems updates and triggers onChange(of:). The epsilon will be calculated at the maps current zoom level. In the code below, epsilon will represent the geographic equivalent of thirty pixels on the screen. Finally, clustering algorithm can be implemented.

import Foundation
import SwiftUI
import MapKit

struct HikingMap : View {

@State var mapItems : [MKMapItem]

var body: some View {
MapReader { mapProxy // <--- wrap Map with MapReader
Map(){
ForEach(mapItems, id:\.self) { mapItem in
Marker( mapItem.name ?? "Hiking Trail",
systemImage: "figure.hiking",
coordinate: mapItem.placemark.coordinate)
}
}
.onChange(of: mapItems, { oldValue, newValue in
if let distance = mapProxy.degrees(fromPixels: 30) {
// clustering algorithm will run here
}
})
}
}
}

#Preview {
HikingMap()
}

Next the SwiftUI code needed to render location clusters can be written. Instead of rendering from a simple list of MKMapItems, we’ll need a slightly more complicated model. Essentially, this new model is a wrapper for MKItem, but also can contain a list of MKItems to represent clusters.

struct PlaceCluster : Hashable {
let items : [MKMapItem]
let center : CLLocationCoordinate2D
var size :Int {
items.count
}

public func hash(into hasher: inout Hasher) {
return hasher.combine(self.center.hashValue)
}

internal init(items: [MKMapItem]) {
self.items = items
let intoCoord = CLLocationCoordinate2D(latitude: 0.0,longitude: 0.0)
let factor = 1.0 / Double(items.count)
self.center = items.reduce( intoCoord ) { runningAverage, mapItem in
let itemCoord = mapItem.placemark.coordinate
let lat = itemCoord.latitude * factor
let lon = itemCoord.longitude * factor
return CLLocationCoordinate2D(latitude: average.latitude + lat, longitude: average.longitude + lon)
}
}
}

Above, PlaceCluster implements the Hashable protocol in order to work in ForEach loops. Since each location will be unique, we can hash the coordinate at the center of the cluster. In the case that the cluster represents a single location, that location will also be the center. Now the output of the clustering algorithm can be rendered.

import Foundation
import SwiftUI
import MapKit

struct HikingMap : View {

@State var mapItems : [MKMapItem]
@State var clusters : [PlaceCluster]

func cluster( _ items : [MKMapItem], epsilon : Double ) -> [PlaceCluster] {
// clustering algorithm will run here
}

var body: some View {
MapReader { mapProxy
Map(){
ForEach(clusters, id:\.center) { cluster in
if cluster.size == 1 {
let mapItem = cluster.items.first!
Marker( mapItem.name ?? "Hiking Trail",
systemImage: "figure.hiking",
coordinate:mapItem.placemark.coordinate)
} else {
Marker( "More Trails",
systemImage: "\(cluster.size.description).circle",
coordinate:cluster.center)
.tint(Color.green)
}
}
}
.onChange(of: mapItems, { oldValue, newValue in
if let distance = mapProxy.degrees(fromPixels: 30) {
clusters = cluster( newValue, epsilon: distance )
}
})
}
}
}

#Preview {
HikingMap()
}

Now, clusters will be rendered instead of mapItems. Inside the ForEach, a differently styled Marker is created depending on whether the cluster has multiple locations or a single location. When the right algorithm is used to generate clusters, the map should automatically coalesce locations that are closer to one another than 30 pixels. Rendering the same twenty locations in Yosemite Valley now looks much cleaner.

Grouped map markers with increased map legibility

Thank you for reading the first part of this two part tutorial. I hope this has been a helpful intro into MapKit with SwiftUI. To complete this project of course a clustering algorithm is needed and that will be covered in part two.

--

--

Steven Kish

A designer and developer with over a decade of experience in big tech