Location and Maps

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Field notes are about places: where the heron was fishing, which bridge the storm damaged. This page adds location to FieldNotes. A note is tagged with where it was written, notebooks turn into geofences that notice when you’re back at a site, a field trip keeps recording while the phone is in a pocket, and every located note appears on a map.

The baseline is the 27 releases (iOS, iPadOS, macOS, watchOS and visionOS 27), Xcode 27 and Swift 6.4, with a deployment minimum of 26. Nothing on this page is new in 27. Core Location’s Swift-concurrency API arrived in 17 and 18, and MapKit’s geocoding and address types in 26. So every example runs at the deployment minimum without #available checks. The #if os(…​) guards that remain are platform gaps, not version gaps, and the platform table at the end lists them.

The page uses these canonical FieldNotes names:

  • NoteLocation, the Hashable, Sendable, Codable value in FieldNotesModel with latitude, longitude and an optional placeName, introduced as Note.location on Persisting Models with SwiftData;

  • LocationService, an @Observable @MainActor class that owns every Core Location session;

  • NotesMapView, the map of located notes.

Wrapping older delegate-based location code in an AsyncStream is shown on Swift Concurrency in Apps. This page uses the native async API instead. The underlying Swift features are covered in Async/Await and Tasks.

Location Privacy and the Permission Flow

Location is one of the most sensitive things an app can ask for, and the system guards it closely. Nothing reaches your code until the person has agreed, and they can change their answer at any time in Settings. They answer three separate questions:

  • When in Use or Always. When in Use covers the app while it is on screen, plus a background session that the app started in the foreground and that shows a visible indicator. Always also lets the system relaunch the app for some events. Ask for When in Use unless a feature truly can’t work without Always. FieldNotes never needs it: its field trips and geofences work with When in Use, as shown below.

  • Precise or approximate. Since iOS 14, the person can share an approximate location, a few kilometres wide, instead of a precise one. The app can ask for temporary full accuracy, with a reason, for a single feature.

  • Location Services on or off. This is a system-wide switch. If it is off, no app gets location, and the fix is in Settings, not in your app.

Usage descriptions

Every question the system asks shows a sentence from your Info.plist, and requests fail immediately if the required key is missing. Write the sentence about the person’s benefit, and localise it like any other string (see Localization with String Catalogs):

<!-- FieldNotes/Info.plist (excerpt) -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>FieldNotes tags each note with where you wrote it, and shows your notes on a map.</string>

<!-- Shown when a feature asks for temporary precise location while approximate location is on. -->
<key>NSLocationTemporaryUsageDescriptionDictionary</key>
<dict>
    <key>PreciseNoteLocation</key>
    <string>Precise location puts this note on the exact spot on the trail, not just the nearest town.</string>
</dict>

<!-- macOS -->
<key>NSLocationUsageDescription</key>
<string>FieldNotes tags each note with where you wrote it.</string>

<!-- iOS: lets a field trip that started in the foreground keep receiving updates in the background. -->
<key>UIBackgroundModes</key>
<array>
    <string>location</string>
</array>

FieldNotes has no NSLocationAlwaysAndWhenInUseUsageDescription, because it never asks for Always. On macOS, When in Use and Always behave the same, since a running Mac app is always "in use".

Figure: the permission flow

The same flow applies to every protected resource. Camera, Media and Photos and Health, Motion and Accessories reuse it for the camera, photos, HealthKit and Bluetooth.

