How to Implement Custom Map Annotations in SwiftUI
Use SwiftUI's Annotation API inside a Map to place any SwiftUI view at a coordinate. Pass your identifiable data collection to the map, then return your custom view in the Annotation closure.
import MapKit
import SwiftUI
struct Place: Identifiable {
let id = UUID()
let name: String
let coordinate: CLLocationCoordinate2D
}
struct ContentView: View {
let places = [
Place(name: "Eiffel Tower",
coordinate: .init(latitude: 48.8584, longitude: 2.2945))
]
var body: some View {
Map {
ForEach(places) { place in
Annotation(place.name, coordinate: place.coordinate) {
Image(systemName: "star.circle.fill")
.font(.title)
.foregroundStyle(.yellow)
}
}
}
}
}
Full implementation
The complete example below models a list of landmarks, renders each with a fully custom SwiftUI annotation view, and shows a detail sheet when the user taps a pin. Selection is tracked via a @State optional that drives the sheet presentation, keeping the map and detail panel in sync without any UIKit bridge code.
import MapKit
import SwiftUI
// MARK: – Data model
struct Landmark: Identifiable {
let id = UUID()
let name: String
let category: LandmarkCategory
let coordinate: CLLocationCoordinate2D
}
enum LandmarkCategory: String, CaseIterable {
case food = "fork.knife"
case nature = "leaf.fill"
case culture = "building.columns.fill"
var tint: Color {
switch self {
case .food: return .orange
case .nature: return .green
case .culture: return .indigo
}
}
}
// MARK: – Custom annotation view
struct LandmarkPin: View {
let landmark: Landmark
var body: some View {
VStack(spacing: 0) {
ZStack {
Circle()
.fill(landmark.category.tint)
.frame(width: 40, height: 40)
.shadow(radius: 4, y: 2)
Image(systemName: landmark.category.rawValue)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.white)
}
// Teardrop tail
Image(systemName: "arrowtriangle.down.fill")
.font(.system(size: 10))
.foregroundStyle(landmark.category.tint)
.offset(y: -2)
}
.accessibilityLabel("\(landmark.name), \(landmark.category.rawValue) category")
}
}
// MARK: – Detail sheet
struct LandmarkDetail: View {
let landmark: Landmark
var body: some View {
VStack(spacing: 16) {
Image(systemName: landmark.category.rawValue)
.font(.system(size: 48))
.foregroundStyle(landmark.category.tint)
.padding(.top, 24)
Text(landmark.name)
.font(.title2.bold())
Text("Category: \(landmark.category.rawValue)")
.font(.subheadline)
.foregroundStyle(.secondary)
Map(bounds: .init(
centerCoordinateBounds: MKCoordinateRegion(
center: landmark.coordinate,
latitudinalMeters: 400,
longitudinalMeters: 400
)
)
) {
Annotation(landmark.name, coordinate: landmark.coordinate) {
LandmarkPin(landmark: landmark)
}
}
.frame(height: 180)
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(.horizontal)
Spacer()
}
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
}
}
// MARK: – Main map view
struct MapAnnotationsView: View {
@State private var selectedLandmark: Landmark?
@State private var position: MapCameraPosition = .region(
MKCoordinateRegion(
center: .init(latitude: 48.860, longitude: 2.305),
latitudinalMeters: 4_000,
longitudinalMeters: 4_000
)
)
private let landmarks: [Landmark] = [
Landmark(name: "Eiffel Tower",
category: .culture,
coordinate: .init(latitude: 48.8584, longitude: 2.2945)),
Landmark(name: "Café de Flore",
category: .food,
coordinate: .init(latitude: 48.8543, longitude: 2.3329)),
Landmark(name: "Jardin du Luxembourg",
category: .nature,
coordinate: .init(latitude: 48.8462, longitude: 2.3372)),
Landmark(name: "Louvre Museum",
category: .culture,
coordinate: .init(latitude: 48.8606, longitude: 2.3376)),
Landmark(name: "Marché d'Aligre",
category: .food,
coordinate: .init(latitude: 48.8501, longitude: 2.3743)),
]
var body: some View {
Map(position: $position) {
ForEach(landmarks) { landmark in
Annotation(landmark.name, coordinate: landmark.coordinate, anchor: .bottom) {
LandmarkPin(landmark: landmark)
.scaleEffect(selectedLandmark?.id == landmark.id ? 1.25 : 1.0)
.animation(.spring(duration: 0.25), value: selectedLandmark?.id)
.onTapGesture { selectedLandmark = landmark }
}
}
}
.mapStyle(.standard(elevation: .realistic))
.mapControls {
MapUserLocationButton()
MapCompass()
MapScaleView()
}
.sheet(item: $selectedLandmark) { landmark in
LandmarkDetail(landmark: landmark)
}
.ignoresSafeArea(edges: .top)
}
}
// MARK: – Preview
#Preview {
MapAnnotationsView()
}
How it works
-
Annotation(label:coordinate:anchor:content:) — The iOS 17 SwiftUI-native
Annotationreplaces the deprecatedMapAnnotation. Theanchor: .bottomparameter pins the bottom-center of your view to the coordinate, so the teardrop tail points exactly at the location. -
LandmarkPin view — A plain SwiftUI
VStackwith aCircleand anImage(systemName:)forms the pin head, while a smallarrowtrianglesymbol acts as the tail. No UIKit, noMKAnnotationViewsubclass needed. -
Selection state & animation —
@State private var selectedLandmark: Landmark?drives both thescaleEffectspring animation (line 88) and the sheet presentation, keeping a single source of truth. -
MapCameraPosition —
@State private var position: MapCameraPositiongives programmatic camera control. Passing a binding toMap(position:)lets you fly to a landmark or adjust zoom without dropping down to MapKit's imperative API. -
mapStyle(.standard(elevation: .realistic)) — New in iOS 17, this replaces the old
mapTypemodifier and enables 3-D building elevation alongside standard annotations with zero extra configuration.
Variants
Animated pulsing beacon annotation
struct PulsingBeacon: View {
@State private var isPulsing = false
var body: some View {
ZStack {
Circle()
.stroke(Color.blue.opacity(0.4), lineWidth: 2)
.frame(width: isPulsing ? 56 : 32, height: isPulsing ? 56 : 32)
.opacity(isPulsing ? 0 : 1)
.animation(
.easeOut(duration: 1.4).repeatForever(autoreverses: false),
value: isPulsing
)
Circle()
.fill(Color.blue)
.frame(width: 18, height: 18)
.overlay(Circle().stroke(.white, lineWidth: 2))
}
.onAppear { isPulsing = true }
.accessibilityLabel("Current location beacon")
}
}
// Usage inside Map:
// Annotation("You", coordinate: userCoord) { PulsingBeacon() }
Clustering annotations with MapCluster
Wrap your annotation content view in an Annotation and pass a clusteringIdentifier (available via the underlying MKPointAnnotation bridge) to group overlapping pins automatically at low zoom levels. In pure SwiftUI, set .annotationTitles(.hidden) on the Map to suppress the built-in label when you're rendering your own, and use MapCluster as a sibling content item to style the merged badge.
Common pitfalls
- iOS 16 / MapAnnotation deprecation:
MapAnnotationwas deprecated in iOS 17 and removed in iOS 18. The newAnnotationAPI shown here requires a minimum deployment target of iOS 17. Gate behind#if os(iOS)or@availablechecks if you still support iOS 16. - Anchor point confusion: Omitting
anchor:defaults to.center, so a teardrop-style pin appears floating above its coordinate. Always passanchor: .bottomwhen your view has a downward-pointing tail. - Performance with large datasets: Rendering hundreds of full SwiftUI annotation views simultaneously can cause frame drops. For 50+ pins, prefer
MapMarkerorMapCirclefor off-screen items and only promote visible or selected pins to the full custom view. Useinstruments → Core Animationto verify hit testing overhead.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement custom map annotations in SwiftUI for iOS 17+. Use MapKit/Annotation API. Make it accessible (VoiceOver labels on each pin and the map region). Add a #Preview with realistic sample data (5 varied landmarks with coordinates).
Drop this prompt in Soarias during the Build phase after your screen map has been approved — the agent will scaffold the data model, annotation views, and sheet detail in one pass, ready to wire into your SwiftData store.
Related
FAQ
Does this work on iOS 16?
No — the Annotation API and the new Map(position:) initialiser require iOS 17.0+. On iOS 16 you would use the now-deprecated MapAnnotation API. Since iOS 16 reached end-of-life in 2025 and App Store submissions require iOS 17+ from Xcode 16, targeting iOS 17 minimum is strongly recommended for new projects.
Can I use a UIView or image asset instead of an SF Symbol inside Annotation?
Yes — the Annotation closure accepts any SwiftUI View, so you can use Image("my-asset"), AsyncImage for remote icons, or even embed a UIViewRepresentable. Keep the view lightweight — avoid heavy gradients or Canvas drawing for pins that may be rendered in bulk.
What's the UIKit equivalent?
In UIKit you subclass MKAnnotationView, override init(annotation:reuseIdentifier:), and register the class with mapView.register(_:forAnnotationViewWithReuseIdentifier:). The SwiftUI Annotation API handles all of that internally — no reuse queue management or delegate method needed.
Last reviewed: 2026-05-11 by the Soarias team.