7.4 — MapKit & CoreLocation

Opening scenario

The PM wants a “Find My”-style screen: live user location, a few annotations, the ability to draw a route. You start with Map { ... } in SwiftUI and hit a wall the moment the brief grows: clustering 10,000 pins, drawing a polyline, switching to a custom tile source, getting reverse-geocoded street addresses for an annotation. Welcome to MapKit + CoreLocation — twin frameworks deep enough to fill a year, accessible enough that the basics fit in a single chapter.

ContextWhat it usually means
Reads “Map in SwiftUI”Has built simple location views
Reads “MKMapView”Has used UIKit MapKit for advanced features
Reads “geocoding”Has converted between coordinates and addresses
Reads “CLLocationManager”Has wrangled the permission dance
Reads “region monitoring / CLVisit”Has built location-aware background features

Concept → Why → How → Code

Concept

  • CoreLocation owns the device’s location: GPS, Wi-Fi/cell triangulation, region monitoring, visit detection, motion-based activity.
  • MapKit displays maps and annotations. SwiftUI’s Map is a thin layer over MKMapView; for complex needs, drop back to UIKit MKMapView via UIViewRepresentable.

Why

Native MapKit is free, integrates with Apple Maps for navigation handoffs, respects user privacy, and offers richer features (3D buildings, Look Around, Maps Server API for search) than most third-party SDKs without leaking user behavior.

How — CoreLocation permission

Add to Info.plist:

  • NSLocationWhenInUseUsageDescription — required for foreground access.
  • NSLocationAlwaysAndWhenInUseUsageDescription — required if you ever want background.
  • NSLocationTemporaryUsageDescriptionDictionary — for precise-location prompts in iOS 14+.
import CoreLocation

@Observable
@MainActor
final class LocationManager: NSObject, CLLocationManagerDelegate {
    private let manager = CLLocationManager()
    var lastLocation: CLLocation?
    var authorization: CLAuthorizationStatus = .notDetermined

    override init() {
        super.init()
        manager.delegate = self
        manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
        authorization = manager.authorizationStatus
    }

    func requestWhenInUse() { manager.requestWhenInUseAuthorization() }
    func startUpdates() { manager.startUpdatingLocation() }

    nonisolated func locationManagerDidChangeAuthorization(_ mgr: CLLocationManager) {
        Task { @MainActor in
            self.authorization = mgr.authorizationStatus
            if self.authorization == .authorizedWhenInUse {
                self.startUpdates()
            }
        }
    }

    nonisolated func locationManager(_ mgr: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        guard let location = locations.last else { return }
        Task { @MainActor in self.lastLocation = location }
    }

    nonisolated func locationManager(_ mgr: CLLocationManager, didFailWithError error: Error) {
        // Most failures are transient; log and ignore.
    }
}

Precise vs reduced accuracy (iOS 14+)

If the user grants only “Approximate Location,” lastLocation is fuzzed to ~1km. For one-off precise reads:

manager.requestTemporaryFullAccuracyAuthorization(withPurposeKey: "PreciseForRouteSearch")

PreciseForRouteSearch must exist as a key in NSLocationTemporaryUsageDescriptionDictionary.

SwiftUI Map (iOS 17+)

import MapKit
import SwiftUI

struct StoresMap: View {
    @State private var position: MapCameraPosition = .automatic
    let stores: [Store]
    let user: CLLocation?

    var body: some View {
        Map(position: $position) {
            UserAnnotation()
            ForEach(stores) { store in
                Marker(store.name, systemImage: "cup.and.saucer", coordinate: store.coordinate)
                    .tint(.brown)
            }
            if let user, let nearest = stores.min(by: { $0.distance(to: user) < $1.distance(to: user) }) {
                MapPolyline(coordinates: [user.coordinate, nearest.coordinate])
                    .stroke(.blue, lineWidth: 4)
            }
        }
        .mapStyle(.standard(elevation: .realistic))
        .mapControls {
            MapUserLocationButton()
            MapCompass()
            MapPitchToggle()
        }
        .safeAreaInset(edge: .bottom) {
            StoreListSheet(stores: stores)
        }
    }
}

MapContentBuilder (the closure DSL) supports Marker, Annotation (custom view), MapCircle, MapPolyline, MapPolygon.

Custom annotation view

Annotation("HQ", coordinate: CLLocationCoordinate2D(latitude: 37.33, longitude: -122.03)) {
    VStack(spacing: 0) {
        Image(systemName: "building.2.fill")
            .padding(8)
            .background(.tint.opacity(0.2), in: .circle)
        Image(systemName: "arrowtriangle.down.fill")
            .offset(y: -4)
    }
}

Forward & reverse geocoding

let geocoder = CLGeocoder()

// Address → coordinate
let placemarks = try await geocoder.geocodeAddressString("1 Infinite Loop, Cupertino")
let coordinate = placemarks.first?.location?.coordinate

// Coordinate → address
let placemark = try await geocoder.reverseGeocodeLocation(location).first
let street = placemark?.thoroughfare ?? "—"

CLGeocoder is rate-limited by Apple (~one request per few seconds). For high-volume server-side geocoding, use Apple MapKit Server API or Google/Mapbox.

Region monitoring (geofencing)

let region = CLCircularRegion(
    center: CLLocationCoordinate2D(latitude: 40.78, longitude: -73.97),
    radius: 100,
    identifier: "central-park-entrance"
)
region.notifyOnEntry = true
region.notifyOnExit = true
manager.startMonitoring(for: region)