flowchart TD PLIST["Info.plist usage string
NSLocationWhenInUseUsageDescription"] --> USE["Person uses a feature
that needs location"] USE --> REQ["Request in context
CLServiceSession(authorization: .whenInUse)"] REQ --> STATE{"Current authorization?"} STATE -- "Not determined" --> PROMPT["System prompt
shows the usage string"] PROMPT --> ANSWER{"Person's answer"} STATE -- "Already decided" --> ANSWER ANSWER -- "Allow, precise" --> FULL["Authorized
full accuracy"] ANSWER -- "Allow, approximate" --> LIMITED["Authorized, limited
accuracyLimited"] ANSWER -- "Don't Allow" --> DENIED["Denied
authorizationDenied"] STATE -- "Parental controls / MDM" --> RESTRICTED["Restricted
authorizationRestricted"] STATE -- "Location Services off" --> OFF["Denied globally
authorizationDeniedGlobally"] LIMITED -- "Feature needs precision" --> TEMP["Temporary full accuracy
fullAccuracyPurposeKey"] TEMP --> FULL DENIED --> SETTINGS["Explain, then offer a
Settings deep link"] OFF --> SETTINGS RESTRICTED --> HIDE["Hide the feature;
the person can't change it"] SETTINGS -. "Person changes it in Settings" .-> STATE

The flow has three rules:

  • ask at the moment of use, never at launch;

  • treat limited as success and ask for more only when a feature needs it;

  • after a denial, never ask again: explain what the feature would do and link to Settings.

Authorization sessions: CLServiceSession

Before iOS 18, you asked for authorization once, with requestWhenInUseAuthorization(), and tracked the state through a delegate. With CLServiceSession, an object declares "while I exist, this feature needs this level of authorization". Core Location shows the prompt when it can, for example when the app is in the foreground and the person hasn’t already answered. It then keeps the requirement for as long as you hold the session. Hold a session for each feature that is active, and release it by calling invalidate() or dropping the reference.

#if !os(macOS)
import CoreLocation

// While this session exists, location features may prompt for and use When in Use authorization.
let taggingSession = CLServiceSession(authorization: .whenInUse)

// For one precise-location feature: also ask for temporary full accuracy, using the Info.plist purpose key.
let preciseSession = CLServiceSession(authorization: .whenInUse, fullAccuracyPurposeKey: "PreciseNoteLocation")

// A session that never prompts: it only records that the feature runs if authorization already exists.
let passiveSession = CLServiceSession(authorization: .none)
#endif

A session reports why it can’t do its job through diagnostics, an async sequence of CLServiceSession.Diagnostic values. These carry flags such as authorizationDenied, authorizationDeniedGlobally, authorizationRestricted, fullAccuracyDenied and insufficientlyInUse:

#if !os(macOS)
func explainProblems(of session: CLServiceSession) async throws {
    for try await diagnostic in session.diagnostics {
        if diagnostic.authorizationDeniedGlobally { print("Location Services is off") }
        else if diagnostic.authorizationDenied { print("FieldNotes was denied location") }
        else if diagnostic.fullAccuracyDenied { print("Only approximate location is available") }
        else if diagnostic.insufficientlyInUse { print("Start this feature while the app is in the foreground") }
    }
}
#endif

Apple’s documentation lists CLServiceSession for iOS, iPadOS, Mac Catalyst, watchOS and visionOS, but not for native macOS apps. On the Mac, FieldNotes relies on the implicit request that liveUpdates and CLMonitor make the first time they’re used without authorization.

Sending the person to Settings

After a denial, the app can’t prompt again. The only thing it can do is explain and open its page in Settings:

// FieldNotes/Location/LocationDeniedView.swift
import SwiftUI

struct LocationDeniedView: View {
    @Environment(\.openURL) private var openURL

    var body: some View {
        ContentUnavailableView {
            Label("Location Is Off for FieldNotes", systemImage: "location.slash")
        } description: {
            Text("Turn on location to tag notes with where you wrote them. Notes you already have are unaffected.")
        } actions: {
            #if os(iOS) || os(visionOS)
            Button("Open Settings") {
                if let url = URL(string: UIApplication.openSettingsURLString) { openURL(url) }
            }
            #endif
        }
    }
}

On macOS, where UIApplication doesn’t exist, the text tells the person to go to System Settings > Privacy & Security > Location Services. When Location Services is off system-wide, say so. A button that opens the app’s own settings page wouldn’t help.

LocationService

All of FieldNotes' location code lives in one observable service. Views read its state and call its methods, and they never touch Core Location directly. That keeps sessions, background activity and monitoring in one place, where their lifetimes can be managed. Like NoteStore, the app creates it once and injects it into the environment:

// FieldNotes/FieldNotesApp.swift (excerpt)
@main
struct FieldNotesApp: App {
    @State private var store = AppServices.shared.store   // the single owner, see Background Execution
    @State private var locationService = LocationService()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(store)
                .environment(locationService)
        }
    }
}

