├── .gitignore ├── .spi.yml ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── d3-async-location │ ├── LocationManagerAsync+ │ ├── Delegate.swift │ └── Permission.swift │ ├── enum │ ├── AsyncLocationErrors.swift │ └── LocationStreamingState.swift │ ├── helper │ ├── AsyncFIFOQueue.swift │ ├── LocationManager.swift │ └── Strategies.swift │ ├── protocol │ ├── ILocationDelegate.swift │ ├── ILocationManager.swift │ ├── ILocationResultStrategy.swift │ └── ILocationStreamer.swift │ └── service │ ├── LocationStreamer.swift │ └── ObservableLocationStreamer.swift ├── Tests └── d3-async-locationTests │ └── d3_async_locationTests.swift └── img ├── apple_watch_swiftui.gif ├── image11.gif ├── image2.png └── image6.png /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [swift-async-corelocation-streamer] 5 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Igor Shelopaev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.6 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "d3-async-location", 8 | platforms: [ 9 | .iOS(.v14), .watchOS(.v7), 10 | ], 11 | products: [ 12 | // Products define the executables and libraries a package produces, and make them visible to other packages. 13 | .library( 14 | name: "d3-async-location", 15 | targets: ["d3-async-location"]), 16 | ], 17 | dependencies: [ 18 | // Dependencies declare other packages that this package depends on. 19 | // .package(url: /* package url */, from: "1.0.0"), 20 | ], 21 | targets: [ 22 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 23 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 24 | .target( 25 | name: "d3-async-location", 26 | dependencies: []), 27 | .testTarget( 28 | name: "d3-async-locationTests", 29 | dependencies: ["d3-async-location"]), 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Async location streamer using new concurrency 2 | 3 | ### Please star the repository if you believe continuing the development of this package is worthwhile. This will help me understand which package deserves more effort. 4 | 5 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fswiftuiux%2Fswift-async-corelocation-streamer%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/swiftuiux/swift-async-corelocation-streamer) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fswiftuiux%2Fswift-async-corelocation-streamer%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/swiftuiux/swift-async-corelocation-streamer) 6 | 7 | This package uses Core Location under the hood to harness the power of GPS for accurate and efficient location tracking. By leveraging Swift’s async/await concurrency model, it provides a modern, clean, and scalable way to stream GPS data asynchronously. Core Location works with GPS, Wi-Fi, and cellular networks to determine a device’s position, ensuring both high accuracy and optimal performance. 8 | 9 | ## [SwiftUI example](https://github.com/swiftuiux/corelocation-manager-tracker-swift-apple-maps-example) 10 | 11 | Available for watchOS 12 | 13 | ![simulate locations](https://github.com/swiftuiux/swift-async-corelocation-streamer/blob/main/img/apple_watch_swiftui.gif) 14 | 15 | 16 | 17 | if you are using the simulator don't forget to simulate locations 18 | 19 | ![simulate locations](https://github.com/swiftuiux/swift-async-corelocation-streamer/blob/main/img/image11.gif) 20 | 21 | ## Features 22 | - [x] Using new concurrency swift model around CoreLocation manager 23 | - [x] Extend API to allow customization of `CLLocationManager` 24 | - [x] Support for iOS from 14.1 and watchOS from 7.0 25 | - [x] Seamless SwiftUI Integration Uses `@Published` properties for real-time UI updates or @observable is you can afford iOS17 or newer. 26 | - [x] Streaming current location asynchronously 27 | - [x] Different strategies - Keep and publish all stack of locations since streaming has started or the last one 28 | - [x] Errors handling (as **AsyncLocationErrors** so CoreLocation errors **CLError**) 29 | 30 | 31 | ## How to use 32 | 33 | ### 1. Add to info the option "Privacy - Location When In Use Usage Description" 34 | ![Add to info](https://github.com/swiftuiux/swift-async-corelocation-streamer/blob/main/img/image2.png) 35 | 36 | **Background Updates** 37 | - Ensure the app has `location` included in `UIBackgroundModes` for background updates to function. 38 | 39 | ### 2. Add or inject LocationStreamer into a View 40 | 41 | ``` 42 | @StateObject var service: LocationStreamer 43 | ``` 44 | For iOS 17+ and watchOS 10+, using @State macro: 45 | ``` 46 | @State var service: ObservableLocationStreamer 47 | ``` 48 | 49 | ### 3. Call LocationStreamer method start() within async environment or check SwiftUI example 50 | ``` 51 | try await service.start() 52 | ``` 53 | 54 | ### LocationStreamer parameters 55 | 56 | |Param|Description| 57 | | --- | --- | 58 | |strategy| Strategy for publishing locations. Default value is **KeepLastStrategy**. Another predefined option is **KeepAllStrategy**, or you can implement and test your own custom strategy by conforming to the `LocationResultStrategy` protocol. | 59 | |accuracy| The accuracy of a geographical coordinate.| 60 | |activityType| Constants indicating the type of activity associated with location updates.| 61 | |distanceFilter| A distance in meters from an existing location to trigger updates.| 62 | |backgroundUpdates| A Boolean value that indicates whether the app receives location updates when running in the background. | 63 | 64 | or 65 | 66 | |Param|Description| 67 | | --- | --- | 68 | |strategy| Strategy for publishing locations. Default value is **KeepLastStrategy**. Another predefined option is **KeepAllStrategy**, or you can implement and test your own custom strategy by conforming to the `LocationResultStrategy` protocol. | 69 | |locationManager| A pre-configured `CLLocationManager`. | 70 | 71 | 72 | ### Default location 73 | 1. Product > Scheme > Edit Scheme 74 | 2. Click Run .app 75 | 3. Option tab 76 | 4. Already checked Core Location > select your location 77 | 5. Press OK 78 | 79 | ![Default location](https://github.com/swiftuiux/swift-async-corelocation-streamer/blob/main/img/image6.png) 80 | 81 | 82 | ## Documentation(API) 83 | - You need to have Xcode 13 installed in order to have access to Documentation Compiler (DocC) 84 | - Go to Product > Build Documentation or **⌃⇧⌘ D** 85 | -------------------------------------------------------------------------------- /Sources/d3-async-location/LocationManagerAsync+/Delegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Delegate.swift 3 | // 4 | // 5 | // Created by Igor on 07.02.2023. 6 | // 7 | 8 | import CoreLocation 9 | 10 | extension LocationManager { 11 | 12 | /// Delegate class that implements `CLLocationManagerDelegate` methods to receive location updates 13 | /// and errors from `CLLocationManager`, and forwards them into an `AsyncFIFOQueue` for asynchronous consumption. 14 | @available(iOS 14.0, watchOS 7.0, *) 15 | final class Delegate: NSObject, ILocationDelegate { 16 | 17 | typealias DelegateOutput = LocationStreamer.Output 18 | 19 | /// The `CLLocationManager` instance used to obtain location updates. 20 | private let manager: CLLocationManager 21 | 22 | /// The FIFO queue used to emit location updates or errors as an asynchronous stream. 23 | private let fifoQueue: AsyncFIFOQueue 24 | 25 | // MARK: - Lifecycle 26 | 27 | /// Initializes the delegate with specified location settings. 28 | /// - Parameters: 29 | /// - accuracy: The desired accuracy of the location data. 30 | /// - activityType: The type of user activity associated with the location updates. 31 | /// - distanceFilter: The minimum distance (in meters) the device must move before an update event is generated. 32 | /// - backgroundUpdates: A Boolean indicating whether the app should receive location updates when suspended. 33 | public init( 34 | _ accuracy: CLLocationAccuracy?, 35 | _ activityType: CLActivityType?, 36 | _ distanceFilter: CLLocationDistance?, 37 | _ backgroundUpdates: Bool = false 38 | ) { 39 | self.manager = CLLocationManager() 40 | self.fifoQueue = AsyncFIFOQueue() 41 | super.init() 42 | manager.delegate = self 43 | updateSettings(accuracy, activityType, distanceFilter, backgroundUpdates) 44 | } 45 | 46 | /// Initializes the delegate with a given `CLLocationManager` instance. 47 | /// - Parameter locationManager: The `CLLocationManager` instance to manage location updates. 48 | public init(locationManager: CLLocationManager) { 49 | self.manager = locationManager 50 | self.fifoQueue = AsyncFIFOQueue() 51 | super.init() 52 | manager.delegate = self 53 | } 54 | 55 | deinit { 56 | finish() 57 | manager.delegate = nil 58 | #if DEBUG 59 | print("deinit delegate") 60 | #endif 61 | } 62 | 63 | // MARK: - API 64 | 65 | /// Starts location streaming. 66 | /// - Returns: An async stream of location outputs. 67 | public func start() -> AsyncStream { 68 | // Initialize the stream when needed. 69 | let stream = fifoQueue.initializeQueue() 70 | 71 | manager.startUpdatingLocation() 72 | return stream 73 | } 74 | 75 | /// Stops location updates and finishes the asynchronous FIFO queue. 76 | public func finish() { 77 | fifoQueue.finish() 78 | manager.stopUpdatingLocation() 79 | } 80 | 81 | /// Requests location permissions if not already granted. 82 | /// - Throws: An error if the permission is not granted. 83 | public func permission() async throws { 84 | let permission = Permission(with: manager.authorizationStatus) 85 | try await permission.grant(for: manager) 86 | } 87 | 88 | // MARK: - Delegate Methods 89 | 90 | public func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { 91 | fifoQueue.enqueue(.success(locations)) 92 | } 93 | 94 | public func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { 95 | let cleError = error as? CLError ?? CLError(.locationUnknown) 96 | fifoQueue.enqueue(.failure(cleError)) 97 | } 98 | 99 | public func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { 100 | NotificationCenter.default.post(name: Permission.authorizationStatus, object: manager.authorizationStatus) 101 | } 102 | 103 | // MARK: - Private Methods 104 | 105 | private func updateSettings( 106 | _ accuracy: CLLocationAccuracy?, 107 | _ activityType: CLActivityType?, 108 | _ distanceFilter: CLLocationDistance?, 109 | _ backgroundUpdates: Bool = false 110 | ) { 111 | manager.desiredAccuracy = accuracy ?? kCLLocationAccuracyBest 112 | manager.activityType = activityType ?? .other 113 | manager.distanceFilter = distanceFilter ?? kCLDistanceFilterNone 114 | #if os(iOS) || os(watchOS) 115 | manager.allowsBackgroundLocationUpdates = backgroundUpdates 116 | #endif 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /Sources/d3-async-location/LocationManagerAsync+/Permission.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Permission.swift 3 | // 4 | // 5 | // Created by Igor on 06.02.2023. 6 | // 7 | 8 | import CoreLocation 9 | import Combine 10 | import SwiftUI 11 | 12 | extension LocationManager{ 13 | 14 | /// Helper class to determine permission to get access for streaming ``CLLocation`` 15 | @available(iOS 14.0, watchOS 7.0, *) 16 | final class Permission{ 17 | 18 | /// Name of notification for event location manager changed authorization status 19 | static public let authorizationStatus = Notification.Name("authorizationStatus") 20 | 21 | // MARK: - Private properties 22 | 23 | /// The current authorization status for the app 24 | private var status : CLAuthorizationStatus 25 | 26 | /// Continuation to get permission if status is not defined 27 | private var flow : CheckedContinuation? 28 | 29 | /// Check if status is determined 30 | private var isDetermined : Bool{ 31 | status != .notDetermined 32 | } 33 | 34 | /// Subscription to authorization status changes 35 | private var cancelable : AnyCancellable? 36 | 37 | // MARK: - Life circle 38 | 39 | /// Init defining is access to location service is granted 40 | /// - Parameter status: Constant indicating the app's authorization to use location services 41 | init(with status: CLAuthorizationStatus){ 42 | self.status = status 43 | initSubscription() 44 | } 45 | 46 | /// resume continuation if it was not called with status .notDetermined 47 | deinit { 48 | resume(with: .notDetermined) 49 | } 50 | 51 | // MARK: - API 52 | 53 | /// Get status asynchronously and check is it authorized to start getting the stream of locations 54 | public func grant(for manager: CLLocationManager) async throws { 55 | let status = await requestPermission(manager) 56 | if status.isNotAuthorized{ 57 | throw AsyncLocationErrors.accessIsNotAuthorized 58 | } 59 | } 60 | 61 | // MARK: - Private methods 62 | 63 | /// Subscribe for event when location manager change authorization status to go on access permission flow 64 | private func initSubscription(){ 65 | let name = Permission.authorizationStatus 66 | cancelable = NotificationCenter.default.publisher(for: name) 67 | .sink { [weak self] value in 68 | self?.statusChanged(value) 69 | } 70 | } 71 | 72 | /// Determine status after the request permission 73 | /// - Parameter manager: Location manager 74 | private func statusChanged(_ value: Output) { 75 | if let s = value.object as? CLAuthorizationStatus{ 76 | status = s 77 | resume(with: status) 78 | } 79 | } 80 | 81 | /// Resume continuation 82 | /// - Parameter status: resume 83 | private func resume(with status : CLAuthorizationStatus) { 84 | flow?.resume(returning: status) 85 | flow = nil 86 | } 87 | 88 | /// Requests the user’s permission to use location services while the app is in use 89 | /// Don't forget to add in Info "Privacy - Location When In Use Usage Description" something like "Show list of locations" 90 | /// - Returns: Permission status 91 | private func requestPermission(_ manager : CLLocationManager) async -> CLAuthorizationStatus{ 92 | manager.requestWhenInUseAuthorization() 93 | 94 | if isDetermined{ 95 | return status 96 | } 97 | 98 | return await withCheckedContinuation{ continuation in 99 | flow = continuation 100 | } 101 | } 102 | } 103 | } 104 | 105 | // MARK: - Alias types - 106 | 107 | fileprivate typealias Output = NotificationCenter.Publisher.Output 108 | 109 | // MARK: - Extensions - 110 | 111 | fileprivate extension CLAuthorizationStatus { 112 | /// Check if access is not authorized 113 | /// denied - The user denied the use of location services for the app or they are disabled globally in Settings 114 | /// restricted - The app is not authorized to use location services 115 | /// - Returns: Return `True` if it was denied 116 | var isNotAuthorized: Bool { 117 | [.denied, .restricted].contains(self) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /Sources/d3-async-location/enum/AsyncLocationErrors.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocationManagerErrors.swift 3 | // 4 | // 5 | // Created by Igor on 03.02.2023. 6 | // 7 | 8 | import CoreLocation 9 | 10 | /// Async locations manager errors 11 | @available(iOS 14.0, watchOS 7.0, *) 12 | public enum AsyncLocationErrors: Error{ 13 | ///Access was denied by user 14 | case accessIsNotAuthorized 15 | } 16 | 17 | 18 | @available(iOS 14.0, watchOS 7.0, *) 19 | extension AsyncLocationErrors: LocalizedError { 20 | 21 | public var errorDescription: String? { 22 | switch self { 23 | case .accessIsNotAuthorized: 24 | return NSLocalizedString("Access was denied by the user.", comment: "") 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/d3-async-location/enum/LocationStreamingState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocationStreamingState.swift 3 | // 4 | // 5 | // Created by Igor on 04.02.2023. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Streaming states 11 | @available(iOS 14.0, watchOS 7.0, *) 12 | public enum LocationStreamingState{ 13 | 14 | /// not streaming 15 | case idle 16 | 17 | /// streaming has been started 18 | case streaming 19 | } 20 | -------------------------------------------------------------------------------- /Sources/d3-async-location/helper/AsyncFIFOQueue.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncFIFOQueue.swift 3 | // d3-async-location 4 | // 5 | // Created by Igor Shelopaev on 04.12.24. 6 | // 7 | 8 | import Foundation 9 | 10 | extension LocationManager { 11 | 12 | /// A generic FIFO queue that provides an asynchronous stream of elements. 13 | /// The stream is initialized lazily and can be terminated and cleaned up. 14 | @available(iOS 14.0, watchOS 7.0, *) 15 | final class AsyncFIFOQueue{ 16 | 17 | /// Type alias for the AsyncStream Continuation. 18 | typealias Continuation = AsyncStream.Continuation 19 | 20 | 21 | /// The asynchronous stream that consumers can iterate over. 22 | private var stream: AsyncStream? 23 | 24 | /// The continuation used to produce values for the stream. 25 | private var continuation: Continuation? 26 | 27 | /// Initializes the stream and continuation. 28 | /// Should be called before starting to enqueue elements. 29 | /// - Parameter onTermination: An escaping closure to handle termination events. 30 | /// - Returns: The initialized `AsyncStream`. 31 | func initializeQueue() -> AsyncStream { 32 | // Return the existing stream if it's already initialized. 33 | if let existingStream = stream { 34 | return existingStream 35 | } 36 | 37 | let (newStream, newContinuation) = AsyncStream.makeStream(of: Element.self) 38 | 39 | newContinuation.onTermination = { [weak self] termination in 40 | self?.finish() 41 | } 42 | 43 | self.stream = newStream 44 | self.continuation = newContinuation 45 | 46 | return newStream 47 | } 48 | 49 | /// Provides access to the asynchronous stream. 50 | /// - Returns: The initialized `AsyncStream` instance, or `nil` if not initialized. 51 | func getQueue() -> AsyncStream? { 52 | return stream 53 | } 54 | 55 | /// Enqueues a new element into the stream. 56 | /// - Parameter element: The element to enqueue. 57 | func enqueue(_ element: Element) { 58 | continuation?.yield(element) 59 | } 60 | 61 | /// Finishes the stream, indicating no more elements will be enqueued. 62 | /// Cleans up the stream and continuation. 63 | func finish() { 64 | continuation?.finish() 65 | continuation = nil 66 | stream = nil 67 | } 68 | 69 | deinit { 70 | #if DEBUG 71 | print("deinit async queue") 72 | #endif 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Sources/d3-async-location/helper/LocationManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocationManagerAsync.swift 3 | // 4 | // Created by Igor on 03.02.2023. 5 | // 6 | 7 | import CoreLocation 8 | 9 | /// A location manager that streams data asynchronously using an `AsyncStream`. 10 | /// It requests permission in advance if it hasn't been determined yet. 11 | /// Use the `start()` method to begin streaming location updates. 12 | @available(iOS 14.0, watchOS 7.0, *) 13 | final class LocationManager: ILocationManager { 14 | 15 | /// The delegate responsible for handling location updates and forwarding them to the async stream. 16 | private let delegate: ILocationDelegate 17 | 18 | // MARK: - Lifecycle 19 | 20 | /// Initializes a new `LocationManagerAsync` instance with the specified settings. 21 | /// - Parameters: 22 | /// - accuracy: The desired accuracy of the location data. 23 | /// - activityType: The type of user activity associated with the location updates. 24 | /// - distanceFilter: The minimum distance (in meters) that the device must move before an update event is generated. kCLDistanceFilterNone (equivalent to -1.0) means updates are sent regardless of the distance traveled. This is a safe default for apps that don’t require filtering updates based on distance. 25 | /// - backgroundUpdates: A Boolean value indicating whether the app should receive location updates when suspended. 26 | init( 27 | _ accuracy: CLLocationAccuracy?, 28 | _ activityType: CLActivityType?, 29 | _ distanceFilter: CLLocationDistance?, 30 | _ backgroundUpdates: Bool 31 | ) { 32 | delegate = LocationManager.Delegate(accuracy, activityType, distanceFilter, backgroundUpdates) 33 | } 34 | 35 | /// Initializes the `LocationManagerAsync` instance with a specified `CLLocationManager` instance. 36 | /// - Parameter locationManager: A pre-configured `CLLocationManager` instance used to manage location updates. 37 | public init(locationManager: CLLocationManager) { 38 | delegate = LocationManager.Delegate(locationManager: locationManager) 39 | } 40 | 41 | /// Deinitializes the `LocationManagerAsync` instance, performing any necessary cleanup. 42 | deinit { 43 | #if DEBUG 44 | print("deinit manager") 45 | #endif 46 | } 47 | 48 | // MARK: - API 49 | 50 | /// Checks permission status and starts streaming location data asynchronously. 51 | /// - Returns: An `AsyncStream` emitting location updates or errors. 52 | /// - Throws: An error if permission is not granted. 53 | public func start() async throws -> AsyncStream { 54 | 55 | try await delegate.permission() 56 | 57 | return delegate.start() 58 | } 59 | 60 | /// Stops the location streaming process. 61 | public func stop() { 62 | delegate.finish() 63 | 64 | #if DEBUG 65 | print("stop updating") 66 | #endif 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Sources/d3-async-location/helper/Strategies.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Strategies.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 10.02.2023. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | 12 | /// A concrete strategy that keeps only the last location result. 13 | /// This strategy ensures that only the most recent location update is retained. 14 | @available(iOS 14.0, watchOS 7.0, *) 15 | public struct KeepLastStrategy: ILocationResultStrategy { 16 | 17 | /// Initializes a new instance of `KeepLastStrategy`. 18 | public init() {} 19 | 20 | /// Processes the results array by replacing it with the new result. 21 | /// - Parameters: 22 | /// - results: The current array of processed results (ignored in this strategy). 23 | /// - newResult: The new result to be stored. 24 | /// - Returns: An array containing only the new result. 25 | public func process(results: [Output], newResult: Output) -> [Output] { 26 | return [newResult] 27 | } 28 | } 29 | 30 | /// A concrete strategy that keeps all location results. 31 | /// This strategy appends each new location result to the existing array. 32 | @available(iOS 14.0, watchOS 7.0, *) 33 | public struct KeepAllStrategy: ILocationResultStrategy { 34 | 35 | /// Initializes a new instance of `KeepAllStrategy`. 36 | public init() {} 37 | 38 | /// Processes the results array by appending the new result to it. 39 | /// - Parameters: 40 | /// - results: The current array of processed results. 41 | /// - newResult: The new result to be added to the array. 42 | /// - Returns: A new array of results, including the new result appended to the existing results. 43 | public func process(results: [Output], newResult: Output) -> [Output] { 44 | var updatedResults = results 45 | updatedResults.append(newResult) 46 | return updatedResults 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/d3-async-location/protocol/ILocationDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ILocationDelegate.swift 3 | // 4 | // 5 | // Created by Igor on 05.07.2023. 6 | // 7 | 8 | import Foundation 9 | import CoreLocation 10 | 11 | /// A protocol that defines the interface for location delegates. 12 | @available(iOS 14.0, watchOS 7.0, *) 13 | public protocol ILocationDelegate: NSObjectProtocol, CLLocationManagerDelegate { 14 | 15 | /// Starts the location streaming process. 16 | /// 17 | /// - Returns: An `AsyncStream` emitting `LocationStreamer.Output` values. 18 | /// The stream provides asynchronous location updates or errors to the consumer. 19 | func start() -> AsyncStream 20 | 21 | /// Stops the location streaming process. 22 | /// 23 | /// Cleans up resources, stops receiving location updates, 24 | /// and ensures that any ongoing streams are properly terminated. 25 | func finish() 26 | 27 | /// Requests the necessary location permissions from the user if not already granted. 28 | /// 29 | /// - Throws: An error if the permission request fails or the user denies access. 30 | /// 31 | /// This method should be called before starting the location updates to ensure that the app has the required permissions. 32 | func permission() async throws 33 | } 34 | -------------------------------------------------------------------------------- /Sources/d3-async-location/protocol/ILocationManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Igor on 03.02.2023. 6 | // 7 | 8 | import Foundation 9 | import CoreLocation 10 | 11 | @available(iOS 14.0, watchOS 7.0, *) 12 | protocol ILocationManager { 13 | 14 | /// Starts the async stream of location updates. 15 | /// - Returns: An `AsyncStream` of `Output` that emits location updates or errors. 16 | /// - Throws: An error if the streaming cannot be started. 17 | func start() async throws -> AsyncStream 18 | 19 | /// Stops the location streaming process. 20 | func stop() 21 | } 22 | -------------------------------------------------------------------------------- /Sources/d3-async-location/protocol/ILocationResultStrategy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ILocationResultStrategy.swift 3 | // d3-async-location 4 | // 5 | // Created by Igor on 11.12.24. 6 | // 7 | 8 | /// A protocol that defines a strategy for processing location results. 9 | /// Implementations of this protocol determine how new location results are handled 10 | /// (e.g., whether they replace the previous results or are appended). 11 | @available(iOS 14.0, watchOS 7.0, *) 12 | public protocol ILocationResultStrategy { 13 | 14 | /// The type of output that the strategy processes. 15 | /// `Output` is defined as `LocationStreamer.Output`, which is a `Result` containing 16 | /// an array of `CLLocation` objects or a `CLError`. 17 | typealias Output = LocationStreamer.Output 18 | 19 | /// Processes the results array by incorporating a new location result. 20 | /// - Parameters: 21 | /// - results: The current array of processed results. 22 | /// - newResult: The new result to be processed and added. 23 | /// - Returns: A new array of results after applying the strategy. 24 | func process(results: [Output], newResult: Output) -> [Output] 25 | } 26 | -------------------------------------------------------------------------------- /Sources/d3-async-location/protocol/ILocationStreamer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ILocationManagerViewModel.swift 3 | // 4 | // 5 | // Created by Igor on 03.02.2023. 6 | // 7 | 8 | import CoreLocation 9 | import SwiftUI 10 | 11 | @available(iOS 14.0, watchOS 7.0, *) 12 | @MainActor 13 | public protocol ILocationStreamer{ 14 | 15 | /// List of locations 16 | var results : [LocationStreamer.Output] { get } 17 | 18 | /// Strategy for publishing locations Default value is .keepLast 19 | var strategy : ILocationResultStrategy { get } 20 | 21 | /// Start streaming locations 22 | func start(clean: Bool) async throws 23 | 24 | /// Stop streaming locations 25 | func stop() 26 | } 27 | -------------------------------------------------------------------------------- /Sources/d3-async-location/service/LocationStreamer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocationStreamer.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 03.02.2023. 6 | // 7 | 8 | import SwiftUI 9 | import CoreLocation 10 | 11 | /// A ViewModel for asynchronously streaming location updates. 12 | /// This class leverages `ObservableObject` to publish updates for SwiftUI Views. 13 | /// 14 | /// Example usage in a View: 15 | /// ``` 16 | /// @EnvironmentObject var model: LocationStreamer 17 | /// ``` 18 | /// 19 | /// To start streaming updates, call the `start()` method within an async environment. 20 | /// 21 | /// - Available: iOS 14.0+, watchOS 7.0+ 22 | @available(iOS 14.0, watchOS 7.0, *) 23 | public final class LocationStreamer: ILocationStreamer, ObservableObject { 24 | 25 | /// Represents the output of the location manager. 26 | /// Each output is either: 27 | /// - A list of results (`[CLLocation]` objects), or 28 | /// - A `CLError` in case of failure. 29 | public typealias Output = Result<[CLLocation], CLError> 30 | 31 | // MARK: - Public Properties 32 | 33 | /// Defines the strategy for processing and publishing location updates. 34 | /// Default strategy retains only the most recent update (`KeepLastStrategy`). 35 | public let strategy: ILocationResultStrategy 36 | 37 | /// A list of location results, published for subscribing Views. 38 | /// This property is updated based on the chosen `strategy`. 39 | @MainActor @Published public private(set) var results: [Output] = [] 40 | 41 | /// Indicates the current streaming state of the ViewModel. 42 | /// State transitions include `.idle`, `.streaming`, and `.error`. 43 | @MainActor @Published public private(set) var state: LocationStreamingState = .idle 44 | 45 | // MARK: - Private Properties 46 | 47 | /// Handles the actual location updates asynchronously. 48 | private let manager: LocationManager 49 | 50 | /// Checks if the streaming process is idle. 51 | /// A computed property for convenience. 52 | @MainActor 53 | public var isIdle: Bool { 54 | return state == .idle 55 | } 56 | 57 | // MARK: - Lifecycle 58 | 59 | /// Initializes the `LocationStreamer` with configurable parameters. 60 | /// - Parameters: 61 | /// - strategy: A `LocationResultStrategy` for managing location results. Defaults to `KeepLastStrategy`. 62 | /// - accuracy: Specifies the desired accuracy of location updates. Defaults to `kCLLocationAccuracyBest`. 63 | /// - activityType: The type of activity for location updates (e.g., automotive, fitness). Defaults to `.other`. 64 | /// - distanceFilter: The minimum distance (in meters) before generating an update. Defaults to `kCLDistanceFilterNone` (no filtering). 65 | /// - backgroundUpdates: Whether the app should continue receiving location updates in the background. Defaults to `false`. 66 | public init( 67 | strategy: ILocationResultStrategy = KeepLastStrategy(), 68 | _ accuracy: CLLocationAccuracy? = kCLLocationAccuracyBest, 69 | _ activityType: CLActivityType? = .other, 70 | _ distanceFilter: CLLocationDistance? = kCLDistanceFilterNone, 71 | _ backgroundUpdates: Bool = false 72 | ) { 73 | self.strategy = strategy 74 | manager = .init(accuracy, activityType, distanceFilter, backgroundUpdates) 75 | } 76 | 77 | /// Initializes the `LocationStreamer` with a pre-configured `CLLocationManager`. 78 | /// - Parameters: 79 | /// - strategy: A `LocationResultStrategy` for managing location results. Defaults to `KeepLastStrategy`. 80 | /// - locationManager: A pre-configured `CLLocationManager` instance. 81 | public init( 82 | strategy: ILocationResultStrategy = KeepLastStrategy(), 83 | locationManager: CLLocationManager 84 | ) { 85 | self.strategy = strategy 86 | manager = .init(locationManager: locationManager) 87 | } 88 | 89 | /// Cleans up resources when the instance is deallocated. 90 | deinit { 91 | #if DEBUG 92 | print("deinit LocationStreamer") 93 | #endif 94 | } 95 | 96 | // MARK: - API 97 | 98 | /// Starts streaming location updates asynchronously. 99 | /// - Parameters: 100 | /// - clean: Whether to clear previous results before starting. Defaults to `true`. 101 | /// - Throws: `AsyncLocationErrors.streamingProcessHasAlreadyStarted` if streaming is already active. 102 | @MainActor 103 | public func start(clean: Bool = true) async throws { 104 | if state == .streaming { stop() } 105 | if clean { self.clean() } 106 | 107 | setState(.streaming) 108 | 109 | let stream = try await manager.start() 110 | 111 | for await result in stream { 112 | add(result) 113 | } 114 | 115 | stop() 116 | setState(.idle) 117 | } 118 | 119 | /// Stops the location streaming process and sets the state to idle. 120 | @MainActor 121 | public func stop() { 122 | manager.stop() 123 | setState(.idle) 124 | 125 | #if DEBUG 126 | print("stop manager") 127 | #endif 128 | } 129 | 130 | // MARK: - Private Methods 131 | 132 | /// Clears all stored results. 133 | @MainActor 134 | private func clean() { 135 | results = [] 136 | } 137 | 138 | /// Adds a new location result to the `results` array. 139 | /// The behavior depends on the configured `strategy`. 140 | /// - Parameter result: The new result to be processed and added. 141 | @MainActor 142 | private func add(_ result: Output) { 143 | results = strategy.process(results: results, newResult: result) 144 | } 145 | 146 | /// Updates the streaming state of the ViewModel. 147 | /// - Parameter value: The new state to set (e.g., `.idle`, `.streaming`). 148 | @MainActor 149 | private func setState(_ value: LocationStreamingState) { 150 | state = value 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /Sources/d3-async-location/service/ObservableLocationStreamer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ObservableLocationStreamer.swift 3 | // 4 | // 5 | // Created by Igor on 03.02.2023. 6 | // 7 | 8 | import SwiftUI 9 | import CoreLocation 10 | 11 | #if compiler(>=5.9) && canImport(Observation) 12 | 13 | /// ViewModel for asynchronously posting location updates. 14 | @available(iOS 17.0, watchOS 10.0, *) 15 | @Observable 16 | public final class ObservableLocationStreamer: ILocationStreamer{ 17 | 18 | /// Represents the output of the location manager. 19 | /// Contains either a list of results (e.g., `CLLocation` objects) or a `CLError` in case of failure. 20 | public typealias Output = Result<[CLLocation], CLError> 21 | 22 | // MARK: - Public Properties 23 | 24 | /// Strategy for publishing updates. Default value is `.keepLast`. 25 | public let strategy: ILocationResultStrategy 26 | 27 | /// A list of results published for subscribed Views. 28 | /// Results may include various types of data (e.g., `CLLocation` objects) depending on the implementation. 29 | /// Use this publisher to feed Views with updates or create a proxy to manipulate the flow, 30 | /// such as filtering, mapping, or dropping results. 31 | @MainActor public private(set) var results: [Output] = [] 32 | 33 | /// Current streaming state of the ViewModel. 34 | @MainActor public private(set) var state: LocationStreamingState = .idle 35 | 36 | // MARK: - Private Properties 37 | 38 | /// The asynchronous locations manager responsible for streaming updates. 39 | private let manager: LocationManager 40 | 41 | /// Indicates whether the streaming process is idle. 42 | @MainActor 43 | public var isIdle: Bool { 44 | return state == .idle 45 | } 46 | 47 | // MARK: - Lifecycle 48 | 49 | /// Initializes the `LocationStreamer` with configurable parameters. 50 | /// - Parameters: 51 | /// - strategy: A `LocationResultStrategy` for managing location results. Defaults to `KeepLastStrategy`. 52 | /// - accuracy: Specifies the desired accuracy of location updates. Defaults to `kCLLocationAccuracyBest`. 53 | /// - activityType: The type of activity for location updates (e.g., automotive, fitness). Defaults to `.other`. 54 | /// - distanceFilter: The minimum distance (in meters) before generating an update. Defaults to `kCLDistanceFilterNone` (no filtering). 55 | /// - backgroundUpdates: Whether the app should continue receiving location updates in the background. Defaults to `false`. 56 | public init( 57 | strategy: ILocationResultStrategy = KeepLastStrategy(), 58 | _ accuracy: CLLocationAccuracy? = kCLLocationAccuracyBest, 59 | _ activityType: CLActivityType? = .other, 60 | _ distanceFilter: CLLocationDistance? = kCLDistanceFilterNone, 61 | _ backgroundUpdates: Bool = false 62 | ) { 63 | self.strategy = strategy 64 | manager = .init(accuracy, activityType, distanceFilter, backgroundUpdates) 65 | } 66 | 67 | /// Initializes the `LocationStreamer` with a pre-configured `CLLocationManager`. 68 | /// - Parameters: 69 | /// - strategy: A `LocationResultStrategy` for managing location results. Defaults to `KeepLastStrategy`. 70 | /// - locationManager: A pre-configured `CLLocationManager` instance. 71 | public init( 72 | strategy: ILocationResultStrategy = KeepLastStrategy(), 73 | locationManager: CLLocationManager 74 | ) { 75 | self.strategy = strategy 76 | manager = .init(locationManager: locationManager) 77 | } 78 | 79 | /// Cleans up resources when the instance is deallocated. 80 | deinit { 81 | #if DEBUG 82 | print("deinit LocationStreamer") 83 | #endif 84 | } 85 | 86 | // MARK: - API 87 | 88 | /// Starts streaming location updates asynchronously. 89 | /// - Parameters: 90 | /// - clean: Whether to clear previous results before starting. Defaults to `true`. 91 | /// - Throws: `AsyncLocationErrors.streamingProcessHasAlreadyStarted` if streaming is already active. 92 | @MainActor public func start(clean: Bool = true) async throws { 93 | if state == .streaming { stop() } 94 | if clean { self.clean() } 95 | 96 | setState(.streaming) 97 | 98 | let stream = try await manager.start() 99 | 100 | for await result in stream { 101 | add(result) 102 | } 103 | setState(.idle) 104 | } 105 | 106 | /// Stops the location streaming process and sets the state to idle. 107 | @MainActor public func stop() { 108 | manager.stop() 109 | setState(.idle) 110 | 111 | #if DEBUG 112 | print("stop manager") 113 | #endif 114 | } 115 | 116 | // MARK: - Private Methods 117 | 118 | /// Clears all stored results. 119 | @MainActor 120 | private func clean() { 121 | results = [] 122 | } 123 | 124 | /// Adds a new location result to the `results` array. 125 | /// The behavior depends on the configured `strategy`. 126 | /// - Parameter result: The new result to be processed and added. 127 | @MainActor 128 | private func add(_ result: Output) { 129 | results = strategy.process(results: results, newResult: result) 130 | } 131 | 132 | /// Updates the streaming state of the ViewModel. 133 | /// - Parameter value: The new state to set (e.g., `.idle`, `.streaming`). 134 | @MainActor 135 | private func setState(_ value: LocationStreamingState) { 136 | state = value 137 | } 138 | } 139 | 140 | #endif 141 | -------------------------------------------------------------------------------- /Tests/d3-async-locationTests/d3_async_locationTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import d3_async_location 3 | 4 | final class d3_async_locationTests: XCTestCase { 5 | func testExample() throws { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | // XCTAssertEqual(d3_async_location().text, "Hello, World!") 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /img/apple_watch_swiftui.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swiftuiux/swift-async-corelocation-streamer/HEAD/img/apple_watch_swiftui.gif -------------------------------------------------------------------------------- /img/image11.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swiftuiux/swift-async-corelocation-streamer/HEAD/img/image11.gif -------------------------------------------------------------------------------- /img/image2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swiftuiux/swift-async-corelocation-streamer/HEAD/img/image2.png -------------------------------------------------------------------------------- /img/image6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swiftuiux/swift-async-corelocation-streamer/HEAD/img/image6.png --------------------------------------------------------------------------------