---
name: mapkit-location
description: "MapKit and CoreLocation on iOS and macOS: SwiftUI Map, markers, annotations, polylines and overlays, camera control, user location tracking, CLLocationUpdate streams, location authorization, geocoding and reverse geocoding, search and autocomplete, directions, routes and ETA, CLMonitor geofencing and region monitoring, background location, distance math. Use when implementing, reviewing or improving anything built on maps, coordinates, addresses, places, directions or distances."
metadata:
  source: multi-agent-pipeline
---

# MapKit and CoreLocation

Two frameworks do the work here. MapKit draws the map and answers questions
about places (search, geocoding, routes). CoreLocation tells you where the
device is and when it crosses a boundary. Since iOS 17 both have a Swift-first
surface: a SwiftUI `Map` whose content is written in a `MapContentBuilder`
closure, the async sequence `CLLocationUpdate.liveUpdates()` for position, and
the `CLMonitor` actor for geofences. Write new code against those; reach for
`MKMapView` or `CLLocationManager` only for the gaps named below.

Baseline: iOS 17. Sections that need a later release say so, and code that
needs it carries `@available`.

Longer material lives in two references:

- [Map patterns](references/mapkit-patterns.md): a complete
  map screen, custom annotations, camera control, search with autocomplete,
  routes, Look Around, snapshots, dense annotations, iOS 26 place APIs,
  coordinate helpers, accessibility.
- [Location patterns](references/mapkit-corelocation-patterns.md):
  the location update lifecycle, diagnostics, `CLServiceSession`, `CLMonitor`
  geofencing, background location, legacy `CLLocationManager` cases, accuracy,
  simulator testing, Info.plist privacy keys.

## Scope

In scope: maps, markers and annotations, overlays, routes, search and
geocoding, the user's position, `CLMonitor` geofencing and the rules for
location in the background.

Neighbouring work belongs elsewhere. MapKit does not draw CarPlay templates,
launch App Clips or pair Bluetooth hardware, so never claim it does. What this
skill owns is the handoff point: a geofence event, a search result or a route
that the other framework then acts on.

| Topic | Where it goes |
|-------|---------------|
| CarPlay map templates, navigation alerts, CarPlay UI | CarPlay framework guidance (no skill in this collection) |
| App Clip invocation from a place card, size limits, handoff to the full app | `app-clips` |
| Pairing a beacon or accessory when a geofence fires | `core-bluetooth`, or AccessorySetupKit |
| A Live Activity used to keep background location alive | `live-activities` |

## Workflows

### 1. Put places on a map

1. `import MapKit` (and `SwiftUI`).
2. Create a `Map`. Bind a `MapCameraPosition` if code needs to move the camera.
3. Inside the content closure add `Marker`, `Annotation`, `MapPolyline`,
   `MapPolygon` or `MapCircle`.
4. Pick a look with `.mapStyle(...)`.
5. Add controls with `.mapControls { ... }`.
6. For tap-to-select, pass a `selection:` binding.

### 2. Follow the user's position

1. Put a `NSLocationWhenInUseUsageDescription` string in Info.plist.
2. On iOS 18 and later, create a `CLServiceSession` when the feature starts
   and keep it until the feature ends; it drives the authorization prompt.
3. Iterate `CLLocationUpdate.liveUpdates()` inside a `Task` that you keep.
4. Drop samples that are inaccurate, old or implausible before the UI sees them.
5. Cancel the task as soon as the feature no longer needs position.

### 3. Find places

1. Feed typed text to an `MKLocalSearchCompleter` for suggestions.
2. Wait at least 300 ms after the last keystroke before updating the query.
3. Turn the picked suggestion into an `MKLocalSearch.Request` for full results.
4. Show the `MKMapItem` results as markers, a list, or both.

### 4. Route between two places

1. Build an `MKDirections.Request` with a source and destination `MKMapItem`.
2. Choose a mode in `transportType` (car, walking, transit or cycling).
3. `try await MKDirections(request:).calculate()`.
4. Draw `MapPolyline(route.polyline)`.

### 5. Review existing code