Live updates with CLLocationUpdate.liveUpdates

CLLocationUpdate.liveUpdates(_:) returns an async sequence of updates. You start receiving updates by iterating it, and stop by cancelling the task that iterates. There is no manager, delegate or start/stop pair to keep in step. The configuration argument tells Core Location what kind of movement to expect. .otherNavigation suits walking on trails, and .fitness suits running or cycling. Each update carries an optional location and, since iOS 18, diagnostic flags that explain a missing location:

// FieldNotes/Location/LocationService.swift
import CoreLocation
import MapKit
import Observation
import FieldNotesModel

@Observable
@MainActor
final class LocationService {
    enum Access: Equatable {
        case notDetermined, authorized, approximateOnly, denied, restricted, servicesOff
    }

    private(set) var access: Access = .notDetermined
    private(set) var lastLocation: CLLocation?
    private(set) var isStationary = false
    private(set) var isRecordingFieldTrip = false
    private(set) var nearbyNotebookID: Notebook.ID?

    @ObservationIgnored private var updatesTask: Task<Void, Never>?
    @ObservationIgnored private var monitorTask: Task<Void, Never>?
    #if !os(macOS)
    @ObservationIgnored private var serviceSession: CLServiceSession?
    @ObservationIgnored private var backgroundSession: CLBackgroundActivitySession?
    #endif

    func startUpdates() {
        guard updatesTask == nil else { return }
        #if !os(macOS)
        serviceSession = CLServiceSession(authorization: .whenInUse)
        #endif
        updatesTask = Task { [weak self] in
            do {
                for try await update in CLLocationUpdate.liveUpdates(.otherNavigation) {
                    guard let self else { return }
                    self.apply(update)
                }
            } catch {
                self?.lastLocation = nil
            }
        }
    }

    func stopUpdates() {
        guard !isRecordingFieldTrip else { return }   // a field trip keeps updates running
        updatesTask?.cancel()
        updatesTask = nil
        #if !os(macOS)
        serviceSession?.invalidate()
        serviceSession = nil
        #endif
    }

    private func apply(_ update: CLLocationUpdate) {
        if update.authorizationDeniedGlobally { access = .servicesOff; return }
        if update.authorizationDenied { access = .denied; return }
        if update.authorizationRestricted { access = .restricted; return }
        isStationary = update.stationary
        if let location = update.location {
            lastLocation = location
            access = update.accuracyLimited ? .approximateOnly : .authorized
        }
    }
}

The iterating task is created inside a @MainActor class, so it runs on the main actor, and every property it sets is safe for views to observe. Updates arrive only a few times per second, so the main actor isn’t a bottleneck. When the person stops moving, Core Location pauses delivery and sends an update with stationary set. The next update comes when they move again, and saving power this way needs no code.

Writing a NoteLocation

Tagging a note needs one good fix, not a stream. currentNoteLocation() uses a recent fix if there is one, and otherwise waits for the first accurate update. It then turns the coordinate into a place name with MKReverseGeocodingRequest, which is new in 26 and replaces the deprecated CLGeocoder:

// LocationService (continued)
extension LocationService {
    func currentNoteLocation() async -> NoteLocation? {
        guard let location = await accurateFix() else { return nil }
        return await noteLocation(for: location)
    }

    private func accurateFix() async -> CLLocation? {
        if let lastLocation, lastLocation.timestamp.timeIntervalSinceNow > -30,
           lastLocation.horizontalAccuracy <= 100 {
            return lastLocation
        }
        #if !os(macOS)
        let session = CLServiceSession(authorization: .whenInUse, fullAccuracyPurposeKey: "PreciseNoteLocation")
        defer { session.invalidate() }
        #endif
        do {
            for try await update in CLLocationUpdate.liveUpdates() {
                if update.authorizationDenied || update.authorizationDeniedGlobally { return nil }
                if let location = update.location, location.horizontalAccuracy <= 100 { return location }
            }
        } catch {}
        return nil
    }

