How to Implement Geofencing in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: CLCircularRegion, CLLocationManager Updated: May 12, 2026
TL;DR

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

  1. @Observable GeofenceManager — Wrapping CLLocationManager in Swift's @Observable macro (iOS 17+) means any view that reads manager.monitoredRegions or manager.lastEvent automatically re-renders when those properties change — no manual objectWillChange needed.
  2. CLCircularRegion constructionaddFence(coordinate:radius:identifier:) caps the radius at locationManager.maximumRegionMonitoringDistance (device-specific, typically ~100 km) and hard-guards against the 20-region system cap before calling startMonitoring(for:).
  3. Background deliverydidEnterRegion / didExitRegion fire 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.
  4. 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 via didDetermineState — handy after app launch to sync UI.
  5. Local notificationssendNotification(title:body:) fires a UNNotificationRequest with a nil trigger (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

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.