SwiftUI: Monitor User Location Relative to a Region (Geofencing) 2 Ways

*****************Apple!Stop keeping not-working articles and code sample in the documentation!****************Screaming from the bottom of my heart!If you are looking for a way to receive background updates on device locations, you probably have read t…


This content originally appeared on Level Up Coding - Medium and was authored by Itsuki

*****************

Apple!
Stop keeping not-working articles and code sample in the documentation!

****************

Screaming from the bottom of my heart!

If you are looking for a way to receive background updates on device locations, you probably have read this Handling location updates in the background article, or check out the sample code from WWDC23!

They won’t work! Background-wise!

***************

Anyway!

Condition monitoring, or geofencing, allows us to alert our user when they enter or exits a geographical region. This is commonly used in marketing and advertising, where, for example, we might have a retail store where we want to show our user promotions when they get close to the store.

In this article, let’s check out how we can monitor user’s proximity to a region with two different approaches.

  1. With Local Push Notification: UNLocationNotificationTrigger
  2. With CLMonitor and CLCircularGeographicCondition
Of course, for both foreground and background (Task kill)!

In addition, I will also be sharing with you on how we can use CLLocationUpdate.liveUpdates (a working version of the sample code) if you want to get an update on the location regularly and check the condition by yourselves.

By the end, we will make a quick comparison between the two methods and see when to use which!

>>>Code available on GitHub!

Before Started

Before we dive into coding, let me first share with you really quick on how we can simulate locations on simulators.

Two ways.

With Xcode Debugger

When we run our app, we will see the following toolbar showing up at the bottom of Xcode with a location arrow (similar to the one on iPhones).

By tapping on it, we can choose whether to simulate location or not and where we want our location be.

With Simulator Features

Above is great if we have our app attached to the Xcode debugger, but obviously, when we kill our app to test background deliveries, the debugger will be detached.

We can also simulate location by clicking on the simulator we have launched and choose Simulator > Features > Locations.

Common Set Up

App Delegate

In order to for our App to receive location updates while in the background (task kill), we will need to implement lifecycle event support that enables an app’s @main app to have explicit support for the creation and resumption of background run-loops.

This enables the system to deliver Core Location events to the app and allows the delivery of events to resume in the event of return from background, launch of the app, or relaunch after a crash.

That is add App Delegate and implement the application(_:didFinishLaunchingWithOptions:) method!


import SwiftUI

class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
return true
}
}

(We will be adding a little more to it as we move on, but this should be good for now.)

Let’s also add the following to our App struct.

@main
struct LocationMonitorDemoApp: App {
@UIApplicationDelegateAdaptor private var appDelegate: AppDelegate
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

File Logger

Since we will be killing our app to test background delivery, logs will not be available in the Debug Console anymore.

So! Here is a simple FileLogger that will both print and write to a file.

class FileLogger {

static let shared = FileLogger()

init() {
print("File Logger initialized.")
print("Destination File URL: \(String(describing: fileURL))")
}

func debug(_ string: String) {
#if DEBUG
write(level: .debug, string: string)
#endif
}

func info(_ string: String) {
#if DEBUG
write(level: .info, string: string)
#endif
}

func warning(_ string: String) {
write(level: .warning, string: string)
}

func error(_ string: String) {
write(level: .error, string: string)
}

private enum LogLevel: String {
case debug
case info
case warning
case error
}

private let fileManager = FileManager.default

private var filename: String {
let appName = Bundle.main.infoDictionary?[kCFBundleNameKey as String] as? String
if let appName {
return "\(appName).txt"
}
return "log.txt"
}

private var fileURL: URL? {
guard let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {return nil}
return documentsDirectory.appendingPathComponent(filename)
}

private func write(level: LogLevel, string: String) {
let log = "\(Date()) [\(level.rawValue)] \(string)\n"
print(log)
guard let fileURL else {
print("error writing to file")
return
}

if let handle = try? FileHandle(forWritingTo: fileURL) {
handle.seekToEndOfFile()
handle.write(log.data(using: .utf8)!)
handle.closeFile()
} else {
do {
try log.data(using: .utf8)?.write(to: fileURL)
} catch (let error) {
print("Error writing to file: \(error.localizedDescription)")
}
}
}
}

With Local Push Notification

Let’s first take a look how we can monitor the user’s proximity to geographic regions with local push notifications.

General Introduction

If you get a chance to work with Local Push Notifications, you probably know that there are three kinds of trigger we can use to specify the condition for the notification delivery.

And as you might already realize from the name, but the UNLocationNotificationTrigger is what we will be using in this article.

Let’s check it out!

If you want a more detailed catch up, please check out one of my previous articles SwiftUI: Local Push Notifications where we took a look at pretty much most of the stuff we need to know regarding to it!

Permission Set Up

Before we are able to schedule any notifications using UNLocationNotificationTrigger, our app must have authorization to use Core Location and must have when-in-use permissions.

Let’s add the NSLocationWhenInUseUsageDescription key to our Info.plist.

Yes! That’s it!

Even though we will be receiving our notifications even when our app is in the background, we DO NOT NEED

Schedule Notifications

To schedule a local notification, three steps