    func noteLocation(for location: CLLocation) async -> NoteLocation {
        var placeName: String?
        if let request = MKReverseGeocodingRequest(location: location),
           let item = try? await request.mapItems.first {
            placeName = item.name ?? item.address?.shortAddress ?? item.addressRepresentations?.cityWithContext
        }
        return NoteLocation(latitude: location.coordinate.latitude,
                            longitude: location.coordinate.longitude,
                            placeName: placeName)
    }
}

The note editor offers the service as one button. The editor edits the store in place, as it does on App Architecture the Apple Way, so assigning location is enough:

// FieldNotes/Views/NoteLocationSection.swift
import SwiftUI
import FieldNotesModel
import FieldNotesServices

struct NoteLocationSection: View {
    @Environment(NoteStore.self) private var store
    @Environment(LocationService.self) private var locationService
    let noteID: Note.ID
    @State private var isLocating = false

    var body: some View {
        if let index = store.notes.firstIndex(where: { $0.id == noteID }) {
            Section("Location") {
                if let place = store.notes[index].location {
                    Label(place.placeName ?? "Tagged location", systemImage: "mappin.and.ellipse")
                    Button("Remove Location", role: .destructive) { store.notes[index].location = nil }
                } else if locationService.access == .denied || locationService.access == .servicesOff {
                    LocationDeniedView()
                } else {
                    Button("Tag Current Location", systemImage: "location") {
                        isLocating = true
                        Task {
                            let location = await locationService.currentNoteLocation()
                            if let index = store.notes.firstIndex(where: { $0.id == noteID }) {
                                store.notes[index].location = location
                            }
                            isLocating = false
                        }
                    }
                    .disabled(isLocating)
                }
            }
        }
    }
}

#Preview {
    let store = NoteStore(notes: [Note(title: "Heron at the estuary")])
    Form { NoteLocationSection(noteID: store.notes[0].id) }
        .environment(store)
        .environment(LocationService())
}

The index is looked up again after the await, because other code may have changed the notes array while the fix was in progress. The first tap is also the moment the permission prompt appears, which is when the person understands why the app is asking. Running this on a device gives real fixes. In the Simulator, choose a location from Features > Location.

Geofences per notebook: CLMonitor

CLMonitor is an actor that watches a set of named conditions and delivers an events sequence whenever one changes state. CircularGeographicCondition is a geofence. Conditions persist across launches, and the system can relaunch the app to deliver an event. FieldNotes derives one geofence per notebook, centred on the average position of the notebook’s located notes. When you walk back into a study site, the app suggests that notebook for the next note. The same area type draws the circles on the map later on this page:

// FieldNotes/Location/NotebookArea.swift
import CoreLocation
import FieldNotesModel

struct NotebookArea: Identifiable {
    static let radius: CLLocationDistance = 300

    let id: Notebook.ID
    let name: String
    let symbolName: String
    let center: CLLocationCoordinate2D

    static func areas(notebooks: [Notebook], notes: [Note]) -> [NotebookArea] {
        notebooks.compactMap { notebook in
            let places = notes.filter { $0.notebookID == notebook.id }.compactMap(\.location)
            guard !places.isEmpty else { return nil }
            let count = Double(places.count)
            let center = CLLocationCoordinate2D(latitude: places.map(\.latitude).reduce(0, +) / count,
                                                longitude: places.map(\.longitude).reduce(0, +) / count)
            return NotebookArea(id: notebook.id, name: notebook.name, symbolName: notebook.symbolName, center: center)
        }
    }
}

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

NotebookArea and the coordinate helper live in the app target, not in FieldNotesModel, so the model module still doesn’t depend on Core Location.

The service replaces the monitor’s conditions whenever the notebooks change, and listens for events:

