How to Implement Geofencing in SwiftUI
Create a CLCircularRegion with a center coordinate and radius, then call locationManager.startMonitoring(for:) after requesting Always authorization — iOS fires delegate callbacks whenever the device crosses the fence boundary, even in the background.
import CoreLocation
let manager = CLLocationManager()
manager.requestAlwaysAuthorization()
let center = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194)
let region = CLCircularRegion(center: center, radius: 200, identifier: "HQ")
region.notifyOnEntry = true
region.notifyOnExit = true
manager.startMonitoring(for: region)
// Delegate
func locationManager(_ manager: CLLocationManager,
didEnterRegion region: CLRegion) {
print("Entered: \(region.identifier)")
}
func locationManager(_ manager: CLLocationManager,
didExitRegion region: CLRegion) {
print("Exited: \(region.identifier)")
}
Full implementation
The cleanest SwiftUI pattern wraps CLLocationManager in an @Observable class that acts as both manager and delegate, then exposes geofence state as published properties your views react to. Because geofencing needs Always authorization to fire in the background, you must add both location usage description keys to Info.plist and enable the Location Updates background mode in your target's Signing & Capabilities tab. The 20-region iOS system limit applies per app — plan your fences accordingly.
import SwiftUI
import CoreLocation
import UserNotifications
// MARK: - Observable Location Manager
@Observable
final class GeofenceManager: NSObject, CLLocationManagerDelegate {
private let locationManager = CLLocationManager()
var authorizationStatus: CLAuthorizationStatus = .notDetermined
var monitoredRegions: [CLCircularRegion] = []
var lastEvent: String = "None"
override init() {
super.init()
locationManager.delegate = self
locationManager.desiredAccuracy = kCLLocationAccuracyBest
}
// MARK: - Authorization
func requestPermission() {
locationManager.requestAlwaysAuthorization()
}
// MARK: - Region Management
func addFence(coordinate: CLLocationCoordinate2D,
radius: CLLocationDistance,
identifier: String) {
guard CLLocationManager.isMonitoringAvailable(for: CLCircularRegion.self) else {
lastEvent = "Monitoring unavailable on this device"
return
}
guard locationManager.monitoredRegions.count < 20 else {
lastEvent = "iOS limit: max 20 monitored regions"
return
}
let region = CLCircularRegion(center: coordinate,
radius: min(radius, locationManager.maximumRegionMonitoringDistance),
identifier: identifier)
region.notifyOnEntry = true
region.notifyOnExit = true
locationManager.startMonitoring(for: region)
monitoredRegions = locationManager.monitoredRegions
.compactMap { $0 as? CLCircularRegion }
lastEvent = "Started monitoring '\(identifier)'"
}
func removeFence(identifier: String) {
locationManager.monitoredRegions
.filter { $0.identifier == identifier }
.forEach { locationManager.stopMonitoring(for: $0) }
monitoredRegions = locationManager.monitoredRegions
.compactMap { $0 as? CLCircularRegion }
lastEvent = "Removed '\(identifier)'"
}
func checkCurrentState(for region: CLCircularRegion) {
locationManager.requestState(for: region)
}
// MARK: - CLLocationManagerDelegate
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
authorizationStatus = manager.authorizationStatus
}
func locationManager(_ manager: CLLocationManager,
didEnterRegion region: CLRegion) {
lastEvent = "Entered '\(region.identifier)' at \(Date.now.formatted(date: .omitted, time: .standard))"
sendNotification(title: "Geofence Entry", body: "You entered \(region.identifier).")
}
func locationManager(_ manager: CLLocationManager,
didExitRegion region: CLRegion) {
lastEvent = "Exited '\(region.identifier)' at \(Date.now.formatted(date: .omitted, time: .standard))"
sendNotification(title: "Geofence Exit", body: "You left \(region.identifier).")
}
func locationManager(_ manager: CLLocationManager,
didDetermineState state: CLRegionState,
for region: CLRegion) {
let stateString = switch state {
case .inside: "Inside"
case .outside: "Outside"
case .unknown: "Unknown"
@unknown default: "Unknown"
}
lastEvent = "State for '\(region.identifier)': \(stateString)"
}
func locationManager(_ manager: CLLocationManager,
monitoringDidFailFor region: CLRegion?,
withError error: Error) {
lastEvent = "Monitoring failed: \(error.localizedDescription)"
}
// MARK: - Local Notification helper
private func sendNotification(title: String, body: String) {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
let request = UNNotificationRequest(identifier: UUID().uuidString,
content: content,
trigger: nil)
UNUserNotificationCenter.current().add(request)
}
}
// MARK: - SwiftUI View
struct GeofencingView: View {
@State private var manager = GeofenceManager()
private let sfHQ = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194)
private let appleHQ = CLLocationCoordinate2D(latitude: 37.3349, longitude: -122.0090)
var body: some View {
NavigationStack {
List {
Section("Authorization") {
HStack {
Label("Status", systemImage: "lock.shield")
Spacer()
Text(authStatusText)
.foregroundStyle(authStatusColor)
.font(.footnote)
}
if manager.authorizationStatus != .authorizedAlways {
Button("Request Always Authorization") {
manager.requestPermission()
}
}
}
Section("Last Event") {
Text(manager.lastEvent)
.font(.footnote.monospaced())
.foregroundStyle(.secondary)
}
Section("Quick-add Fences") {
Button("Add SF HQ (200 m)") {
manager.addFence(coordinate: sfHQ, radius: 200, identifier: "SF HQ")
}
Button("Add Apple Park (500 m)") {
manager.addFence(coordinate: appleHQ, radius: 500, identifier: "Apple Park")
}
}
Section("Monitored Regions (\(manager.monitoredRegions.count)/20)") {
if manager.monitoredRegions.isEmpty {
Text("No active fences").foregroundStyle(.secondary)
}
ForEach(manager.monitoredRegions, id: \.identifier) { region in
VStack(alignment: .leading, spacing: 2) {
Text(region.identifier).fontWeight(.medium)
Text("Radius: \(Int(region.radius)) m · \(region.center.latitude, format: .number.precision(.fractionLength(4))), \(region.center.longitude, format: .number.precision(.fractionLength(4)))")
.font(.caption)
.foregroundStyle(.secondary)
}
.swipeActions {
Button("Remove", role: .destructive) {
manager.removeFence(identifier: region.identifier)
}
}
}
}
}
.navigationTitle("Geofencing")
}
}
private var authStatusText: String {
switch manager.authorizationStatus {
case .authorizedAlways: "Always"
case .authorizedWhenInUse: "When In Use"
case .denied: "Denied"
case .restricted: "Restricted"
case .notDetermined: "Not Determined"
@unknown default: "Unknown"
}
}
private var authStatusColor: Color {
manager.authorizationStatus == .authorizedAlways ? .green : .orange
}
}
#Preview {
GeofencingView()
}
How it works
-
@Observable GeofenceManager — Wrapping
CLLocationManagerin Swift's@Observablemacro (iOS 17+) means any view that readsmanager.monitoredRegionsormanager.lastEventautomatically re-renders when those properties change — no manualobjectWillChangeneeded. -
CLCircularRegion construction —
addFence(coordinate:radius:identifier:)caps the radius atlocationManager.maximumRegionMonitoringDistance(device-specific, typically ~100 km) and hard-guards against the 20-region system cap before callingstartMonitoring(for:). -
Background delivery —
didEnterRegion/didExitRegionfire even when the app is suspended, because iOS re-launches the app in the background for region events as long as Always authorization is granted and the Background Modes capability includes Location Updates. -
requestState(for:) — Calling
locationManager.requestState(for:)asks the system whether the device is currently inside or outside a region without waiting for a natural crossing event. The answer arrives viadidDetermineState— handy after app launch to sync UI. -
Local notifications —
sendNotification(title:body:)fires aUNNotificationRequestwith aniltrigger (immediate delivery) so the user sees a banner when a fence boundary is crossed in the background — a simple way to surface location events without a full notification extension.
Variants
Entry-only fence with a dwell timer
Sometimes you only want to trigger after the user has been inside a region for a set duration — useful for "arrived at work" logic.
// In GeofenceManager — entry-only with 30-second dwell check
private var dwellTimers: [String: Task<Void, Never>] = [:]
func locationManager(_ manager: CLLocationManager,
didEnterRegion region: CLRegion) {
// Only notify on entry; cancel exit timer if re-entering quickly
dwellTimers[region.identifier]?.cancel()
dwellTimers[region.identifier] = Task {
try? await Task.sleep(for: .seconds(30))
guard !Task.isCancelled else { return }
await MainActor.run {
lastEvent = "Dwelling inside '\(region.identifier)'"
sendNotification(title: "Arrived",
body: "You've been at \(region.identifier) for 30 s.")
}
}
}
func locationManager(_ manager: CLLocationManager,
didExitRegion region: CLRegion) {
dwellTimers[region.identifier]?.cancel()
dwellTimers[region.identifier] = nil
lastEvent = "Left '\(region.identifier)' — dwell timer cancelled"
}
Persisting fences across launches
iOS persists monitored regions automatically across app restarts — CLLocationManager.monitoredRegions survives kills. However, the identifiers and their meaning (e.g. a user-given name) live only in your app's data store. Mirror each CLCircularRegion into SwiftData or UserDefaults keyed by identifier so you can map a raw region event back to a human-readable label after relaunch. On app start, reconcile locationManager.monitoredRegions against your store and remove any orphans.
Common pitfalls
-
⚠️ "When In Use" is not enough. Region monitoring fires in the background only with
authorizedAlways. If the user grants When In Use,startMonitoringwill silently fail or stop when the app is suspended. Always checkauthorizationStatus == .authorizedAlwaysbefore registering fences and surface a clear prompt to the user. - ⚠️ 20-region hard cap. iOS enforces a maximum of 20 simultaneously monitored regions per app. If you need more, implement a proximity-based strategy: monitor a large "super-region" encompassing a cluster, then activate individual fences only when the device enters that cluster.
- ⚠️ Simulator testing is unreliable. The iOS Simulator does not accurately replicate Core Location background launches. Always test geofencing on a physical device — use the GPX simulation feature in Xcode's debug toolbar or a dedicated location spoofing app to walk the device coordinates across your fence boundary.
-
⚠️ Entry events fire on app launch. When iOS relaunches your app due to a region event, it delivers the
didEnterRegioncallback almost immediately. Make sure yourCLLocationManagerDelegateis set up in yourAppstruct orAppDelegate, not lazily inside a SwiftUI view lifecycle.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement geofencing in SwiftUI for iOS 17+. Use CLCircularRegion and CLLocationManager with @Observable. Request Always authorization and guard against the 20-region cap. Fire a UNUserNotificationCenter local notification on entry and exit. Make it accessible (VoiceOver labels on all interactive controls). Add a #Preview with realistic sample data (3 pre-loaded fences).
In Soarias's Build phase, paste this into the prompt bar after scaffolding your screen list — Claude Code will generate the GeofenceManager, the SwiftUI view, and the necessary Info.plist keys as a single committed changeset you can iterate on immediately.
Related
FAQ
Does geofencing work on iOS 16?
CLCircularRegion and region monitoring have been available since iOS 4, so the Core Location APIs work on iOS 16. However, this guide uses the @Observable macro and Swift 5.9 parameter packs that require iOS 17+ and Xcode 15+. If you need iOS 16 support, replace @Observable with ObservableObject / @Published — the Core Location logic is identical.
How accurate is the geofence boundary, and what is the minimum radius I should use?
iOS geofencing uses a combination of GPS, Wi-Fi positioning, and cell tower triangulation — accuracy varies from ~10 m in open-sky GPS conditions to several hundred meters indoors. Apple recommends a minimum radius of 100–200 metres for reliable crossing events. Smaller radii will still be accepted by the API (down to kCLLocationAccuracyBestForNavigation), but crossing events may be delayed or missed entirely in areas with poor signal. For indoor use cases, consider iBeacon ranging instead.
What is the UIKit / MapKit equivalent?
The underlying CLLocationManager API is framework-agnostic — it works identically in UIKit. In a UIKit app you would typically set the delegate in your AppDelegate and manage state with NotificationCenter or a shared singleton. MapKit's MKCircle overlay is a visual counterpart to CLCircularRegion — you can draw the fence boundary on a Map view using MapCircle(center:radius:) (SwiftUI MapKit, iOS 17+) alongside your monitoring logic.
Last reviewed: 2026-05-12 by the Soarias team.