feat: Added UserLocationFetcher helper that requests authorization and subscribes to user location updates

This commit is contained in:
Ivan Smolin 2023-06-02 10:37:19 +03:00
parent 33cc31b957
commit 005d80c531
10 changed files with 305 additions and 39 deletions

View File

@ -2,7 +2,10 @@
### 1.46.0
- **Added**:
- **Added**: `AsyncSingleValueStorage` and `SingleValueStorageAsyncWrapper<SingleValueStorage>` for async access to SingleValue storages wtih swift concurrency support
- **Added**: `BaseMapUISettings` used to configure map view of different providers + user location icon rendering for yandex maps
- **Added**: `UserLocationFetcher` helper that requests authorization and subscribes to user location updates
- **Update**: add `DEVELOPMENT_INSTALL` support for all podspecs and fix playground compilation issues
### 1.45.0

View File

@ -108,7 +108,7 @@ let package = Package(
// MARK: - Maps
.target(name: "TIMapUtils",
dependencies: [],
dependencies: ["TILogging"],
path: "TIMapUtils/Sources",
plugins: [.plugin(name: "TISwiftLintPlugin")]),

View File

@ -24,8 +24,28 @@ import TIMapUtils
import MapKit
open class AppleMapUISettings: BaseMapUISettings<MKMapView> {
open class Defaults: BaseMapUISettings<MKMapView>.Defaults {
public static var showCompassButton: Bool {
false
}
}
public var showCompassButton = false
public init(showUserLocation: Bool = Defaults.showUserLocation,
isZoomEnabled: Bool = Defaults.isZoomEnabled,
isTiltEnabled: Bool = Defaults.isTiltEnabled,
isRotationEnabled: Bool = Defaults.isRotationEnabled,
showCompassButton: Bool = Defaults.showCompassButton) {
self.showCompassButton = showCompassButton
super.init(showUserLocation: showUserLocation,
isZoomEnabled: isZoomEnabled,
isTiltEnabled: isTiltEnabled,
isRotationEnabled: isRotationEnabled)
}
override open func apply(to mapView: MKMapView) {
super.apply(to: mapView)

View File

@ -50,8 +50,13 @@ open class ApplePlacemarkManager<Model>: BaseItemPlacemarkManager<MKAnnotationVi
Although the icon is being updated, it is necessary to manually deselect
the annotation of the current placemark if it is currently selected.
*/
if state == .default, let annotation = placemark.annotation {
map.deselectAnnotation(annotation, animated: true)
if let annotation = placemark.annotation {
switch state {
case .default:
map.deselectAnnotation(annotation, animated: true)
case .selected:
map.selectAnnotation(annotation, animated: true)
}
}
placemark.image = iconFactory?.markerIcon(for: dataModel, state: state)

View File

@ -0,0 +1,187 @@
//
// Copyright (c) 2023 Touch Instinct
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the Software), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
//
import CoreLocation
import TILogging
open class UserLocationFetcher: NSObject, CLLocationManagerDelegate {
public enum AccuracyRequest {
case `default`
case fullAccuracy(purposeKey: String)
}
public enum Failure: Error {
case restricted
case denied
case fullAccuracyDenied
}
public typealias LocationCallback = (CLLocation) -> Void
public typealias OnAuthSuccessCallback = (CLLocationManager) -> Void
public typealias OnAuthFailureCallback = (Failure) -> Void
public var locationManager: CLLocationManager
public var accuracyRequest: AccuracyRequest
public var authorized: Bool {
if #available(iOS 14.0, *) {
return isAuthorized(status: locationManager.authorizationStatus)
} else {
return isAuthorized(status: CLLocationManager.authorizationStatus())
}
}
var authorizedFullAccuracy: Bool {
if #available(iOS 14.0, *) {
switch locationManager.accuracyAuthorization {
case .fullAccuracy:
return true
case .reducedAccuracy:
return false
@unknown default:
assertionFailure("Unimplemented accuracyAuthorization case: \(locationManager.accuracyAuthorization)")
return true
}
} else {
return true
}
}
public var authorizedAccuracy: Bool {
switch accuracyRequest {
case .default:
return true
case .fullAccuracy:
return authorizedFullAccuracy
}
}
public var authSuccessCallback: OnAuthSuccessCallback
public var authFailureCallback: OnAuthFailureCallback?
public var locationCallback: LocationCallback?
public var logger: ErrorLogger = DefaultOSLogErrorLogger(subsystem: "TIMapUtils", category: "UserLocationFetcher")
public init(locationManager: CLLocationManager = CLLocationManager(),
accuracyRequest: AccuracyRequest = .default,
onSuccess: @escaping OnAuthSuccessCallback = { $0.requestLocation() },
onFailure: OnAuthFailureCallback? = nil,
locationCallback: LocationCallback? = nil) {
self.locationManager = locationManager
self.accuracyRequest = accuracyRequest
self.authSuccessCallback = onSuccess
self.authFailureCallback = onFailure
self.locationCallback = locationCallback
super.init()
}
open func requestLocationUpdates(onlyIfHasAccess: Bool = false) {
if authorized && authorizedAccuracy || !onlyIfHasAccess {
locationManager.delegate = self
}
}
open func requestAuth(for manager: CLLocationManager) {
manager.requestWhenInUseAuthorization()
}
open func isAuthorized(status: CLAuthorizationStatus) -> Bool {
switch status {
case .authorizedWhenInUse, .authorizedAlways:
return true
case .restricted, .denied:
return false
case .notDetermined:
return false
@unknown default:
assertionFailure("Unimplemented authorizationStatus case: \(status))")
return true
}
}
// MARK: - CLLocationManagerDelegate
open func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
switch status {
case .notDetermined:
requestAuth(for: manager)
case .restricted:
authFailureCallback?(.restricted)
case .denied:
authFailureCallback?(.denied)
case .authorizedAlways, .authorizedWhenInUse:
handleSuccessAuthorization(with: status, for: manager)
@unknown default:
assertionFailure("Unimplemented authorizationStatus case: \(status))")
authSuccessCallback(manager)
}
}
open func handleSuccessAuthorization(with status: CLAuthorizationStatus, for manager: CLLocationManager) {
if authorizedFullAccuracy {
authSuccessCallback(manager)
} else {
switch accuracyRequest {
case let .fullAccuracy(purposeKey):
if #available(iOS 14.0, *) {
locationManager.requestTemporaryFullAccuracyAuthorization(withPurposeKey: purposeKey) { [weak self] in
if $0 != nil {
self?.authFailureCallback?(.fullAccuracyDenied)
} else {
self?.authSuccessCallback(manager)
}
}
} else {
authSuccessCallback(manager)
}
case .default:
authSuccessCallback(manager)
}
}
}
open func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let locationCallback else {
return
}
locations.forEach(locationCallback)
}
open func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
logger.log(error: error, file: #file, line: #line)
}
}

