---
title: "MapKit Best Practices"
slug: "mapkit"
category: "ios"
tags: ["ios", "mapkit", "swift"]
status: "stable"
last_updated: 2026-08-14
summary: "MapKit for SwiftUI on iOS 18: Map view, annotations, overlays, camera control, look-around, clustering, and user location."
related: ["[[ios/swiftui]]", "[[ios/core-data]]", "[[coding/swift]]"]
---

## Overview

MapKit for SwiftUI (iOS 17+) replaces the UIViewRepresentable wrapper era. The new `Map` view, `Annotation`, `Marker`, `MapPolyline`, and `MapCameraPosition` give a declarative API that fits the rest of [[ios/swiftui|SwiftUI]]. This page covers the patterns to default to, the controls worth wiring up first, and the performance traps to avoid. For persistence of map data, see [[ios/core-data]]; for the Swift language rules, see [[coding/swift]].

## Use the SwiftUI `Map` view, not UIViewRepresentable

On iOS 17 and above, use the native `Map` initializer. Drop legacy `MKMapView` wrappers from new code; reach for them only when an API the SwiftUI surface does not expose is load-bearing (custom tile overlays, fine-grained gesture state).

```swift
import MapKit
import SwiftUI

struct CityMap: View {
  @State private var camera: MapCameraPosition = .region(
    MKCoordinateRegion(
      center: .init(latitude: 37.7749, longitude: -122.4194),
      span: .init(latitudeDelta: 0.05, longitudeDelta: 0.05)
    )
  )
  var body: some View {
    Map(position: $camera) {
      Marker("Ferry Building", coordinate: .init(latitude: 37.7955, longitude: -122.3937))
    }
  }
}
```

The new API ships with `Marker`, `Annotation`, polylines, polygons, look-around, and selection out of the box.

## Pick `Marker` for stock pins, `Annotation` for custom views

`Marker` renders the system pin with a tint and a glyph. Use it when the visual is "a pin on a map."

```swift
Marker("Office", systemImage: "building.2.fill", coordinate: office)
  .tint(.blue)
```

`Annotation` takes an arbitrary SwiftUI view. Use it for branded markers, photo bubbles, or anything with state.

```swift
Annotation("Trailhead", coordinate: trailhead) {
  Image(systemName: "figure.hiking")
    .padding(8)
    .background(.thinMaterial, in: .circle)
}
```

Default to `Marker` for density and `Annotation` only when you need the custom view; system pins draw faster.

## Draw paths with `MapPolyline` and `MapPolygon`

Use the declarative overlays. They batch through MapKit's renderer without a custom `MKOverlayRenderer`.

```swift
Map {
  MapPolyline(coordinates: route.coordinates)
    .stroke(.blue, lineWidth: 4)
  MapPolygon(coordinates: zone.coordinates)
    .foregroundStyle(.green.opacity(0.2))
    .stroke(.green, lineWidth: 2)
}
```

Keep the coordinate arrays in the model, not in the view; recomputing them in `body` re-uploads geometry on every render.

## Drive the camera through `MapCameraPosition`

Use a single `@State` of type `MapCameraPosition` as the source of truth. Animate transitions with `withAnimation`.

```swift
@State private var camera: MapCameraPosition = .automatic

Button("Frame route") {
  withAnimation(.easeInOut) {
    camera = .rect(route.boundingRect, edgePadding: .init(top: 40, left: 40, bottom: 40, right: 40))
  }
}
```

Use `.onMapCameraChange(frequency: .onEnd)` to read the user's final camera; the `.continuous` frequency fires on every frame and will dominate a profile.

## Enable look-around and selection where they help

Look-around adds a street-level preview without leaving the app. Use `LookAroundPreview` bound to a scene fetched with `MKLookAroundSceneRequest`.

```swift
@State private var scene: MKLookAroundScene?

LookAroundPreview(scene: $scene, allowsNavigation: true)
  .frame(height: 180)
  .task(id: selectedCoordinate) {
    scene = try? await MKLookAroundSceneRequest(coordinate: selectedCoordinate).scene
  }
```

For selectable annotations, bind `Map(selection:)` to an `Identifiable` value and switch the detail view on the selection.

## Cluster dense annotations and throttle camera work

A few hundred markers on screen will drop frames. Two rules cover most cases.

- Cluster when annotation count exceeds the visible region's complexity. SwiftUI's `Map` has no built-in clustering API; bucket coordinates into a grid keyed to the current zoom level and feed the map a small array of cluster and pin items, or fall back to `MKMapView` with `MKClusterAnnotation` for extreme density. See [[ios/mapkit-annotations]] for a worked example.
- Throttle camera-driven fetches. Use `.onMapCameraChange(frequency: .onEnd)` and debounce server calls; do not fire a request on every frame of a pan.

Render only what is inside the visible region. Filter your annotation list against `context.region` before passing it to `Map`.

## Request location when in use; degrade gracefully

Request `whenInUse` authorization from `CLLocationManager`. Show the user's location only after authorization resolves; never prompt at app launch.

```swift
@State private var manager = CLLocationManager()

.task {
  manager.requestWhenInUseAuthorization()
}

Map(position: $camera) {
  UserAnnotation()
}
.mapControls {
  MapUserLocationButton()
  MapCompass()
}
```

If the user denies access, hide the location button and center on a sensible default. Add `NSLocationWhenInUseUsageDescription` to `Info.plist` with a one-sentence reason; vague reasons get rejected at App Review.

## Related

- [[ios/swiftui]]
- [[ios/core-data]]
- [[coding/swift]]