// LocationService (continued)
#if os(iOS) || os(macOS)
extension LocationService {
    func monitorNotebooks(_ notebooks: [Notebook], notes: [Note]) {
        let areas = NotebookArea.areas(notebooks: notebooks, notes: notes)
        monitorTask?.cancel()
        monitorTask = Task { [weak self] in
            let monitor = await CLMonitor("FieldNotesNotebooks")
            for identifier in await monitor.identifiers {
                await monitor.remove(identifier)
            }
            for area in areas {
                let fence = CLMonitor.CircularGeographicCondition(center: area.center, radius: NotebookArea.radius)
                await monitor.add(fence, identifier: area.id.uuidString, assuming: .unsatisfied)
            }
            do {
                for try await event in await monitor.events {
                    guard let self else { return }
                    let notebookID = UUID(uuidString: event.identifier)
                    if event.state == .satisfied {
                        self.nearbyNotebookID = notebookID
                    } else if self.nearbyNotebookID == notebookID {
                        self.nearbyNotebookID = nil
                    }
                }
            } catch {}
        }
    }
}
#endif

A few details matter here:

  • Names are unique. Only one CLMonitor with a given name may exist at a time, so the service keeps a single task that owns it.

  • Relaunch. When the system relaunches the app for an event, create the monitor with the same name early, for example from FieldNotesApp's initializer, and iterate events again to receive it.

  • Limits. An app can monitor only a small number of conditions. Events such as conditionLimitExceeded, authorizationDenied and persistenceUnavailable report the problem, so show them in development builds.

  • Authorization. Monitoring works with When in Use while a session or background activity is active. The system relaunches the app for a geofence only with Always. FieldNotes accepts that trade-off and only suggests notebooks while the app is running or recording a trip.

The call site keeps the monitor in sync with the store:

// ContentView (excerpt)
.task(id: store.notebooks) {
    #if os(iOS) || os(macOS)
    locationService.monitorNotebooks(store.notebooks, notes: store.notes)
    #endif
}

Field trips in the background: CLBackgroundActivitySession

A field trip records a track while the phone is in a pocket. On iOS, updates normally stop soon after the app leaves the foreground. A CLBackgroundActivitySession, created while the app is in the foreground, keeps the app "in use" in the background, with a visible location indicator in the status bar so the person always knows. This works with When in Use authorization. Holding the object keeps the activity alive, and invalidate() ends it:

// LocationService (continued)
#if !os(macOS)
extension LocationService {
    func startFieldTrip() {
        guard backgroundSession == nil else { return }
        backgroundSession = CLBackgroundActivitySession()   // call from a button tap, in the foreground
        isRecordingFieldTrip = true
        startUpdates()
    }

    func endFieldTrip() {
        backgroundSession?.invalidate()
        backgroundSession = nil
        isRecordingFieldTrip = false
        stopUpdates()
    }
}
#endif

Needs the location value in UIBackgroundModes (the Location updates background mode under Signing & Capabilities) and a physical device, since the Simulator’s background behaviour differs. The trip’s progress is shown on the Lock Screen by FieldTripLiveActivity on Notifications, APNs and Live Activities. Background Execution explains why location is one of the few ways an app keeps running in the background.

NotesMapView: MapKit for SwiftUI

MapKit for SwiftUI builds a map as a view. Map takes a content builder of map content: Marker, Annotation, MapCircle, UserAnnotation and ForEach over them. Modifiers control the style, the controls and the camera. NotesMapView shows every located note, the notebook areas, and the person’s own position:

// FieldNotes/Views/NotesMapView.swift
import SwiftUI
import MapKit
import FieldNotesModel
import FieldNotesServices

struct NotesMapView: View {
    @Environment(NoteStore.self) private var store
    @Environment(LocationService.self) private var locationService
    @State private var position: MapCameraPosition = .automatic
    @State private var selectedNoteID: Note.ID?

    private struct NotePin: Identifiable {
        let id: Note.ID
        let title: String
        let isPinned: Bool
        let coordinate: CLLocationCoordinate2D
    }

    private var pins: [NotePin] {
        store.notes.compactMap { note in
            note.location.map { NotePin(id: note.id, title: note.title, isPinned: note.isPinned,
                                        coordinate: $0.coordinate) }
        }
    }