View File

@ -43,6 +43,8 @@ open class BaseMapManager<Map: AnyObject,
public typealias CameraUpdateOnPointTap = (CUF.Position) -> CUF.Update
public typealias CameraUpdateOnClusterTap = (CUF.BoundingBox) -> CUF.Update
public typealias LocationCallback = (PM.Position) -> Void
private let placemarkManagerCreator: PlacemarkManagerCreator
private let clusterPlacemarkManagerCreator: ClusterPlacemarkManagerCreator
@ -83,8 +85,12 @@ open class BaseMapManager<Map: AnyObject,
}
open func set(items: [PM.DataModel]) {
let placemarkTapHandler: PM.TapHandlerClosure = { [weak map, selectPlacemarkHandler, animationDuration, cameraUpdateOnMarkerTap] model, location in
if let map = map {
// closure capture alternative
let animationDuration = animationDuration
weak var weakMap = map
let placemarkTapHandler: PM.TapHandlerClosure = { [selectPlacemarkHandler, cameraUpdateOnMarkerTap] model, location in
if let map = weakMap {
cameraUpdateOnMarkerTap?(location).update(map: map, animationDuration: animationDuration)
}
@ -93,8 +99,8 @@ open class BaseMapManager<Map: AnyObject,
let placemarkManagers = items.compactMap { placemarkManagerCreator($0, placemarkTapHandler) }
let clusterTapHandler: CPM.TapHandlerClosure = { [weak map, animationDuration, cameraUpdateOnClusterTap] managers, boundingBox in
if let map = map {
let clusterTapHandler: CPM.TapHandlerClosure = { [cameraUpdateOnClusterTap] _, boundingBox in
if let map = weakMap {
cameraUpdateOnClusterTap?(boundingBox).update(map: map, animationDuration: animationDuration)
}
@ -107,7 +113,7 @@ open class BaseMapManager<Map: AnyObject,
self.clusterPlacemarkManager = clusterPlacemarkManagerCreator(placemarkManagers, clusterTapHandler)
cameraUpdateOnMarkersAdded?.update(map: map, animationDuration: animationDuration)
cameraUpdateOnMarkersAdded?.update(map: self.map, animationDuration: animationDuration)
}
open func remove(clusterPlacemarkManager: CPM) {

View File

@ -21,13 +21,39 @@
//
open class BaseMapUISettings<MapView>: MapUISettings {
public var showUserLocation = true
open class Defaults { // swiftlint:disable:this convenience_type
public static var showUserLocation: Bool {
true
}
public var isZoomEnabled = true
public var isTiltEnabled = false
public var isRotationEnabled = false
public static var isZoomEnabled: Bool {
true
}
public init() {
public static var isTiltEnabled: Bool {
false
}
public static var isRotationEnabled: Bool {
false
}
}
public var showUserLocation: Bool
public var isZoomEnabled: Bool
public var isTiltEnabled: Bool
public var isRotationEnabled: Bool
public init(showUserLocation: Bool = Defaults.showUserLocation,
isZoomEnabled: Bool = Defaults.isZoomEnabled,
isTiltEnabled: Bool = Defaults.isTiltEnabled,
isRotationEnabled: Bool = Defaults.isRotationEnabled) {
self.showUserLocation = showUserLocation
self.isZoomEnabled = isZoomEnabled
self.isTiltEnabled = isTiltEnabled
self.isRotationEnabled = isRotationEnabled
}
open func apply(to mapView: MapView) {

View File

@ -19,4 +19,6 @@ Pod::Spec.new do |s|
s.exclude_files = s.name + '/*.app'
end
s.dependency 'TILogging', s.version.to_s
end

View File

@ -118,31 +118,10 @@ public struct EndpointCacheService<Content: Codable>: SingleValueStorage {
return asyncCache.hasStoredValue {
if $0 {
guard !cancellableBag.isCancelled else {
return
}
asyncCache.getValue {
switch $0 {
case let .success(cachedValue):
completion(.success(cachedValue))
case .failure:
guard !cancellableBag.isCancelled else {
return
}
requestClosure {
if case let .success(newValue) = $0 {
_ = store(value: newValue)
}
completion($0)
}
.add(to: cancellableBag)
}
}
.add(to: cancellableBag)
fetchCached(from: asyncCache,
cancellableBag: cancellableBag,
requestClosure: requestClosure,
completion: completion)
} else {
requestClosure {
if case let .success(newValue) = $0 {
@ -156,4 +135,38 @@ public struct EndpointCacheService<Content: Codable>: SingleValueStorage {
}
}
}
public func fetchCached<Failure: Error,
AsyncStorage: AsyncSingleValueStorage>(from asyncCache: AsyncStorage,
cancellableBag: BaseCancellableBag,
requestClosure: @escaping ContentRequestClosure<Failure>,
completion: @escaping FetchContentCompletion<Failure>)
where AsyncStorage.ValueType == Content {
guard !cancellableBag.isCancelled else {
return
}
asyncCache.getValue {
switch $0 {
case let .success(cachedValue):
completion(.success(cachedValue))
case .failure:
guard !cancellableBag.isCancelled else {
return
}
requestClosure {
if case let .success(newValue) = $0 {
_ = store(value: newValue)
}
completion($0)
}
.add(to: cancellableBag)
}
}
.add(to: cancellableBag)
}
}

View File

@ -15,4 +15,8 @@
for module_name in $(cat ${SRCROOT}/project-scripts/ordered_modules_list.txt); do
bundle exec pod repo push https://git.svc.touchin.ru/TouchInstinct/Podspecs ${SRCROOT}/${module_name}/${module_name}.podspec "$@" --allow-warnings
if [ $? -ne 0 ]; then
exit $?
fi
done