// Delegate
func locationManager(_ mgr: CLLocationManager, didEnterRegion region: CLRegion) {
    // Fired even if app is killed (subject to OS budget)
}

iOS caps at 20 monitored regions per app. For more, swap them in/out as the user moves.

Significant location changes (battery-friendly background)

manager.startMonitoringSignificantLocationChanges()

Wakes the app every ~500m of movement. Far cheaper than continuous updates; perfect for crash-resilient location loggers.

CLVisit

manager.startMonitoringVisits()

func locationManager(_ mgr: CLLocationManager, didVisit visit: CLVisit) {
    // Triggered when the user arrives/departs from a "significant" place
}

The OS does the inference: dwell time + radius. Lifelogging apps love this.

In the wild

  • Apple Maps, Find My, Reminders (location-based) — all use MapKit + CoreLocation.
  • Yelp, OpenTable — MapKit for restaurant pins, often layered with custom annotations.
  • Strava, Nike Run Club — CoreLocation continuous high-accuracy in foreground, polyline rendering on MapKit.
  • Citymapper — uses MapKit basemap with overlay routing computed server-side.
  • Find My Friends — region monitoring + significant change for stalker-free location sharing.

Common misconceptions

  1. requestAlwaysAuthorization works immediately.” Apple now shows the “Always” prompt only after the user has been on “When In Use” for a while. Plan a two-step UX.
  2. “Background updates work as long as my background mode is on.” iOS will kill your app eventually if it sits in the background only for location. Use significant-change or region monitoring for long-running needs.
  3. CLGeocoder can handle bulk addresses.” It’s rate-limited and intended for occasional UI use. Bulk geocoding belongs on a server.
  4. “MapKit needs an API key.” It doesn’t on iOS. The server-side MapKit JS and MapKit Server API require a JWT, but the iOS framework is free with your Apple Developer Program membership.
  5. Map { } in SwiftUI can do everything MKMapView does.” SwiftUI’s map gets closer each year but still lacks fine-grained gestures, custom tile overlays, and some legacy delegate hooks. Drop to UIViewRepresentable for those.

Seasoned engineer’s take

Location is the place teams overspend battery and underspend privacy review. The two best instincts:

  1. Always ask the question: “What’s the minimum accuracy and frequency that satisfies the feature?” A delivery app’s “ETA updating” feature does not need 1-meter precision every second; reduced accuracy + significant location changes are usually enough.
  2. The user is right when they grant “Approximate.” Don’t keep nagging for “Precise.” Build the feature to be useful with approximate; offer a one-tap temporary-precise upgrade for the moments that demand it (e.g., turning navigation).

On MapKit specifically: prefer Marker for known SF Symbols, drop to Annotation for custom UI, and don’t try to render 10K annotations in SwiftUI — MKMapView with MKClusterAnnotation still wins at scale.

TIP: Set manager.pausesLocationUpdatesAutomatically = true and manager.activityType = .fitness (or your app’s activity) to let iOS smartly pause updates when the user is stationary. Apps that override these defaults blindly are why battery icons turn yellow.

WARNING: Never log raw CLLocation values to a third-party analytics service. Always bucket to ~1km or coarser. Apple’s privacy reviewers and your DPO will both care.

Interview corner

Junior: “How do you ask for location permission and read the current location?”

Add the NSLocationWhenInUseUsageDescription Info.plist key, create a CLLocationManager, set its delegate, call requestWhenInUseAuthorization(), and start updates after locationManagerDidChangeAuthorization confirms authorized. Read locations.last in didUpdateLocations.

Mid: “How would you implement a low-battery geofencing feature for 50 stores in a city?”

iOS caps startMonitoring(for:) at 20 regions per app. Compute the 20 closest stores to the user’s current significant-change region, monitor those. When didExitRegion fires, re-rank and update the monitored set. Use startMonitoringSignificantLocationChanges instead of continuous updates so the app wakes every ~500m, not every second.

Senior: “Design the location pipeline for a ride-sharing driver app where the server needs the driver’s position every 5 seconds with sub-10-meter accuracy.”

Foreground, high-accuracy CLLocationManager updates with desiredAccuracy = kCLLocationAccuracyBest, distanceFilter = kCLDistanceFilterNone. Background: enable Location background mode, use allowsBackgroundLocationUpdates = true. Wrap updates in an actor that batches and sends every 5s rather than every update; gzip the payload. Heartbeat with a server keep-alive over WebSocket; on disconnect, queue updates locally for 60s then drop oldest. Battery hedge: when CLActivityManager reports stationary for >2min, drop to significant-change to save power; on motion resume, return to high-accuracy. Privacy: rotate the analytics ID per shift; obfuscate the precise lat/lng of pickup/dropoff in any non-active-trip context.

Red flag: “We just keep continuous location updates running in the background to be safe.”

Battery, App Review rejection, and a privacy violation in one sentence. Demonstrates the candidate doesn’t understand iOS’s background execution budget or the user-facing battery and privacy implications.

Lab preview

Lab 7.1 — Weather + Map app wires this chapter together: user location + MapKit annotations + WeatherKit overlay. You’ll see exactly how CLLocationManager, Map, and WeatherService cooperate in a real screen.


Next: 7.5 — HealthKit