Go through the [Review Checklist](#review-checklist) below.

## SwiftUI Map (iOS 17+)

A SwiftUI map file imports both `SwiftUI` and `MapKit`.

```swift
import SwiftUI
import MapKit

struct TrailheadMap: View {
    @State private var camera: MapCameraPosition = .automatic

    private let lot = CLLocationCoordinate2D(latitude: 46.852, longitude: -121.760)
    private let summit = CLLocationCoordinate2D(latitude: 46.853, longitude: -121.727)

    var body: some View {
        Map(position: $camera) {
            Marker("Parking", coordinate: lot)
            Marker("Summit", systemImage: "mountain.2.fill", coordinate: summit)
        }
        .mapStyle(.standard(elevation: .realistic))
        .mapControls {
            MapUserLocationButton()
            MapCompass()
            MapScaleView()
        }
    }
}
```

### Marker or Annotation

- `Marker(_:systemImage:coordinate:)` draws the system balloon. It is the
  cheapest way to flag a spot, VoiceOver reads its title, and `.tint(_:)`
  recolours it.
- `Annotation(_:coordinate:anchor:content:)` puts any SwiftUI view at a
  coordinate. `anchor` is a `UnitPoint`; `.bottom` makes the bottom edge of
  the view touch the coordinate, which suits pin-shaped content.

```swift
Annotation("Ranger station", coordinate: station, anchor: .bottom) {
    Image(systemName: "house.fill")
        .padding(6)
        .foregroundStyle(.white)
        .background(Circle().fill(.green.gradient))
}
```

### Overlays

| Content | Draws | Styling |
|---------|-------|---------|
| `MapPolyline(coordinates:)` | a line through the points | `.stroke(.orange, lineWidth: 4)` |
| `MapPolygon(coordinates:)` | a filled area | `.foregroundStyle(.teal.opacity(0.25))` plus `.stroke(...)` for the edge |
| `MapCircle(center:radius:)` | a circle, radius in metres | `.foregroundStyle(...)` and `.stroke(...)` |

### Camera position

`MapCameraPosition` is what the map is showing. Bound to the map, it moves
when the user pans and moves the map when code assigns it. Its constructors:
`.region`, `.userLocation(fallback:)`, `.camera`, `.item`, `.rect`,
`.automatic`. Animated moves, framing all content and reading the visible
region are in [Camera Control](references/mapkit-patterns.md#camera-control).

### Map styles

| Modifier | Result |
|----------|--------|
| `.mapStyle(.standard)` | default road map |
| `.mapStyle(.standard(elevation: .realistic, showsTraffic: true))` | roads, 3D terrain, live traffic |
| `.mapStyle(.imagery)` | satellite |
| `.mapStyle(.imagery(elevation: .realistic))` | satellite in 3D |
| `.mapStyle(.hybrid)` | satellite with labels |
| `.mapStyle(.hybrid(elevation: .realistic, showsTraffic: true))` | labelled satellite, 3D terrain, live traffic |

### Interaction modes

| Modifier | Allows |
|----------|--------|
| `.mapInteractionModes(.all)` | pan, zoom, rotate, pitch (the default) |
| `.mapInteractionModes(.pan)` | panning only |
| `.mapInteractionModes([.pan, .zoom])` | moving and zooming, no rotation or tilt |
| `.mapInteractionModes([])` | nothing; a static picture |

### Selection

The value in `.tag(_:)` must have the same type as the `selection` binding,
or taps select nothing.

```swift
struct CampsiteMap: View {
    let sites: [Campsite]
    @State private var chosen: MKMapItem?
    @State private var headline = ""

    var body: some View {
        Map(selection: $chosen) {
            ForEach(sites) { site in
                Marker(site.name, coordinate: site.coordinate)
                    .tag(site.mapItem)
            }
        }
        .onChange(of: chosen) { _, item in
            guard let item else { return }
            headline = item.name ?? ""
        }
    }
}
```

## CoreLocation

### CLLocationUpdate.liveUpdates() (iOS 17+)

One async sequence replaces the `CLLocationManagerDelegate` callbacks. Each
element is a `CLLocationUpdate` whose `location` is optional. Rules:

- Store the iterating `Task` in a property; cancelling it is how tracking stops.
- Throw away samples with invalid or poor accuracy, old timestamps or
  impossible jumps before they touch map UI or background work.
- On iOS 18 and later the update also carries diagnostics (authorization
  denied, Location Services off system-wide, location unavailable, app not
  sufficiently in use). React with a visible fallback instead of waiting on a
  sequence that will not deliver.

```swift
import CoreLocation
import Observation

@MainActor
@Observable
final class RunTracker {
    private(set) var lastFix: CLLocation?
    private var stream: Task<Void, Never>?

    func begin() {
        stream?.cancel()
        stream = Task {
            do {
                for try await reading in CLLocationUpdate.liveUpdates() {
                    guard let fix = reading.location,
                          fix.horizontalAccuracy >= 0,
                          fix.horizontalAccuracy < 50 else { continue }
                    lastFix = fix
                }
            } catch is CancellationError {
            } catch {
                lastFix = nil
            }
        }
    }

    func end() {
        stream?.cancel()
        stream = nil
    }
}
```

Diagnostics, the full filter set and a mapping from each state to UI are in
the [location reference](references/mapkit-corelocation-patterns.md#diagnostics-and-fallbacks-ios-18).

### CLServiceSession (iOS 18+)

A `CLServiceSession` states what authorization a feature needs, for as long
as the feature runs. Store it in a property and release it when the feature
ends. On iOS 18 `liveUpdates()` and `CLMonitor` create a session implicitly
when you have not; create one yourself when you need `.always` or full
accuracy. It is not available on macOS.

```swift
@available(iOS 18, *)
let nearbySession = CLServiceSession(
    authorization: .whenInUse,
    fullAccuracyPurposeKey: "NearbyTrails"
)
```

### Authorization

- `NSLocationWhenInUseUsageDescription` is required for any location use.
- `NSLocationAlwaysAndWhenInUseUsageDescription` only when the feature truly
  needs `.always`.

A denied user should see why the feature is empty and a way out:

```swift
struct LocationOffView: View {
    @Environment(\.openURL) private var openURL

    var body: some View {
        ContentUnavailableView {
            Label("Location is off", systemImage: "location.slash")
        } description: {
            Text("Allow location access to see trails near you.")
        } actions: {
            Button("Open Settings") {
                guard let settings = URL(string: UIApplication.openSettingsURLString) else { return }
                openURL(settings)
            }
        }
    }
}
```

## Geocoding

### MKGeocodingRequest and MKReverseGeocodingRequest (iOS 26+)

MapKit now geocodes by itself and returns `MKMapItem`, which carries more than
a placemark: an `MKAddress` and `MKAddressRepresentations` for formatting.
Both initializers are failable; `mapItems` is an async throwing property.

```swift
@available(iOS 26, *)
func lookUp(_ text: String) async throws -> MKMapItem? {
    guard let request = MKGeocodingRequest(addressString: text) else { return nil }
    return try await request.mapItems.first
}
```

Reverse lookup is `MKReverseGeocodingRequest(location:)`. Formatting and
batch lookups are in [Geocoding and Places on iOS 26](references/mapkit-patterns.md#geocoding-and-places-on-ios-26).

### CLGeocoder (available since iOS 5, deprecated in iOS 26)

Use it only while the deployment target is below iOS 26. The SDK's deprecation
messages point reverse lookups to `MKReverseGeocodingRequest` and forward
lookups to `MKGeocodingRequest`. Every `CLPlacemark`
field is optional. `CLGeocoder` is not `Sendable`, so Swift 6 rejects one
shared global instance; make a geocoder for each lookup instead.

```swift
func coordinate(for address: String) async throws -> CLLocationCoordinate2D? {
    try await CLGeocoder().geocodeAddressString(address).first?.location?.coordinate
}

func label(for spot: CLLocation) async throws -> String {
    let marks = try await CLGeocoder().reverseGeocodeLocation(spot)
    guard let mark = marks.first else { return "" }
    return [mark.name, mark.locality, mark.administrativeArea]
        .compactMap { $0 }
        .joined(separator: ", ")
}
```

## Search

### MKLocalSearchCompleter (autocomplete)

Set `queryFragment` and read `results` from the delegate. Debounce input by
300 ms or more and set `completer.region` to the visible map region so
suggestions stay local.

### MKLocalSearch (full results)

```swift
func places(for pick: MKLocalSearchCompletion) async throws -> [MKMapItem] {
    let request = MKLocalSearch.Request(completion: pick)
    request.resultTypes = [.pointOfInterest, .address]
    let reply = try await MKLocalSearch(request: request).start()
    return reply.mapItems
}
```

For free text, set `request.naturalLanguageQuery` and `request.region`
instead of passing a completion. The delegate-based service and the
`.searchable` screen are in
[Autocomplete Search](references/mapkit-patterns.md#autocomplete-search).

## Directions

`MKMapItem` and `MKRoute` are not `Sendable`. Keep the helper on the main
actor, where the SwiftUI view that calls it already runs, so the items never
cross an isolation boundary:

```swift
@MainActor
func fastestRoute(
    from start: MKMapItem,
    to end: MKMapItem,
    by mode: MKDirectionsTransportType = .automobile
) async throws -> MKRoute? {
    let request = MKDirections.Request()
    request.source = start
    request.destination = end
    request.transportType = mode
    let reply = try await MKDirections(request: request).calculate()
    return reply.routes.first
}
```

### Drawing the route

```swift
struct FerryRouteMap: View {
    let pier: MKMapItem
    let island: MKMapItem
    @State private var route: MKRoute?

    var body: some View {
        Map {
            if let route {
                MapPolyline(route.polyline).stroke(.teal, lineWidth: 5)
            }
            Marker(item: pier)
            Marker(item: island)
        }
        .task { route = try? await fastestRoute(from: pier, to: island) }
    }
}
```

### Travel time only, and cycling

- `MKDirections.calculateETA()` returns `expectedTravelTime` without route
  geometry; use it when a label is all you need
  ([ETA Calculation](references/mapkit-patterns.md#eta-calculation)).
- Bike routes: `request.transportType = .cycling`, iOS 14 and later
  ([Cycling Directions](references/mapkit-patterns.md#cycling-directions-ios-14)).

## PlaceDescriptor (iOS 26+)

`PlaceDescriptor`, from `GeoToolbox`, describes a place by coordinate or
address when you have no MapKit place ID. `MKMapItemRequest` resolves it into
a full `MKMapItem`.

```swift
import GeoToolbox
import MapKit

@available(iOS 26, *)
func mapItem(near point: CLLocationCoordinate2D, named name: String) async throws -> MKMapItem {
    let place = PlaceDescriptor(representations: [.coordinate(point)], commonName: name)
    return try await MKMapItemRequest(placeDescriptor: place).mapItem
}
```

## Common Mistakes

| Mistake | Do instead |
|---------|-----------|
| Asking for Always authorization at launch | Start with when-in-use; ask for `.always` only for a background feature that needs relaunch after termination |
| No `CLServiceSession` on iOS 18+ | Hold one for the life of the feature |
| A `CLLocationManagerDelegate` just to read position on iOS 17+ | Iterate `liveUpdates()`; less code, same result |
| Ignoring diagnostics (denied, off system-wide, unavailable) | Switch to a reduced mode, point the user at Settings, and leave search and manual entry usable |
| `liveUpdates()` in a task nobody owns | Keep the `Task` in a property and cancel it with the screen; filter bad fixes |
| Force-unwrapping `CLPlacemark` fields | They are all optional: `mark.locality ?? "Unknown"` |
| Querying the completer on every keystroke | `.task(id: text)` plus `Task.sleep(for: .milliseconds(300))` |
| Silent failure when denied | Detect `.denied` and offer a Settings link |
| Assuming geocoding returns something | Plan for zero matches and for being offline |

## Review Checklist

- [ ] `NSLocationWhenInUseUsageDescription` is present and names a concrete benefit
- [ ] A denied user gets an explanation and a Settings deep link
- [ ] The `CLLocationUpdate` task is cancelled when not needed, to save battery
- [ ] Requested accuracy matches what the feature needs
- [ ] Annotation data is `Identifiable` with stable IDs
- [ ] Geocoding handles network failure and empty results
- [ ] Completer input is debounced
- [ ] `CLMonitor` stays at or under 20 conditions and the instance is kept alive
- [ ] Background location uses `CLBackgroundActivitySession`
- [ ] The map has been tried with VoiceOver
- [ ] Annotation models and location-driven UI state are `@MainActor`

## References

- [Map patterns](references/mapkit-patterns.md): map setup,
  annotations, camera, search, routes, dense maps and clustering, Look Around,
  snapshots, iOS 26 APIs.
- [Location patterns](references/mapkit-corelocation-patterns.md):
  `CLLocationUpdate`, `CLServiceSession`, `CLMonitor`, background location,
  testing, privacy keys.
