
Axiom Location
- 584 installs
- 1.1k repo stars
- Updated August 3, 2026
- charleswiltgen/axiom
axiom-location is an agent skill that helps iOS and SwiftUI developers implement Core Location, CLMonitor geofencing, and MapKit maps without guessing authorization flows, monitoring modes, or annotation APIs.
About
axiom-location is an MIT-licensed skill from the charleswiltgen/axiom collection for any Core Location, geofencing, or MapKit work in iOS and SwiftUI apps. The skill mandates consulting it before implementing location services, mapping, or debugging MapKit issues, and routes tasks through a symptom table covering When In Use versus Always authorization, continuous versus significant-change monitoring, CLMonitor geofencing, accuracy selection, background location, CLLocationManager setup, MapKit annotations, and directions. Detailed guidance lives in bundled references such as skills/core-location.md rather than inline guesses. Developers reach for axiom-location when adding map screens, region monitoring, permission copy, or fixing authorization denials and stale location updates in SwiftUI projects. The skill is framed as a required checklist for Apple location APIs so agents pick correct monitoring strategies and MapKit patterns instead of mixing deprecated callbacks or wrong permission tiers.
- Routing table maps symptoms (no updates, denied auth, geofence failures) to dedicated Core Location and MapKit diagnosti
- Covers authorization strategy (When In Use vs Always), monitoring modes, accuracy, and background location
- Documents CLLocationUpdate, CLMonitor, CLServiceSession, geofencing, and authorization API patterns
- SwiftUI Map vs MKMapView guidance plus annotations, markers, clustering, search, directions, Look Around, and snapshots
- Mandatory skill gate: agent must use it for any location, mapping, or geofencing work
Axiom Location by the numbers
- 584 all-time installs (skills.sh)
- Ranked #286 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-locationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 584 |
|---|---|
| repo stars | ★ 1.1k |
| Security audit | 3 / 3 scanners passed |
| Last updated | August 3, 2026 |
| Repository | charleswiltgen/axiom ↗ |
How do you configure iOS geofencing and MapKit correctly?
Ship Core Location, geofencing, and MapKit features in an iOS or SwiftUI app without guessing authorization, monitoring mode, or MapKit API choices.
Who is it for?
iOS engineers shipping location-aware SwiftUI features who need authoritative Core Location and MapKit patterns instead of trial-and-error API calls.
Skip if: Android geolocation with Fused Location Provider or server-side geospatial queries unrelated to Apple Core Location frameworks.
When should I use this skill?
A developer implements iOS location permissions, geofencing, background location, MapKit maps, or debugs CLMonitor or CLLocationManager behavior.
What you get
SwiftUI location managers, Info.plist permission strings, CLMonitor region setup, and MapKit annotation or directions integration.
- Location authorization flow
- Geofence monitoring setup
- MapKit map and annotation code
Files
Location & Maps
You MUST use this skill for ANY location services, mapping, geofencing, or Core Location / MapKit work.
Quick Reference
| Symptom / Task | Reference |
|---|---|
| Authorization strategy (When In Use vs Always) | See skills/core-location.md |
| Monitoring approach (continuous, significant-change, CLMonitor) | See skills/core-location.md |
| Accuracy selection, background location | See skills/core-location.md |
| CLLocationUpdate, CLMonitor, CLServiceSession APIs | See skills/core-location-ref.md |
| Authorization API patterns, geofencing API | See skills/core-location-ref.md |
Compass heading, heading reference body (headingBody) | See skills/core-location-ref.md (Part 12) |
| Location updates never arrive | See skills/core-location-diag.md |
| Background location stops working | See skills/core-location-diag.md |
| Authorization always denied, geofence failures | See skills/core-location-diag.md |
| SwiftUI Map, annotations, markers, clustering | See skills/mapkit.md |
| MKMapView vs SwiftUI Map decision | See skills/mapkit.md |
| Search, directions, routing | See skills/mapkit.md |
| MapKit API: Marker, Annotation, MKLocalSearch, MKDirections | See skills/mapkit-ref.md |
| Look Around, MKMapSnapshotter, MKMapItem | See skills/mapkit-ref.md |
| Annotations not appearing, region jumping | See skills/mapkit-diag.md |
| Clustering not working, search failures | See skills/mapkit-diag.md |
| Overlay rendering, user location not showing | See skills/mapkit-diag.md |
Decision Tree
digraph location {
start [label="Location / Maps task" shape=ellipse];
domain [label="Core Location or MapKit?" shape=diamond];
cl_type [label="Need what?" shape=diamond];
mk_type [label="Need what?" shape=diamond];
start -> domain;
domain -> cl_type [label="Core Location"];
domain -> mk_type [label="MapKit / Maps"];
cl_type -> "skills/core-location.md" [label="authorization, monitoring\nstrategy, accuracy,\nbackground location"];
cl_type -> "skills/core-location-ref.md" [label="API syntax\n(CLLocationUpdate,\nCLMonitor, CLServiceSession)"];
cl_type -> "skills/core-location-diag.md" [label="something broken\n(no updates, denied,\ngeofence not firing)"];
mk_type -> "skills/mapkit.md" [label="patterns, decisions\n(SwiftUI Map vs MKMapView,\nannotations, search)"];
mk_type -> "skills/mapkit-ref.md" [label="API syntax\n(Marker, Annotation,\nMKLocalSearch, MKDirections)"];
mk_type -> "skills/mapkit-diag.md" [label="something broken\n(annotations missing,\nregion jumping, clustering)"];
}1. Authorization strategy, monitoring approach, accuracy? → skills/core-location.md 1a. Need specific API syntax (CLLocationUpdate, CLMonitor, CLServiceSession)? → skills/core-location-ref.md 1b. Location not working? → skills/core-location-diag.md 2. Adding a map, annotations, search, directions? → skills/mapkit.md 2a. Need specific MapKit API syntax (Marker, MKLocalSearch, MKDirections)? → skills/mapkit-ref.md 2b. Map display broken? → skills/mapkit-diag.md 3. Location draining battery? → See axiom-performance (skills/energy.md) 4. Background task scheduling for location? → See axiom-integration 5. Privacy manifest for location? → See axiom-integration
Conflict Resolution
location vs axiom-performance: When location is draining battery: 1. Try location FIRST — Excessive accuracy or continuous updates are the #1 cause. skills/core-location.md covers accuracy selection and monitoring strategy. 2. Only use axiom-performance if location settings are already correct — Profile after ruling out obvious over-tracking.
location vs axiom-integration: When implementing background location:
- Background location configuration (Info.plist, capabilities, CLServiceSession) → use location
- BGTaskScheduler for periodic location processing → use axiom-integration
location vs axiom-data: When storing or syncing location data:
- Getting location updates, geofencing → use location
- Persisting location history, CloudKit sync → use axiom-data
location vs axiom-build: When location permissions fail in simulator:
- Authorization dialogs, Info.plist keys → use location (
skills/core-location-diag.md) - Simulator GPS simulation,
simctl location→ use axiom-build
Critical Patterns
Core Location (skills/core-location.md):
- Authorization escalation strategy (When In Use first, Always later)
- Monitoring decision tree (continuous vs significant-change vs CLMonitor)
- Accuracy selection with battery impact
- Background location configuration
- Anti-patterns with time costs (premature Always authorization, unnecessary continuous updates)
Core Location API (skills/core-location-ref.md):
- CLLocationUpdate AsyncSequence (iOS 17+)
- CLMonitor condition-based geofencing (iOS 17+)
- CLServiceSession declarative authorization (iOS 18+)
- Authorization API patterns, background mode configuration
Core Location Diagnostics (skills/core-location-diag.md):
- Location updates never arrive
- Background location stops working
- Authorization always denied
- Geofence events not triggering
- Location accuracy unexpectedly poor
MapKit (skills/mapkit.md):
- SwiftUI Map vs MKMapView decision tree
- Annotation patterns (Marker, custom Annotation)
- Search (MKLocalSearch, autocomplete)
- Directions and routing
- Anti-patterns (annotations in view body, no view reuse, setRegion loops)
MapKit API (skills/mapkit-ref.md):
- SwiftUI Map API (MapCameraPosition, content builders, controls)
- MKMapView delegate patterns
- MKLocalSearch, MKDirections
- Look Around, MKMapSnapshotter
- Clustering configuration
MapKit Diagnostics (skills/mapkit-diag.md):
- Annotations not appearing (lat/lng swapped, missing delegate)
- Map region jumping/looping (updateUIView guard)
- Clustering not working (missing clusteringIdentifier)
- Search returning no results (resultTypes, region bias)
- Overlay rendering failures
Anti-Rationalization
| Thought | Reality |
|---|---|
| "Just request Always authorization upfront" | 30-60% denial rate. Request When In Use first, escalate later. skills/core-location.md covers the strategy. |
| "Continuous updates are fine for my use case" | Continuous updates drain battery even when the app doesn't need sub-second location. Use significant-change or CLMonitor. |
| "I'll use MKMapView, it's more flexible" | SwiftUI Map covers most use cases since iOS 17. MKMapView means UIViewRepresentable boilerplate. skills/mapkit.md has the decision tree. |
| "Annotations in the view body is fine for a few items" | Annotations recreate on every view update. Even 50 items cause hitches. Move to model with @State or @Observable. |
| "I know how geofencing works" | CLMonitor (iOS 17+) replaces legacy region monitoring with conditions. The API changed significantly. |
| "Background location just needs the capability" | It needs Info.plist keys, capability, CLServiceSession (iOS 18+), AND correct authorization level. Missing any one silently fails. |
| "I'll handle location errors later" | Authorization denial is not an error — it's the default state. Handle it from the start. |
Example Invocations
User: "How do I request location permissions?" → Read: skills/core-location.md
User: "What's the CLLocationUpdate API?" → Read: skills/core-location-ref.md
User: "My location updates never arrive" → Read: skills/core-location-diag.md
User: "How do I add a map with pins?" → Read: skills/mapkit.md
User: "What's the SwiftUI Map API?" → Read: skills/mapkit-ref.md
User: "My annotations aren't showing on the map" → Read: skills/mapkit-diag.md
User: "How do I implement geofencing?" → Read: skills/core-location.md then skills/core-location-ref.md
User: "Location is draining battery" → Read: skills/core-location.md, then See axiom-performance (skills/energy.md)
User: "How do I add search to my map?" → Read: skills/mapkit.md then skills/mapkit-ref.md
Core Location Diagnostics
Symptom-based troubleshooting for Core Location issues.
When to Use
- Location updates never arrive
- Background location stops working
- Authorization always denied
- Location accuracy unexpectedly poor
- Geofence events not triggering
- Location icon won't go away
Red Flags
| Red Flag | Why It Bites | Fix |
|---|---|---|
Requesting .always / .authorizedAlways on first launch | Up-front Always denial runs ~30-60%; When-In-Use then escalate runs ~5-10% (WWDC 2024-10212) | Request When In Use first, escalate to Always only when a feature needs it |
Using a location with horizontalAccuracy < 0 | Coordinate is INVALID — Apple docs: "latitude and longitude are invalid" | Discard: guard loc.horizontalAccuracy >= 0 else { continue } before using or computing distance |
horizontalAccuracy > ~100m treated as a real position | WiFi/cell-only fix, not GPS — wrong distances | Wait for a tighter fix or surface "locating…" instead of a number |
Deprecated authorizationStatus() / class method checked once at launch | Static, never reflects later grants/revocations | Read the instance manager.authorizationStatus inside locationManagerDidChangeAuthorization(_:) |
CLLocationManager held in a local var | Deallocates at end of scope; no delegate callbacks ever fire | Store the manager in a property |
No launchOptions[.location] handling | Background relaunch event is dropped — significant-change "doesn't wake the app" | Recreate manager + restart services in didFinishLaunching when the key is present |
Related Skills
axiom-location (skills/core-location.md)— Implementation patterns, decision treesaxiom-location (skills/core-location-ref.md)— API reference, code examplesaxiom-performance (skills/energy-diag.md)— Battery drain from locationaxiom-location (skills/mapkit-diag.md)— For map-specific location display issues (Symptom 7)
---
Symptom 1: Location Updates Never Arrive
Quick Checks
// 1. Check authorization
let status = CLLocationManager().authorizationStatus
print("Authorization: \(status.rawValue)")
// 0=notDetermined, 1=restricted, 2=denied, 3=authorizedAlways, 4=authorizedWhenInUse
// 2. Check if location services enabled system-wide
print("Services enabled: \(CLLocationManager.locationServicesEnabled())")
// 3. Check accuracy authorization
let accuracy = CLLocationManager().accuracyAuthorization
print("Accuracy: \(accuracy == .fullAccuracy ? "full" : "reduced")")Decision Tree
Q1: What does authorizationStatus return?
├─ .notDetermined → Authorization never requested
│ Fix: Add CLServiceSession(authorization: .whenInUse) or requestWhenInUseAuthorization()
│
├─ .denied → User denied access
│ Fix: Show UI explaining why location needed, link to Settings
│
├─ .restricted → Parental controls block access
│ Fix: Inform user, offer manual location input
│
└─ .authorizedWhenInUse / .authorizedAlways → Check next
Q2: Is locationServicesEnabled() returning true?
├─ NO → Location services disabled system-wide
│ Fix: Show UI prompting user to enable in Settings → Privacy → Location Services
│
└─ YES → Check next
Q3: Are you iterating the AsyncSequence?
├─ NO → Updates only arrive when you await
│ Fix: Task { for try await update in CLLocationUpdate.liveUpdates() { ... } }
│
└─ YES → Check next
Q4: Is the Task cancelled or broken?
├─ YES → Task cancelled before updates arrived
│ Fix: Ensure Task lives long enough (store in property, not local)
│
└─ NO → Check next
Q5: Is location available? (iOS 17+)
├─ Check update.locationUnavailable
│ If true: Device cannot determine location (indoors, airplane mode, no GPS)
│ Fix: Wait or inform user to move to better location
│
└─ Check update.authorizationDenied / update.authorizationDeniedGlobally
If true: Handle denial gracefullyInfo.plist Checklist
<!-- Required for any location access -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>Your clear explanation here</string>
<!-- Required for Always authorization -->
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Your clear explanation here</string>Missing these keys = silent failure with no prompt.
---
Symptom 2: Background Location Not Working
Quick Checks
1. Background mode capability: Xcode → Signing & Capabilities → Background Modes → Location updates 2. Info.plist: Should have UIBackgroundModes with location value 3. CLBackgroundActivitySession: Must be created AND held
Decision Tree
Q1: Is "Location updates" checked in Background Modes?
├─ NO → Background location silently disabled
│ Fix: Xcode → Signing & Capabilities → Background Modes → Location updates
│
└─ YES → Check next
Q2: Are you holding CLBackgroundActivitySession?
├─ NO / Using local variable → Session deallocates, background stops
│ Fix: Store in property: var backgroundSession: CLBackgroundActivitySession?
│
└─ YES → Check next
Q3: Was session started from foreground?
├─ NO → Cannot start new session from background
│ Fix: Create CLBackgroundActivitySession while app in foreground
│
└─ YES → Check next
Q4: Was the app relaunched into the background for a queued event?
├─ launchOptions[.location] present → System woke you to deliver one event
│ CRITICAL: The event is delivered to the delegate, NOT in launchOptions.
│ If you don't recreate the manager + restart services synchronously in
│ didFinishLaunching, the queued event is DROPPED and the app sleeps again.
│ This is why significant-change "doesn't wake the app" — it does wake it,
│ but the relaunch handler never restarts monitoring. (See Relaunch Recovery below.)
│
└─ NO → Check authorization level
Q5: What is authorization level?
├─ .authorizedWhenInUse → This is fine with CLBackgroundActivitySession
│ The blue indicator allows background access
│
├─ .authorizedAlways → Should work, check session lifecycle
│
└─ .denied → No background access possibleCommon Mistakes
// ❌ WRONG: Local variable deallocates immediately
func startTracking() {
let session = CLBackgroundActivitySession() // Dies at end of function!
startLocationUpdates()
}
// ✅ RIGHT: Property keeps session alive
var backgroundSession: CLBackgroundActivitySession?
func startTracking() {
backgroundSession = CLBackgroundActivitySession()
startLocationUpdates()
}Relaunch Recovery (required for significant-change and region monitoring)
When the system relaunches a terminated app to deliver a location event, the event goes to the delegate — never the launch dictionary. If you don't recreate the manager and restart services in didFinishLaunching, the queued event is dropped.
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
if options?[.location] != nil {
// Background relaunch for a queued event — recreate + restart NOW
locationManager = makeConfiguredManager() // store in a property
locationManager.startMonitoringSignificantLocationChanges()
// Restart whatever you were monitoring before termination
}
return true
}---
Symptom 3: Authorization Always Denied
Authorization Strategy
Requesting .always on first launch is the single biggest cause of avoidable denials. Apple's data (WWDC 2024-10212): an up-front Always prompt is denied ~30-60% of the time, while requesting When In Use first and escalating to Always only when a feature needs it lands ~5-10% denial. Asking for "Always everywhere" up front does not buy more access — it loses access.
When a tech lead says "just request Always up front so we never have to ask again": escalate the request, not the prompt. Take a When-In-Use CLServiceSession at launch, then take an .always session the moment the user enables a background feature (geofence reminder, trip logging). iOS shows the Always upgrade prompt in that context, where users say yes far more often.
Decision Tree
Q1: Is this a fresh install or returning user?
├─ FRESH INSTALL with immediate denial → Check Info.plist strings
│ Missing/empty NSLocationWhenInUseUsageDescription = automatic denial
│
└─ RETURNING USER → Check previous denial
Q2: Did user previously deny?
├─ YES → User must manually re-enable in Settings
│ Fix: Show UI explaining value, with button to open Settings:
│ UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!)
│
└─ NO → Check next
Q3: Are you requesting authorization at wrong time?
├─ Requesting when app not "in use" → insufficientlyInUse
│ Check: update.insufficientlyInUse or diagnostic.insufficientlyInUse
│ Fix: Only request authorization from foreground, during user interaction
│
└─ NO → Check next
Q4: Is device in restricted mode?
├─ YES → .restricted status (parental controls, MDM)
│ Fix: Cannot override. Offer manual location input.
│
└─ NO → Check Info.plist again
Q5: Are Info.plist strings compelling?
├─ Generic string → Users more likely to deny
│ Bad: "This app needs your location"
│ Good: "Your location helps us show restaurants within walking distance"
│
└─ Review: Look at string from user's perspectiveInfo.plist String Best Practices
<!-- ❌ BAD: Vague, no value proposition -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>We need your location.</string>
<!-- ✅ GOOD: Specific benefit to user -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>Your location helps show restaurants, coffee shops, and attractions within walking distance.</string>
<!-- ❌ BAD: No explanation for Always -->
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We need your location always.</string>
<!-- ✅ GOOD: Explains background benefit -->
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Enable background location to receive reminders when you arrive at saved places, even when the app is closed.</string>---
Symptom 4: Location Accuracy Unexpectedly Poor
Quick Checks
// 1. Check accuracy authorization
let accuracy = CLLocationManager().accuracyAuthorization
print("Accuracy auth: \(accuracy == .fullAccuracy ? "full" : "reduced")")
// 2. Check update's accuracy flag (iOS 17+) and FILTER invalid fixes
for try await update in CLLocationUpdate.liveUpdates() {
if update.accuracyLimited {
print("Accuracy limited - updates every 15-20 min")
}
guard let location = update.location else { continue }
// Hard filter: a negative accuracy means the coordinate is invalid.
// Never compute distance or display a position from these.
guard location.horizontalAccuracy >= 0 else { continue }
// > ~100m is a WiFi/cell-only fix, not GPS — wrong distances
if location.horizontalAccuracy > 100 {
print("Coarse fix (\(location.horizontalAccuracy)m) — waiting for GPS")
}
}Decision Tree
Q1: What is accuracyAuthorization?
├─ .reducedAccuracy → User chose approximate location
│ Options:
│ 1. Accept reduced accuracy (weather, city-level features)
│ 2. Request temporary full accuracy:
│ CLServiceSession(authorization: .whenInUse, fullAccuracyPurposeKey: "Navigation")
│ 3. Explain value and link to Settings
│
└─ .fullAccuracy → Check environment and configuration
Q2: What is horizontalAccuracy on locations?
├─ < 0 (typically -1) → INVALID location, do not use
│ Meaning: System could not determine accuracy (no valid fix)
│ Fix: Filter out: guard location.horizontalAccuracy >= 0 else { continue }
│ Common when: Indoors with no WiFi, airplane mode, immediately after cold start
│
├─ > 100m → Likely using WiFi/cell only (no GPS)
│ Causes: Indoors, airplane mode, dense urban canyon
│ Fix: User needs to move to better location, or wait for GPS lock
│
├─ 10-100m → Normal for most use cases
│ If need better: Use .automotiveNavigation or .otherNavigation config
│
└─ < 10m → Good GPS accuracy
Note: .automotiveNavigation can achieve ~5m
Q3: What LiveConfiguration are you using?
├─ .default or none → System manages, may prioritize battery
│ If need more accuracy: Use .fitness, .otherNavigation, or .automotiveNavigation
│
├─ .fitness → Good for pedestrian activities
│
└─ .automotiveNavigation → Highest accuracy, highest battery
Only use for actual navigation
Q4: Is the location stale?
├─ Check location.timestamp
│ If old: Device hasn't moved, or updates paused (isStationary)
│
└─ If timestamp recent but accuracy poor: Environmental issueRequesting Temporary Full Accuracy
Both paths require an Info.plist purpose dictionary. The dictionary key is NSLocationTemporaryUsageDescriptionDictionary; each entry maps a purpose key to a user-facing string:
<key>NSLocationTemporaryUsageDescriptionDictionary</key>
<dict>
<key>NavigationPurpose</key>
<string>Precise location enables turn-by-turn directions.</string>
</dict>// Declarative (iOS 18+): hold the session while the feature is active
let session = CLServiceSession(
authorization: .whenInUse,
fullAccuracyPurposeKey: "NavigationPurpose"
)
// Imperative (iOS 14+): for code still on CLLocationManager
manager.requestTemporaryFullAccuracyAuthorization(withPurposeKey: "NavigationPurpose")---
Symptom 5: Geofence Events Not Triggering
Quick Checks
let monitor = await CLMonitor("MyMonitor")
// 1. Check condition count (max 20)
let count = await monitor.identifiers.count
print("Conditions: \(count)/20")
// 2. Check specific condition
if let record = await monitor.record(for: "MyGeofence") {
let lastEvent = record.lastEvent
print("State: \(lastEvent.state)")
print("Date: \(lastEvent.date)")
if let geo = record.condition as? CLMonitor.CircularGeographicCondition {
print("Center: \(geo.center)")
print("Radius: \(geo.radius)m")
}
}Decision Tree
Q1: How many conditions are monitored?
├─ 20 → At the limit, new conditions ignored
│ Fix: Prioritize important conditions, swap dynamically based on user location
│ Check: lastEvent.conditionLimitExceeded
│
└─ < 20 → Check next
Q2: What is the radius?
├─ < 100m → Unreliable, may not trigger
│ Fix: Use minimum 100m radius for reliable detection
│
└─ >= 100m → Check next
Q3: Is the app awaiting monitor.events?
├─ NO → Events not processed, lastEvent not updated
│ Fix: Always have a Task awaiting:
│ for try await event in monitor.events { ... }
│
└─ YES → Check next
Q4: Was monitor reinitialized on app launch?
├─ NO → Monitor conditions lost after termination
│ Fix: Recreate monitor with same name in didFinishLaunchingWithOptions
│
└─ YES → Check next
Q5: What does lastEvent show?
├─ state: .unknown → System hasn't determined state yet
│ Wait for determination, or check if monitoring is working
│
├─ state: .satisfied → Inside region, waiting for exit
│
├─ state: .unsatisfied → Outside region, waiting for entry
│
└─ Check lastEvent.date → When was last update?
If very old: May not be monitoring correctly
Q6: Is accuracyLimited preventing monitoring?
├─ Check: lastEvent.accuracyLimited
│ If true: Reduced accuracy prevents geofencing
│ Fix: Request full accuracy or accept limitation
│
└─ NO → Check environment (device must have location access)Common Mistakes
// ❌ WRONG: Not awaiting events
let monitor = await CLMonitor("Test")
await monitor.add(condition, identifier: "Place")
// Nothing happens - no Task awaiting events!
// ✅ RIGHT: Always await events
let monitor = await CLMonitor("Test")
await monitor.add(condition, identifier: "Place")
Task {
for try await event in monitor.events {
switch event.state {
case .satisfied: handleEntry(event.identifier)
case .unsatisfied: handleExit(event.identifier)
case .unknown: break
@unknown default: break
}
}
}
// ❌ WRONG: Creating multiple monitors with same name
let monitor1 = await CLMonitor("App") // OK
let monitor2 = await CLMonitor("App") // UNDEFINED BEHAVIOR
// ✅ RIGHT: One monitor instance per name
class LocationService {
private var monitor: CLMonitor?
func setup() async {
monitor = await CLMonitor("App")
}
}---
Symptom 6: Location Icon Won't Go Away
Quick Checks
The location arrow appears when:
- App actively receiving location updates
- CLMonitor is monitoring conditions
- Background activity session active
Decision Tree
Q1: Is your app still iterating liveUpdates?
├─ YES → Updates continue until you break/cancel
│ Fix: Cancel the Task or break from loop:
│ locationTask?.cancel()
│
└─ NO → Check next
Q2: Is CLBackgroundActivitySession still held?
├─ YES → Session keeps location access active
│ Fix: Invalidate when done:
│ backgroundSession?.invalidate()
│ backgroundSession = nil
│
└─ NO → Check next
Q3: Is CLMonitor still monitoring conditions?
├─ YES → CLMonitor uses location for geofencing
│ Note: This is expected behavior - icon shows monitoring active
│ Fix: If truly done, remove all conditions:
│ for id in await monitor.identifiers {
│ await monitor.remove(id)
│ }
│
└─ NO → Check next
Q4: Is legacy CLLocationManager still running?
├─ Check: manager.stopUpdatingLocation() called?
│ Check: manager.stopMonitoring(for: region) for all regions?
│ Fix: Ensure all legacy APIs stopped
│
└─ NO → Check other location-using frameworks
Q5: Other frameworks using location?
├─ MapKit with showsUserLocation = true → Shows location
│ Fix: mapView.showsUserLocation = false when not needed
│
├─ Core Motion with location → Shows location
│
└─ Check all location-using codeForce Stop All Location
// Stop modern APIs
locationTask?.cancel()
backgroundSession?.invalidate()
backgroundSession = nil
// Remove all CLMonitor conditions
for id in await monitor.identifiers {
await monitor.remove(id)
}
// Stop legacy APIs
manager.stopUpdatingLocation()
manager.stopMonitoringSignificantLocationChanges()
manager.stopMonitoringVisits()
for region in manager.monitoredRegions {
manager.stopMonitoring(for: region)
}---
Console Debugging
Filter Location Logs
# View locationd logs
log stream --predicate 'subsystem == "com.apple.locationd"' --level debug
# View your app's location-related logs
log stream --predicate 'subsystem == "com.apple.CoreLocation"' --level debug
# Filter for specific process
log stream --predicate 'process == "YourAppName" AND subsystem == "com.apple.CoreLocation"'Common Log Messages
| Log Message | Meaning |
|---|---|
Client is not authorized | Authorization denied or not requested |
Location services disabled | System-wide toggle off |
Accuracy authorization is reduced | User chose approximate location |
Condition limit exceeded | At 20-condition maximum |
Background location access denied | Missing background capability or session |
---
Resources
WWDC: 2023-10180, 2023-10147, 2024-10212
Docs: /corelocation, /corelocation/clmonitor, /corelocation/cllocationupdate
Skills: axiom-location (skills/core-location.md), axiom-location (skills/core-location-ref.md), axiom-performance (skills/energy-diag.md)
Core Location Reference
Comprehensive API reference for modern Core Location (iOS 17+).
When to Use
- Need API signatures for CLLocationUpdate, CLMonitor, CLServiceSession
- Implementing geofencing or region monitoring
- Configuring background location updates
- Understanding authorization patterns
- Debugging location service issues
Related Skills
axiom-location (skills/core-location.md)— Anti-patterns, decision trees, pressure scenariosaxiom-location (skills/core-location-diag.md)— Symptom-based troubleshootingaxiom-performance (skills/energy-ref.md)— Location as battery subsystem (accuracy vs power)
---
Part 1: Modern API Overview (iOS 17+)
Four key classes replace legacy CLLocationManager patterns:
| Class | Purpose | iOS |
|---|---|---|
CLLocationUpdate | AsyncSequence for location updates | 17+ |
CLMonitor | Condition-based geofencing/beacons | 17+ |
CLServiceSession | Declarative authorization goals | 18+ |
CLBackgroundActivitySession | Background location support | 17+ |
Migration path: Legacy CLLocationManager still works, but new APIs provide:
- Swift concurrency (async/await)
- Automatic pause/resume
- Simplified authorization
- Better battery efficiency
---
Part 2: CLLocationUpdate API
Basic Usage
import CoreLocation
Task {
do {
for try await update in CLLocationUpdate.liveUpdates() {
if let location = update.location {
// Process location
}
if update.isStationary {
break // Stop when user stops moving
}
}
} catch {
// Handle location errors
}
}LiveConfiguration Options
CLLocationUpdate.liveUpdates(.default)
CLLocationUpdate.liveUpdates(.automotiveNavigation)
CLLocationUpdate.liveUpdates(.otherNavigation)
CLLocationUpdate.liveUpdates(.fitness)
CLLocationUpdate.liveUpdates(.airborne)Choose based on use case. If unsure, use .default or omit parameter.
Key Properties
| Property | Type | Description |
|---|---|---|
location | CLLocation? | Current location (nil if unavailable) |
isStationary | Bool | True when device stopped moving |
authorizationDenied | Bool | User denied location access |
authorizationDeniedGlobally | Bool | Location services disabled system-wide |
authorizationRequestInProgress | Bool | Awaiting user authorization decision |
accuracyLimited | Bool | Reduced accuracy (updates every 15-20 min) |
locationUnavailable | Bool | Cannot determine location |
insufficientlyInUse | Bool | Can't request auth (not in foreground) |
Automatic Pause/Resume
When device becomes stationary: 1. Final update delivered with isStationary = true and valid location 2. Updates pause (saves battery) 3. When device moves, updates resume with isStationary = false
No action required—happens automatically.
AsyncSequence Operations
// Get first location with speed > 10 m/s
let fastUpdate = try await CLLocationUpdate.liveUpdates()
.first { $0.location?.speed ?? 0 > 10 }
// WARNING: Avoid filters that may never match (e.g., horizontalAccuracy < 1)---
Part 3: CLMonitor API
Swift actor for monitoring geographic conditions and beacons.
Basic Geofencing
let monitor = await CLMonitor("MyMonitor")
// Add circular region
let condition = CLMonitor.CircularGeographicCondition(
center: CLLocationCoordinate2D(latitude: 37.33, longitude: -122.01),
radius: 100
)
await monitor.add(condition, identifier: "ApplePark")
// Await events
for try await event in monitor.events {
switch event.state {
case .satisfied: // User entered region
handleEntry(event.identifier)
case .unsatisfied: // User exited region
handleExit(event.identifier)
case .unknown:
break
@unknown default:
break
}
}CircularGeographicCondition
CLMonitor.CircularGeographicCondition(
center: CLLocationCoordinate2D,
radius: CLLocationDistance // meters, minimum ~100m effective
)BeaconIdentityCondition
Three granularity levels:
// All beacons with UUID (any site)
CLMonitor.BeaconIdentityCondition(uuid: myUUID)
// Specific site (UUID + major)
CLMonitor.BeaconIdentityCondition(uuid: myUUID, major: 100)
// Specific beacon (UUID + major + minor)
CLMonitor.BeaconIdentityCondition(uuid: myUUID, major: 100, minor: 5)Condition Limit
Maximum 20 conditions per app. Prioritize what to monitor. Swap regions dynamically based on user location if needed.
Adding with Assumed State
// If you know initial state
await monitor.add(condition, identifier: "Work", assuming: .unsatisfied)Core Location will correct if assumption wrong.
Accessing Records
// Get single record
if let record = await monitor.record(for: "ApplePark") {
let condition = record.condition
let lastEvent = record.lastEvent
let state = lastEvent.state
let date = lastEvent.date
}
// Get all identifiers
let allIds = await monitor.identifiersEvent Properties
| Property | Description |
|---|---|
identifier | String identifier of condition |
state | .satisfied, .unsatisfied, .unknown |
date | When state changed |
refinement | For wildcard beacons, actual UUID/major/minor detected |
conditionLimitExceeded | Too many conditions (max 20) |
conditionUnsupported | Condition type not available |
accuracyLimited | Reduced accuracy prevents monitoring |
Critical Requirements
1. One monitor per name — Only one instance with given name at a time 2. Always await events — Events only become lastEvent after handling 3. Reinitialize on launch — Recreate monitor in didFinishLaunchingWithOptions
---
Part 4: CLServiceSession API (iOS 18+)
Declarative authorization—tell Core Location what you need, not what to do.
Basic Usage
// Hold session for duration of feature
let session = CLServiceSession(authorization: .whenInUse)
for try await update in CLLocationUpdate.liveUpdates() {
// Process updates
}Authorization Requirements
CLServiceSession(authorization: .none) // No auth request
CLServiceSession(authorization: .whenInUse) // Request When In Use
CLServiceSession(authorization: .always) // Request Always (must start in foreground)Full Accuracy Request
// For features requiring precise location (e.g., navigation)
CLServiceSession(
authorization: .whenInUse,
fullAccuracyPurposeKey: "NavigationPurpose" // Key in Info.plist
)Requires NSLocationTemporaryUsageDescriptionDictionary in Info.plist.
Implicit Sessions
Iterating CLLocationUpdate.liveUpdates() or CLMonitor.events creates implicit session with .whenInUse goal.
To disable implicit sessions:
<!-- Info.plist -->
<key>NSLocationRequireExplicitServiceSession</key>
<true/>Session Layering
Don't replace sessions—layer them:
// Base session for app
let baseSession = CLServiceSession(authorization: .whenInUse)
// Additional session when navigation feature active
let navSession = CLServiceSession(
authorization: .whenInUse,
fullAccuracyPurposeKey: "Nav"
)
// Both sessions active simultaneouslyDiagnostic Properties
for try await diagnostic in session.diagnostics {
if diagnostic.authorizationDenied {
// User denied—offer alternative
}
if diagnostic.authorizationDeniedGlobally {
// Location services off system-wide
}
if diagnostic.insufficientlyInUse {
// Can't request auth (not foreground)
}
if diagnostic.alwaysAuthorizationDenied {
// Always auth specifically denied
}
if !diagnostic.authorizationRequestInProgress {
// Decision made (granted or denied)
break
}
}Session Lifecycle
Sessions persist through:
- App backgrounding
- App suspension
- App termination (Core Location tracks)
On relaunch, recreate sessions immediately in didFinishLaunchingWithOptions.
---
Part 5: Authorization State Machine
Authorization Levels
| Status | Description |
|---|---|
.notDetermined | User hasn't decided |
.restricted | Parental controls prevent access |
.denied | User explicitly refused |
.authorizedWhenInUse | Access while app active |
.authorizedAlways | Background access |
Accuracy Authorization
| Value | Description |
|---|---|
.fullAccuracy | Precise location |
.reducedAccuracy | Approximate (~5km), updates every 15-20 min |
Required Info.plist Keys
<!-- Required for When In Use -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>We need your location to show nearby places</string>
<!-- Required for Always -->
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We track your location to send arrival reminders</string>
<!-- Optional: default to reduced accuracy -->
<key>NSLocationDefaultAccuracyReduced</key>
<true/>Legacy Authorization Pattern
@MainActor
class LocationManager: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
switch manager.authorizationStatus {
case .notDetermined:
manager.requestWhenInUseAuthorization()
case .authorizedWhenInUse, .authorizedAlways:
enableLocationFeatures()
case .denied, .restricted:
disableLocationFeatures()
@unknown default:
break
}
}
}---
Part 6: Background Location
Requirements
1. Background mode capability: Signing & Capabilities → Background Modes → Location updates 2. Info.plist: Adds UIBackgroundModes with location value 3. CLBackgroundActivitySession or LiveActivity
CLBackgroundActivitySession
// Create and HOLD reference (deallocation invalidates session)
var backgroundSession: CLBackgroundActivitySession?
func startBackgroundTracking() {
// Must start from foreground
backgroundSession = CLBackgroundActivitySession()
Task {
for try await update in CLLocationUpdate.liveUpdates() {
processUpdate(update)
}
}
}
func stopBackgroundTracking() {
backgroundSession?.invalidate()
backgroundSession = nil
}Background Indicator
Blue status bar/pill appears when:
- App authorized as "When In Use"
- App receiving location in background
- CLBackgroundActivitySession active
App Lifecycle
1. Foreground → Background: Session continues 2. Background → Suspended: Session preserved, updates pause 3. Suspended → Terminated: Core Location tracks session 4. Terminated → Background launch: Recreate session immediately
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Recreate background session if was tracking
if wasTrackingLocation {
backgroundSession = CLBackgroundActivitySession()
startLocationUpdates()
}
return true
}---
Part 7: Legacy APIs (iOS 12-16)
CLLocationManager Delegate Pattern
class LocationManager: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
override init() {
super.init()
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyBest
manager.distanceFilter = 10 // meters
}
func startUpdates() {
manager.startUpdatingLocation()
}
func stopUpdates() {
manager.stopUpdatingLocation()
}
func locationManager(_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]) {
guard let location = locations.last else { return }
// Process location
}
}Accuracy Constants
| Constant | Accuracy | Battery Impact |
|---|---|---|
kCLLocationAccuracyBestForNavigation | ~5m | Highest |
kCLLocationAccuracyBest | ~10m | Very High |
kCLLocationAccuracyNearestTenMeters | ~10m | High |
kCLLocationAccuracyHundredMeters | ~100m | Medium |
kCLLocationAccuracyKilometer | ~1km | Low |
kCLLocationAccuracyThreeKilometers | ~3km | Very Low |
kCLLocationAccuracyReduced | ~5km | Lowest |
Legacy Region Monitoring
// Deprecated in iOS 17, use CLMonitor instead
let region = CLCircularRegion(
center: coordinate,
radius: 100,
identifier: "MyRegion"
)
region.notifyOnEntry = true
region.notifyOnExit = true
manager.startMonitoring(for: region)Significant Location Changes
Low-power alternative for coarse tracking:
manager.startMonitoringSignificantLocationChanges()
// Updates ~500m movements, works in backgroundVisit Monitoring
Detect arrivals/departures:
manager.startMonitoringVisits()
func locationManager(_ manager: CLLocationManager, didVisit visit: CLVisit) {
let arrival = visit.arrivalDate
let departure = visit.departureDate
let coordinate = visit.coordinate
}---
Part 8: Geofencing Best Practices
Region Size
- Minimum effective radius: ~100 meters
- Smaller regions: May not trigger reliably
- Larger regions: More reliable but less precise
20-Region Limit Strategy
// Dynamic region management
func updateMonitoredRegions(userLocation: CLLocation) async {
let nearbyPOIs = fetchNearbyPOIs(around: userLocation, limit: 20)
// Remove old regions
for id in await monitor.identifiers {
if !nearbyPOIs.contains(where: { $0.id == id }) {
await monitor.remove(id)
}
}
// Add new regions
for poi in nearbyPOIs {
let condition = CLMonitor.CircularGeographicCondition(
center: poi.coordinate,
radius: 100
)
await monitor.add(condition, identifier: poi.id)
}
}Entry/Exit Timing
- Entry: Usually within seconds to minutes
- Exit: May take 3-5 minutes after leaving
- Accuracy depends on: Cell towers, WiFi, GPS availability
Persistence
- Conditions persist across app launches
- Must reinitialize monitor with same name on launch
- Core Location wakes app for events
---
Part 9: Testing and Simulation
Xcode Location Simulation
1. Run on simulator 2. Debug → Simulate Location → Choose location 3. Or use custom GPX file
Custom GPX Route
<?xml version="1.0"?>
<gpx version="1.1">
<wpt lat="37.331686" lon="-122.030656">
<time>2024-01-01T00:00:00Z</time>
</wpt>
<wpt lat="37.332686" lon="-122.031656">
<time>2024-01-01T00:00:10Z</time>
</wpt>
</gpx>Testing Authorization States
Settings → Privacy & Security → Location Services:
- Toggle app authorization
- Toggle system-wide location services
- Test reduced accuracy
Console Filtering
# Filter location logs
log stream --predicate 'subsystem == "com.apple.locationd"'---
Part 10: Swift Concurrency Integration
Task Cancellation
let locationTask = Task {
for try await update in CLLocationUpdate.liveUpdates() {
if Task.isCancelled { break }
processUpdate(update)
}
}
// Later
locationTask.cancel()MainActor Considerations
@MainActor
class LocationViewModel: ObservableObject {
@Published var currentLocation: CLLocation?
func startTracking() {
Task {
for try await update in CLLocationUpdate.liveUpdates() {
// Already on MainActor, safe to update @Published
self.currentLocation = update.location
}
}
}
}Error Handling
Task {
do {
for try await update in CLLocationUpdate.liveUpdates() {
if update.authorizationDenied {
throw LocationError.authorizationDenied
}
processUpdate(update)
}
} catch {
handleError(error)
}
}---
Part 11: Geocoding
CLGeocoder — Forward Geocoding (Address → Coordinate)
let geocoder = CLGeocoder()
func geocodeAddress(_ address: String) async throws -> CLLocation? {
let placemarks = try await geocoder.geocodeAddressString(address)
return placemarks.first?.location
}
// With locale for localized results
let placemarks = try await geocoder.geocodeAddressString(
"1 Apple Park Way",
in: nil, // CLRegion hint (optional)
preferredLocale: Locale(identifier: "en_US")
)CLGeocoder — Reverse Geocoding (Coordinate → Address)
func reverseGeocode(_ location: CLLocation) async throws -> CLPlacemark? {
let placemarks = try await geocoder.reverseGeocodeLocation(location)
return placemarks.first
}
// Usage
if let placemark = try await reverseGeocode(location) {
let street = placemark.thoroughfare // "Apple Park Way"
let city = placemark.locality // "Cupertino"
let state = placemark.administrativeArea // "CA"
let zip = placemark.postalCode // "95014"
let country = placemark.country // "United States"
let isoCountry = placemark.isoCountryCode // "US"
}CLPlacemark Key Properties
| Property | Example | Notes |
|---|---|---|
name | "Apple Park" | Location name |
thoroughfare | "Apple Park Way" | Street name |
subThoroughfare | "1" | Street number |
locality | "Cupertino" | City |
subLocality | "Silicon Valley" | Neighborhood |
administrativeArea | "CA" | State/province |
postalCode | "95014" | ZIP/postal code |
country | "United States" | Country name |
isoCountryCode | "US" | ISO country code |
timeZone | America/Los_Angeles | Time zone |
location | CLLocation | Coordinate |
Geocoding Rate Limits
- One request at a time — CLGeocoder throws if a request is in progress
- Apple rate-limits — Throttle to avoid
kCLErrorGeocodeCanceled - Cache results — Don't re-geocode the same address/coordinate
- Batch carefully — Add delays between sequential geocode requests
// Check if geocoder is busy
if geocoder.isGeocoding {
geocoder.cancelGeocode() // Cancel previous before starting new
}---
Part 12: Heading
Compass heading comes from startUpdatingHeading() / CLHeading (trueHeading, magneticHeading, headingAccuracy) on CLLocationManager — long-standing API, not available on tvOS/visionOS.
Heading Reference Body OS27
headingOrientation (manually telling Core Location which device orientation to reference heading against) is deprecated in the 27 SDKs. Its replacement is headingBody: assign a body — any CLBodyIdentifiable; UIView is the built-in conformer (no AppKit type conforms on macOS; conform your own type there) — and heading calculation is referenced to it, with no manual orientation plumbing as the interface rotates:
@available(iOS 27, *) // also macOS 27 and watchOS 27; not tvOS/visionOS
@MainActor
func startHeading(manager: CLLocationManager, mapView: MKMapView) {
manager.headingBody = mapView // reference heading to the map view
manager.startUpdatingHeading()
}---
Troubleshooting Quick Reference
| Symptom | Check |
|---|---|
| No location updates | Authorization status, Info.plist keys |
| Background not working | Background mode capability, CLBackgroundActivitySession |
| Always auth not effective | CLServiceSession with .always, started in foreground |
| Geofence not triggering | Region count (max 20), radius (min ~100m) |
| Reduced accuracy only | Check accuracyAuthorization, request temporary full accuracy |
| Location icon stays on | Ensure stopUpdatingLocation() or break from async loop |
---
Resources
WWDC: 2023-10180, 2023-10147, 2024-10212
Docs: /corelocation, /corelocation/clmonitor, /corelocation/cllocationupdate, /corelocation/clservicesession
Skills: axiom-location (skills/core-location.md), axiom-location (skills/core-location-diag.md), axiom-performance (skills/energy-ref.md)
Core Location Patterns
Discipline skill for Core Location implementation decisions. Prevents common authorization mistakes, battery drain, and background location failures.
When to Use
- Choosing authorization strategy (When In Use vs Always)
- Deciding monitoring approach (continuous vs significant-change vs CLMonitor)
- Implementing geofencing or background location
- Debugging "location not working" issues
- Reviewing location code for anti-patterns
Related Skills
axiom-location (skills/core-location-ref.md)— API reference, code examplesaxiom-location (skills/core-location-diag.md)— Symptom-based troubleshootingaxiom-performance (skills/energy.md)— Location as battery subsystem
---
Part 1: Anti-Patterns (with Time Costs)
Anti-Pattern 1: Premature Always Authorization
Wrong (30-60% denial rate):
// First launch: "Can we have Always access?"
manager.requestAlwaysAuthorization()Right (5-10% denial rate):
// Start with When In Use
CLServiceSession(authorization: .whenInUse)
// Later, when user triggers background feature:
CLServiceSession(authorization: .always)Time cost: 15 min to fix code, but 30-60% of users permanently denied = feature adoption destroyed.
Why: Users deny aggressive requests. Start minimal, upgrade when user understands value.
---
Anti-Pattern 2: Continuous Updates for Geofencing
Wrong (10x battery drain):
for try await update in CLLocationUpdate.liveUpdates() {
if isNearTarget(update.location) {
triggerGeofence()
}
}Right (system-managed, low power):
let monitor = await CLMonitor("Geofences")
let condition = CLMonitor.CircularGeographicCondition(
center: target, radius: 100
)
await monitor.add(condition, identifier: "Target")
for try await event in monitor.events {
if event.state == .satisfied { triggerGeofence() }
}Time cost: 5 min to refactor, saves 10x battery.
---
Anti-Pattern 3: Ignoring Stationary Detection
Wrong (wasted battery):
for try await update in CLLocationUpdate.liveUpdates() {
processLocation(update.location)
// Never stops, even when device stationary
}Right (automatic pause/resume):
for try await update in CLLocationUpdate.liveUpdates() {
if let location = update.location {
processLocation(location)
}
if update.isStationary, let location = update.location {
// Device stopped moving - updates pause automatically
// Will resume when device moves again
saveLastKnownLocation(location)
}
}Time cost: 2 min to add check, saves significant battery.
---
Anti-Pattern 4: No Graceful Denial Handling
Wrong (broken UX):
for try await update in CLLocationUpdate.liveUpdates() {
guard let location = update.location else { continue }
// User denied - silent failure, no feedback
}Right (graceful degradation):
for try await update in CLLocationUpdate.liveUpdates() {
if update.authorizationDenied {
showManualLocationPicker()
break
}
if update.authorizationDeniedGlobally {
showSystemLocationDisabledMessage()
break
}
if let location = update.location {
processLocation(location)
}
}Time cost: 10 min to add handling, prevents confused users.
---
Anti-Pattern 5: Wrong Accuracy for Use Case
Wrong (battery drain for weather app):
// Weather app using navigation accuracy
CLLocationUpdate.liveUpdates(.automotiveNavigation)Right (match accuracy to need):
// Weather: city-level is fine
CLLocationUpdate.liveUpdates(.default) // or .fitness for runners
// Navigation: needs high accuracy
CLLocationUpdate.liveUpdates(.automotiveNavigation)| Use Case | Configuration | Accuracy | Battery |
|---|---|---|---|
| Navigation | .automotiveNavigation | ~5m | Highest |
| Fitness tracking | .fitness | ~10m | High |
| Store finder | .default | ~10-100m | Medium |
| Weather | .default | ~100m+ | Low |
Time cost: 1 min to change, significant battery savings.
---
Anti-Pattern 6: Not Stopping Updates
Wrong (battery drain, location icon persists):
func viewDidLoad() {
Task {
for try await update in CLLocationUpdate.liveUpdates() {
updateMap(update.location)
}
}
}
// User navigates away, updates continue foreverRight (cancel when done):
private var locationTask: Task<Void, Error>?
func startTracking() {
locationTask = Task {
for try await update in CLLocationUpdate.liveUpdates() {
if Task.isCancelled { break }
updateMap(update.location)
}
}
}
func stopTracking() {
locationTask?.cancel()
locationTask = nil
}Time cost: 5 min to add cancellation, stops battery drain.
---
Anti-Pattern 7: Ignoring CLServiceSession (iOS 18+)
Wrong (procedural authorization juggling):
func requestAuth() {
switch manager.authorizationStatus {
case .notDetermined:
manager.requestWhenInUseAuthorization()
case .authorizedWhenInUse:
if needsFullAccuracy {
manager.requestTemporaryFullAccuracyAuthorization(...)
}
// Complex state machine...
}
}Right (declarative goals):
// Just declare what you need - Core Location handles the rest
let session = CLServiceSession(authorization: .whenInUse)
// For feature needing full accuracy
let navSession = CLServiceSession(
authorization: .whenInUse,
fullAccuracyPurposeKey: "Navigation"
)
// Monitor diagnostics if needed
for try await diag in session.diagnostics {
if diag.authorizationDenied { handleDenial() }
}Time cost: 30 min to migrate, simpler code, fewer bugs.
---
Anti-Pattern 8: Treating Reduced Accuracy as a Toggle You Control
Wrong (geofences silently unreliable for Approximate-location users):
// Assumes full accuracy — a 100m geofence "should" be fine
let condition = CLMonitor.CircularGeographicCondition(center: target, radius: 100)Right (detect reduced accuracy, escalate only for the feature that needs it):
if manager.accuracyAuthorization == .reducedAccuracy {
// Approximate fuzzes location to a several-km area — region monitoring and
// any sub-kilometer geofence degrade or stop firing. Request temporary full
// accuracy scoped to the feature, with a matching purpose key.
manager.requestTemporaryFullAccuracyAuthorization(withPurposeKey: "Geofencing")
}The user, not you, chooses Precise vs Approximate at the auth prompt. With Approximate granted, Core Location fuzzes location to a large area, so geofencing, turn-by-turn, and any sub-kilometer feature break with no error. Check accuracyAuthorization, and escalate via fullAccuracyPurposeKey on a CLServiceSession (iOS 18+) or requestTemporaryFullAccuracyAuthorization(withPurposeKey:) only for the feature that needs it. The purpose key must have a matching NSLocationTemporaryUsageDescriptionDictionary entry in Info.plist or the request silently no-ops.
Time cost: 10 min to add the check; without it, geofences fail for every Approximate-location user with no diagnostic.
---
Part 2: Decision Trees
Authorization Strategy
Q1: Does your feature REQUIRE background location?
├─ NO → Use .whenInUse
│ └─ Q2: Does any feature need precise location?
│ ├─ ALWAYS → Add fullAccuracyPurposeKey to session
│ └─ SOMETIMES → Layer full-accuracy session when feature active
│
└─ YES → Start with .whenInUse, upgrade to .always when user triggers feature
└─ Q3: When does user first need background location?
├─ IMMEDIATELY (e.g., fitness tracker) → Request .always on first relevant action
└─ LATER (e.g., geofence reminders) → Add .always session when user creates first geofenceMonitoring Strategy
Q1: What are you monitoring for?
├─ USER POSITION (continuous tracking)
│ └─ Use CLLocationUpdate.liveUpdates()
│ └─ Q2: What activity?
│ ├─ Driving navigation → .automotiveNavigation
│ ├─ Walking/cycling nav → .otherNavigation
│ ├─ Fitness tracking → .fitness
│ ├─ Airplane apps → .airborne
│ └─ General → .default or omit
│
├─ ENTRY/EXIT REGIONS (geofencing)
│ └─ Use CLMonitor with CircularGeographicCondition
│ └─ Note: Maximum 20 conditions per app
│
├─ BEACON PROXIMITY
│ └─ Use CLMonitor with BeaconIdentityCondition
│ └─ Choose granularity: UUID only, UUID+major, UUID+major+minor
│
└─ SIGNIFICANT CHANGES ONLY (lowest power)
└─ Use startMonitoringSignificantLocationChanges() (legacy)
└─ Updates ~500m movements, works in backgroundAccuracy Selection
Q1: What's the minimum accuracy that makes your feature work?
├─ TURN-BY-TURN NAV needs 5-10m → .automotiveNavigation / .otherNavigation
├─ FITNESS TRACKING needs 10-20m → .fitness
├─ STORE FINDER needs 100m → .default
├─ WEATHER/CITY needs 1km+ → .default (reduced accuracy acceptable)
└─ GEOFENCING uses system determination → CLMonitor handles it
Q2: Will user be moving fast?
├─ DRIVING (high speed) → .automotiveNavigation (extra processing for speed)
├─ CYCLING/WALKING → .otherNavigation
└─ STATIONARY/SLOW → .default
Always start with lowest acceptable accuracy. Higher accuracy = higher battery drain.---
Part 3: Pressure Scenarios
Scenario 1: "Just Use Always Authorization"
Context: PM says "Users want location reminders. Just request Always access on first launch so it works."
Pressure: Ship fast, seems simpler.
Reality:
- 30-60% of users will deny Always authorization when asked upfront
- Users who deny can only re-enable in Settings (most won't)
- Feature adoption destroyed before users understand value
Response:
"Always authorization has 30-60% denial rates when requested upfront. We should start with When In Use, then request Always upgrade when the user creates their first location reminder. This gives us a 5-10% denial rate because users understand why they need it."
Evidence: Apple's own guidance in WWDC 2024-10212: "CLServiceSessions should be taken proactively... hold one requiring full-accuracy when people engage a feature that would warrant a special ask for it."
---
Scenario 2: "Location Isn't Working in Background"
Context: QA reports "App stops getting location when backgrounded."
Pressure: Quick fix before release.
Wrong fixes:
- Add all background modes
- Use
allowsBackgroundLocationUpdates = truewithout understanding - Request Always authorization
Right diagnosis: 1. Check background mode capability exists 2. Check CLBackgroundActivitySession is held (not deallocated) 3. Check session started from foreground 4. Check authorization level (.whenInUse works with CLBackgroundActivitySession)
Response:
"Background location requires specific setup. Let me check: (1) Background mode capability, (2) CLBackgroundActivitySession held during tracking, (3) session started from foreground. Missing any of these causes silent failure."
Checklist:
// 1. Signing & Capabilities → Background Modes → Location updates
// 2. Hold session reference (property, not local variable)
var backgroundSession: CLBackgroundActivitySession?
func startBackgroundTracking() {
// 3. Must start from foreground
backgroundSession = CLBackgroundActivitySession()
startLocationUpdates()
}---
Scenario 3: "Geofence Events Aren't Firing"
Context: Geofences work in testing but not in production for some users.
Pressure: "It works on my device" dismissal.
Common causes: 1. Too many conditions: Maximum 20 per app 2. Radius too small: Minimum ~100m for reliable triggering 3. Overlapping regions: Can cause confusion 4. Not awaiting events: Events only become lastEvent after handled 5. Not reinitializing on launch: Monitor must be recreated
Response:
"Geofencing has several system constraints. Check: (1) Are we within the 20-condition limit? (2) Are all radii at least 100m? (3) Is the app reinitializing CLMonitor on launch? (4) Is the app always awaiting on monitor.events?"
Diagnostic code:
// Check condition count
let count = await monitor.identifiers.count
if count >= 20 {
print("At 20-condition limit!")
}
// Check all conditions
for id in await monitor.identifiers {
if let record = await monitor.record(for: id) {
let condition = record.condition
if let geo = condition as? CLMonitor.CircularGeographicCondition {
if geo.radius < 100 {
print("Radius too small: \(id)")
}
}
}
}---
Part 4: Checklists
Pre-Release Location Checklist
Info.plist:
- [ ]
NSLocationWhenInUseUsageDescriptionwith clear explanation - [ ]
NSLocationAlwaysAndWhenInUseUsageDescriptionif using Always (clear why background needed) - [ ]
NSLocationDefaultAccuracyReducedif reduced accuracy acceptable - [ ]
NSLocationTemporaryUsageDescriptionDictionaryif requesting temporary full accuracy - [ ]
UIBackgroundModesincludeslocationif background tracking
Privacy manifest (PrivacyInfo.xcprivacy):
- [ ] Declare your location collection under
NSPrivacyCollectedDataTypeswith the correct purpose(s) and linkage/tracking flags. This feeds the App Store privacy label and must match what you disclose in App Store Connect — an inaccurate or missing entry is a disclosure-accuracy problem (and a possible manual App Review issue under 5.1.1), not a runtime crash. Core Location is not a required-reason API, so omitting it does not trigger the ITMS-91053 upload rejection — that hard gate (enforced since 2024-05-01) coversNSPrivacyAccessedAPITypesand listed third-party SDKs, so a bundled maps/analytics SDK shipping without its own manifest can still block your upload. Info.plist usage strings and the privacy manifest are separate requirements; you need both.
Authorization:
- [ ] Start with minimal authorization (.whenInUse)
- [ ] Upgrade to .always only when user triggers background feature
- [ ] Handle authorization denial gracefully (offer alternatives)
- [ ] Handle global location services disabled
- [ ] Test with reduced accuracy authorization
Updates:
- [ ] Using appropriate LiveConfiguration for use case
- [ ] Handling isStationary for pause/resume
- [ ] Cancelling location tasks when feature inactive
- [ ] Not using continuous updates for geofencing
Testing:
- [ ] Tested authorization denial flow
- [ ] Tested reduced accuracy mode
- [ ] Tested background-to-foreground transitions
- [ ] Tested app termination and relaunch recovery
Background Location Checklist
Setup:
- [ ] Background mode capability added (Location updates)
- [ ] CLBackgroundActivitySession created and HELD (not local variable)
- [ ] Session started from foreground
- [ ] Updates restarted on background launch in didFinishLaunchingWithOptions
Authorization:
- [ ] Using .whenInUse with CLBackgroundActivitySession, OR
- [ ] Using .always (but only if needed beyond background indicator)
Lifecycle:
- [ ] Persisting "was tracking" state for relaunch recovery
- [ ] Recreating CLBackgroundActivitySession on background launch
- [ ] Restarting CLLocationUpdate iteration on launch
- [ ] CLMonitor reinitialized with same name on launch
Testing:
- [ ] Blue background location indicator appears when backgrounded
- [ ] Updates continue when app backgrounded
- [ ] Updates resume after app suspended and resumed
- [ ] Updates resume after app terminated and relaunched
---
Part 5: iOS Version Considerations
| Feature | iOS Version | Notes |
|---|---|---|
| CLLocationUpdate | iOS 17+ | AsyncSequence API |
| CLMonitor | iOS 17+ | Replaces CLCircularRegion |
| CLBackgroundActivitySession | iOS 17+ | Background with blue indicator |
| CLServiceSession | iOS 18+ | Declarative authorization |
| Implicit service sessions | iOS 18+ | From iterating liveUpdates |
| CLLocationManager | iOS 2+ | Legacy but still works |
For iOS 14-16 support: Use CLLocationManager delegate pattern (see core-location-ref Part 7).
For iOS 17+: Prefer CLLocationUpdate and CLMonitor.
For iOS 18+: Add CLServiceSession for declarative authorization.
---
Resources
WWDC: 2023-10180, 2023-10147, 2024-10212
Docs: /corelocation, /corelocation/clmonitor, /corelocation/cllocationupdate, /corelocation/clservicesession
Skills: axiom-location (skills/core-location-ref.md), axiom-location (skills/core-location-diag.md), axiom-performance (skills/energy.md)
MapKit Diagnostics
Symptom-based MapKit troubleshooting. Start with the symptom you're seeing, follow the diagnostic path.
Red Flags
Stop and fix these before anything else — they are the most common causes of "map is slow / search broken / pins are a mess" that look like other problems.
| Red flag | Why it's wrong | Fix |
|---|---|---|
MKLocalSearch().start() called in the search field's onChange / per keystroke | Apple rate-limits MKLocalSearch (~1/sec); type-ahead exhausts the budget and returns errors | Type-ahead = one long-lived MKLocalSearchCompleter; run MKLocalSearch only on the resolved selection via MKLocalSearch.Request(completion:) |
New MKLocalSearchCompleter() created per query/keystroke | Loses internal throttling and warm state | Keep ONE completer for the field's lifetime; just set queryFragment |
| Adding all annotations regardless of count | Memory ≈ N views with no reuse; 5K places = 5K live views | Three layers: reuse → clustering → visible-region filtering (see thresholds below) |
| Loading every annotation even with clustering at 1000+ | Clustering thins the display, not the working set | Filter to mapView.region and refresh via .onMapCameraChange(frequency: .onEnd) |
| "Clustering is over-engineering for our count" | 5K overlapping pins is unusable at every zoom and spikes memory | Clustering is the baseline scaling tool, not a nice-to-have — keep it |
Related Skills
axiom-location (skills/mapkit.md)— Patterns, decision trees, anti-patternsaxiom-location (skills/mapkit-ref.md)— API reference, code examples
---
Quick Reference
| Symptom | Check First | Common Fix |
|---|---|---|
| Annotations not appearing | Coordinate values (lat/lng swapped?) | Verify coordinate, check viewFor delegate |
| Map region jumps/loops | updateUIView guard | Add region equality check |
| Slow with many annotations | Annotation count, view reuse | Enable clustering, implement view reuse |
| Clustering not working | clusteringIdentifier set? | Set same identifier on all views |
| Overlays not rendering | renderer delegate method | Return correct MKOverlayRenderer subclass |
| Search returns no results | resultTypes, region bias | Set appropriate resultTypes and region |
| User location not showing | Authorization status | Request CLLocationManager authorization first |
| Coordinates appear wrong | lat/lng order | MapKit uses (latitude, longitude) — verify data source |
---
Symptom 1: Annotations Not Appearing
Decision Tree
Q1: Are coordinates valid?
├─ 0,0 or NaN → Data source returning default/empty values
│ Fix: Validate coordinates before adding annotations
│ Debug: print("\(annotation.coordinate.latitude), \(annotation.coordinate.longitude)")
│
└─ Valid numbers → Check next
Q2: Are lat/lng swapped?
├─ YES (common with GeoJSON which uses [longitude, latitude]) → Swap values
│ GeoJSON: [lng, lat] — MapKit: CLLocationCoordinate2D(latitude:, longitude:)
│ Fix: CLLocationCoordinate2D(latitude: json[1], longitude: json[0])
│
└─ NO → Check next
Q3: (MKMapView) Is mapView(_:viewFor:) delegate returning nil for your annotations?
├─ Not implemented → System uses default pin (should appear)
├─ Returns nil → System uses default pin (should appear)
├─ Returns wrong view → Check implementation
│
└─ Check delegate is set
Q4: (MKMapView) Is delegate set?
├─ NO → mapView.delegate = self (or context.coordinator in UIViewRepresentable)
│ Without delegate: default pins appear. But if viewFor returns nil, check annotation type
│
└─ YES → Check next
Q5: (SwiftUI) Are annotations in Map content builder?
├─ NO → Annotations must be inside Map { ... } content closure
│ Fix: Map(position: $pos) { Marker("Name", coordinate: coord) }
│
└─ YES → Check next
Q6: Is the map region showing the annotation coordinates?
├─ Map centered elsewhere → Adjust camera/region to include annotation coordinates
│ Debug: Compare mapView.region with annotation coordinates
│ Fix: Use .automatic camera position or set region to fit annotations
│
└─ Region includes annotations → Check displayPriority
Q7: (MKMapView) Is displayPriority too low?
├─ .defaultLow → System may hide annotations at certain zoom levels
│ Fix: view.displayPriority = .required for must-show annotations
│
└─ .required → Annotation should appear — file a bug report with minimal repro---
Symptom 2: Map Region Jumping / Infinite Loops
Decision Tree
Q1: (UIViewRepresentable) Is setRegion called in updateUIView without guard?
├─ YES → Classic infinite loop:
│ 1. SwiftUI state changes → updateUIView called
│ 2. updateUIView calls setRegion
│ 3. setRegion triggers regionDidChangeAnimated delegate
│ 4. Delegate updates SwiftUI state → back to step 1
│
│ Fix: Guard against unnecessary updates
│ if mapView.region.center.latitude != region.center.latitude
│ || mapView.region.center.longitude != region.center.longitude {
│ mapView.setRegion(region, animated: true)
│ }
│
│ Alternative: Use a flag in coordinator
│ coordinator.isUpdating = true
│ mapView.setRegion(region, animated: true)
│ coordinator.isUpdating = false
│ // In regionDidChangeAnimated: guard !isUpdating
│
└─ NO → Check next
Q2: Are multiple state sources fighting over the region?
├─ YES → Two bindings or state variables controlling the same region
│ Fix: Single source of truth for camera position
│ One @State var cameraPosition, not two conflicting values
│
└─ NO → Check next
Q3: (SwiftUI) Is MapCameraPosition properly bound?
├─ Using .constant() or recreating position on each render → Camera resets
│ Fix: @State private var cameraPosition: MapCameraPosition = .automatic
│ Use the binding: Map(position: $cameraPosition)
│
└─ Properly bound → Check next
Q4: Animation conflict?
├─ Using animated: true in updateUIView alongside SwiftUI animations → Double animation
│ Fix: Avoid animated: true in updateUIView, or disable SwiftUI animation for map
│
└─ NO → Check next
Q5: Is onMapCameraChange triggering state updates that move the camera?
├─ YES → Camera change → callback → state change → camera change
│ Fix: Only update non-camera state in the callback
│ Don't set cameraPosition inside onMapCameraChange
│
└─ NO → Check delegate implementation for unintended state mutations---
Symptom 3: Performance Issues
Three Scaling Layers (apply by count — they stack, not either/or)
| Annotation count | Required layers |
|---|---|
| < 500 | View reuse (dequeue with for:) |
| 500–1000 | Reuse + clustering |
| > 1000 | Reuse + clustering + visible-region filtering (mapView.region, refresh on .onMapCameraChange(frequency: .onEnd)) |
| 5000+ | All three + server-side pre-clustering (return cluster summaries, not raw points) |
Clustering thins the display; visible-region filtering thins the working set. At 5K places you need both — clustering alone still holds 5K live annotation objects.
Memory cost without reuse: ~N annotation views in memory ≈ N annotations (5K places → ~5K views, the laggy/memory-spike symptom). With reuse, MapKit recycles ~20–30 views as the user scrolls.
Let MapKit thin overlapping pins: set view.displayPriority (.required only for must-show pins; .defaultLow lets MapKit drop them when crowded) and view.collisionMode (.circle collides on the glyph radius so dense pins drop instead of overlapping into a wall).
Decision Tree
Q1: How many annotations?
├─ > 500 without clustering → Enable clustering
│ Clustering is UIKit-only (no SwiftUI Map modifier)
│ MKMapView: view.clusteringIdentifier = "poi"
│
├─ > 1000 → ADD visible-region filtering on top of clustering
│ Only load annotations within mapView.region
│ Refresh via .onMapCameraChange(frequency: .onEnd) — fires once at gesture end, not per frame
│
└─ < 500 → Check next
Q2: (MKMapView) Using dequeueReusableAnnotationView?
├─ NO → Every annotation creates a new view → memory spike
│ Fix: Register view class and dequeue in delegate
│ mapView.register(MKMarkerAnnotationView.self, forAnnotationViewWithReuseIdentifier: "marker")
│
└─ YES → Check next
Q3: Complex custom annotation views?
├─ YES → Rich SwiftUI views or complex UIViews per annotation
│ Fix: Pre-render to UIImage for MKAnnotationView.image
│ Or simplify to MKMarkerAnnotationView with glyph
│
└─ NO → Check next
Q4: Overlays with many coordinates?
├─ YES → Polylines/polygons with 10K+ points
│ Fix: Simplify geometry (Douglas-Peucker algorithm)
│ Or render at reduced detail for zoomed-out views
│
└─ NO → Check next
Q5: Geocoding in a loop?
├─ YES → CLGeocoder has rate limit (~1/second)
│ Fix: Batch geocoding, throttle requests, cache results
│ Use MKLocalSearch for batch lookups instead of per-item geocoding
│
└─ NO → Profile with Instruments → Time Profiler for CPU, Allocations for memory---
Symptom 4: Clustering Not Working
Decision Tree
Q1: Is clusteringIdentifier set on annotation views?
├─ NO → Clustering requires an identifier on each annotation view
│ MKMapView: view.clusteringIdentifier = "poi" in viewFor delegate
│ Clustering is UIKit-only — SwiftUI Map has no clustering modifier
│
└─ YES → Check next
Q2: Are ALL relevant views using the SAME identifier?
├─ NO → Different identifiers = different cluster groups
│ Fix: Use consistent identifier for annotations that should cluster together
│
└─ YES → Check next
Q3: (MKMapView) Is mapView(_:clusterAnnotationForMemberAnnotations:) needed?
├─ Not implemented → System creates default cluster
│ If you need custom cluster appearance, implement this delegate method
│
└─ Implemented → Check return value
Q4: Too few annotations in visible area?
├─ YES → Clustering only activates when annotations physically overlap
│ At low zoom (city level), 10 annotations might cluster
│ At high zoom (street level), same 10 might all be visible individually
│
└─ NO → Check next
Q5: (MKMapView) Are annotation views registered?
├─ NO → Register both individual and cluster view classes
│ mapView.register(MKMarkerAnnotationView.self, forAnnotationViewWithReuseIdentifier: "marker")
│
└─ YES → Verify viewFor delegate handles both MKClusterAnnotation and individual annotations---
Symptom 5: Overlays Not Rendering
Decision Tree
Q1: (MKMapView) Is mapView(_:rendererFor:) delegate method implemented?
├─ NO → Overlays require a renderer — without this delegate method, nothing renders
│ Fix: Implement the delegate method, return appropriate renderer subclass
│
└─ YES → Check next
Q2: Is the correct renderer subclass returned?
├─ MKCircle → MKCircleRenderer
│ MKPolyline → MKPolylineRenderer
│ MKPolygon → MKPolygonRenderer
│ MKTileOverlay → MKTileOverlayRenderer
│ Mismatch → Crash or silent failure
│
└─ Correct → Check next
Q3: Is renderer styled?
├─ No strokeColor/fillColor/lineWidth set → Renderer exists but invisible
│ Fix: Set at minimum strokeColor and lineWidth
│ renderer.strokeColor = .systemBlue
│ renderer.lineWidth = 2
│
└─ Styled → Check next
Q4: Overlay level wrong?
├─ .aboveRoads → Overlay may be behind labels (hard to see)
│ Try: mapView.addOverlay(overlay, level: .aboveLabels)
│
└─ Check overlay coordinates match visible region
Q5: (SwiftUI) Using MapCircle/MapPolyline without styling?
├─ No .foregroundStyle or .stroke → May render transparent
│ Fix: MapCircle(center: coord, radius: 500)
│ .foregroundStyle(.blue.opacity(0.3))
│ .stroke(.blue, lineWidth: 2)
│
└─ Styled → Check coordinates are within visible map region---
Symptom 6: Search / Directions Failures
Decision Tree
Q1: Network available?
├─ NO → MapKit search requires network connectivity
│ Fix: Check URLSession connectivity or NWPathMonitor
│
└─ YES → Check next
Q2: resultTypes too restrictive?
├─ Only .physicalFeature but searching for "Starbucks" → No results
│ Fix: Use .pointOfInterest for businesses, .address for streets
│ Or combine: [.pointOfInterest, .address]
│
└─ Appropriate → Check next
Q3: Region bias missing?
├─ NO region set → Results may be from anywhere in the world
│ Fix: request.region = mapView.region (or visible region)
│ This biases results to what the user can see
│
└─ Region set → Check next
Q4: Natural language query format?
├─ Structured format (lat/lng, codes) → Won't parse
│ Good: "coffee shops near San Francisco"
│ Good: "123 Main St"
│ Bad: "lat:37.7 lng:-122.4 coffee"
│ Bad: "POI_TYPE=cafe"
│
└─ Natural language → Check next
Q5: Rate limited? (search returns nothing after working briefly, or errors after many requests)
├─ Firing MKLocalSearch per keystroke → Apple rate-limits MKLocalSearch (~1/sec); type-ahead blows the budget
│ Fix: Split the work by responsibility —
│ 1. Type-ahead: ONE long-lived MKLocalSearchCompleter; set queryFragment each keystroke
│ (it self-throttles; do NOT recreate it per query)
│ 2. Run MKLocalSearch ONLY on the selection the user taps:
│ let req = MKLocalSearch.Request(completion: selectedCompletion)
│ Never call MKLocalSearch.start() inside onChange of the text field
│
└─ NO → Check next
Q6: (Directions) Source and destination valid?
├─ source or destination is nil → Request will fail
│ Fix: Verify both are valid MKMapItem instances
│ MKMapItem.forCurrentLocation() requires location authorization
│
└─ Both valid → Check transportType availability
Transit directions not available in all regions
Walking/driving available globally---
Symptom 7: User Location Not Showing
Decision Tree
Q1: What is CLLocationManager.authorizationStatus?
├─ .notDetermined → Authorization never requested
│ Fix: Request authorization first, then enable user location
│ CLServiceSession(authorization: .whenInUse)
│
├─ .denied → User denied location access
│ Fix: Show UI explaining value, link to Settings
│
├─ .restricted → Parental controls block access
│ Fix: Inform user, cannot override
│
└─ .authorizedWhenInUse / .authorizedAlways → Check next
Q2: (MKMapView) Is showsUserLocation set to true?
├─ NO → mapView.showsUserLocation = true
│
└─ YES → Check next
Q3: (SwiftUI) Using UserAnnotation() in Map content?
├─ NO → Add UserAnnotation() inside Map { ... }
│
└─ YES → Check next
Q4: Running in Simulator?
├─ YES, no custom location set → Simulator doesn't have GPS
│ Fix: Debug menu → Location → Custom Location (or Apple/City Bicycle Ride/etc.)
│ Xcode: Debug → Simulate Location → pick a location
│
└─ Physical device → Check next
Q5: MapKit implicitly requests authorization — was it previously denied?
├─ MapKit shows no prompt if already denied
│ Check: Settings → Privacy & Security → Location Services → Your App
│ If "Never": User must manually re-enable
│
└─ Authorized → Check if location services enabled system-wide
Settings → Privacy & Security → Location Services → toggle at top
Q6: Location icon appearing but blue dot not on screen?
├─ User is outside the visible map region
│ Fix: Use MapCameraPosition.userLocation(fallback: .automatic)
│ Or add MapUserLocationButton() in .mapControls
│
└─ See axiom-location (skills/core-location-diag.md) for deeper location troubleshooting---
Symptom 8: Coordinate System Confusion
Common coordinate mistakes that cause annotations to appear in wrong locations.
MapKit vs GeoJSON
| System | Order | Example |
|---|---|---|
| MapKit (CLLocationCoordinate2D) | latitude, longitude | CLLocationCoordinate2D(latitude: 37.77, longitude: -122.42) |
| GeoJSON | longitude, latitude | [-122.42, 37.77] |
| Google Maps | latitude, longitude | Same as MapKit |
| PostGIS ST_MakePoint | longitude, latitude | Same as GeoJSON |
The #1 coordinate bug: Swapping lat/lng when parsing GeoJSON.
// ❌ WRONG: Using GeoJSON order directly
let coord = CLLocationCoordinate2D(
latitude: geoJson[0], // This is longitude!
longitude: geoJson[1] // This is latitude!
)
// ✅ RIGHT: GeoJSON is [lng, lat], MapKit wants (lat, lng)
let coord = CLLocationCoordinate2D(
latitude: geoJson[1],
longitude: geoJson[0]
)MKMapPoint vs CLLocationCoordinate2D
CLLocationCoordinate2D— geographic coordinates (lat/lng in degrees)MKMapPoint— projected coordinates for flat map rendering- Convert:
MKMapPoint(coordinate)andcoordinateproperty on MKMapPoint - Never use MKMapPoint x/y as lat/lng — they're completely different number spaces
Validation
func isValidCoordinate(_ coord: CLLocationCoordinate2D) -> Bool {
coord.latitude >= -90 && coord.latitude <= 90
&& coord.longitude >= -180 && coord.longitude <= 180
&& !coord.latitude.isNaN && !coord.longitude.isNaN
}If latitude > 90 or longitude > 180, coordinates are likely swapped or in wrong format.
---
Console Debugging
MapKit Logs
# View MapKit-related logs
log stream --predicate 'subsystem == "com.apple.MapKit"' --level debug
# Filter for your app
log stream --predicate 'process == "YourApp" AND (subsystem == "com.apple.MapKit" OR subsystem == "com.apple.CoreLocation")'Common Console Messages
| Message | Meaning |
|---|---|
No renderer for overlay | Missing rendererFor delegate method |
Reuse identifier not registered | Call register before dequeue |
CLLocationManager authorizationStatus is denied | User denied location |
---
Resources
WWDC: 2023-10043, 2024-10094
Docs: /mapkit, /mapkit/mklocalsearch
Skills: axiom-location (skills/mapkit.md), axiom-location (skills/mapkit-ref.md), axiom-location (skills/core-location-diag.md)
MapKit API Reference
Complete MapKit API reference for iOS development. Covers both SwiftUI Map (iOS 17+) and MKMapView (UIKit).
Related Skills
axiom-location (skills/mapkit.md)— Decision trees, anti-patterns, pressure scenariosaxiom-location (skills/mapkit-diag.md)— Symptom-based troubleshooting
---
Part 1: Modern API Overview
| Feature | SwiftUI Map (iOS 17+) | MKMapView |
|---|---|---|
| Declaration | Map(position:) { content } | MKMapView() |
| Camera control | MapCameraPosition binding | setRegion(_:animated:) |
| Annotations | Marker, Annotation in content | addAnnotation(_:) + delegate |
| Overlays | MapCircle, MapPolyline, MapPolygon | addOverlay(_:) + renderer delegate |
| User location | UserAnnotation() | showsUserLocation = true |
| Selection | .mapSelection($selection) | delegate didSelect |
| Controls | .mapControls { } | showsCompass, showsScale |
| Interaction modes | .mapInteractionModes([]) | delegate methods |
| Clustering | No built-in modifier — use MKMapView | MKAnnotationView.clusteringIdentifier + MKClusterAnnotation |
---
Part 2: SwiftUI Map API
Basic Map
@State private var cameraPosition: MapCameraPosition = .automatic
Map(position: $cameraPosition) {
Marker("Home", coordinate: homeCoord)
Annotation("Custom", coordinate: coord) {
Image(systemName: "star.fill")
.foregroundStyle(.yellow)
.padding(4)
.background(.blue, in: Circle())
}
UserAnnotation()
MapCircle(center: coord, radius: 500)
.foregroundStyle(.blue.opacity(0.3))
MapPolyline(coordinates: routeCoords)
.stroke(.blue, lineWidth: 3)
}
.mapStyle(.standard(elevation: .realistic))
.mapControls {
MapUserLocationButton()
MapCompass()
MapScaleView()
}MapCameraPosition
Controls where the camera is positioned:
// System manages camera to show all content
.automatic
// Specific region
.region(MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194),
span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)
))
// Specific camera with pitch and heading
.camera(MapCamera(
centerCoordinate: coordinate,
distance: 1000, // meters from center
heading: 90, // degrees from north
pitch: 60 // degrees from vertical (0 = top-down)
))
// Follow user location
.userLocation(followsHeading: true, fallback: .automatic)
// Show specific item
.item(mapItem)
// Show specific rect
.rect(MKMapRect(...))Programmatic Camera Changes
// Animate to new position
withAnimation {
cameraPosition = .region(newRegion)
}
// Keyframe animation (iOS 17+)
Map(position: $cameraPosition)
.mapCameraKeyframeAnimator(trigger: flyToTrigger) { initialCamera in
KeyframeTrack(\.centerCoordinate) {
LinearKeyframe(destination, duration: 2.0)
}
KeyframeTrack(\.distance) {
CubicKeyframe(5000, duration: 1.0)
CubicKeyframe(1000, duration: 1.0)
}
}Map Selection
@State private var selectedItem: MKMapItem?
Map(position: $cameraPosition, selection: $selectedItem) {
ForEach(mapItems, id: \.self) { item in
Marker(item: item)
}
}
.onChange(of: selectedItem) { _, newItem in
if let newItem {
// Handle selection
}
}Camera Change Callback
Map(position: $cameraPosition) { ... }
.onMapCameraChange { context in
// context.region — visible MKCoordinateRegion
// context.camera — current MapCamera
// context.rect — visible MKMapRect
fetchAnnotations(in: context.region)
}
.onMapCameraChange(frequency: .continuous) { context in
// Called during gesture (not just at end)
}Map Styles
.mapStyle(.standard) // Default
.mapStyle(.standard(elevation: .realistic)) // 3D buildings
.mapStyle(.standard(emphasis: .muted)) // Muted colors
.mapStyle(.standard(pointsOfInterest: .including([.restaurant, .cafe])))
.mapStyle(.imagery) // Satellite
.mapStyle(.imagery(elevation: .realistic)) // 3D satellite
.mapStyle(.hybrid) // Satellite + labels
.mapStyle(.hybrid(elevation: .realistic)) // 3D hybridInteraction Modes
// Allow all interactions (default)
.mapInteractionModes(.all)
// Read-only map (no interaction)
.mapInteractionModes([])
// Pan only, no zoom
.mapInteractionModes([.pan])
// Pan and zoom, no rotate/pitch
.mapInteractionModes([.pan, .zoom])---
Part 3: Map Content
Marker
System-styled map marker with callout:
// Basic marker
Marker("Coffee Shop", coordinate: coord)
// With system image
Marker("Coffee Shop", systemImage: "cup.and.saucer.fill", coordinate: coord)
// With monogram (2 characters max)
Marker("Coffee Shop", monogram: Text("CS"), coordinate: coord)
// Color
Marker("Coffee Shop", coordinate: coord)
.tint(.brown)
// From MKMapItem
Marker(item: mapItem)Annotation
Fully custom view at a coordinate:
Annotation("Custom Pin", coordinate: coord) {
VStack {
Image(systemName: "mappin.circle.fill")
.font(.title)
.foregroundStyle(.red)
Text("Here")
.font(.caption)
}
}
// Anchor point (default is bottom center)
Annotation("Pin", coordinate: coord, anchor: .center) {
Circle()
.fill(.blue)
.frame(width: 20, height: 20)
}UserAnnotation
Current user location indicator:
UserAnnotation()
// Custom appearance
UserAnnotation(anchor: .center) {
Image(systemName: "location.circle.fill")
.foregroundStyle(.blue)
}Shape Overlays
// Circle
MapCircle(center: coord, radius: 1000) // radius in meters
.foregroundStyle(.blue.opacity(0.2))
.stroke(.blue, lineWidth: 2)
// Polygon
MapPolygon(coordinates: polygonCoords)
.foregroundStyle(.green.opacity(0.3))
.stroke(.green, lineWidth: 2)
// Polyline
MapPolyline(coordinates: routeCoords)
.stroke(.blue, lineWidth: 4)
// From MKRoute
MapPolyline(route.polyline)
.stroke(.blue, lineWidth: 5)Clustering
SwiftUI's declarative Map has no built-in clustering modifier. Clustering is UIKit-only: set clusteringIdentifier on the MKAnnotationView vended by your MKMapViewDelegate (iOS 11+, macOS 10.13+, tvOS 11+; unavailable on watchOS). Annotation views that share an identifier collapse into an MKClusterAnnotation automatically.
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
let view = mapView.dequeueReusableAnnotationView(
withIdentifier: "marker",
for: annotation
) as! MKMarkerAnnotationView
view.clusteringIdentifier = "locations" // Views sharing this identifier cluster
return view
}To use clustering with a SwiftUI app, wrap MKMapView in a UIViewRepresentable (see Part 4).
---
Part 4: MKMapView Lifecycle and Delegates
Creating MKMapView in SwiftUI
struct MapViewWrapper: UIViewRepresentable {
@Binding var region: MKCoordinateRegion
let annotations: [MKAnnotation]
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.delegate = context.coordinator
mapView.showsUserLocation = true
mapView.register(
MKMarkerAnnotationView.self,
forAnnotationViewWithReuseIdentifier: "marker"
)
return mapView
}
func updateUIView(_ mapView: MKMapView, context: Context) {
// Guard against infinite loops
if !regionsAreEqual(mapView.region, region) {
mapView.setRegion(region, animated: true)
}
// Diff annotations instead of removing all
let current = Set(mapView.annotations.compactMap { $0 as? MyAnnotation })
let desired = Set(annotations.compactMap { $0 as? MyAnnotation })
let toAdd = desired.subtracting(current)
let toRemove = current.subtracting(desired)
mapView.addAnnotations(Array(toAdd))
mapView.removeAnnotations(Array(toRemove))
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
static func dismantleUIView(_ mapView: MKMapView, coordinator: Coordinator) {
mapView.removeAnnotations(mapView.annotations)
mapView.removeOverlays(mapView.overlays)
}
}Key MKMapViewDelegate Methods
class Coordinator: NSObject, MKMapViewDelegate {
var parent: MapViewWrapper
init(_ parent: MapViewWrapper) {
self.parent = parent
}
// Annotation view customization
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
guard !(annotation is MKUserLocation) else { return nil } // Use default for user
let view = mapView.dequeueReusableAnnotationView(
withIdentifier: "marker",
for: annotation
) as! MKMarkerAnnotationView
view.markerTintColor = .systemRed
view.glyphImage = UIImage(systemName: "mappin")
view.clusteringIdentifier = "poi"
view.canShowCallout = true
return view
}
// Overlay rendering
func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
if let circle = overlay as? MKCircle {
let renderer = MKCircleRenderer(circle: circle)
renderer.fillColor = UIColor.systemBlue.withAlphaComponent(0.2)
renderer.strokeColor = .systemBlue
renderer.lineWidth = 2
return renderer
}
if let polyline = overlay as? MKPolyline {
let renderer = MKPolylineRenderer(polyline: polyline)
renderer.strokeColor = .systemBlue
renderer.lineWidth = 4
return renderer
}
if let polygon = overlay as? MKPolygon {
let renderer = MKPolygonRenderer(polygon: polygon)
renderer.fillColor = UIColor.systemGreen.withAlphaComponent(0.3)
renderer.strokeColor = .systemGreen
renderer.lineWidth = 2
return renderer
}
return MKOverlayRenderer(overlay: overlay)
}
// Region change tracking
func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
parent.region = mapView.region
}
// Annotation selection
func mapView(_ mapView: MKMapView, didSelect annotation: MKAnnotation) {
// Handle tap
}
// Cluster annotation
func mapView(
_ mapView: MKMapView,
clusterAnnotationForMemberAnnotations memberAnnotations: [MKAnnotation]
) -> MKClusterAnnotation {
MKClusterAnnotation(memberAnnotations: memberAnnotations)
}
}---
Part 5: Annotation Types and Customization
MKMarkerAnnotationView (iOS 11+)
Balloon-shaped marker with glyph:
let view = MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: "marker")
view.markerTintColor = .systemPurple
view.glyphImage = UIImage(systemName: "star.fill")
view.glyphText = "A" // Text glyph (overrides image)
view.displayPriority = .required // Always visible
view.clusteringIdentifier = "category" // Enable clustering
view.canShowCallout = true
view.titleVisibility = .adaptive // Show title based on space
view.subtitleVisibility = .hiddenMKAnnotationView
Fully custom annotation view:
let view = MKAnnotationView(annotation: annotation, reuseIdentifier: "custom")
view.image = UIImage(named: "custom-pin")
view.centerOffset = CGPoint(x: 0, y: -view.image!.size.height / 2)
view.canShowCallout = true
view.leftCalloutAccessoryView = UIImageView(image: thumbnail)
view.rightCalloutAccessoryView = UIButton(type: .detailDisclosure)Custom Callout
func mapView(
_ mapView: MKMapView,
annotationView view: MKAnnotationView,
calloutAccessoryControlTapped control: UIControl
) {
guard let annotation = view.annotation as? MyAnnotation else { return }
// Navigate to detail view
}Annotation View Reuse
Always use dequeueReusableAnnotationView(withIdentifier:for:):
// Register in makeUIView (once)
mapView.register(MKMarkerAnnotationView.self, forAnnotationViewWithReuseIdentifier: "marker")
// Dequeue in delegate (every time)
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
let view = mapView.dequeueReusableAnnotationView(withIdentifier: "marker", for: annotation)
// Configure...
return view
}Without reuse: 1000 annotations = 1000 views in memory. With reuse: ~20-30 views recycled as user scrolls.
---
Part 6: MKLocalSearch and MKLocalSearchCompleter
MKLocalSearchCompleter — Real-Time Autocomplete
let completer = MKLocalSearchCompleter()
completer.delegate = self
completer.resultTypes = [.pointOfInterest, .address]
completer.region = visibleMapRegion // Bias results to visible area
// Update on each keystroke
completer.queryFragment = "coffee"
// Delegate receives results
func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
let results = completer.results // [MKLocalSearchCompletion]
for result in results {
// result.title — "Starbucks"
// result.subtitle — "123 Main St, San Francisco, CA"
// result.titleHighlightRanges — Ranges matching query
}
}
func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) {
// Network error, rate limit, etc.
}MKLocalSearch — Full Search
// From autocomplete completion
let request = MKLocalSearch.Request(completion: selectedCompletion)
// From natural language
let request = MKLocalSearch.Request()
request.naturalLanguageQuery = "coffee shops"
request.region = mapRegion // Bias results
request.resultTypes = .pointOfInterest // Filter type
request.pointOfInterestFilter = MKPointOfInterestFilter(
including: [.cafe, .restaurant]
)
let search = MKLocalSearch(request: request)
let response = try await search.start()
for item in response.mapItems {
// item.name — "Starbucks"
// item.location — CLLocation (placemark is deprecated since 26)
// item.address / item.addressRepresentations — structured address
// item.phoneNumber — optional phone
// item.url — optional website
// item.pointOfInterestCategory — .cafe, .restaurant, etc.
}The 27 SDKs add 11 MKPointOfInterestCategory values (all platforms): .airportTerminal, .automotiveDealership, .commercialVehicleDealership, .informationBooth, .motorbikeDealership, .picnicArea, .rangerStation, .restArea, .scenicView, .ticketOffice, .visitorCenter — usable in MKPointOfInterestFilter and returned in pointOfInterestCategory.
Result Types
// Filter what kind of results to return
request.resultTypes = .address // Street addresses only
request.resultTypes = .pointOfInterest // Businesses, landmarks
request.resultTypes = .physicalFeature // Mountains, lakes, parks (iOS 18+)
request.resultTypes = [.pointOfInterest, .address] // Multiple typesRate Limiting
MKLocalSearchCompleterhandles its own throttling — safe to call on every keystrokeMKLocalSearch— Apple rate-limits these; don't fire more than ~1/second- If rate-limited, you'll get an error in the completion handler
- Reuse
MKLocalSearchCompleterinstances — don't create new ones per query
---
Part 7: MKDirections and MKRoute
Calculate Directions
let request = MKDirections.Request()
request.source = MKMapItem.forCurrentLocation()
request.destination = destinationMapItem
request.transportType = .automobile
request.requestsAlternateRoutes = true // Get multiple routes
let directions = MKDirections(request: request)
let response = try await directions.calculate()
for route in response.routes {
route.polyline // MKPolyline — display on map
route.expectedTravelTime // TimeInterval in seconds
route.distance // CLLocationDistance in meters
route.name // "I-280 S" — route name
route.advisoryNotices // [String] — warnings
route.steps // [MKRoute.Step] — turn-by-turn
}Transport Types
.automobile // Driving directions
.walking // Pedestrian directions
.transit // Public transit (where available)
.any // All modesETA Only (Faster)
let directions = MKDirections(request: request)
let eta = try await directions.calculateETA()
eta.expectedTravelTime // TimeInterval
eta.distance // CLLocationDistance
eta.expectedArrivalDate // Date
eta.expectedDepartureDate // Date
eta.transportType // MKDirectionsTransportTypeTurn-by-Turn Steps
for step in route.steps {
step.instructions // "Turn right onto Main St"
step.distance // CLLocationDistance in meters
step.polyline // MKPolyline for this step's segment
step.transportType // May change for transit routes
step.notice // Optional advisory
}---
Part 8: Look Around
Check Availability
let request = MKLookAroundSceneRequest(coordinate: coordinate)
do {
let scene = try await request.scene
// scene is non-nil — Look Around available at this coordinate
} catch {
// Look Around not available here
}SwiftUI
@State private var lookAroundScene: MKLookAroundScene?
LookAroundPreview(scene: $lookAroundScene)
.frame(height: 200)
// Load scene
func loadLookAround(for coordinate: CLLocationCoordinate2D) async {
let request = MKLookAroundSceneRequest(coordinate: coordinate)
lookAroundScene = try? await request.scene
}UIKit
let controller = MKLookAroundViewController(scene: scene)
// Present modally or embed as child view controllerStatic Snapshot
let snapshotter = MKLookAroundSnapshotter(scene: scene, options: .init())
let snapshot = try await snapshotter.snapshot
let image = snapshot.image // UIImage---
Part 9: Overlays and Renderers
Adding Overlays (MKMapView)
// Circle
let circle = MKCircle(center: coordinate, radius: 1000)
mapView.addOverlay(circle)
// Polygon
let polygon = MKPolygon(coordinates: &coords, count: coords.count)
mapView.addOverlay(polygon)
// Polyline
let polyline = MKPolyline(coordinates: &coords, count: coords.count)
mapView.addOverlay(polyline, level: .aboveRoads)
// Custom tile overlay
let template = "https://tile.example.com/{z}/{x}/{y}.png"
let tileOverlay = MKTileOverlay(urlTemplate: template)
tileOverlay.canReplaceMapContent = true // Hides Apple Maps base layer
mapView.addOverlay(tileOverlay, level: .aboveLabels)Renderer Delegate
func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
switch overlay {
case let circle as MKCircle:
let renderer = MKCircleRenderer(circle: circle)
renderer.fillColor = UIColor.systemBlue.withAlphaComponent(0.2)
renderer.strokeColor = .systemBlue
renderer.lineWidth = 2
return renderer
case let polyline as MKPolyline:
let renderer = MKPolylineRenderer(polyline: polyline)
renderer.strokeColor = .systemBlue
renderer.lineWidth = 4
return renderer
case let polygon as MKPolygon:
let renderer = MKPolygonRenderer(polygon: polygon)
renderer.fillColor = UIColor.systemGreen.withAlphaComponent(0.3)
renderer.strokeColor = .systemGreen
renderer.lineWidth = 2
return renderer
case let tile as MKTileOverlay:
return MKTileOverlayRenderer(tileOverlay: tile)
default:
return MKOverlayRenderer(overlay: overlay)
}
}Overlay Levels
mapView.addOverlay(overlay, level: .aboveRoads) // Above roads, below labels
mapView.addOverlay(overlay, level: .aboveLabels) // Above everythingGradient Polyline
let renderer = MKGradientPolylineRenderer(polyline: polyline)
renderer.setColors([.green, .yellow, .red], locations: [0.0, 0.5, 1.0])
renderer.lineWidth = 6---
Part 10: Map Snapshots
Generate static map images for sharing, thumbnails, or offline display:
let options = MKMapSnapshotter.Options()
options.region = MKCoordinateRegion(
center: coordinate,
span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01)
)
options.size = CGSize(width: 300, height: 200)
options.scale = UIScreen.main.scale // Retina support
options.mapType = .standard
options.showsBuildings = true
options.pointOfInterestFilter = .excludingAll // Clean map
let snapshotter = MKMapSnapshotter(options: options)
let snapshot = try await snapshotter.start()
let image = snapshot.image
// Draw custom annotations on snapshot
UIGraphicsBeginImageContextWithOptions(image.size, true, image.scale)
image.draw(at: .zero)
let pinImage = UIImage(systemName: "mappin.circle.fill")!
let point = snapshot.point(for: coordinate)
pinImage.draw(at: CGPoint(
x: point.x - pinImage.size.width / 2,
y: point.y - pinImage.size.height
))
let finalImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()Snapshot Coordinate Conversion
// Convert coordinate to point in snapshot image
let point = snapshot.point(for: coordinate)
// Check if coordinate is in snapshot bounds
let isVisible = CGRect(origin: .zero, size: snapshot.image.size).contains(point)---
Part 11: iOS Version Feature Matrix
| Feature | iOS Version |
|---|---|
| MKMapView | 3.0+ |
| MKLocalSearch | 6.1+ |
| MKDirections | 7.0+ |
| MKMarkerAnnotationView | 11.0+ |
| MKMapSnapshotter | 7.0+ |
| MKLookAroundSceneRequest | 16.0+ |
| LookAroundPreview (SwiftUI) | 17.0+ |
| SwiftUI Map (content builder) | 17.0+ |
| MapCameraPosition | 17.0+ |
| .mapSelection | 17.0+ |
| .mapCameraKeyframeAnimator | 17.0+ |
| .onMapCameraChange | 17.0+ |
| MapUserLocationButton | 17.0+ |
| MapCompass | 17.0+ |
| MapScaleView | 17.0+ |
| .mapInteractionModes | 17.0+ |
| MKLocalSearchResultType.physicalFeature | 18.0+ |
| GeoToolbox / PlaceDescriptor | 26.0+ |
| MKGeocodingRequest | 26.0+ |
| MKReverseGeocodingRequest | 26.0+ |
| MKAddress | 26.0+ |
| 11 new MKPointOfInterestCategory values (airportTerminal, scenicView, restArea, …) | 27.0+ |
---
Part 12: GeoToolbox and Geocoding
GeoToolbox Framework
GeoToolbox provides PlaceDescriptor — a standardized representation of physical locations that works across MapKit and third-party mapping services.
import GeoToolbox
// From address
let fountain = PlaceDescriptor(
representations: [.address("121-122 James's St \n Dublin 8 \n D08 ET27 \n Ireland")],
commonName: "Obelisk Fountain"
)
// From coordinates
let tower = PlaceDescriptor(
representations: [.coordinate(CLLocationCoordinate2D(latitude: 48.8584, longitude: 2.2945))],
commonName: "Eiffel Tower"
)
// Multiple representations
let statue = PlaceDescriptor(
representations: [
.coordinate(CLLocationCoordinate2D(latitude: 40.6892, longitude: -74.0445)),
.address("Liberty Island, New York, NY 10004, United States")
],
commonName: "Statue of Liberty"
)The only public PlaceDescriptor initializers are init(representations:commonName:supportingRepresentations:) (non-failable) and Codable's init(from:). There is no PlaceDescriptor(item:) initializer — build a descriptor from representations as shown above.
PlaceRepresentation
Enum representing a place using common mapping concepts:
| Case | Usage |
|---|---|
.coordinate(CLLocationCoordinate2D) | Latitude/longitude |
.address(String) | Full address string |
Convenience accessors on PlaceDescriptor:
descriptor.coordinate // CLLocationCoordinate2D?
descriptor.address // String?
descriptor.commonName // String?SupportingPlaceRepresentation
Proprietary identifiers for places from different mapping services:
let place = PlaceDescriptor(
representations: [.coordinate(CLLocationCoordinate2D(latitude: 51.5074, longitude: -0.1278))],
commonName: "London Eye",
supportingRepresentations: [
.serviceIdentifiers([
"com.apple.maps": "AppleMapsID123",
"com.google.maps": "GoogleMapsID456"
])
]
)
// Retrieve a specific service identifier
let appleID = place.serviceIdentifier(for: "com.apple.maps")MKGeocodingRequest — Forward Geocoding
Convert an address string to map items (address to coordinates):
guard let request = MKGeocodingRequest(addressString: "1 Apple Park Way, Cupertino, CA") else {
return
}
let mapItems = try await request.mapItemsMKReverseGeocodingRequest — Reverse Geocoding
Convert coordinates to map items (coordinates to address):
let location = CLLocation(latitude: 37.3349, longitude: -122.0090)
guard let request = MKReverseGeocodingRequest(location: location) else {
return
}
let mapItems = try await request.mapItemsMKAddress
Structured address type used when creating MKMapItem from a PlaceDescriptor:
if let coordinate = descriptor.coordinate {
let location = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
let address = MKAddress()
let mapItem = MKMapItem(location: location, address: address)
}Geocoding vs MKLocalSearch
| Need | Use |
|---|---|
| Address string to coordinates | MKGeocodingRequest |
| Coordinates to address | MKReverseGeocodingRequest |
| Natural language place search | MKLocalSearch |
| Autocomplete suggestions | MKLocalSearchCompleter |
| Cross-service place identifiers | PlaceDescriptor with SupportingPlaceRepresentation |
---
Resources
WWDC: 2023-10043, 2024-10094
Docs: /mapkit, /mapkit/map, /mapkit/mklocalsearch, /mapkit/mkdirections, /geotoolbox, /geotoolbox/placedescriptor, /mapkit/mkgeocodingrequest, /mapkit/mkreversegeocodingrequest, /mapkit/mkaddress
Skills: mapkit, mapkit-diag, core-location-ref
MapKit Patterns
MapKit patterns and anti-patterns for iOS apps. Prevents common mistakes: using MKMapView when SwiftUI Map suffices, annotations in view bodies, setRegion loops, and performance issues with large annotation counts.
When to Use
- Adding a map to your iOS app
- Displaying annotations, markers, or custom pins
- Implementing search (address, POI, autocomplete)
- Adding directions/routing to a map
- Debugging map display issues (annotations not showing, region jumping)
- Optimizing map performance with many annotations
- Deciding between SwiftUI Map and MKMapView
Related Skills
axiom-location (skills/mapkit-ref.md)— Complete API referenceaxiom-location (skills/mapkit-diag.md)— Symptom-based troubleshootingaxiom-location (skills/core-location.md)— Location authorization and monitoring
---
Part 1: Anti-Patterns (with Time Costs)
| Anti-Pattern | Time Cost | Fix |
|---|---|---|
| Using MKMapView when SwiftUI Map suffices | 2-4 hours UIViewRepresentable boilerplate | Use SwiftUI Map {} for standard map features (iOS 17+) |
| Creating annotations in SwiftUI view body | UI freeze with 100+ items, view recreation on every update | Move annotations to model, use @State or @Observable |
| No annotation view reuse (MKMapView) | Memory spikes, scroll lag with 500+ annotations | dequeueReusableAnnotationView(withIdentifier:for:) |
setRegion in updateUIView without guard | Infinite loop — region change triggers update, update sets region | Guard with mapView.region != region or use flag |
| Ignoring MapCameraPosition (SwiftUI) | Can't programmatically control camera, broken "center on user" | Bind position parameter to @State var cameraPosition |
| Synchronous geocoding on main thread | UI freeze for 1-3 seconds per geocode | Use CLGeocoder().geocodeAddressString with async/await |
| Not filtering annotations to visible region | Loading all 10K annotations at once | Use mapView.annotations(in:) or fetch by visible region |
Ignoring resultTypes in MKLocalSearch | Irrelevant results, slow search | Set .resultTypes = [.pointOfInterest] or .address to filter |
---
Part 2: Decision Trees
Decision Tree 1: SwiftUI Map vs MKMapView
digraph {
"Need map in app?" [shape=diamond];
"iOS 17+ target?" [shape=diamond];
"Need custom tile overlay?" [shape=diamond];
"Need fine-grained delegate control?" [shape=diamond];
"Use SwiftUI Map" [shape=box];
"Use MKMapView\nvia UIViewRepresentable" [shape=box];
"Need map in app?" -> "iOS 17+ target?" [label="yes"];
"iOS 17+ target?" -> "Need custom tile overlay?" [label="yes"];
"iOS 17+ target?" -> "Use MKMapView\nvia UIViewRepresentable" [label="no"];
"Need custom tile overlay?" -> "Use MKMapView\nvia UIViewRepresentable" [label="yes"];
"Need custom tile overlay?" -> "Need fine-grained delegate control?" [label="no"];
"Need fine-grained delegate control?" -> "Use MKMapView\nvia UIViewRepresentable" [label="yes"];
"Need fine-grained delegate control?" -> "Use SwiftUI Map" [label="no"];
}When SwiftUI Map is Right (most apps)
- Standard map with markers and annotations
- Programmatic camera control
- Built-in user location display
- Shape overlays (circle, polygon, polyline)
- Map style selection (standard, imagery, hybrid)
- Selection handling
- Clustering
When MKMapView is Required
- Custom tile overlays (e.g., OpenStreetMap, custom imagery)
- Fine-grained delegate control (willBeginLoadingMap, didFinishLoadingMap)
- Custom annotation view animations beyond SwiftUI
- Pre-iOS 17 deployment target
- Advanced overlay rendering with custom MKOverlayRenderer subclasses
Decision Tree 2: Annotation Strategy by Count
Annotation count?
├─ < 100 → Use Marker/Annotation directly in Map {} content builder
│ Simple, declarative, no performance concern
│
├─ 100-1000 → Enable clustering
│ Set .clusteringIdentifier on annotation views
│ SwiftUI: Marker("", coordinate:).tag(id)
│ MKMapView: view.clusteringIdentifier = "poi"
│
└─ 1000+ → Server-side clustering or visible-region filtering
Fetch only annotations within mapView.region
Or pre-cluster on server, send cluster centroids
MKMapView with view reuse is preferred for very large datasetsVisible-Region Filtering (SwiftUI)
Only load annotations within the visible map region. Prevents loading all 10K+ annotations at once:
struct MapView: View {
@State private var cameraPosition: MapCameraPosition = .automatic
@State private var visibleAnnotations: [Location] = []
let allLocations: [Location] // Full dataset
var body: some View {
Map(position: $cameraPosition) {
ForEach(visibleAnnotations) { location in
Marker(location.name, coordinate: location.coordinate)
}
}
.onMapCameraChange(frequency: .onEnd) { context in
visibleAnnotations = allLocations.filter { location in
context.region.contains(location.coordinate)
}
}
}
}
extension MKCoordinateRegion {
func contains(_ coordinate: CLLocationCoordinate2D) -> Bool {
let latRange = (center.latitude - span.latitudeDelta / 2)...(center.latitude + span.latitudeDelta / 2)
let lngRange = (center.longitude - span.longitudeDelta / 2)...(center.longitude + span.longitudeDelta / 2)
return latRange.contains(coordinate.latitude) && lngRange.contains(coordinate.longitude)
}
}Why Clustering Matters
Without clustering at 500 annotations:
- Map is unreadable (pins overlap completely)
- Scroll/zoom lag increases with every annotation
- Memory grows linearly with annotation count
With clustering:
- User sees meaningful groups with counts
- Only visible cluster markers rendered
- Tap to expand reveals individual annotations
Decision Tree 3: Search and Directions
Search implementation:
├─ User types search query
│ └─ MKLocalSearchCompleter (real-time autocomplete)
│ Configure: resultTypes, region bias
│ └─ User selects result
│ └─ MKLocalSearch (full result with MKMapItem)
│ Use completion.title for MKLocalSearch.Request
│
└─ Programmatic search (e.g., "nearest gas station")
└─ MKLocalSearch with naturalLanguageQuery
Configure: resultTypes, region, pointOfInterestFilter
Directions implementation:
├─ MKDirections.Request
│ Set source (MKMapItem.forCurrentLocation()) and destination
│ Set transportType (.automobile, .walking, .transit)
│
└─ MKDirections.calculate()
└─ MKRoute
├─ .polyline → Display as MapPolyline or MKPolylineRenderer
├─ .expectedTravelTime → Show ETA
├─ .distance → Show distance
└─ .steps → Turn-by-turn instructions---
Part 3: Pressure Scenarios
Scenario 1: "Just Wrap MKMapView in UIViewRepresentable"
Setup: Adding a map to a SwiftUI app. Developer is familiar with MKMapView from UIKit projects.
Pressure: "I know MKMapView well. SwiftUI Map is new and might be limited."
Expected with skill: Check the decision tree. If the app needs standard markers, annotations, camera control, user location, and shape overlays — SwiftUI Map handles all of that. Use it.
Anti-pattern without skill: 200+ lines of UIViewRepresentable + Coordinator wrapping MKMapView, manually bridging state, implementing delegate methods for annotation views, fighting updateUIView infinite loops — when 20 lines of Map {} with content builder would have worked.
Time cost: 2-4 hours of unnecessary boilerplate + ongoing maintenance burden.
The test: Can you list a specific feature the app needs that SwiftUI Map cannot provide? If not, use SwiftUI Map.
Scenario 2: "Add All 10,000 Pins to the Map"
Setup: App has a database of 10,000 location data points. Product manager wants users to see all locations on the map.
Pressure: "Users need to see ALL locations. Just add them all."
Expected with skill: Use clustering + visible region filtering. 10K annotations without clustering is unusable — pins overlap, scrolling lags, memory spikes. Clustering shows meaningful groups. Visible region filtering loads only what's on screen.
Anti-pattern without skill: Adding all 10,000 annotations at once. Map becomes an unreadable blob of overlapping pins. Scroll lag makes the app feel broken. Memory usage spikes 200-400MB.
Implementation path: 1. Enable clustering (.clusteringIdentifier) 2. Fetch annotations only within visible region (.onMapCameraChange + query) 3. Server-side pre-clustering for datasets > 5K if possible
Scenario 3: "Search Isn't Finding Results"
Setup: MKLocalSearch returns irrelevant or empty results. Developer considers adding Google Maps SDK.
Pressure: "MapKit search is broken. Let me add a third-party SDK."
Expected with skill: Check configuration first. MapKit search needs: 1. resultTypes — filter to .pointOfInterest or .address (default returns everything) 2. region — bias results to the visible map region 3. Query format — natural language like "coffee shops" works; structured queries don't
Anti-pattern without skill: Adding Google Maps SDK (50+ MB binary, API key management, billing setup) when MapKit search works correctly with proper configuration.
Time cost: 4-8 hours adding third-party SDK vs 5 minutes configuring MapKit search.
---
Part 4: Core Location Integration
MapKit and Core Location interact in ways that surprise developers.
Implicit Authorization
When you set showsUserLocation = true on MKMapView or add UserAnnotation() in SwiftUI Map, MapKit implicitly requests location authorization if it hasn't been requested yet.
This means:
- The authorization prompt appears at map display time, not when the developer expects
- The user sees a prompt with no context about why location is needed
- If denied, the blue dot silently doesn't appear
Recommended Pattern
Request authorization explicitly BEFORE showing the map:
// 1. Request authorization with context
let session = CLServiceSession(authorization: .whenInUse)
// 2. Then show map with user location
Map {
UserAnnotation()
}CLServiceSession (iOS 17+)
For continuous location display on a map, create a CLServiceSession:
@Observable
class MapModel {
var cameraPosition: MapCameraPosition = .automatic
private var locationSession: CLServiceSession?
func startShowingUserLocation() {
locationSession = CLServiceSession(authorization: .whenInUse)
}
func stopShowingUserLocation() {
locationSession = nil
}
}Cross-Reference
For full authorization decision trees, monitoring patterns, and background location:
axiom-location (skills/core-location.md)— Authorization strategy, monitoring approachaxiom-location (skills/core-location-diag.md)— "Location not working" troubleshootingaxiom-performance (skills/energy.md)— Location as battery subsystem
---
Part 5: SwiftUI Map Quick Start
The most common pattern — a map with markers and user location:
struct ContentView: View {
@State private var cameraPosition: MapCameraPosition = .automatic
@State private var selectedItem: MKMapItem?
let locations: [Location] // Your model
var body: some View {
Map(position: $cameraPosition, selection: $selectedItem) {
UserAnnotation()
ForEach(locations) { location in
Marker(location.name, coordinate: location.coordinate)
.tint(location.category.color)
}
}
.mapStyle(.standard(elevation: .realistic))
.mapControls {
MapUserLocationButton()
MapCompass()
MapScaleView()
}
.onChange(of: selectedItem) { _, item in
if let item {
handleSelection(item)
}
}
}
}Key Points
@State var cameraPosition— bind for programmatic camera controlselection: $selectedItem— handle tap on markersMapCameraPosition.automatic— system manages initial view.mapControls {}— built-in UI for location button, compass, scaleForEachin content builder — dynamic annotations from data
---
Part 6: Search Implementation Pattern
Complete search with autocomplete:
@Observable
class SearchModel {
var searchText = ""
var completions: [MKLocalSearchCompletion] = []
var searchResults: [MKMapItem] = []
private let completer = MKLocalSearchCompleter()
private var completerDelegate: CompleterDelegate?
init() {
completerDelegate = CompleterDelegate { [weak self] results in
self?.completions = results
}
completer.delegate = completerDelegate
completer.resultTypes = [.pointOfInterest, .address]
}
func updateSearch(_ text: String) {
searchText = text
completer.queryFragment = text
}
func search(for completion: MKLocalSearchCompletion) async throws {
let request = MKLocalSearch.Request(completion: completion)
request.resultTypes = [.pointOfInterest, .address]
let search = MKLocalSearch(request: request)
let response = try await search.start()
searchResults = response.mapItems
}
func search(query: String, in region: MKCoordinateRegion) async throws {
let request = MKLocalSearch.Request()
request.naturalLanguageQuery = query
request.region = region
request.resultTypes = .pointOfInterest
let search = MKLocalSearch(request: request)
let response = try await search.start()
searchResults = response.mapItems
}
}MKLocalSearchCompleter Delegate (Required)
class CompleterDelegate: NSObject, MKLocalSearchCompleterDelegate {
let onUpdate: ([MKLocalSearchCompletion]) -> Void
init(onUpdate: @escaping ([MKLocalSearchCompletion]) -> Void) {
self.onUpdate = onUpdate
}
func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
onUpdate(completer.results)
}
func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) {
// Handle error — network issues, rate limiting
}
}Rate Limiting
Apple rate-limits MapKit search. For autocomplete:
MKLocalSearchCompleterhandles its own throttling internally- Don't create a new completer per keystroke — reuse one instance
- Set
queryFragmenton each keystroke; the completer debounces
For MKLocalSearch:
- Don't fire a search on every keystroke — use the completer for autocomplete
- Fire
MKLocalSearchonly when the user selects a completion or submits
---
Part 7: Directions Implementation Pattern
func calculateDirections(
from source: CLLocationCoordinate2D,
to destination: MKMapItem,
transportType: MKDirectionsTransportType = .automobile
) async throws -> MKRoute {
let request = MKDirections.Request()
request.source = MKMapItem(
location: CLLocation(latitude: source.latitude, longitude: source.longitude),
address: nil
)
request.destination = destination
request.transportType = transportType
let directions = MKDirections(request: request)
let response = try await directions.calculate()
guard let route = response.routes.first else {
throw MapError.noRouteFound
}
return route
}Displaying the Route (SwiftUI)
Map(position: $cameraPosition) {
if let route {
MapPolyline(route.polyline)
.stroke(.blue, lineWidth: 5)
}
Marker("Start", coordinate: startCoord)
Marker("End", coordinate: endCoord)
}Displaying the Route (MKMapView)
// Add overlay
mapView.addOverlay(route.polyline, level: .aboveRoads)
// Implement renderer delegate
func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
if let polyline = overlay as? MKPolyline {
let renderer = MKPolylineRenderer(polyline: polyline)
renderer.strokeColor = .systemBlue
renderer.lineWidth = 5
return renderer
}
return MKOverlayRenderer(overlay: overlay)
}Route Information
let route: MKRoute = ...
let travelTime = route.expectedTravelTime // TimeInterval in seconds
let distance = route.distance // CLLocationDistance in meters
let steps = route.steps // [MKRoute.Step]
for step in steps {
print("\(step.instructions) — \(step.distance)m")
// "Turn right on Main St — 450m"
}---
Part 8: Clustering Pattern
SwiftUI's declarative Map has no clustering modifier — clustering is UIKit-only. Set clusteringIdentifier on the MKAnnotationView you return from the delegate; views sharing an identifier collapse into an MKClusterAnnotation. To cluster in a SwiftUI app, wrap MKMapView in a UIViewRepresentable.
MKMapView
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
if let cluster = annotation as? MKClusterAnnotation {
let view = mapView.dequeueReusableAnnotationView(
withIdentifier: "cluster",
for: annotation
) as! MKMarkerAnnotationView
view.markerTintColor = .systemBlue
view.glyphText = "\(cluster.memberAnnotations.count)"
return view
}
let view = mapView.dequeueReusableAnnotationView(
withIdentifier: "pin",
for: annotation
) as! MKMarkerAnnotationView
view.clusteringIdentifier = "locations"
view.markerTintColor = .systemRed
return view
}Clustering Requirements
1. All annotation views that should cluster MUST share the same clusteringIdentifier 2. Register annotation view classes: mapView.register(MKMarkerAnnotationView.self, forAnnotationViewWithReuseIdentifier: "pin") 3. Clustering only activates when annotations physically overlap at the current zoom level 4. System manages cluster/uncluster animation automatically
---
Part 9: Pre-Release Checklist
- [ ] Map loads and displays correctly
- [ ] Annotations appear at correct coordinates (lat/lng not swapped)
- [ ] Clustering works with 100+ annotations
- [ ] Search returns relevant results (resultTypes configured)
- [ ] Camera position controllable programmatically
- [ ] Memory stable when scrolling/zooming with many annotations
- [ ] User location shows correctly (authorization handled before display)
- [ ] Directions render as polyline overlay
- [ ] Map works in Dark Mode (map styles adapt automatically)
- [ ] Accessibility: VoiceOver announces map elements
- [ ] No setRegion/updateUIView infinite loops (if using MKMapView)
- [ ] MKLocalSearchCompleter reused (not recreated per keystroke)
- [ ] Annotation views reused via
dequeueReusableAnnotationView(MKMapView) - [ ] Look Around availability checked before displaying (
MKLookAroundSceneRequest)
---
Resources
WWDC: 2023-10043, 2024-10094
Docs: /mapkit, /mapkit/map
Skills: mapkit-ref, mapkit-diag, core-location
Related skills
How it compares
Use axiom-location for Apple-native location and maps; pick cross-platform geolocation skills when the same logic must run on Android or web.
FAQ
What Apple APIs does axiom-location cover?
axiom-location covers Core Location authorization and monitoring modes—including CLMonitor geofencing—plus MapKit annotations and directions for iOS and SwiftUI apps, with detailed patterns in skills/core-location.md.
When must developers load axiom-location?
axiom-location should load for any location services, mapping, geofencing, or Core Location and MapKit debugging task so agents follow bundled references instead of improvising permission or monitoring choices.
Is Axiom Location safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.