  1. Create the content of the request
  2. Create the trigger of the request
  3. Register the request

Since I have shared with you about the entire process of creating notification contents and registering delivery triggers in super details in my SwiftUI: Local Push Notifications, I will just throw you some code here!

Notification Manager


@Observable
class NotificationManager {
static private let triggerRegisteredKey = "localNotificationTriggerRegistered"
static let shared = NotificationManager()

var triggerRegistered: Bool = UserDefaults.standard.bool(forKey: NotificationManager.triggerRegisteredKey) {
didSet {
if triggerRegistered {
Task {
await registerLocationNotification()
}
} else {
removeRegisteredNotifications()
}
UserDefaults.standard.setValue(triggerRegistered, forKey: NotificationManager.triggerRegisteredKey)
}
}

enum NotificationError: Error {
case coreLocationNotAuthorized
case notificationNotAuthorized
}

var error: NotificationError? = nil

private let notificationCenter = UNUserNotificationCenter.current()
private let locationManager = CLLocationManager()
private let logger = FileLogger.shared

init() {
locationManager.requestWhenInUseAuthorization()
Task {
await requestNotificationPermission()
}
}

private func requestNotificationPermission() async -> Bool {
do {
let success = try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge])
logger.info("Request notification authorization with success: \(success)")
if !success {
logger.error("Fail to request notification authorization.")
self.error = .notificationNotAuthorized
}
return success
} catch (let error) {
logger.error("Fail to request notification authorization: \(error.localizedDescription)")
self.error = .notificationNotAuthorized
return false
}
}

func notificationResponseReceived(_ response: UNNotificationResponse) async {
let content = response.notification.request.content
logger.info("notification response received: \(content)")
}

func willPresentNotification(_ notification: UNNotification) async -> UNNotificationPresentationOptions {
print(notification.request.identifier)
let content = notification.request.content
logger.info("will present notification: \(content)")
return [[.badge, .sound, .banner, .list]]
}


func removeRegisteredNotifications() {
notificationCenter.removeAllDeliveredNotifications()
notificationCenter.removeAllPendingNotificationRequests()
}

private func registerLocationNotification() async {
let result = await requestNotificationPermission()
if !result {
return
}

locationManager.requestWhenInUseAuthorization()

if locationManager.authorizationStatus != .authorizedWhenInUse && locationManager.authorizationStatus != .authorizedAlways {
logger.error("Fail to request location authorization.")
self.error = .coreLocationNotAuthorized
return
}


// for entry
let entryRegion = CLCircularRegion(center: CENTER, radius: RADIUS, identifier: "itsuki_world_entry")
let entryContent = UNMutableNotificationContent()
entryContent.title = "Entered from UNTrigger!"
entryContent.body = "Welcome to my world!"
entryContent.sound = .default

entryRegion.notifyOnEntry = true
entryRegion.notifyOnExit = false
let entryTrigger = UNLocationNotificationTrigger(region: entryRegion, repeats: true)
await registerNotificationRequest(content: entryContent, trigger: entryTrigger)

// for exit
let exitRegion = CLCircularRegion(center: CENTER, radius: RADIUS, identifier: "itsuki_world_exit")
let exitContent = UNMutableNotificationContent()
exitContent.title = "Exited from UNTrigger!"
exitContent.body = "See you next time!"
exitContent.sound = .default

exitRegion.notifyOnEntry = false
exitRegion.notifyOnExit = true
let exitTrigger = UNLocationNotificationTrigger(region: exitRegion, repeats: true)
await registerNotificationRequest(content: exitContent, trigger: exitTrigger)

}