    var body: some View {
        Map(position: $position, selection: $selectedNoteID) {
            ForEach(NotebookArea.areas(notebooks: store.notebooks, notes: store.notes)) { area in
                MapCircle(center: area.center, radius: NotebookArea.radius)
                    .foregroundStyle(.teal.opacity(0.15))
                    .stroke(.teal, lineWidth: 1)
                Annotation(area.name, coordinate: area.center, anchor: .center) {
                    Image(systemName: area.symbolName)
                        .padding(6)
                        .background(.thinMaterial, in: .circle)
                }
            }
            ForEach(pins) { pin in
                Marker(pin.title, systemImage: pin.isPinned ? "pin.fill" : "note.text", coordinate: pin.coordinate)
                    .tint(pin.isPinned ? .orange : .blue)
                    .tag(pin.id)
            }
            UserAnnotation()
        }
        #if !os(visionOS)
        .mapStyle(.standard(elevation: .realistic, pointsOfInterest: .including([.nationalPark, .park, .beach])))
        .mapControls {
            MapUserLocationButton()
            MapCompass()
            MapScaleView()
        }
        #endif
        .onAppear { locationService.startUpdates() }
        .onDisappear { locationService.stopUpdates() }
    }
}

#Preview {
    NotesMapView()
        .environment(NoteStore(notes: [
            Note(title: "Heron", body: "Fishing at low tide.",
                 location: NoteLocation(latitude: 43.33, longitude: -3.03, placeName: "Urdaibai"))
        ]))
        .environment(LocationService())
}

Marker is the system balloon: cheap, consistent and automatically clustered. Annotation takes any SwiftUI view and suits the notebook badges. Both ForEach loops use stable identities, the note and notebook IDs, never the coordinates. .tag(pin.id) makes each marker selectable into selectedNoteID. Showing the person’s position with UserAnnotation or MapUserLocationButton needs location authorization. The map then uses the same permission flow, and startUpdates() ties the session to the map being on screen. mapStyle and mapControls aren’t listed for visionOS, so they are left out there.

Camera position

The camera is state. MapCameraPosition is either a semantic position, such as .automatic (fit the content), .userLocation(fallback:), .item(_:) or .region(_:), or an explicit .camera(MapCamera). Bind it with position:. Assigning a new value moves the map, inside withAnimation to animate the move. When the person pans, the binding becomes a user-positioned value, so the app knows not to fight the gesture:

// NotesMapView (continued)
.onChange(of: selectedNoteID) { _, id in
    guard let pin = pins.first(where: { $0.id == id }) else { return }
    withAnimation {
        position = .region(MKCoordinateRegion(center: pin.coordinate,
                                              latitudinalMeters: 800, longitudinalMeters: 800))
    }
}
.toolbar {
    Button("Show All Notes", systemImage: "map") { withAnimation { position = .automatic } }
    Button("Near Me", systemImage: "location") {
        withAnimation { position = .userLocation(fallback: .automatic) }
    }
}

MKMapItem: handing a place to Maps

MKMapItem is MapKit’s value for "a place": a location, an optional MKAddress, a name and, for points of interest, a stable identifier. In 26 it gained init(location:address:), address and addressRepresentations, which replace the placemark-based API. FieldNotes uses a map item to hand a note’s location to the Maps app for walking directions:

extension NotesMapView {
    func openInMaps(noteID: Note.ID) {
        guard let pin = pins.first(where: { $0.id == noteID }) else { return }
        let item = MKMapItem(location: CLLocation(latitude: pin.coordinate.latitude,
                                                  longitude: pin.coordinate.longitude),
                             address: nil)
        item.name = pin.title
        _ = item.openInMaps(launchOptions: [MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeWalking])
    }
}

The launch options are the same dictionary keys Maps has always accepted. openInMaps returns false if Maps couldn’t be opened, for example when it has been removed from the device.

Look Around

Look Around is Apple Maps' street-level imagery. Showing it takes two steps. First, request a MKLookAroundScene for a coordinate or map item. The result is nil where there’s no imagery, which is common on remote trails. Then show the scene in a LookAroundPreview, a small interactive view that opens the full-screen viewer when tapped:

