🕌

SwiftUIとCombineでCore Locationを扱う

2021/05/23に公開

Combineを理解するためにSwiftUIでCore Locationのデータを表示するコードを書いてみました。

ソースコードはこちらです。
https://github.com/yorifuji/swiftui-combine-corelocation

Model

  • PublisherとしてPassthroughSubjectを作成、Delegateでsend
LocationDataSource.swift
import CoreLocation
import Combine

final class LocationDataSource: NSObject {

    private let locationManager: CLLocationManager = .init()
    private let authorizationSubject: PassthroughSubject<CLAuthorizationStatus, Never> = .init()
    private let locationSubject: PassthroughSubject<[CLLocation], Never> = .init()

    override init() {
        super.init()
        locationManager.delegate = self
    }

    func authorizationPublisher() -> AnyPublisher<CLAuthorizationStatus, Never> {
        return Just(CLLocationManager().authorizationStatus).merge(with: authorizationSubject).eraseToAnyPublisher()
    }

    func locationPublisher() -> AnyPublisher<[CLLocation], Never> {
        return locationSubject.eraseToAnyPublisher()
    }

    func requestAuthorization() {
        if locationManager.authorizationStatus == .notDetermined {
            locationManager.requestWhenInUseAuthorization()
        }
    }

    func startTracking() {
        locationManager.startUpdatingLocation()
    }

    func stopTracking() {
        locationManager.stopUpdatingLocation()
    }
}

extension LocationDataSource: CLLocationManagerDelegate {
    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
        authorizationSubject.send(manager.authorizationStatus)
    }

    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        locationSubject.send(locations)
    }
}

ViewModel

  • ModelのPublisherに対してsinkでsubscribeする
ViewModel.swift
import CoreLocation
import Combine

final class ViewModel: NSObject, ObservableObject {
    let model: LocationDataSource
    var cancellables = Set<AnyCancellable>()
    @Published var authorizationStatus = CLAuthorizationStatus.notDetermined
    @Published var location: CLLocation = .init()

    var latitude: CLLocationDegrees {
        location.coordinate.latitude
    }

    var longitude: CLLocationDegrees {
        location.coordinate.longitude
    }

    init(model: LocationDataSource) {
        self.model = model
    }

    func requestAuthorization() {
        model.requestAuthorization()
    }

    func activate() {
        model.authorizationPublisher().print("dump:status").sink { [weak self] authorizationStatus in
            guard let self = self else { return }
            self.authorizationStatus = authorizationStatus
        }.store(in: &cancellables)

        model.locationPublisher().print("dump:location").sink { [weak self] locations in
            guard let self = self else { return }
            if let last = locations.last {
                self.location = last
            }
        }.store(in: &cancellables)
    }

    func deactivate() {
        cancellables.removeAll()
    }

    func startTracking() {
        model.startTracking()
    }

    func stopTracking() {
        model.stopTracking()
    }
}
  • Publisherに対してprint()することでコンソールにログが流れます
dump:status: receive subscription: (Merge)
dump:status: request unlimited
dump:status: receive value: (Not Determined)
dump:location: receive subscription: (PassthroughSubject)
dump:location: request unlimited
dump:status: receive value: (Authorized When In Use)
dump:location: receive value: ([<+37.33442613,-122.06864035> +/- 5.00m (speed 33.67 mps / course 266.13) @ 5/23/21, 3:34:00 PM Japan Standard Time])
dump:location: receive value: ([<+37.33442613,-122.06864035> +/- 5.00m (speed 33.67 mps / course 266.13) @ 5/23/21, 3:34:02 PM Japan Standard Time])
dump:location: receive value: ([<+37.33435661,-122.06939204> +/- 5.00m (speed 33.72 mps / course 261.91) @ 5/23/21, 3:34:02 PM Japan Standard Time])
dump:location: receive value: ([<+37.33430397,-122.06976336> +/- 5.00m (speed 33.69 mps / course 259.10) @ 5/23/21, 3:34:03 PM Japan Standard Time])
dump:location: receive value: ([<+37.33424182,-122.07013275> +/- 5.00m (speed 33.59 mps / course 257.70) @ 5/23/21, 3:34:04 PM Japan Standard Time])

View

ContentView.swift
import SwiftUI

struct ContentView: View {
    @ObservedObject var viewModel: ViewModel
    var body: some View {
        VStack(spacing: 16) {
            HStack(spacing: 16) {
                Button("request") {
                    viewModel.requestAuthorization()
                }
                Button("start") {
                    viewModel.startTracking()
                }
                Button("stop") {
                    viewModel.stopTracking()
                }
            }
            Text(viewModel.authorizationStatus.description)
            Text(String(format: "longitude: %f", viewModel.longitude))
            Text(String(format: "latitude: %f", viewModel.latitude))
        }.onAppear {
            viewModel.activate()
        }.onDisappear {
            viewModel.deactivate()
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static let viewModel = ViewModel(model: LocationDataSource())
    static var previews: some View {
        ContentView(viewModel: viewModel)
    }
}

App

App.swift
import SwiftUI

@main
struct MyApp: App {
    @StateObject var viewModel = ViewModel(model: LocationDataSource())
    var body: some Scene {
        WindowGroup {
            ContentView(viewModel: viewModel)
        }
    }
}

Extension

Extension.swift
import CoreLocation

extension CLAuthorizationStatus: CustomStringConvertible {
  public var description: String {
    switch self {
    case .authorizedAlways:
      return "Always Authorized"
    case .authorizedWhenInUse:
      return "Authorized When In Use"
    case .denied:
      return "Denied"
    case .notDetermined:
      return "Not Determined"
    case .restricted:
      return "Restricted"
    @unknown default:
      return "🤷‍♂️"
    }
  }
}

参考

https://booth.pm/ja/items/2377560

https://learningswift.brightdigit.com/combine-corelocation-publishers-delegates/

Discussion