private func registerNotificationRequest(content: UNMutableNotificationContent, trigger: UNNotificationTrigger) async {
let identifier = UUID().uuidString
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)

// Schedule the request with the system.
do {
try await notificationCenter.add(request)
logger.info("registration succeed for request with identifier \(identifier)")
} catch(let error) {
// Handle errors that may occur during add.
logger.error("error adding request: \(error.localizedDescription)")
}

}
}

let CENTER: CLLocationCoordinate2D = .init(
latitude: 35,
longitude: 140
)

let RADIUS: CLLocationDistance = 500

Feel free the change CENTER and RADIUS to one you like!

App Delegate


import SwiftUI

class AppDelegate: NSObject, UIApplicationDelegate {
private let notificationManager = NotificationManager.shared
private let logger = FileLogger.shared

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {

logger.info("Launch with options: \(launchOptions ?? [:])")

UNUserNotificationCenter.current().delegate = self

return true
}
}

extension AppDelegate: UNUserNotificationCenterDelegate {

func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
return await notificationManager.willPresentNotification(notification)
}

func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
await notificationManager.notificationResponseReceived(response)
}

}

View


import SwiftUI

struct LocationTriggerView: View {
@State private var manager = NotificationManager.shared
var body: some View {
@Bindable var manager = manager

VStack(spacing: 48) {
Text("UNLocationNotificationTrigger")
.font(.title3)
.fontWeight(.bold)

Toggle("Register Trigger", isOn: $manager.triggerRegistered)
.frame(width: 240)

if let error = manager.error {
Text("Error: \(error.localizedDescription)")
.padding(.horizontal, 32)
.multilineTextAlignment(.leading)
.foregroundStyle(.red)
}
}
.padding(.vertical, 64)
}
}

Triggered for both Background (Task Kill) & Foreground with NO Problem!

Will Present Triggering Twice

As you can see from my screenshots, notification for each trigger is actually presented twice. This seems to be a bug for iOS 18 where the userNotificationCenter(_:willPresent:withCompletionHandler:) function is trigger twice.

The issue is still under investigation. You can check it out here.

Before Apple decides to actually solve it, there are couple things we can do to avoid this.

For me, since I have separated out the entry and exit trigger, I can simply add another variable to keep track of the previous presented notification.request.identifier , and if the new one has the same identifier as the previous, we can just not presenting anything.

Different Region Identifier for Different Trigger

Even though our entry and exit region are essentially the same region, we have to give those regions two different identifiers for the trigger to works properly!

let entryRegion = CLCircularRegion(
center: CENTER, radius: RADIUS,
identifier: "itsuki_world_entry")
// ...
let exitRegion = CLCircularRegion(
center: CENTER, radius: RADIUS,
identifier: "itsuki_world_exit")

Also, the reason I am creating two triggers instead of just setting notifyOnEntry and notifyOnExit both to true to a single one is because this will allow us to handle each trigger differently.

Extra Notes

Now, I am simply showing the notifications, but if you were to perform some extra tasks when the entry and exit event triggered, willPresentNotification function will be the place to do so.

Within this function, we can check notification.request.identifier to see which trigger fires the push notification and do our processing accordingly.

Also, if you don’t want to show your user any push notifications, but simply want to use the trigger as a way for internal processing, you can simply return an empty array (ie: [[]]) for the userNotificationCenter(_:willPresent:withCompletionHandler:) function.

Little Warning

CLCircularRegion is actually marked as deprecated in the official document!

However, if we check out the source code, it mentions nothing about the deprecation.

Also, without using it, we won’t even able to create a UNLocationNotificationTrigger from the beginning. Since Apple hasn’t really provide an alternative for this trigger nor the CLCircularRegion itself, I guess it will still stay around for a while?

With CLMonitor

If I spent 10 minutes on getting the above to work, I probably have spent 10 hours on this approach (for the background part of course)…

Reasons:

  1. CLMonitor-related stuff is super badly documented
  2. Apple is providing NOT-working sample code and articles! (Because they are not up to date!)

Anyway!

General Overview

