Created
December 18, 2025 15:55
-
-
Save barbaramartina/93e170192ce342f9545af8103978673b to your computer and use it in GitHub Desktop.
How to implement a lightweight indoors map solution
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /// In the last days, I have been playing around with a technology which is not new, but I've never looked into before. iBeacons. It's very interesting how beacons could be used to implement all sorts of applications, such as an indoor positioning system, but also an outdoor game for example, or in a very large restaurant, it could be used to identify where the customer is seating and deliver food to their table. | |
| /// It basically consists in utilizing beacons capable units and positioning them spread across an area, registering each unit and monitoring when the users's iPhone enters the area. | |
| /// iBeacons structure | |
| /// The main framework involved in dealing with iBeacons is CoreLocation, I've work extensively with CoreLocation in GPS positioning logics before, but I've not yet utilized CoreLocation to track iBeacons. There is a class name CLBeacon and a set of relatively new APIs which I used for this example: CLMonitor and CLBeaconIdentifyCondition (both available since iOS 17) | |
| /// CLBeacon | |
| /// CLBeacon is a class present since iOS 7.0, there are several apps in the App Store which allow you to use any iPhone and simulate an iBeacon. The main values that you need to define are: | |
| /// - UUID | |
| /// - Major | |
| /// - Minor | |
| /// Interestingly UUID does not necessarily needs to be unique to one beacon, and the identification logic takes into account the three values, uuid, major and minor. | |
| /// There is a very helpful video from Apple explaining how these 3 values can be used to distribute physically the beacons in a building, in order to reflect for example the floor number and the sections of the building. You can check it out here: Build location-aware enterprise apps. | |
| /// CLMonitor | |
| /// CLMonitor is available since iOS 17. It was introduced in 2023, and it enables to keep track of location and beacons event in a swift-concurrency friendlier way (compared to CLLocationManagerDelegate). You can check out the basis of CLMonitor here: Meet CoreLocation Monitor. | |
| /// CLBeaconIdentityCondition | |
| /// CLBeaconIdentityCondition is also available since iOS 17 and it allows to define a criteria for beacons-matching, this criteria is added to the monitor and then, it starts to look for it. The criteria is based on the beacon's properties: UUID, major and minor. | |
| /// CLLocationManagerDelegate | |
| /// CLLocationManagerDelegate, is our beloved old delegate, which will be helpful as well, because once the CLMonitor detects that a condition for a beacon was met, then we can start asking the CLLocationManager to start ranging the beacon, to establish proximity and see how far we are from the actual beacon. Therefore we still need to combine CLMonitor with CLLocationManager and its delegate. | |
| /// Pre-conditions | |
| /// As preconditions to be able to implement the solution, we need: | |
| /// - Location permissions info PLIST Key NSLocationWhenInUseUsageDescription needs to be added to the project. | |
| /// - The user needs to allow also location always, since the app will need to monitor background locations, but for testing whenInUse is enough. | |
| /// - iBeacons need to be transmitting and defined (You can use an application like Beacons Walker to simulate iBeacons, be careful that sometimes this one is not reliable and actually it seems that the CLMonitor does not work, but it is the simulator of iBeacons that does not work). | |
| /// - Background mode for locations need to be enabled, so the app can detect when you enter or leave a region where an iBeacon is https://developer.apple.com/documentation/corelocation/handling-location-updates-in-the-background | |
| /// Logic | |
| /// The main steps can be divided as follows: | |
| /// - We need a CLMonitor | |
| /// - We need a CLLocationManager and a delegate for it | |
| /// - We need to know the information of the simulated beacon (UUID, major, minor) | |
| /// - We need to create a CLBeaconCondition and ask the CLMonitor to start looking for it | |
| /// - We need to register for the events coming from the monitor (see startConsumingMonitorEvents) to know when the monitor matches or unmatches a condition | |
| /// - When the monitor matches a condition, we need to engage the CLLocationManager and ask it to start ranging for the found condition / beacon | |
| /// - When a condition is unmatched we need to stop the CLLocationManager ranging (it can be resources expensive) | |
| // | |
| // BeaconsManager.swift | |
| // IndoorMaps | |
| // | |
| // Created by Barbara Rodeker on 16/12/25. | |
| // | |
| import CoreLocation | |
| import Foundation | |
| // MARK: - BeaconsManagerDelegate | |
| /// when the manager start observing a set of beacons, or stop observing them, | |
| /// or when a concrete beacon region is enter, and when a concrete beacon is been ranged, | |
| /// the BeaconManager informs its delegate of it | |
| protocol BeaconsManagerDelegate: AnyObject { | |
| /// informs when a whole set of beacons are starting to be monitored | |
| func didStartObserving(beacons: [Beacon]) | |
| /// this happens for example when the user removed location permissions, beacons are not longer monitored | |
| func didStopObserving(beacons: [Beacon]) | |
| /// whenever a beacon is found, either near, far or on spot, it is informed to the delegate. The delegate can check | |
| /// the proximity distance using the beacon properties | |
| func didFind(beacon: Beacon) | |
| /// when a region where a beacon is, is left, it is also informed | |
| func didLeaveArea(of beacon: Beacon) | |
| } | |
| // MARK: - BeaconsManagerProtocol | |
| protocol BeaconsManagerProtocol { | |
| func startObserving(beacons: [Beacon]) async throws | |
| func updateDelegate(to delegate: BeaconsManagerDelegate) | |
| } | |
| enum BeaconsManagerError: Error { | |
| case locationServicesNotEnabled | |
| case locationNotAuthorized | |
| case observationInProgress | |
| case emptyBeaconsCanNotBeObserved | |
| /// The availability of region monitoring support is dependent on the hardware present on the device. | |
| /// This method does not take into account the availability of location services or the fact that the user might have disabled them | |
| case deviceDoesNotSupportBeaconRegionMonitoring | |
| case rangingIsNotAvailableInThisDevice | |
| } | |
| /// a wrapper to provide UUID, major and minor information | |
| struct Beacon { | |
| let uuid: UUID | |
| let major: UInt16 | |
| let minor: UInt16 | |
| } | |
| /// It handles location updates / permissions and discovering and ranging iBeacons. | |
| /// Since the app can be killed... make sure that you start re-monitoring/observing for beacons when the app becomes alive again | |
| @MainActor | |
| final class BeaconsManager: NSObject, CLLocationManagerDelegate, | |
| BeaconsManagerProtocol | |
| { | |
| /// if you want to be informed when a beacon is detected or a region is enter for example, set this delegate | |
| private weak var delegate: BeaconsManagerDelegate? | |
| /// needed to discover / monitor beacons | |
| private var locationManager: CLLocationManager = CLLocationManager() | |
| /// to work with the latest iOS17 monitoring APIs, we can define a monitor and later on add different conditions for beaconRegions | |
| private var monitor: CLMonitor? | |
| /// the currently observed beacons, is this array is not nil, then any call to start observing will fail | |
| /// the user of this manager should explicitly cancel any previous observation request | |
| /// For a production app it might be worthy to add more dynamis and allow for start observation multiple times, | |
| /// for that, then a diff-ing would be needed, maybe also other functions such as appendObservedBeacon could be added | |
| private var observedBeacons: [Beacon]? | |
| func startObserving(beacons: [Beacon]) async throws { | |
| locationManager.delegate = self | |
| // do not initialize the monitor twice | |
| if monitor == nil { | |
| monitor = await CLMonitor("BeaconsMonitor") | |
| startConsumingMonitorEvents() | |
| } | |
| guard CLLocationManager.isMonitoringAvailable(for: CLBeaconRegion.self) | |
| else { | |
| throw BeaconsManagerError.deviceDoesNotSupportBeaconRegionMonitoring | |
| } | |
| guard CLLocationManager.isRangingAvailable() else { | |
| throw BeaconsManagerError.rangingIsNotAvailableInThisDevice | |
| } | |
| guard beacons.isEmpty == false else { | |
| throw BeaconsManagerError.emptyBeaconsCanNotBeObserved | |
| } | |
| // no new observations-start if there is already an observation in progress | |
| guard observedBeacons == nil else { | |
| throw BeaconsManagerError.observationInProgress | |
| } | |
| // check that the user did not already denied permissions for location | |
| guard | |
| locationManager.authorizationStatus != .denied | |
| && locationManager.authorizationStatus != .restricted | |
| else { | |
| throw BeaconsManagerError.locationNotAuthorized | |
| } | |
| // if the user was still no asked for permissions, then... ask... | |
| if locationManager.authorizationStatus == .notDetermined { | |
| // keep beacons to start observing them | |
| observedBeacons = beacons | |
| // when in use is enough permissions level | |
| locationManager.requestAlwaysAuthorization() | |
| } else { | |
| // if the user was already asked, and it is not denied, then we can proceed to start observing | |
| await registerForObserving(beacons: beacons) | |
| } | |
| } | |
| func updateDelegate(to delegate: BeaconsManagerDelegate) { | |
| self.delegate = delegate | |
| } | |
| // MARK: - CLLocationManagerDelegate | |
| private func locationManager( | |
| _ manager: CLLocationManager, | |
| didChangeAuthorization status: CLAuthorizationStatus | |
| ) async { | |
| switch status { | |
| case .notDetermined: | |
| break | |
| case .restricted, .denied: | |
| // stop observing | |
| guard let observedBeacons else { return } | |
| for beacon in observedBeacons { | |
| await monitor?.remove(beaconIdentityIdentifier(for: beacon)) | |
| } | |
| delegate?.didStopObserving(beacons: observedBeacons) | |
| self.observedBeacons = nil | |
| case .authorizedAlways, .authorizedWhenInUse: | |
| guard let observedBeacons else { return } | |
| await registerForObserving(beacons: observedBeacons) | |
| @unknown default: | |
| fatalError() | |
| } | |
| } | |
| // MARK: - Monitor's events consuming logic | |
| private func startConsumingMonitorEvents() { | |
| guard let monitor else { return } | |
| Task { | |
| for try await event in await monitor.events { | |
| if let record = await monitor.record(for: event.identifier) { | |
| if let beaconCondition = record.condition | |
| as? CLMonitor.BeaconIdentityCondition | |
| { | |
| let lastEvent = record.lastEvent | |
| // if the user met with the condition beaconID + beaconMajor + beaconMinor | |
| if lastEvent.state == .satisfied { | |
| // let's start asking the location manager to check the distance | |
| locationManager.startRangingBeacons( | |
| satisfying: beaconCondition.constraint | |
| ) | |
| } else if lastEvent.state == .unsatisfied { | |
| // otherwise, stop any previous ranging | |
| locationManager.stopRangingBeacons( | |
| satisfying: beaconCondition.constraint | |
| ) | |
| delegate?.didLeaveArea( | |
| of: | |
| Beacon( | |
| uuid: beaconCondition.uuid, | |
| major: beaconCondition.major ?? 1, | |
| minor: beaconCondition.minor ?? 1 | |
| ) | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // MARK: CLLocationManager ranging Beacons | |
| internal func locationManager( | |
| _ manager: CLLocationManager, | |
| didRange beacons: [CLBeacon], | |
| satisfying beaconConstraint: CLBeaconIdentityConstraint | |
| ) { | |
| beacons.forEach { | |
| // only inform about far, near or immediate findings | |
| guard $0.proximity != .unknown else { return } | |
| delegate?.didFind( | |
| beacon: Beacon( | |
| uuid: $0.uuid, | |
| major: $0.major.uint16Value, | |
| minor: $0.minor.uint16Value | |
| ) | |
| ) | |
| } | |
| } | |
| // MARK: - Private | |
| private func registerForObserving(beacons: [Beacon]) async { | |
| guard let monitor else { return } | |
| observedBeacons = beacons | |
| for beacon in beacons { | |
| let condition = CLMonitor.BeaconIdentityCondition(uuid: beacon.uuid, major: beacon.major, minor: beacon.minor) | |
| await monitor.add( | |
| condition, | |
| identifier: beaconIdentityIdentifier(for: beacon) | |
| ) | |
| } | |
| delegate?.didStartObserving(beacons: beacons) | |
| } | |
| private func beaconIdentityIdentifier(for beacon: Beacon) -> String { | |
| beacon.uuid.uuidString + beacon.major.description | |
| + beacon.minor.description | |
| } | |
| } | |
| extension CLMonitor.BeaconIdentityCondition { | |
| var constraint: CLBeaconIdentityConstraint { | |
| let constraint: CLBeaconIdentityConstraint | |
| switch (self.major, self.minor) { | |
| case (.some(let major), .some(let minor)): | |
| constraint = CLBeaconIdentityConstraint( | |
| uuid: self.uuid, | |
| major: major, | |
| minor: minor | |
| ) | |
| case (.some(let major), .none): | |
| constraint = CLBeaconIdentityConstraint( | |
| uuid: self.uuid, | |
| major: major | |
| ) | |
| default: | |
| constraint = CLBeaconIdentityConstraint(uuid: self.uuid) | |
| } | |
| return constraint | |
| } | |
| } | |
| extension CLMonitor.BeaconIdentityCondition { | |
| init(uuid: UUID, major: UInt16?, minor: UInt16?) { | |
| if let major, let minor { | |
| self = CLMonitor.BeaconIdentityCondition(uuid: uuid, major: major, minor: minor) | |
| } else if let major { | |
| self = CLMonitor.BeaconIdentityCondition(uuid: uuid, major: major) | |
| } else { | |
| self = CLMonitor.BeaconIdentityCondition(uuid: uuid) | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment