# MapKit Patterns for SwiftUI

Everything here targets iOS 17 and SwiftUI unless a heading names a later
release. Each file that uses these types imports both `MapKit` and `SwiftUI`.

## Contents

- [A Full Map Screen](#a-full-map-screen)
- [Custom Annotations](#custom-annotations)
- [Camera Control](#camera-control)
- [Selection](#selection)
- [Autocomplete Search](#autocomplete-search)
- [Route Display](#route-display)
- [Cycling Directions (iOS 14+)](#cycling-directions-ios-14)
- [Street-Level Look Around](#street-level-look-around)
- [Still Images with MKMapSnapshotter](#still-images-with-mkmapsnapshotter)
- [Dense Annotations and Clustering](#dense-annotations-and-clustering)
- [MKMapItem Helpers](#mkmapitem-helpers)
- [Geocoding and Places on iOS 26](#geocoding-and-places-on-ios-26)
- [Showing the User](#showing-the-user)
- [Maps Inside Scrolling Content](#maps-inside-scrolling-content)
- [Coordinate Math](#coordinate-math)
- [Accessibility](#accessibility)
- [Apple Documentation](#apple-documentation)

## A Full Map Screen

A bike-dock finder: user location, one marker per dock coloured by
availability, built-in points of interest hidden, a detail card for the
selected dock.

```swift
import SwiftUI
import MapKit

struct BikeDock: Identifiable, Hashable {
    let id: UUID
    let name: String
    let latitude: Double
    let longitude: Double
    let freeBikes: Int

    var coordinate: CLLocationCoordinate2D {
        CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
    }
}

struct DockFinderView: View {
    let docks: [BikeDock]
    @State private var camera: MapCameraPosition = .userLocation(fallback: .automatic)
    @State private var picked: BikeDock?

    var body: some View {
        Map(position: $camera, selection: $picked) {
            UserAnnotation()
            ForEach(docks) { dock in
                Marker(dock.name, systemImage: "bicycle", coordinate: dock.coordinate)
                    .tint(dock.freeBikes > 0 ? .green : .gray)
                    .tag(dock)
            }
        }
        .mapControls {
            MapCompass()
            MapPitchToggle()
            MapScaleView()
            MapUserLocationButton()
        }
        .mapStyle(.standard(pointsOfInterest: .excludingAll))
        .safeAreaInset(edge: .bottom) {
            if let picked {
                Text("\(picked.name): \(picked.freeBikes) bikes")
                    .padding()
                    .frame(maxWidth: .infinity)
                    .background(.regularMaterial)
            }
        }
    }
}
```

Whatever you tag and select must be `Hashable`. `CLLocationCoordinate2D` is
neither `Hashable` nor `Equatable`, so a model that stores one cannot get the
synthesised conformance. Either keep plain `Double` latitude and longitude, as
above, and build the coordinate on demand, or write `==` and `hash(into:)`
yourself using only `id`.

## Custom Annotations

`Annotation` takes arbitrary SwiftUI content. For an ordinary pin, prefer
`Marker`: it looks like the platform marker and VoiceOver reads its title
without extra work.

```swift
struct FriendPin: MapContent {
    let name: String
    let photo: URL
    let spot: CLLocationCoordinate2D

    var body: some MapContent {
        Annotation(name, coordinate: spot, anchor: .bottom) {
            VStack(spacing: 0) {
                AsyncImage(url: photo) { loaded in
                    loaded.resizable().aspectRatio(contentMode: .fill)
                } placeholder: {
                    ProgressView()
                }
                .frame(width: 40, height: 40)
                .clipShape(Circle())
                .overlay { Circle().strokeBorder(.white, lineWidth: 2) }

                Image(systemName: "arrowtriangle.down.fill")
                    .imageScale(.small)
                    .foregroundStyle(.white)
            }
        }
    }
}
```

### Callout-style annotation anchored at the bottom

SwiftUI's `Annotation` positions content with `anchor`, a `UnitPoint`; it has
no point-offset parameter. For a label floating above a pin, stack the label
over the symbol and anchor at `.bottom`, so the tip of the symbol sits on the
coordinate.

```swift
struct LabeledPin: MapContent {
    let title: String
    let spot: CLLocationCoordinate2D

    var body: some MapContent {
        Annotation(title, coordinate: spot, anchor: .bottom) {
            VStack(spacing: 3) {
                Text(title)
                    .font(.caption.weight(.semibold))
                    .padding(.horizontal, 8)
                    .padding(.vertical, 4)
                    .background(.ultraThinMaterial, in: Capsule())
                Image(systemName: "mappin.circle.fill")
                    .font(.title2)
                    .foregroundStyle(.white, .pink)
            }
        }
    }
}
```

## Camera Control

### Setting an initial position

| Position | Shows |
|----------|-------|
| `.region(MKCoordinateRegion(center: c, span: nearbySpan))` | a region around a centre (`nearbySpan` here is about 0.05 degrees each way) |
| `.userLocation(fallback: .automatic)` | follows the user |
| `.camera(MapCamera(centerCoordinate: c, distance: 1200, heading: 120, pitch: 55))` | a tilted 3D viewpoint, 1.2 km out, facing south-east |
| `.item(MKMapItem.forCurrentLocation())` | frames a map item |
| `.rect(someMapRect)` | frames an `MKMapRect` |

### Animate camera changes

Assign inside `withAnimation` (any curve, for example `.easeInOut`) for a fly-to:

```swift
func fly(to target: CLLocationCoordinate2D) {
    withAnimation(.smooth(duration: 1)) {
        let overhead = MapCamera(centerCoordinate: target, distance: 1800, heading: 30, pitch: 50)
        camera = .camera(overhead)
    }
}
```

### Frame multiple annotations

`camera = .automatic` (inside `withAnimation`) frames all map content.
`camera = .region(someRegion)` frames a region you computed, for example with
the helper in [Coordinate Math](#coordinate-math).

### Read current camera position

```swift
Map(position: $camera) { /* content */ }
    .onMapCameraChange(frequency: .onEnd) { ctx in
        visibleRegion = ctx.region
    }
```

`.onEnd` fires once the user stops moving the map. `.continuous` fires during
the gesture and costs more CPU; use it only for live tracking.

## Selection

### Selecting an MKMapItem

Bind selection to `MKMapItem?` when a pick should lead to place details.

```swift
struct MuseumMap: View {
    let museums: [MKMapItem]
    @State private var chosen: MKMapItem?
    @State private var streetScene: MKLookAroundScene?

    var body: some View {
        Map(selection: $chosen) {
            ForEach(museums, id: \.self) { museum in
                Marker(item: museum)
            }
        }
        .onChange(of: chosen) { _, museum in
            guard let museum else { return }
            Task { streetScene = try? await MKLookAroundSceneRequest(mapItem: museum).scene }
        }
    }
}
```

### Selecting by your own ID

Bind to `Place.ID?` and tag each marker with `.tag(place.id)`. The tag type and
the binding type must match.

## Autocomplete Search

Suggestions come from `MKLocalSearchCompleter`; picking one runs a full
`MKLocalSearch` that returns `MKMapItem`s.

```swift
import MapKit
import Observation

@MainActor
@Observable
final class PlaceFinder: NSObject, MKLocalSearchCompleterDelegate {
    private(set) var suggestions: [MKLocalSearchCompletion] = []
    private(set) var matches: [MKMapItem] = []
    private let completer: MKLocalSearchCompleter

    var fragment = "" {
        didSet { completer.queryFragment = fragment }
    }

    override init() {
        let engine = MKLocalSearchCompleter()
        engine.resultTypes = [.address, .pointOfInterest]
        completer = engine
        super.init()
        completer.delegate = self
    }

    func focus(on region: MKCoordinateRegion) {
        completer.region = region
    }

    nonisolated func completerDidUpdateResults(_ source: MKLocalSearchCompleter) {
        MainActor.assumeIsolated { suggestions = completer.results }
    }

    nonisolated func completer(_ source: MKLocalSearchCompleter, didFailWithError failure: any Error) {
        MainActor.assumeIsolated { suggestions = [] }
    }

    func choose(_ suggestion: MKLocalSearchCompletion) async {
        let request = MKLocalSearch.Request(completion: suggestion)
        request.resultTypes = [.pointOfInterest, .address]
        let search = MKLocalSearch(request: request)
        do {
            let reply = try await search.start()
            matches = reply.mapItems
        } catch {
            matches = []
        }
    }
}
```

`focus(on:)` narrows suggestions to the visible region. The delegate copies
`results` on update and clears the list on failure. It reads the model's own
`completer` inside `assumeIsolated`, not the callback parameter: the parameter
is a non-`Sendable` value from a nonisolated context, and Swift 6 will not
send it into the main-actor closure.

### Wiring it into a screen

```swift
struct PlaceSearchScreen: View {
    @State private var finder = PlaceFinder()
    @State private var typed = ""
    @State private var camera: MapCameraPosition = .automatic

    var body: some View {
        Map(position: $camera) {
            ForEach(finder.matches, id: \.self) { Marker(item: $0) }
        }
        .searchable(text: $typed, prompt: "Search places")
        .searchSuggestions {
            ForEach(finder.suggestions, id: \.self) { suggestion in
                Button {
                    Task { await finder.choose(suggestion) }
                } label: {
                    VStack(alignment: .leading) {
                        Text(suggestion.title)
                        Text(suggestion.subtitle).font(.caption).foregroundStyle(.secondary)
                    }
                }
            }
        }
        .task(id: typed) {
            let quietPeriod = Duration.milliseconds(300)
            try? await Task.sleep(for: quietPeriod)
            guard !Task.isCancelled else { return }
            finder.fragment = typed
        }
        .onMapCameraChange(frequency: .onEnd) { context in
            finder.focus(on: context.region)
        }
    }
}
```

`.task(id:)` restarts on each keystroke and cancels the previous sleep, which
is the debounce.

## Route Display

```swift
struct CommuteView: View {
    let home: MKMapItem
    let office: MKMapItem
    @State private var route: MKRoute?
    @State private var camera: MapCameraPosition = .automatic
    @State private var failed = false

    var body: some View {
        Map(position: $camera) {
            if let route {
                MapPolyline(route.polyline).stroke(.indigo, lineWidth: 6)
            }
            Marker(item: home).tint(.green)
            Marker(item: office).tint(.red)
        }
        .overlay(alignment: .top) {
            if let route {
                Text(Duration.seconds(route.expectedTravelTime)
                        .formatted(.units(allowed: [.hours, .minutes], width: .abbreviated)))
                    .padding(8)
                    .background(.ultraThinMaterial, in: Capsule())
            } else if failed {
                Text("No route found").padding(8).background(.ultraThinMaterial, in: Capsule())
            }
        }
        .task { await load() }
    }

    private func load() async {
        let request = MKDirections.Request()
        request.source = home
        request.destination = office
        request.transportType = .automobile
        let directions = MKDirections(request: request)
        do {
            let reply = try await directions.calculate()
            guard let best = reply.routes.first else {
                failed = true
                return
            }
            route = best
            withAnimation { camera = .rect(best.polyline.boundingMapRect) }
        } catch {
            failed = true
        }
    }
}
```

The travel time is formatted as hours and minutes in abbreviated units
(`DateComponentsFormatter` with `.abbreviated` and `[.hour, .minute]` does
the same on older code). After the route arrives the camera animates to the
polyline's `boundingMapRect`. Failures set visible state rather than being swallowed.

### Alternate routes

Set `request.requestsAlternateRoutes = true` and `response.routes` holds
several. Emphasise the first, mute the rest:

```swift
ForEach(Array(routes.enumerated()), id: \.offset) { position, option in
    let isBest = position == 0
    MapPolyline(option.polyline)
        .stroke(isBest ? Color.blue : Color.secondary.opacity(0.5), lineWidth: isBest ? 6 : 3)
}
```

### ETA Calculation

When only the duration matters, skip the geometry:

```swift
func minutesAway(_ request: MKDirections.Request) async throws -> TimeInterval {
    try await MKDirections(request: request).calculateETA().expectedTravelTime
}
```

## Cycling Directions (iOS 14+)

`MKDirectionsTransportType.cycling` has existed since iOS 14; it is not an
iOS 26 addition.

```swift
func bikeRoute(toward stop: MKMapItem) async throws -> MKRoute? {
    let request = MKDirections.Request()
    request.source = .forCurrentLocation()
    request.destination = stop
    request.transportType = .cycling
    let directions = MKDirections(request: request)
    let reply = try await directions.calculate()
    return reply.routes.first
}
```

## Street-Level Look Around

Look Around is Apple's street-level imagery. Coverage is regional, so always
plan for "no scene".

```swift
struct StreetPreview: View {
    let place: MKMapItem
    @State private var scene: MKLookAroundScene?

    var body: some View {
        ZStack {
            if let scene {
                LookAroundPreview(scene: .constant(scene))
            } else {
                ContentUnavailableView("No street view here", systemImage: "eye.slash")
            }
        }
        .frame(height: 200)
        .clipShape(RoundedRectangle(cornerRadius: 12))
        .task(id: place) {
            scene = nil
            scene = try? await MKLookAroundSceneRequest(mapItem: place).scene
        }
    }
}
```

### Street view floating over the map

```swift
Map(selection: $chosen) { /* markers */ }
    .overlay(alignment: .bottomTrailing) {
        if streetScene != nil {
            LookAroundPreview(scene: $streetScene)
                .frame(width: 200, height: 130, alignment: .center)
                .clipShape(RoundedRectangle(cornerRadius: 10))
                .padding()
        }
    }
    .onChange(of: chosen) { _, item in
        guard let item else { streetScene = nil; return }
        Task { streetScene = try? await MKLookAroundSceneRequest(mapItem: item).scene }
    }
```

## Still Images with MKMapSnapshotter

A snapshot is a still image of a map: share sheets, widgets, notification
attachments, list thumbnails.

```swift
import MapKit
import UIKit

let cardSize = CGSize(width: 300, height: 200)

func thumbnail(of spot: CLLocationCoordinate2D, fitting frame: CGSize = cardSize) async throws -> UIImage {
    let size = frame
    let options = MKMapSnapshotter.Options()
    options.mapType = .standard
    options.showsBuildings = true
    options.size = size
    options.region = MKCoordinateRegion(center: spot, latitudinalMeters: 1100, longitudinalMeters: 1100)

    let shot = try await MKMapSnapshotter(options: options).start()
    let pinPoint = shot.point(for: spot)
    let symbol = UIImage(systemName: "mappin.circle.fill")
    let pin = symbol?.withTintColor(.systemRed)

    return UIGraphicsImageRenderer(size: size).image { _ in
        shot.image.draw(at: .zero)
        pin?.draw(at: CGPoint(x: pinPoint.x - 12, y: pinPoint.y - 24))
    }
}
```

A square of 1100 m is roughly 0.01 degrees of latitude: street level.
`point(for:)` converts a coordinate into the image's coordinate space.

## Dense Annotations and Clustering

SwiftUI `Map` has no cluster configuration. With many markers:

- hide titles at low zoom with `.annotationTitles(.hidden)` on each `Marker`;
- show only what falls inside the current camera region;
- drop built-in points of interest with
  `.mapStyle(.standard(pointsOfInterest: .excludingAll))`.

Real clustering needs `MKMapView` through UIKit interop: give each
`MKMarkerAnnotationView` the same `clusteringIdentifier`.

## MKMapItem Helpers

### Open in Apple Maps

On iOS 26 build the item from a location; `MKMapItem(placemark:)` is
deprecated there.

```swift
@available(iOS 26, *)
func openDrivingDirections(to spot: CLLocationCoordinate2D, name: String) {
    let item = MKMapItem(location: CLLocation(latitude: spot.latitude, longitude: spot.longitude),
                         address: nil)
    item.name = name
    item.openInMaps(launchOptions: [MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDriving])
}
```

Below iOS 26 the same call starts from
`MKMapItem(placemark: MKPlacemark(coordinate: spot))`.

### Distance

```swift
func metres(from origin: CLLocationCoordinate2D, to target: CLLocationCoordinate2D) -> CLLocationDistance {
    let here = CLLocation(latitude: origin.latitude, longitude: origin.longitude)
    let there = CLLocation(latitude: target.latitude, longitude: target.longitude)
    return here.distance(from: there)
}
```

### Formatting distance

`MKDistanceFormatter` respects the user's locale and units:

```swift
let distanceText = MKDistanceFormatter()
distanceText.unitStyle = .abbreviated
let shown = distanceText.string(fromDistance: 1500)
```

`shown` is "0.9 mi" for a US-English user and "1.5 km" for most others.

## Geocoding and Places on iOS 26

### MKGeocodingRequest

Address text in, `MKMapItem`s out, with more detail than `CLGeocoder` gave.
The initializer is failable.

```swift
@available(iOS 26, *)
func resolveAll(_ lines: [String]) async -> [MKMapItem] {
    var found: [MKMapItem] = []
    for line in lines {
        guard let request = MKGeocodingRequest(addressString: line) else { continue }
        if let items = try? await request.mapItems {
            found.append(contentsOf: items)
        }
    }
    return found
}
```

### MKReverseGeocodingRequest

```swift
@available(iOS 26, *)
func address(at spot: CLLocation) async throws -> MKAddress? {
    guard let request = MKReverseGeocodingRequest(location: spot) else { return nil }
    return try await request.mapItems.first?.address
}
```

### MKAddress and MKAddressRepresentations

`MKAddress` has `fullAddress` and an optional `shortAddress`.
`MKMapItem.addressRepresentations` formats for a context:

```swift
@available(iOS 26, *)
func oneLine(_ item: MKMapItem) -> String {
    let formats = item.addressRepresentations
    if let line = formats?.fullAddress(includingRegion: true, singleLine: true) { return line }
    return item.address?.fullAddress ?? ""
}
```

`addressRepresentations` also offers `cityName`, `cityWithContext` and
`regionName`.

### PlaceDescriptor (via GeoToolbox)

`import GeoToolbox`. A `PlaceDescriptor` names a place by coordinate or
address when no place ID exists; resolve it with
`MKMapItemRequest(placeDescriptor:)` and `try await request.mapItem`. The
result is an ordinary `MKMapItem`, usable with `Marker(item:)`, directions and
place cards. Example in [SKILL.md](../SKILL.md#placedescriptor-ios-26).

## Showing the User

- `UserAnnotation()` in the map content draws the blue dot and accuracy ring.
- `MapUserLocationButton()` in `.mapControls` recentres on the user.
- Without authorization the dot simply is not drawn and no error is raised,
  so a missing dot is a permission question, not a rendering bug.

## Maps Inside Scrolling Content

Map gestures fight with scrolling. Turn them off for a thumbnail, or allow
only pinch zoom:

```swift
Map(position: $camera, interactionModes: []) {
    Marker("Venue", coordinate: venue)
}
.frame(height: 200)
.clipShape(RoundedRectangle(cornerRadius: 12))
```

`interactionModes: [.zoom]` keeps pinch without competing with the scroll.

## Coordinate Math

### Bounding region for a set of points

```swift
func enclosingRegion(_ points: [CLLocationCoordinate2D]) -> MKCoordinateRegion {
    let lats = points.map(\.latitude)
    let lons = points.map(\.longitude)
    guard let south = lats.min(), let north = lats.max(),
          let west = lons.min(), let east = lons.max() else { return MKCoordinateRegion() }
    return MKCoordinateRegion(
        center: CLLocationCoordinate2D(latitude: (south + north) / 2, longitude: (west + east) / 2),
        span: MKCoordinateSpan(latitudeDelta: (north - south) * 1.3,
                               longitudeDelta: (east - west) * 1.3)
    )
}
```

The factor 1.3 leaves 30 percent padding around the points.

### CLLocationCoordinate2D Equatable conformance

The type is not `Equatable`. Add it with `@retroactive`, which (Swift 5.10
and later) acknowledges conforming another module's type to another module's
protocol and silences the warning:

```swift
extension CLLocationCoordinate2D: @retroactive Equatable {
    public static func == (a: CLLocationCoordinate2D, b: CLLocationCoordinate2D) -> Bool {
        a.latitude == b.latitude && a.longitude == b.longitude
    }
}
```

## Accessibility

### Marker accessibility

VoiceOver reads a `Marker`'s title. Add detail when it helps:
`.accessibilityLabel("\(dock.name), \(distanceText) away")`.

### Map accessibility

Summarise the map itself:

```swift
Map(position: $camera) { /* markers */ }
    .accessibilityElement()
    .accessibilityLabel("Map of \(docks.count) bike docks nearby")
```

## Apple Documentation

- [MapKit for SwiftUI](https://developer.apple.com/documentation/mapkit/mapkit-for-swiftui)
- [Map](https://developer.apple.com/documentation/mapkit/map)
- [MapCameraPosition](https://developer.apple.com/documentation/mapkit/mapcameraposition)
- [MKLocalSearch](https://developer.apple.com/documentation/mapkit/mklocalsearch)
- [MKDirections](https://developer.apple.com/documentation/mapkit/mkdirections)
- [MKLookAroundScene](https://developer.apple.com/documentation/mapkit/mklookaroundscene)