Here are the basic steps for using CLMonitor to monitor the device proximity to a specific region.

  1. Create a CLCircularGeographicCondition representing the region
  2. Register the condition for monitoring
  3. Await for events, an AsyncSequence, for monitor results

This is it if you are only interested in foreground monitoring. However, to receive updates in the background, we will also need to EXPLICITLY create a CLServiceSession withAuthorizationRequirement.always.

Permission Set Up

Before we head to code, let’s set up our permissions. This is the part that I probably spent most of my time on.

If you have read this Handling location updates in the background article, or check out the sample code from WWDC23, you might think all we need is to enable the background mode and set the NSLocationWhenInUseUsageDescription key.

Nope!

This is not true any more after WWDC2024!

>>> What’s new in location authorization

As I have mentioned a little bit above, we have to EXPLICITLY create a CLServiceSession withAuthorizationRequirement.always which mean we need

in addition to the background mode capability.

PS: The sample code from WWDC23 doesn’t work anymore! You will not get any background updates!

PPS: This article Monitoring the user’s proximity to geographic regions mentions nothing about background mode, service session or whatever, but they are all needed!

Optional.

We can also add the NSLocationTemporaryUsageDescriptionDictionary if our app needs temporary access to full accuracy location information. Provide a dictionary of messages that address different use cases, keyed by strings that we define.

Location Monitor

This will be our Manager class that helps us handle all the monitoring-related logic.

Let me first share the code with you here. We will then take a more detailed look at some of the important points.

import SwiftUI
import CoreLocation

@MainActor
@Observable
class LocationMonitor {

static let shared = LocationMonitor() // Create a single, shared instance of the object.
static private let monitorStartedKey = "monitorStarted"

enum MonitorError: Error {
case invalidMonitor
case coreLocationNotAuthorized
case existingCondition
case notificationNotAuthorized
case sessionError(String)

}

var error: MonitorError? = nil
var conditionEvents: [String : [CLMonitor.Event]] = [:]

var monitorStarted: Bool = UserDefaults.standard.bool(forKey: LocationMonitor.monitorStartedKey) {
didSet {
monitorStarted ? self.startMonitor() : self.stopMonitor()
UserDefaults.standard.set(monitorStarted, forKey: LocationMonitor.monitorStartedKey)
}
}

@ObservationIgnored
var conditionIdentifier: String {
"itsuki_condition"
}

@ObservationIgnored
var condition: CLMonitor.CircularGeographicCondition {
return .init(center: CENTER, radius: RADIUS)
}

private var monitorIdentifier: String {
if let bundleId = Bundle.main.bundleIdentifier {
return bundleId.replacingOccurrences(of: ".", with: "_")
}
return "itsuki_monitor"
}


private var monitor: CLMonitor?
private let notificationCenter = UNUserNotificationCenter.current()
private let locationManager: CLLocationManager = CLLocationManager()
private var session: CLServiceSession?
private let logger = FileLogger.shared

private init() {
Task {
await requestPermission()
await initializeMonitor()
}
}

private func requestPermission() async {
if locationManager.authorizationStatus != .authorizedAlways {
locationManager.requestAlwaysAuthorization()
}

do {
try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge])
} catch (let error) {
logger.error("Fail to request notification authorization: \(error.localizedDescription)")
self.error = .notificationNotAuthorized
}
}


private func initializeMonitor() async {

if self.monitor == nil {
// create monitor
let monitor = await CLMonitor(monitorIdentifier)
self.monitor = monitor
// if there is any previous events
for identifier in await monitor.identifiers {
guard let lastEvent = await monitor.record(for: identifier)?.lastEvent else { continue }
self.conditionEvents[identifier] = [lastEvent]
}
}

if monitorStarted {
startMonitor()
}
}

private func startMonitor() {
Task {
await requestPermission()
startSessionMonitor()
startEventMonitor()
}
}

