From 092cb43712a0513490790933cc1a2494145d9505 Mon Sep 17 00:00:00 2001 From: Chris Lloyd Date: Wed, 12 Nov 2025 17:13:20 +0000 Subject: [PATCH 1/8] Heading support --- IONGeolocationLib/IONGLOCPositionModel.swift | 14 +- .../Publishers/IONGLOCManagerWrapper.swift | 25 ++- .../IONGLOCManagerWrapperTests.swift | 172 ++++++++++++++++++ .../MockCLLocationManager.swift | 23 +++ 4 files changed, 228 insertions(+), 6 deletions(-) diff --git a/IONGeolocationLib/IONGLOCPositionModel.swift b/IONGeolocationLib/IONGLOCPositionModel.swift index 4862498..7c30a8d 100644 --- a/IONGeolocationLib/IONGLOCPositionModel.swift +++ b/IONGeolocationLib/IONGLOCPositionModel.swift @@ -9,8 +9,10 @@ public struct IONGLOCPositionModel: Equatable { private(set) public var speed: Double private(set) public var timestamp: Double private(set) public var verticalAccuracy: Double + private(set) public var magneticHeading: Double + private(set) public var trueHeading: Double - private init(altitude: Double, course: Double, horizontalAccuracy: Double, latitude: Double, longitude: Double, speed: Double, timestamp: Double, verticalAccuracy: Double) { + private init(altitude: Double, course: Double, horizontalAccuracy: Double, latitude: Double, longitude: Double, speed: Double, timestamp: Double, verticalAccuracy: Double, magneticHeading: Double, trueHeading: Double) { self.altitude = altitude self.course = course self.horizontalAccuracy = horizontalAccuracy @@ -19,12 +21,14 @@ public struct IONGLOCPositionModel: Equatable { self.speed = speed self.timestamp = timestamp self.verticalAccuracy = verticalAccuracy + self.magneticHeading = magneticHeading + self.trueHeading = trueHeading } } public extension IONGLOCPositionModel { - static func create(from location: CLLocation) -> IONGLOCPositionModel { - .init( + static func create(from location: CLLocation, heading: CLHeading? = nil) -> IONGLOCPositionModel { + return .init( altitude: location.altitude, course: location.course, horizontalAccuracy: location.horizontalAccuracy, @@ -32,7 +36,9 @@ public extension IONGLOCPositionModel { longitude: location.coordinate.longitude, speed: location.speed, timestamp: location.timestamp.millisecondsSinceUnixEpoch, - verticalAccuracy: location.verticalAccuracy + verticalAccuracy: location.verticalAccuracy, + magneticHeading: heading?.magneticHeading ?? -1.0, + trueHeading: heading?.trueHeading ?? -1.0 ) } } diff --git a/IONGeolocationLib/Publishers/IONGLOCManagerWrapper.swift b/IONGeolocationLib/Publishers/IONGLOCManagerWrapper.swift index e8b85d0..c1720f1 100644 --- a/IONGeolocationLib/Publishers/IONGLOCManagerWrapper.swift +++ b/IONGeolocationLib/Publishers/IONGLOCManagerWrapper.swift @@ -39,6 +39,8 @@ public class IONGLOCManagerWrapper: NSObject, IONGLOCService { private let servicesChecker: IONGLOCServicesChecker private var isMonitoringLocation = false + private var lastLocation: CLLocation? + private var lastHeading: CLHeading? // Flag used to indicate that the location request has timed out. // When `true`, the wrapper ignores any location updates received from CLLocationManager. @@ -61,7 +63,9 @@ public class IONGLOCManagerWrapper: NSObject, IONGLOCService { public func startMonitoringLocation(options: IONGLOCRequestOptionsModel) { timeoutTriggered = false isMonitoringLocation = true + locationManager.headingFilter = 1.0 locationManager.startUpdatingLocation() + locationManager.startUpdatingHeading() self.startTimer(timeout: options.timeout) } @@ -71,12 +75,15 @@ public class IONGLOCManagerWrapper: NSObject, IONGLOCService { } isMonitoringLocation = true + locationManager.headingFilter = 1.0 locationManager.startUpdatingLocation() + locationManager.startUpdatingHeading() } public func stopMonitoringLocation() { isMonitoringLocation = false locationManager.stopUpdatingLocation() + locationManager.stopUpdatingHeading() } public func requestSingleLocation(options: IONGLOCRequestOptionsModel) { @@ -137,16 +144,30 @@ extension IONGLOCManagerWrapper: CLLocationManagerDelegate { timeoutCancellable?.cancel() timeoutCancellable = nil - guard let latestLocation = locations.last else { + guard let lastLocation = locations.last else { currentLocation = nil + self.lastLocation = nil + lastHeading = nil return } - currentLocation = IONGLOCPositionModel.create(from: latestLocation) + self.lastLocation = lastLocation + currentLocation = IONGLOCPositionModel.create(from: lastLocation, heading: lastHeading) } public func locationManager(_ manager: CLLocationManager, didFailWithError error: any Error) { timeoutCancellable?.cancel() timeoutCancellable = nil currentLocation = nil + lastLocation = nil + lastHeading = nil + } + + public func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) { + guard let lastLocation = self.lastLocation else { + return + } + + lastHeading = newHeading + currentLocation = IONGLOCPositionModel.create(from: lastLocation, heading: lastHeading) } } diff --git a/IONGeolocationLibTests/IONGLOCManagerWrapperTests.swift b/IONGeolocationLibTests/IONGLOCManagerWrapperTests.swift index 1cdee8e..57064f7 100644 --- a/IONGeolocationLibTests/IONGLOCManagerWrapperTests.swift +++ b/IONGeolocationLibTests/IONGLOCManagerWrapperTests.swift @@ -314,6 +314,158 @@ final class IONGLOCManagerWrapperTests: XCTestCase { // Then waitForExpectations(timeout: 1.0) } + + // MARK: - Heading Tests + + func test_startMonitoringLocation_startsUpdatingHeading() { + // Given + XCTAssertFalse(locationManager.didStartUpdatingHeading) + + // When + sut.startMonitoringLocation() + + // Then + XCTAssertTrue(locationManager.didStartUpdatingHeading) + } + + func test_startMonitoringLocationWithOptions_startsUpdatingHeading() { + // Given + XCTAssertFalse(locationManager.didStartUpdatingHeading) + + // When + let options = IONGLOCRequestOptionsModel(timeout: 1000) + sut.startMonitoringLocation(options: options) + + // Then + XCTAssertTrue(locationManager.didStartUpdatingHeading) + } + + func test_stopMonitoringLocation_stopsUpdatingHeading() { + // Given + sut.startMonitoringLocation() + XCTAssertTrue(locationManager.didStartUpdatingHeading) + + // When + sut.stopMonitoringLocation() + + // Then + XCTAssertFalse(locationManager.didStartUpdatingHeading) + } + + func test_locationUpdateWithHeading_includesHeadingInPositionModel() { + // Given + let expectedLocation = CLLocation(latitude: 37.7749, longitude: -122.4194) + let expectedHeading = createMockHeading(magneticHeading: 90.0, trueHeading: 92.0) + let expectedPosition = IONGLOCPositionModel.create(from: expectedLocation, heading: expectedHeading) + let expectation = expectation(description: "Location with heading updated.") + + sut.currentLocationPublisher + .dropFirst() + .sink(receiveCompletion: { _ in }, receiveValue: { position in + if position.magneticHeading == expectedPosition.magneticHeading && position.trueHeading == expectedPosition.trueHeading { + XCTAssertEqual(position, expectedPosition) + expectation.fulfill() + } + }) + .store(in: &cancellables) + + // When + locationManager.updateLocation(to: [expectedLocation]) + locationManager.updateHeading(to: expectedHeading) + + // Then + waitForExpectations(timeout: 1.0) + } + + func test_headingUpdateWithoutLocation_doesNotUpdatePositionModel() { + // Given + let heading = createMockHeading(magneticHeading: 180.0, trueHeading: 182.0) + var updateCount = 0 + let expectation = expectation(description: "No update should occur.") + expectation.isInverted = true + + sut.currentLocationPublisher + .sink(receiveCompletion: { _ in }, receiveValue: { _ in + updateCount += 1 + expectation.fulfill() + }) + .store(in: &cancellables) + + // When + locationManager.updateHeading(to: heading) + + // Then + waitForExpectations(timeout: 0.5) + XCTAssertEqual(updateCount, 0) + } + + func test_headingUpdateAfterLocationUpdate_updatesPositionModelWithNewHeading() { + // Given + let location = CLLocation(latitude: 37.7749, longitude: -122.4194) + let firstHeading = createMockHeading(magneticHeading: 90.0, trueHeading: 92.0) + let secondHeading = createMockHeading(magneticHeading: 180.0, trueHeading: 182.0) + let expectedPosition = IONGLOCPositionModel.create(from: location, heading: secondHeading) + let expectation = expectation(description: "Position updated with new heading.") + + locationManager.updateLocation(to: [location]) + locationManager.updateHeading(to: firstHeading) + + validateCurrentLocationPublisher(expectation, expectedPosition) + + // When + locationManager.updateHeading(to: secondHeading) + + // Then + waitForExpectations(timeout: 1.0) + } + + func test_locationUpdatePreservesExistingHeading() { + // Given + let firstLocation = CLLocation(latitude: 37.7749, longitude: -122.4194) + let secondLocation = CLLocation(latitude: 48.8859, longitude: -111.3083) + let heading = createMockHeading(magneticHeading: 270.0, trueHeading: 272.0) + let expectedPosition = IONGLOCPositionModel.create(from: secondLocation, heading: heading) + let expectation = expectation(description: "Location updated with preserved heading.") + + locationManager.updateLocation(to: [firstLocation]) + locationManager.updateHeading(to: heading) + + validateCurrentLocationPublisher(expectation, expectedPosition) + + // When + locationManager.updateLocation(to: [secondLocation]) + + // Then + waitForExpectations(timeout: 1.0) + } + + func test_headingFilterIsSetToOneDegree() { + // Given + XCTAssertEqual(locationManager.headingFilter, kCLHeadingFilterNone) + + // When + sut.startMonitoringLocation() + + // Then + XCTAssertEqual(locationManager.headingFilter, 1.0) + } + + func test_positionModelWithoutHeading_hasDefaultHeadingValues() { + // Given + let location = CLLocation(latitude: 37.7749, longitude: -122.4194) + let expectedPosition = IONGLOCPositionModel.create(from: location) + let expectation = expectation(description: "Location without heading updated.") + + validateCurrentLocationPublisher(expectation, expectedPosition) + + // When + locationManager.updateLocation(to: [location]) + + // Then + waitForExpectations(timeout: 1.0) + XCTAssertEqual(expectedPosition.magneticHeading, -1.0) + XCTAssertEqual(expectedPosition.trueHeading, -1.0) + } } private extension IONGLOCManagerWrapperTests { @@ -353,6 +505,13 @@ private extension IONGLOCManagerWrapperTests { } .store(in: &cancellables) } + + func createMockHeading(magneticHeading: Double, trueHeading: Double) -> CLHeading { + let heading = MockCLHeading() + heading.mockMagneticHeading = magneticHeading + heading.mockTrueHeading = trueHeading + return heading + } } private extension CLLocationManager { @@ -363,3 +522,16 @@ private extension CLLocationManager { private enum MockLocationUpdateError: Error { case locationUpdateFailed } + +private class MockCLHeading: CLHeading { + var mockMagneticHeading: Double = 0.0 + var mockTrueHeading: Double = 0.0 + + override var magneticHeading: Double { + mockMagneticHeading + } + + override var trueHeading: Double { + mockTrueHeading + } +} diff --git a/IONGeolocationLibTests/MockCLLocationManager.swift b/IONGeolocationLibTests/MockCLLocationManager.swift index 7cbf259..b217d36 100644 --- a/IONGeolocationLibTests/MockCLLocationManager.swift +++ b/IONGeolocationLibTests/MockCLLocationManager.swift @@ -5,12 +5,23 @@ class MockCLLocationManager: CLLocationManager { private(set) var didCallRequestLocation = false private(set) var didCallRequestWhenInUseAuthorization = false private(set) var didStartUpdatingLocation = false + private(set) var didStartUpdatingHeading = false private(set) var mockAuthorizationStatus: CLAuthorizationStatus = .notDetermined + private(set) var mockHeadingFilter: CLLocationDegrees = kCLHeadingFilterNone override var authorizationStatus: CLAuthorizationStatus { mockAuthorizationStatus } + override var headingFilter: CLLocationDegrees { + get { + mockHeadingFilter + } + set { + mockHeadingFilter = newValue + } + } + override func startUpdatingLocation() { didStartUpdatingLocation = true } @@ -19,6 +30,14 @@ class MockCLLocationManager: CLLocationManager { didStartUpdatingLocation = false } + override func startUpdatingHeading() { + didStartUpdatingHeading = true + } + + override func stopUpdatingHeading() { + didStartUpdatingHeading = false + } + override func requestLocation() { didCallRequestLocation = true } @@ -40,6 +59,10 @@ class MockCLLocationManager: CLLocationManager { delegate?.locationManager?(self, didUpdateLocations: locations) } + func updateHeading(to heading: CLHeading) { + delegate?.locationManager?(self, didUpdateHeading: heading) + } + func failWhileUpdatingLocation(_ error: Error) { delegate?.locationManager?(self, didFailWithError: error) } From 3fecbd75d425cc0dae1e6151483b7a29b1a563bb Mon Sep 17 00:00:00 2001 From: Chris Lloyd Date: Wed, 12 Nov 2025 17:30:34 +0000 Subject: [PATCH 2/8] Doc, heading accuracy --- IONGeolocationLib/IONGLOCPositionModel.swift | 7 +++++-- .../IONGLOCManagerWrapperTests.swift | 19 +++++++++++++------ README.md | 11 +++++++++-- 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/IONGeolocationLib/IONGLOCPositionModel.swift b/IONGeolocationLib/IONGLOCPositionModel.swift index 7c30a8d..9a855ea 100644 --- a/IONGeolocationLib/IONGLOCPositionModel.swift +++ b/IONGeolocationLib/IONGLOCPositionModel.swift @@ -11,8 +11,9 @@ public struct IONGLOCPositionModel: Equatable { private(set) public var verticalAccuracy: Double private(set) public var magneticHeading: Double private(set) public var trueHeading: Double + private(set) public var headingAccuracy: Double - private init(altitude: Double, course: Double, horizontalAccuracy: Double, latitude: Double, longitude: Double, speed: Double, timestamp: Double, verticalAccuracy: Double, magneticHeading: Double, trueHeading: Double) { + private init(altitude: Double, course: Double, horizontalAccuracy: Double, latitude: Double, longitude: Double, speed: Double, timestamp: Double, verticalAccuracy: Double, magneticHeading: Double, trueHeading: Double, headingAccuracy: Double) { self.altitude = altitude self.course = course self.horizontalAccuracy = horizontalAccuracy @@ -23,6 +24,7 @@ public struct IONGLOCPositionModel: Equatable { self.verticalAccuracy = verticalAccuracy self.magneticHeading = magneticHeading self.trueHeading = trueHeading + self.headingAccuracy = headingAccuracy } } @@ -38,7 +40,8 @@ public extension IONGLOCPositionModel { timestamp: location.timestamp.millisecondsSinceUnixEpoch, verticalAccuracy: location.verticalAccuracy, magneticHeading: heading?.magneticHeading ?? -1.0, - trueHeading: heading?.trueHeading ?? -1.0 + trueHeading: heading?.trueHeading ?? -1.0, + headingAccuracy: heading?.headingAccuracy ?? -1.0 ) } } diff --git a/IONGeolocationLibTests/IONGLOCManagerWrapperTests.swift b/IONGeolocationLibTests/IONGLOCManagerWrapperTests.swift index 57064f7..943f9cf 100644 --- a/IONGeolocationLibTests/IONGLOCManagerWrapperTests.swift +++ b/IONGeolocationLibTests/IONGLOCManagerWrapperTests.swift @@ -355,7 +355,7 @@ final class IONGLOCManagerWrapperTests: XCTestCase { func test_locationUpdateWithHeading_includesHeadingInPositionModel() { // Given let expectedLocation = CLLocation(latitude: 37.7749, longitude: -122.4194) - let expectedHeading = createMockHeading(magneticHeading: 90.0, trueHeading: 92.0) + let expectedHeading = createMockHeading(magneticHeading: 90.0, trueHeading: 92.0, headingAccuracy: 1.0) let expectedPosition = IONGLOCPositionModel.create(from: expectedLocation, heading: expectedHeading) let expectation = expectation(description: "Location with heading updated.") @@ -379,7 +379,7 @@ final class IONGLOCManagerWrapperTests: XCTestCase { func test_headingUpdateWithoutLocation_doesNotUpdatePositionModel() { // Given - let heading = createMockHeading(magneticHeading: 180.0, trueHeading: 182.0) + let heading = createMockHeading(magneticHeading: 180.0, trueHeading: 182.0, headingAccuracy: 1.0) var updateCount = 0 let expectation = expectation(description: "No update should occur.") expectation.isInverted = true @@ -402,8 +402,8 @@ final class IONGLOCManagerWrapperTests: XCTestCase { func test_headingUpdateAfterLocationUpdate_updatesPositionModelWithNewHeading() { // Given let location = CLLocation(latitude: 37.7749, longitude: -122.4194) - let firstHeading = createMockHeading(magneticHeading: 90.0, trueHeading: 92.0) - let secondHeading = createMockHeading(magneticHeading: 180.0, trueHeading: 182.0) + let firstHeading = createMockHeading(magneticHeading: 90.0, trueHeading: 92.0, headingAccuracy: 1.0) + let secondHeading = createMockHeading(magneticHeading: 180.0, trueHeading: 182.0, headingAccuracy: 1.0) let expectedPosition = IONGLOCPositionModel.create(from: location, heading: secondHeading) let expectation = expectation(description: "Position updated with new heading.") @@ -423,7 +423,7 @@ final class IONGLOCManagerWrapperTests: XCTestCase { // Given let firstLocation = CLLocation(latitude: 37.7749, longitude: -122.4194) let secondLocation = CLLocation(latitude: 48.8859, longitude: -111.3083) - let heading = createMockHeading(magneticHeading: 270.0, trueHeading: 272.0) + let heading = createMockHeading(magneticHeading: 270.0, trueHeading: 272.0, headingAccuracy: 1.0) let expectedPosition = IONGLOCPositionModel.create(from: secondLocation, heading: heading) let expectation = expectation(description: "Location updated with preserved heading.") @@ -465,6 +465,7 @@ final class IONGLOCManagerWrapperTests: XCTestCase { waitForExpectations(timeout: 1.0) XCTAssertEqual(expectedPosition.magneticHeading, -1.0) XCTAssertEqual(expectedPosition.trueHeading, -1.0) + XCTAssertEqual(expectedPosition.headingAccuracy, -1.0) } } @@ -506,10 +507,11 @@ private extension IONGLOCManagerWrapperTests { .store(in: &cancellables) } - func createMockHeading(magneticHeading: Double, trueHeading: Double) -> CLHeading { + func createMockHeading(magneticHeading: Double, trueHeading: Double, headingAccuracy: Double) -> CLHeading { let heading = MockCLHeading() heading.mockMagneticHeading = magneticHeading heading.mockTrueHeading = trueHeading + heading.mockHeadingAccuracy = headingAccuracy return heading } } @@ -526,6 +528,7 @@ private enum MockLocationUpdateError: Error { private class MockCLHeading: CLHeading { var mockMagneticHeading: Double = 0.0 var mockTrueHeading: Double = 0.0 + var mockHeadingAccuracy: Double = 0.0 override var magneticHeading: Double { mockMagneticHeading @@ -534,4 +537,8 @@ private class MockCLHeading: CLHeading { override var trueHeading: Double { mockTrueHeading } + + override var headingAccuracy: Double { + mockHeadingAccuracy + } } diff --git a/README.md b/README.md index 17ea9d0..a7f735c 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,10 @@ It returns the device's latest fetched location position. It can be `nil` if the - `speed`: Instantaneous speed of the device, measured in meters per second (m/s); - `timestamp`: Time at which this location was determined, measured in milliseconds (ms) elapsed since the UNIX epoch (Jan 1, 1970); - `verticalAccuracy`: Validity of the altitude values and their estimated uncertainty, measured in meters (m). +- `magneticHeading`: The heading (measured in degrees) relative to magnetic north. +- `trueHeading`: The heading (measured in degrees) relative to true north. +- `headingAccuracy`: The maximum deviation (measured in degrees) between the reported heading and the true geomagnetic heading. + #### Current Location Publisher @@ -231,7 +235,10 @@ Location updates are delivered as `IONGLOCPositionModel` objects: "verticalAccuracy": 10.0, "course": 180.0, "speed": 0.0, - "timestamp": 1641034800000 + "timestamp": 1641034800000, + "magneticHeading": 5.0, + "trueHeading": 5.0, + "headingAccuracy": 0.0 } ``` @@ -285,4 +292,4 @@ Common issues and solutions: ## Support -- Report issues on our [Issue Tracker](https://github.com/ionic-team/ion-ios-geolocation/issues) \ No newline at end of file +- Report issues on our [Issue Tracker](https://github.com/ionic-team/ion-ios-geolocation/issues) From eb9b141e67662ae3bc40266ba8c201a804d2c9aa Mon Sep 17 00:00:00 2001 From: Chace Daniels Date: Mon, 2 Feb 2026 10:27:01 -0600 Subject: [PATCH 3/8] fix getCurrentPosition and integrate CLHeading into location publishers --- CHANGELOG.md | 6 ++++++ IONGeolocationLib.podspec | 2 +- .../Publishers/IONGLOCManagerWrapper.swift | 11 +++++++++++ README.md | 2 +- 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6b547e..50026e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 2.1.0 + +### Additions +- Added `magneticHeading`, `trueHeading`, and `headingAccuracy` to `IONGLOCPositionModel`. +- Added support for compass heading in both `requestSingleLocation` and monitoring flows using `CLHeading`. + ## 2.0.0 ### Breaking Changes diff --git a/IONGeolocationLib.podspec b/IONGeolocationLib.podspec index d45dbd7..f15607f 100644 --- a/IONGeolocationLib.podspec +++ b/IONGeolocationLib.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = 'IONGeolocationLib' - spec.version = '2.0.0' + spec.version = '2.1.0' spec.summary = 'A native iOS library for Geolocation authorisation and monitoring.' spec.description = 'A Swift library for iOS that provides simple, reliable access to device GPS capabilities. Get location data, monitor position changes, and manage location services with a clean, modern API.' diff --git a/IONGeolocationLib/Publishers/IONGLOCManagerWrapper.swift b/IONGeolocationLib/Publishers/IONGLOCManagerWrapper.swift index c1720f1..5e9fd14 100644 --- a/IONGeolocationLib/Publishers/IONGLOCManagerWrapper.swift +++ b/IONGeolocationLib/Publishers/IONGLOCManagerWrapper.swift @@ -96,6 +96,8 @@ public class IONGLOCManagerWrapper: NSObject, IONGLOCService { return } + locationManager.headingFilter = 1.0 + locationManager.startUpdatingHeading() self.locationManager.requestLocation() self.startTimer(timeout: options.timeout) } @@ -150,6 +152,10 @@ extension IONGLOCManagerWrapper: CLLocationManagerDelegate { lastHeading = nil return } + if !isMonitoringLocation { + locationManager.stopUpdatingHeading() + } + self.lastLocation = lastLocation currentLocation = IONGLOCPositionModel.create(from: lastLocation, heading: lastHeading) } @@ -157,6 +163,11 @@ extension IONGLOCManagerWrapper: CLLocationManagerDelegate { public func locationManager(_ manager: CLLocationManager, didFailWithError error: any Error) { timeoutCancellable?.cancel() timeoutCancellable = nil + + if !isMonitoringLocation { + locationManager.stopUpdatingHeading() + } + currentLocation = nil lastLocation = nil lastHeading = nil diff --git a/README.md b/README.md index a7f735c..e3c4420 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ A Swift library for iOS that provides simple, reliable access to device GPS capa `ion-ios-geolocation` is available through [CocoaPods](https://cocoapods.org). Add this to your Podfile: ```ruby -pod 'IONGeolocationLib', '~> 2.0.0' # Use the latest 2.0.x version +pod 'IONGeolocationLib', '~> 2.2.0' # Use the latest 2.2.x version ``` ## Quick Start From a86ea62954912507e41c2e306dde92963b8e4098 Mon Sep 17 00:00:00 2001 From: Chace Daniels Date: Wed, 4 Feb 2026 09:50:14 -0600 Subject: [PATCH 4/8] remove heading from getCurrentPosition --- CHANGELOG.md | 2 +- IONGeolocationLib/Publishers/IONGLOCManagerWrapper.swift | 9 --------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50026e0..53a7429 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Additions - Added `magneticHeading`, `trueHeading`, and `headingAccuracy` to `IONGLOCPositionModel`. -- Added support for compass heading in both `requestSingleLocation` and monitoring flows using `CLHeading`. +- Added support for compass heading in location monitoring flows using `CLHeading`. ## 2.0.0 diff --git a/IONGeolocationLib/Publishers/IONGLOCManagerWrapper.swift b/IONGeolocationLib/Publishers/IONGLOCManagerWrapper.swift index 5e9fd14..01e97ad 100644 --- a/IONGeolocationLib/Publishers/IONGLOCManagerWrapper.swift +++ b/IONGeolocationLib/Publishers/IONGLOCManagerWrapper.swift @@ -96,8 +96,6 @@ public class IONGLOCManagerWrapper: NSObject, IONGLOCService { return } - locationManager.headingFilter = 1.0 - locationManager.startUpdatingHeading() self.locationManager.requestLocation() self.startTimer(timeout: options.timeout) } @@ -152,9 +150,6 @@ extension IONGLOCManagerWrapper: CLLocationManagerDelegate { lastHeading = nil return } - if !isMonitoringLocation { - locationManager.stopUpdatingHeading() - } self.lastLocation = lastLocation currentLocation = IONGLOCPositionModel.create(from: lastLocation, heading: lastHeading) @@ -164,10 +159,6 @@ extension IONGLOCManagerWrapper: CLLocationManagerDelegate { timeoutCancellable?.cancel() timeoutCancellable = nil - if !isMonitoringLocation { - locationManager.stopUpdatingHeading() - } - currentLocation = nil lastLocation = nil lastHeading = nil From bd5f41f4f34341bc8d72ea9ce100832fc632cf99 Mon Sep 17 00:00:00 2001 From: Chace Daniels Date: Thu, 12 Feb 2026 09:41:34 -0600 Subject: [PATCH 5/8] optional heading values --- CHANGELOG.md | 12 ---------- IONGeolocationLib/IONGLOCPositionModel.swift | 24 ++++++++++++++------ README.md | 2 +- 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5283e41..1198ca4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,3 @@ -# Changelog -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## 2.1.0 - -### Additions -- Added `magneticHeading`, `trueHeading`, and `headingAccuracy` to `IONGLOCPositionModel`. -- Added support for compass heading in location monitoring flows using `CLHeading`. - ## 2.0.0 ### Breaking Changes diff --git a/IONGeolocationLib/IONGLOCPositionModel.swift b/IONGeolocationLib/IONGLOCPositionModel.swift index 9a855ea..200fd12 100644 --- a/IONGeolocationLib/IONGLOCPositionModel.swift +++ b/IONGeolocationLib/IONGLOCPositionModel.swift @@ -9,11 +9,11 @@ public struct IONGLOCPositionModel: Equatable { private(set) public var speed: Double private(set) public var timestamp: Double private(set) public var verticalAccuracy: Double - private(set) public var magneticHeading: Double - private(set) public var trueHeading: Double - private(set) public var headingAccuracy: Double + private(set) public var magneticHeading: Double? + private(set) public var trueHeading: Double? + private(set) public var headingAccuracy: Double? - private init(altitude: Double, course: Double, horizontalAccuracy: Double, latitude: Double, longitude: Double, speed: Double, timestamp: Double, verticalAccuracy: Double, magneticHeading: Double, trueHeading: Double, headingAccuracy: Double) { + private init(altitude: Double, course: Double, horizontalAccuracy: Double, latitude: Double, longitude: Double, speed: Double, timestamp: Double, verticalAccuracy: Double, magneticHeading: Double?, trueHeading: Double?, headingAccuracy: Double?) { self.altitude = altitude self.course = course self.horizontalAccuracy = horizontalAccuracy @@ -30,6 +30,16 @@ public struct IONGLOCPositionModel: Equatable { public extension IONGLOCPositionModel { static func create(from location: CLLocation, heading: CLHeading? = nil) -> IONGLOCPositionModel { + var mHeading: Double? = nil + var tHeading: Double? = nil + var hAccuracy: Double? = nil + + if let heading = heading { + if heading.magneticHeading >= 0 { mHeading = heading.magneticHeading } + if heading.trueHeading >= 0 { tHeading = heading.trueHeading } + if heading.headingAccuracy >= 0 { hAccuracy = heading.headingAccuracy } + } + return .init( altitude: location.altitude, course: location.course, @@ -39,9 +49,9 @@ public extension IONGLOCPositionModel { speed: location.speed, timestamp: location.timestamp.millisecondsSinceUnixEpoch, verticalAccuracy: location.verticalAccuracy, - magneticHeading: heading?.magneticHeading ?? -1.0, - trueHeading: heading?.trueHeading ?? -1.0, - headingAccuracy: heading?.headingAccuracy ?? -1.0 + magneticHeading: mHeading, + trueHeading: tHeading, + headingAccuracy: hAccuracy ) } } diff --git a/README.md b/README.md index cb9e3f3..9afed9b 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ A Swift library for iOS that provides simple, reliable access to device GPS capa `ion-ios-geolocation` is available through [CocoaPods](https://cocoapods.org). Add this to your Podfile: ```ruby -pod 'IONGeolocationLib', '~> 2.1.0' # Use the latest 2.2.x version +pod 'IONGeolocationLib', '~> 2.0.0' ``` ## Quick Start From 5aa81b0719a7ef25929223f0253a8183c7fff41c Mon Sep 17 00:00:00 2001 From: Chace Daniels Date: Fri, 20 Feb 2026 08:09:39 -0600 Subject: [PATCH 6/8] dont send heading to getCurrentPosition --- IONGeolocationLib/Publishers/IONGLOCManagerWrapper.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/IONGeolocationLib/Publishers/IONGLOCManagerWrapper.swift b/IONGeolocationLib/Publishers/IONGLOCManagerWrapper.swift index 01e97ad..2059be3 100644 --- a/IONGeolocationLib/Publishers/IONGLOCManagerWrapper.swift +++ b/IONGeolocationLib/Publishers/IONGLOCManagerWrapper.swift @@ -54,6 +54,7 @@ public class IONGLOCManagerWrapper: NSObject, IONGLOCService { super.init() locationManager.delegate = self + locationManager.headingFilter = 1.0 } public func requestAuthorisation(withType authorisationType: IONGLOCAuthorisationRequestType) { @@ -63,7 +64,6 @@ public class IONGLOCManagerWrapper: NSObject, IONGLOCService { public func startMonitoringLocation(options: IONGLOCRequestOptionsModel) { timeoutTriggered = false isMonitoringLocation = true - locationManager.headingFilter = 1.0 locationManager.startUpdatingLocation() locationManager.startUpdatingHeading() self.startTimer(timeout: options.timeout) @@ -75,7 +75,6 @@ public class IONGLOCManagerWrapper: NSObject, IONGLOCService { } isMonitoringLocation = true - locationManager.headingFilter = 1.0 locationManager.startUpdatingLocation() locationManager.startUpdatingHeading() } @@ -152,7 +151,8 @@ extension IONGLOCManagerWrapper: CLLocationManagerDelegate { } self.lastLocation = lastLocation - currentLocation = IONGLOCPositionModel.create(from: lastLocation, heading: lastHeading) + let currentHeading = isMonitoringLocation ? lastHeading : nil + currentLocation = IONGLOCPositionModel.create(from: lastLocation, heading: currentHeading) } public func locationManager(_ manager: CLLocationManager, didFailWithError error: any Error) { From 94a4231ce41e45b17e8f6eddab40e629319446fb Mon Sep 17 00:00:00 2001 From: Chace Daniels Date: Wed, 25 Feb 2026 08:44:22 -0600 Subject: [PATCH 7/8] update heading tests and cache last heading --- .../Publishers/IONGLOCManagerWrapper.swift | 6 +-- .../IONGLOCManagerWrapperTests.swift | 50 +++---------------- 2 files changed, 8 insertions(+), 48 deletions(-) diff --git a/IONGeolocationLib/Publishers/IONGLOCManagerWrapper.swift b/IONGeolocationLib/Publishers/IONGLOCManagerWrapper.swift index 2059be3..e11a836 100644 --- a/IONGeolocationLib/Publishers/IONGLOCManagerWrapper.swift +++ b/IONGeolocationLib/Publishers/IONGLOCManagerWrapper.swift @@ -165,11 +165,7 @@ extension IONGLOCManagerWrapper: CLLocationManagerDelegate { } public func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) { - guard let lastLocation = self.lastLocation else { - return - } - + guard self.lastLocation != nil else { return } lastHeading = newHeading - currentLocation = IONGLOCPositionModel.create(from: lastLocation, heading: lastHeading) } } diff --git a/IONGeolocationLibTests/IONGLOCManagerWrapperTests.swift b/IONGeolocationLibTests/IONGLOCManagerWrapperTests.swift index 943f9cf..0ef62ca 100644 --- a/IONGeolocationLibTests/IONGLOCManagerWrapperTests.swift +++ b/IONGeolocationLibTests/IONGLOCManagerWrapperTests.swift @@ -354,13 +354,14 @@ final class IONGLOCManagerWrapperTests: XCTestCase { func test_locationUpdateWithHeading_includesHeadingInPositionModel() { // Given + sut.startMonitoringLocation() + let expectedLocation = CLLocation(latitude: 37.7749, longitude: -122.4194) let expectedHeading = createMockHeading(magneticHeading: 90.0, trueHeading: 92.0, headingAccuracy: 1.0) let expectedPosition = IONGLOCPositionModel.create(from: expectedLocation, heading: expectedHeading) let expectation = expectation(description: "Location with heading updated.") sut.currentLocationPublisher - .dropFirst() .sink(receiveCompletion: { _ in }, receiveValue: { position in if position.magneticHeading == expectedPosition.magneticHeading && position.trueHeading == expectedPosition.trueHeading { XCTAssertEqual(position, expectedPosition) @@ -370,8 +371,9 @@ final class IONGLOCManagerWrapperTests: XCTestCase { .store(in: &cancellables) // When - locationManager.updateLocation(to: [expectedLocation]) + locationManager.updateLocation(to: [CLLocation(latitude: 0, longitude: 0)]) locationManager.updateHeading(to: expectedHeading) + locationManager.updateLocation(to: [expectedLocation]) // Then waitForExpectations(timeout: 1.0) @@ -399,45 +401,7 @@ final class IONGLOCManagerWrapperTests: XCTestCase { XCTAssertEqual(updateCount, 0) } - func test_headingUpdateAfterLocationUpdate_updatesPositionModelWithNewHeading() { - // Given - let location = CLLocation(latitude: 37.7749, longitude: -122.4194) - let firstHeading = createMockHeading(magneticHeading: 90.0, trueHeading: 92.0, headingAccuracy: 1.0) - let secondHeading = createMockHeading(magneticHeading: 180.0, trueHeading: 182.0, headingAccuracy: 1.0) - let expectedPosition = IONGLOCPositionModel.create(from: location, heading: secondHeading) - let expectation = expectation(description: "Position updated with new heading.") - - locationManager.updateLocation(to: [location]) - locationManager.updateHeading(to: firstHeading) - validateCurrentLocationPublisher(expectation, expectedPosition) - - // When - locationManager.updateHeading(to: secondHeading) - - // Then - waitForExpectations(timeout: 1.0) - } - - func test_locationUpdatePreservesExistingHeading() { - // Given - let firstLocation = CLLocation(latitude: 37.7749, longitude: -122.4194) - let secondLocation = CLLocation(latitude: 48.8859, longitude: -111.3083) - let heading = createMockHeading(magneticHeading: 270.0, trueHeading: 272.0, headingAccuracy: 1.0) - let expectedPosition = IONGLOCPositionModel.create(from: secondLocation, heading: heading) - let expectation = expectation(description: "Location updated with preserved heading.") - - locationManager.updateLocation(to: [firstLocation]) - locationManager.updateHeading(to: heading) - - validateCurrentLocationPublisher(expectation, expectedPosition) - - // When - locationManager.updateLocation(to: [secondLocation]) - - // Then - waitForExpectations(timeout: 1.0) - } func test_headingFilterIsSetToOneDegree() { // Given @@ -463,9 +427,9 @@ final class IONGLOCManagerWrapperTests: XCTestCase { // Then waitForExpectations(timeout: 1.0) - XCTAssertEqual(expectedPosition.magneticHeading, -1.0) - XCTAssertEqual(expectedPosition.trueHeading, -1.0) - XCTAssertEqual(expectedPosition.headingAccuracy, -1.0) + XCTAssertNil(expectedPosition.magneticHeading) + XCTAssertNil(expectedPosition.trueHeading) + XCTAssertNil(expectedPosition.headingAccuracy) } } From 7a5197452b79d4be91cdfa2a697a53d7992b077d Mon Sep 17 00:00:00 2001 From: Chace Daniels Date: Wed, 25 Feb 2026 08:56:50 -0600 Subject: [PATCH 8/8] fix unit tests on iphone 17 sim --- IONGeolocationLibTests/IONGLOCManagerWrapperTests.swift | 6 ------ 1 file changed, 6 deletions(-) diff --git a/IONGeolocationLibTests/IONGLOCManagerWrapperTests.swift b/IONGeolocationLibTests/IONGLOCManagerWrapperTests.swift index 0ef62ca..0560488 100644 --- a/IONGeolocationLibTests/IONGLOCManagerWrapperTests.swift +++ b/IONGeolocationLibTests/IONGLOCManagerWrapperTests.swift @@ -404,12 +404,6 @@ final class IONGLOCManagerWrapperTests: XCTestCase { func test_headingFilterIsSetToOneDegree() { - // Given - XCTAssertEqual(locationManager.headingFilter, kCLHeadingFilterNone) - - // When - sut.startMonitoringLocation() - // Then XCTAssertEqual(locationManager.headingFilter, 1.0) }