Overview
Annotations are the primary way to communicate point data on a map. MapKit for SwiftUI (iOS 17+) offers Marker and Annotation as first-class view builder items. Marker renders a system callout pin; Annotation embeds an arbitrary SwiftUI view. Choosing correctly between them, managing clustering, and sizing tap targets correctly prevents the most common annotation performance and usability problems. For overlay geometry (lines, polygons), see mapkit-overlays; for camera control, see mapkit-camera.
Prefer Marker for simple pins; use Annotation only when you need a custom view
Marker is rendered by the system, batched off the main thread, and cheaper than a SwiftUI view. Use it when the content is a label plus a tint plus an optional SF Symbol glyph.
Marker("Coffee Shop", systemImage: "cup.and.saucer.fill", coordinate: shop.coordinate)
.tint(.brown)Reserve Annotation for branded views, views with dynamic state, or badges that need a live count. Every Annotation is a live SwiftUI view; fifty of them on screen at once will impact frame rate.
Annotation("Event", coordinate: event.coordinate) {
ZStack {
Circle().fill(.red).frame(width: 36, height: 36)
Image(systemName: "calendar").foregroundStyle(.white)
}
}Size tap targets to at least 44x44 points
A pin whose visual is 16x16 but whose hit area is also 16x16 is inaccessible. Pad the content area of an Annotation view to ensure the effective tap target meets the 44x44 minimum.
Annotation("Pin", coordinate: coordinate) {
Image(systemName: "mappin.circle.fill")
.font(.title)
.padding(10)
.contentShape(Circle())
.accessibilityLabel("Selected pin")
}For Marker, the system enforces a reasonable tap target automatically.
Cluster annotations when density exceeds readability
Clustering prevents overlapping pins from making the map unreadable. In the SwiftUI API, apply the .tag modifier to each Annotation and supply a clusterAnnotation closure on the Map.
Map {
ForEach(cafes) { cafe in
Annotation(cafe.name, coordinate: cafe.coordinate) {
CafePinView()
.annotationTitles(.hidden)
}
.tag(cafe.id)
}
}
.mapAnnotationCluster { cluster in
ZStack {
Circle().fill(.blue).frame(width: 40, height: 40)
Text("\(cluster.memberAnnotations.count)")
.foregroundStyle(.white).bold()
}
}When clustering is inadequate for extreme density (thousands of items), fall back to an MKMapView wrapper using MKAnnotationView.clusteringIdentifier and a custom MKClusterAnnotation view; see mapkit for the UIViewRepresentable bridge pattern.
Filter annotations to the visible region before rendering
Passing a large array of annotations into Map when most are off screen wastes memory and layout time. Filter against the current region.
var visibleAnnotations: [Place] {
let region = cameraState.region
return allPlaces.filter { region.contains($0.coordinate) }
}
Map(position: $camera) {
ForEach(visibleAnnotations) { place in
Marker(place.name, coordinate: place.coordinate)
}
}
.onMapCameraChange(frequency: .onEnd) { context in
cameraState = context
}Use .onEnd frequency; .continuous fires every rendered frame and will saturate the main actor during panning.
Bind selection to Identifiable values for sheet or detail integration
Map(selection:) takes a binding to an optional Identifiable value. Tap a Marker or Annotation tagged with the same value and the binding updates.
@State private var selected: Place?
Map(position: $camera, selection: $selected) {
ForEach(places) { place in
Marker(place.name, coordinate: place.coordinate)
.tag(place)
}
}
.sheet(item: $selected) { place in
PlaceDetailView(place: place)
}Keep the tag type consistent with the selection binding type. Mixing types silently produces no-op taps.
Provide accessibility labels and traits for every annotation
VoiceOver reads annotation content aloud. A custom Annotation view that contains only an image with no label is silent to VoiceOver.
Annotation("", coordinate: coordinate) {
PinView()
.accessibilityLabel(location.name)
.accessibilityHint("Double-tap to view details")
.accessibilityAddTraits(.isButton)
}Test with VoiceOver enabled by running the Accessibility Inspector target against the map screen. Markers surface their title string automatically.
Store annotation data in a model layer, not in view state
Never compute annotation coordinates inside body. Place model objects in a view model or repository backed by core-data or another persistence layer. The map view should receive a stable array; recomputing the array on every render re-diffs the annotation tree unnecessarily.
@Observable final class MapViewModel {
var visiblePlaces: [Place] = []
func updateVisible(for region: MKCoordinateRegion) {
visiblePlaces = placeRepository.places(in: region)
}
}