private func startSessionMonitor() {
Task {
session = CLServiceSession(authorization: .always, fullAccuracyPurposeKey: fullAccuracyPurposeKey)
for try await diagnostics in session!.diagnostics {

if diagnostics.authorizationDenied {
self.error = .sessionError("Authorization Denied. Please authorize the app to access Location Services")
self.logger.info("Authorization Denied")
continue
}

if diagnostics.alwaysAuthorizationDenied {
self.error = .sessionError("Always Authorization Denied. Monitor will only work in the foreground.")
self.logger.info("Always Authorization Denied")
continue
}
if diagnostics.authorizationDeniedGlobally {
self.error = .sessionError("Authorization Denied Globally. Please enable Location Services by going to Settings -> Privacy & Security.")
self.logger.info("authorizationDeniedGlobally")
continue
}
if diagnostics.authorizationRestricted {
self.error = .sessionError("Authorization Restricted. Do you have Parental Controls enabled?")
self.logger.info("Authorization Restricted")
continue
}
if diagnostics.fullAccuracyDenied {
self.error = .sessionError("Full Accuracy Denied. Monitoring might fail without access to the precise location.")
self.logger.info("always authorization denied")
continue
}
if diagnostics.insufficientlyInUse {
self.error = .sessionError("Insufficiently In Use. Location monitoring can't receive condition events while not in the foreground")
self.logger.info("Insufficiently In Use")
continue
}

self.error = nil
}
}
}


private func startEventMonitor() {
guard let monitor else {
self.logger.info("monitor not available")
return
}
self.logger.info("Starting event monitoring")

Task {
// same identifier every time so that we don't end up adding multiple same condition
await monitor.add(condition, identifier: conditionIdentifier)

do {

// about 2~5 seconds delay between the user's action and the event fired
for try await event in await monitor.events {
if !self.monitorStarted { break } // End monitoring updates by breaking out of the loop.

logger.info("event received: \(event)")

// If the event state is the same as the previous state, the only new information is in diagnostics.
if let lastEvent = await monitor.record(for: event.identifier)?.lastEvent, event.state == lastEvent.state {
continue
}

if self.conditionEvents.contains(where: {$0.key == event.identifier}) {
self.conditionEvents[event.identifier]?.insert(event, at: 0)
} else {
self.conditionEvents[event.identifier] = [event]
}

if event.state == .satisfied {
let notificationContent = UNMutableNotificationContent()
notificationContent.title = "Entered from CLMonitor!"
notificationContent.body = "Welcome to my world!"
notificationContent.sound = .default
let notification = UNNotificationRequest(identifier: UUID().uuidString, content: notificationContent, trigger: nil)
try await notificationCenter.add(notification)
}

if event.state == .unsatisfied {
let notificationContent = UNMutableNotificationContent()
notificationContent.title = "Exited from CLMonitor!"
notificationContent.body = "See you next time!"
notificationContent.sound = .default
let notification = UNNotificationRequest(identifier: UUID().uuidString, content: notificationContent, trigger: nil)
try await notificationCenter.add(notification)
}
}
} catch(let error) {
logger.error("Could not monitor events: \(error.localizedDescription)")
}
}

}

private func stopMonitor() {
self.logger.info("Stopping location updates")
self.session?.invalidate()
guard let monitor else { return }
Task {
for identifier in await monitor.identifiers {
await monitor.remove(identifier)
}
}
}
}

Request Permission

We need the Always authorization!

This is the key to receive background updates!

private func requestPermission() async {
if locationManager.authorizationStatus != .authorizedAlways {
locationManager.requestAlwaysAuthorization()
}

// ...
}

If you have added the NSLocationTemporaryUsageDescriptionDictionary key above and want to requests permission to temporarily use location services with full accuracy, here is where you will want to call requestTemporaryFullAccuracyAuthorization(withPurposeKey:completion:) instead.

if locationManager.accuracyAuthorization != .fullAccuracy {
locationManager.requestTemporaryFullAccuracyAuthorization(withPurposeKey: "monitor", completion: { error in
if let error {
self.logger.error("Fail to request full accuracy authorization: \(error.localizedDescription)")
self.error = .fullAccuracyNotAuthorized
}
})
}

The purpose key will be the key you registered in info.plist, in my example, monitor.

(There is also the async/await version available but the error is a lot more descriptive with the completion handler.)

Note!

If we call requestTemporaryFullAccuracyAuthorization after user has already grant us the full accuracy authorization, we will get the following error.

Error Domain=kCLErrorDomain Code=18 
"The user has already made a decision about the app's authorization"
UserInfo={
NSDebugDescription=The user has already made a decision about the app's authorization
}

I am also requesting for notification permission becuase I am using UNNotification to send local push notification when an event state changed to satisfied or unsatisfied just so that we don’t have to check the log to find out new updates on event. You can ignore it if how you will be processing the event is different.

Initialize Monitor

There are couple things we are doing here.

First of all, we create the CLMonitor. It has to have the same identifier across app’s lifecycles. And only one CLMonitor with a specific identifier can exist at a time! (You can have multiple with different identifiers though.)

If monitorStarted is true, ie: user has turned on the monitoring before the App going into background / being task killed, we will

immediately.

If you want a CLServiceSession that might use location services with full accuracy, you can initialize it like following where the fullAccuracyPurposeKey, again, is the key you registered in info.plist.

session = CLServiceSession(authorization: .always, fullAccuracyPurposeKey: "monitor")

You might be wondering if I am adding the same CLMonitor.CircularGeographicCondition every time we call startEventMonitor, won’t that cause our CLMonitor to end up monitoring multiple conditions with essentially the same geographic condition?

await monitor.add(condition, identifier: conditionIdentifier)

As long as we keep the identifier to be the same, nope!

If a given identifier is already added while adding new condition, the condition will be replaced with the new one.

Again, within my events polling, I am simply sending local push notifications when an event state becomes satisfied or unsatisfied, but you can definitely do something more interesting than this!

A Little Extra

I am using CLMonitor to monitor a specific condition, but you can also use CLLocationUpdate.liveUpdates and check on the condition by yourself against the location.

Recall that I said the sample code that uses liveUpdates from WWDC23 does not working anymore? That’s because it does not have CLServiceSession created nor the NSLocationAlwaysAndWhenInUseUsageDescription added.

To use CLLocationUpdate.liveUpdates instead, all we have to do is to change our startEventMonitor to the following.

private func startEventMonitor() {
// live updates approach
self.logger.info("Starting location updates")
Task {
do {
let updates = CLLocationUpdate.liveUpdates()
for try await update in updates {
if !self.monitorStarted { break } // End location updates by breaking out of the loop.
if let loc = update.location {
logger.info("Location \(loc)")
}
}
} catch {
self.logger.error("Could not start location updates")
}
return
}
}

All other parts can stay EXACTLY the same!

That is, we do NOT need a CLBackgroundActivitySession!

Stop Monitor

Again, two things.

  1. Invalidate our CLServiceSession
  2. Remove all the addition monitoring conditions
private func stopMonitor() {
self.logger.info("Stopping location updates")
self.session?.invalidate()
guard let monitor else { return }
Task {
for identifier in await monitor.identifiers {
await monitor.remove(identifier)
}
}
}

That’s it for the LocationMonitor, let’s head to our AppDelegate to pull that last piece together!

App Delegate


import SwiftUI

class AppDelegate: NSObject, UIApplicationDelegate {
private let logger = FileLogger.shared

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {

logger.info("Launch with options: \(launchOptions ?? [:])")

UNUserNotificationCenter.current().delegate = self

let _ = LocationMonitor.shared

return true
}
}

extension AppDelegate: UNUserNotificationCenterDelegate {

func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
return [[.badge, .sound, .banner, .list]]
}

func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
return
}
}

You might be wondering why do we initialize our LocationMonitor if we don’t use it.

This is so that if our monitorStarted is set to true before the App terminates, we can recreate our CLMonitor with the same identifier and the CLServiceSession immediately upon launch.

Again, I am having the UNUserNotificationCenterDelegate here because I am sending local push notifications when a monitored condition is satisfied or unsatisfied as I have mentioned above. You can leave it out if you are processing the events differently.

Test it Out!

Here is a simple view and let’s give it a run!

import SwiftUI
import CoreLocation

struct CLMonitorView: View {
@State var locationMonitor = LocationMonitor.shared

private var dateFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .long
return formatter
}

