Skip to main content

Marker Clustering

Last updated: July 20, 2026 | 5 minutes read

This example demonstrates how to render thousands of campsites loaded from a bundled GeoJSON and cluster them natively with Maps SDK for iOS. At low zoom the markers group into count "pill" bubbles; as you zoom in the clusters break apart into per-type coloured pins (green = info-only, red = bookable). Tapping a cluster fits the map to its members to split it apart; tapping a pin centres the map on it and shows its details in an info panel — at the bottom in portrait, bottom-left in landscape.

Check the full implementation on GitHub.

Clustered markers

How it works​

The map uses two overlapping marker collections. A marker that carries its own per-marker render settings suppresses the SDK's cluster count label, so the count and the coloured pins cannot come from a single collection:

  • Cluster layer — every campsite, native grouping → density "pill" bubbles with the count label. It has no customMarkerSettings, and its loose (ungrouped) markers draw a transparent image.
  • Detail layer — the same full set, grouped at the same zoom but with transparent group images (so it shows nothing while clustered). Each loose pin is coloured green/red through the customMarkerSettings closure.

Because both layers hold the same marker set and group at the same zoom, they make identical group/loose decisions, so a pill never sits on top of a loose pin and the count always comes from the cluster layer.

Both layers set buildPointsGroupConfig = true. A tap hits the topmost (detail) layer, so it needs the group config in order to enumerate a cluster's members (via getPointsGroupHead / getPointsGroupComponents) when the cluster is tapped.

The two-collection renderer​

CampsitePOIRenderer.swiftView on GitHub
func syncPOIs(on mapViewController: MapViewController,
markers: [CampsiteMarkerDescriptor],
selectedPOIId: Int? = nil,
clusterMaxZoom: Double = 6) {

mapViewController.removeAllMarkers()
let clusterZoom = MapZoomConverter.toMagicLaneZoom(clusterMaxZoom)

// 1) Cluster layer — density pills + count label. No customMarkerSettings.
let clustered = MarkerCollectionObject(name: "\(collectionName)-clustered", type: .point)
for marker in markers { clustered.addMarker(markerObject(for: marker)) }

let clusterSettings = MarkerCollectionRenderSettingsObject()
clusterSettings.imageSize = 5.8
clusterSettings.labelGroupTextColor = .white
clusterSettings.labelGroupTextSize = 2.7
clusterSettings.pointsGroupingZoomLevel = NSNumber(value: clusterZoom)
clusterSettings.buildPointsGroupConfig = true
clusterSettings.lowDensityPointsGroupImage = CampsiteMarkerIcons.makePill(digits: 2)
clusterSettings.mediumDensityPointsGroupImage = CampsiteMarkerIcons.makePill(digits: 3)
clusterSettings.highDensityPointsGroupImage = CampsiteMarkerIcons.makePill(digits: 4)
clusterSettings.lowDensityPointsGroupMaxCount = 200
clusterSettings.mediumDensityPointsGroupMaxCount = 4000
clusterSettings.labelingMode = NSNumber(
value: MarkerLabelingMode.group.rawValue | MarkerLabelingMode.groupCenter.rawValue)
clusterSettings.pointImage = CampsiteMarkerIcons.transparent // loose singles invisible
mapViewController.addMarker(clustered, renderSettingsObject: clusterSettings)

// 2) Detail layer — same set, transparent groups, coloured loose pins.
let detail = MarkerCollectionObject(name: "\(collectionName)-detail", type: .point)
for marker in markers { detail.addMarker(markerObject(for: marker)) }

let detailSettings = MarkerCollectionRenderSettingsObject()
detailSettings.pointsGroupingZoomLevel = NSNumber(value: clusterZoom)
detailSettings.buildPointsGroupConfig = true // lets a tap on this layer enumerate members
detailSettings.lowDensityPointsGroupImage = CampsiteMarkerIcons.transparent
detailSettings.mediumDensityPointsGroupImage = CampsiteMarkerIcons.transparent
detailSettings.highDensityPointsGroupImage = CampsiteMarkerIcons.transparent
detailSettings.pointImage = CampsiteMarkerIcons.transparent

// Per-marker colour, keyed off the bookable flag carried in the marker name.
detailSettings.customMarkerSettings = { marker in
marker.getName().contains("\"bookable\":true") ? bookablePin : notBookablePin
}
mapViewController.addMarker(detail, renderSettingsObject: detailSettings)
}
Clusters breaking apart into pins
Tapping a pin shows its info panel

Loading the map and handling taps​

The map opens on a continental-US view; a "Load campsites" button parses the bundled GeoJSON off the main thread and renders the layers. On tap, a .coordinateGroup match is a cluster: its members are fitted to their bounding box with center(onArea:) to split it apart. A single pin centres the map on it (center(onCoordinates:zoomLevel:-1:) keeps the current zoom) and shows its info panel.

ContentView.swiftView on GitHub
private func handleSelection(_ markers: [MarkerMatchObject], proxy: MapProxy) {
guard let mapViewController = proxy.mapViewController, !markers.isEmpty else { return }

if markers.contains(where: { $0.getType() == .coordinateGroup }) {
// Prefer the clustered layer (buildPointsGroupConfig) for enumerating members.
let clusteredMatch = markers.first {
$0.getMarkerCollection()?.getName().hasSuffix("-clustered") == true
} ?? markers.first { $0.getType() == .coordinateGroup }
zoomIntoGroup(mapViewController, match: clusteredMatch)
selected = nil
return
}

// Single campsite → centre on it and show its info card.
let chosen = markers.first { $0.getMarker()?.getName().contains("\"bookable\"") == true } ?? markers.first
if let marker = chosen?.getMarker() {
let info = CampsiteInfo(marker: marker)
centerOn(info, proxy: proxy)
selected = info
}
}

private func zoomIntoGroup(_ mapViewController: MapViewController, match: MarkerMatchObject?) {
guard let match, let marker = match.getMarker(), let collection = match.getMarkerCollection() else { return }

var members: [MarkerObject] = []
let head = collection.getPointsGroupHead(marker.getId()) ?? marker
members.append(head)
members.append(contentsOf: collection.getPointsGroupComponents(head.getId()))
let coords = members.flatMap { $0.getCoordinates() }
guard coords.count >= 2 else { /* fixed zoom-in fallback */ return }

let lats = coords.map(\.latitude), lons = coords.map(\.longitude)
let latSpan = lats.max()! - lats.min()!, lonSpan = lons.max()! - lons.min()!

// Fit the members' padded bounding box (zoomLevel -1 = auto-fit).
let padLat = latSpan * 0.15 + 0.0005, padLon = lonSpan * 0.15 + 0.0005
let area = RectangleGeographicAreaObject(
minLatitude: lats.min()! - padLat, maxLatitude: lats.max()! + padLat,
minLon: lons.min()! - padLon, maxLon: lons.max()! + padLon)
mapViewController.center(onArea: area, zoomLevel: -1, animationDuration: 0.6)
}