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, theHashable,Sendable,Codablevalue inFieldNotesModelwithlatitude,longitudeand an optionalplaceName, introduced asNote.locationon Persisting Models with SwiftData; -
LocationService, an@Observable @MainActorclass 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.
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
CLMonitorwith 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 iterateeventsagain to receive it. -
Limits. An app can monitor only a small number of conditions. Events such as
conditionLimitExceeded,authorizationDeniedandpersistenceUnavailablereport 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 |
|---|---|---|---|---|---|
|
Yes |
Yes |
Yes |
Yes |
Yes |
|
Yes |
Yes |
Yes |
Yes |
Yes |
|
Yes |
Yes |
Not listed (Mac Catalyst only) |
Yes |
Yes |
Always authorization |
Yes |
Yes |
Yes (same as When in Use) |
Yes |
Not available |
|
Yes |
Yes |
Yes |
Not listed |
Not listed |
|
Yes |
Yes |
Not available |
Yes |
Yes |
|
Yes |
Yes |
Yes |
Yes |
Yes |
|
Yes |
Yes |
Yes |
Yes |
Not listed |
|
Yes |
Yes |
Yes |
Not available |
Yes |
|
New in 26 |
New in 26 |
New in 26 |
New in 26 |
New in 26 |
|
New in 26 |
New in 26 |
New in 26 |
New in 26 |
New in 26 |
|
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
-
Apple Developer Documentation — Requesting authorization to use location services
-
Apple Developer Documentation — NSLocationWhenInUseUsageDescription
-
Apple Developer Documentation — NSLocationTemporaryUsageDescriptionDictionary
-
Apple Developer Documentation — CLServiceSession (Objective-C)
-
Apple Developer Documentation — Adopting live updates in Core Location
-
Apple Developer Documentation — Monitoring location changes with Core Location
-
Apple Developer Documentation — Handling location updates in the background