var body: some View {
@Bindable var locationMonitor = locationMonitor

VStack(spacing: 24) {
Text("CLCircularGeographicCondition")
.font(.title3)
.fontWeight(.bold)

Toggle("Start Monitor", isOn: $locationMonitor.monitorStarted)
.frame(width: 200)

if let error = locationMonitor.error {
Text("Error: \(error.localizedDescription)")
.padding(.horizontal, 32)
.multilineTextAlignment(.leading)
.foregroundStyle(.red)
}


List {
Section {
let targetConditionEvents = locationMonitor.conditionEvents[locationMonitor.conditionIdentifier] ?? []
if targetConditionEvents.isEmpty {
Text("No Events Available yet.")
} else {
ForEach(0..<targetConditionEvents.count, id: \.self) { index in
let event: CLMonitor.Event = targetConditionEvents[index]
Text("\(dateFormatter.string(from: event.date)): \(event.state.string)")
}
}

} header: {
VStack {
Text("Events for condition")
.frame(maxWidth: .infinity, alignment: .leading)

Text("Center: \(locationMonitor.condition.center.latitude.twoDecimalString), \(locationMonitor.condition.center.longitude.twoDecimalString); Radius: \(locationMonitor.condition.radius.twoDecimalString)")
.frame(maxWidth: .infinity, alignment: .leading)

}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.contentMargins(.vertical, 8)
}
.padding(.top, 64)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)

}
}

extension Double {
var twoDecimalString: String {
String(format: "%.2f", self)
}
}

Yeah!

Time To Decide!

That’s it for the code!

Final question. Which one should I use? (I am only talking about myself here!)

Here are the points I consider.

First and most important! From Apps functionality perspective!

Does my location-related task should be performed regardless of whether my app gets authorized for push notification or not?

If yes, then the CLMonitor approach is the only way to go.

From developers perspective!

Do you want something simple? Less set up. Less code. Less handling.

If yes, then UNLocationNotificationTrigger.

Are you willing to try Apple’s fancy and newest approach and bearing with the fact that they are super badly documented?

If yes, then CLMonitor.

Lastly, this is just my assumption from the name.

We should get better accuracy with CLMonitor (hopefully) if we have requested for the FullAccuracyAuthorization. I did not find any documentations on by how much it will actually improve so that is just my assumption!

Thank you for reading and listening to me complaining about Apple’s documentation!

Again, feel free to grab the demo from my GitHub!

Happy monitoring!


SwiftUI: Monitor User Location Relative to a Region (Geofencing) 2 Ways was originally published in Level Up Coding on Medium, where people are continuing the conversation by highlighting and responding to this story.


This content originally appeared on Level Up Coding - Medium and was authored by Itsuki


Print Share Comment Cite Upload Translate Updates
APA

Itsuki | Sciencx (2025-02-13T22:25:21+00:00) SwiftUI: Monitor User Location Relative to a Region (Geofencing) 2 Ways. Retrieved from https://www.scien.cx/2025/02/13/swiftui-monitor-user-location-relative-to-a-region-geofencing-2-ways/

MLA
" » SwiftUI: Monitor User Location Relative to a Region (Geofencing) 2 Ways." Itsuki | Sciencx - Thursday February 13, 2025, https://www.scien.cx/2025/02/13/swiftui-monitor-user-location-relative-to-a-region-geofencing-2-ways/
HARVARD
Itsuki | Sciencx Thursday February 13, 2025 » SwiftUI: Monitor User Location Relative to a Region (Geofencing) 2 Ways., viewed ,<https://www.scien.cx/2025/02/13/swiftui-monitor-user-location-relative-to-a-region-geofencing-2-ways/>
VANCOUVER
Itsuki | Sciencx - » SwiftUI: Monitor User Location Relative to a Region (Geofencing) 2 Ways. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2025/02/13/swiftui-monitor-user-location-relative-to-a-region-geofencing-2-ways/
CHICAGO
" » SwiftUI: Monitor User Location Relative to a Region (Geofencing) 2 Ways." Itsuki | Sciencx - Accessed . https://www.scien.cx/2025/02/13/swiftui-monitor-user-location-relative-to-a-region-geofencing-2-ways/
IEEE
" » SwiftUI: Monitor User Location Relative to a Region (Geofencing) 2 Ways." Itsuki | Sciencx [Online]. Available: https://www.scien.cx/2025/02/13/swiftui-monitor-user-location-relative-to-a-region-geofencing-2-ways/. [Accessed: ]
rf:citation
» SwiftUI: Monitor User Location Relative to a Region (Geofencing) 2 Ways | Itsuki | Sciencx | https://www.scien.cx/2025/02/13/swiftui-monitor-user-location-relative-to-a-region-geofencing-2-ways/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.