// NotesMapView (continued)
@State private var lookAroundScene: MKLookAroundScene?

// in body, after .mapControls:
.task(id: selectedNoteID) {
    lookAroundScene = nil
    guard let pin = pins.first(where: { $0.id == selectedNoteID }) else { return }
    lookAroundScene = try? await MKLookAroundSceneRequest(coordinate: pin.coordinate).scene
}
.safeAreaInset(edge: .bottom) {
    if let lookAroundScene {
        LookAroundPreview(initialScene: lookAroundScene)
            .frame(height: 160)
            .clipShape(.rect(cornerRadius: 12))
            .padding()
    }
}

.task(id:) cancels the previous request when the selection changes, so quick taps can’t show a stale scene. LookAroundPreview and MKLookAroundSceneRequest aren’t available on watchOS. NotesMapView is part of the multiplatform app target, which doesn’t build for the watch, so it needs no guard for them.

Geocoding with MKGeocodingRequest

Sometimes a note is written after the fact, at home, about a place. Forward geocoding turns the typed name of a place into a map item. MKGeocodingRequest, new in 26, does this. Like its reverse counterpart it is a one-shot request with an async mapItems property, and it replaces the deprecated CLGeocoder:

extension LocationService {
    /// Looks up a typed place name, such as "Fairy Pools, Isle of Skye", for a note written after the trip.
    func noteLocation(forPlaceNamed text: String) async throws -> NoteLocation? {
        guard let request = MKGeocodingRequest(addressString: text),
              let item = try await request.mapItems.first else { return nil }
        return NoteLocation(latitude: item.location.coordinate.latitude,
                            longitude: item.location.coordinate.longitude,
                            placeName: item.name ?? item.address?.shortAddress ?? text)
    }
}

The initialiser returns nil for an empty string. Geocoding needs a network connection, and Apple rate-limits it per app. Geocode when the person confirms a search, never on every keystroke.

Availability and Requirements

Core Location and MapKit have been in the SDKs for many years. The differences between platforms come from what each device is and does: a Mac has no GPS and doesn’t run apps in the background the way an iPhone does, and a watch has no room for street-level imagery.

Platform availability

API or feature iOS iPadOS macOS watchOS visionOS

CLLocationUpdate.liveUpdates(_:)

Yes

Yes

Yes

Yes

Yes

CLLocationUpdate diagnostics (stationary, authorizationDenied…​)

Yes

Yes

Yes

Yes

Yes

CLServiceSession

Yes

Yes

Not listed (Mac Catalyst only)

Yes

Yes

Always authorization

Yes

Yes

Yes (same as When in Use)

Yes

Not available

CLMonitor, CircularGeographicCondition

Yes

Yes

Yes

Not listed

Not listed

CLBackgroundActivitySession

Yes

Yes

Not available

Yes

Yes

Map, Marker, Annotation, MapCircle, UserAnnotation

Yes

Yes

Yes

Yes

Yes

mapStyle(_:), mapControls(_:)

Yes

Yes

Yes

Yes

Not listed

LookAroundPreview, MKLookAroundSceneRequest

Yes

Yes

Yes

Not available

Yes

MKGeocodingRequest, MKReverseGeocodingRequest, MKAddress

New in 26

New in 26

New in 26

New in 26

New in 26

MKMapItem.init(location:address:), address, addressRepresentations

New in 26

New in 26

New in 26

New in 26

New in 26

CLGeocoder

Deprecated in 26

Deprecated in 26

Deprecated in 26

Deprecated in 26

Deprecated in 26

"Yes" means available before 26 and therefore at FieldNotes' deployment minimum. "Not listed" means Apple’s documentation for that symbol doesn’t include the platform, so the code guards it with #if os(…​).

Beyond the OS, location needs the usage strings above. Background field trips need the Location updates background mode. Geocoding and Look Around need a network connection. The Simulator can simulate a fixed location or a route, but background delivery, geofences and accuracy behave realistically only on a physical device.

References