SwiftUI+MVVMで位置情報を使用した地図表示したい〜アプリの設計からやってみる〜

こんにちは。ママさんエンジニアのトモヨです。
SwiftUIではMVVMのアーキテクチャとの相性がいいので、地図を利用した現在位置情報を表示してみます。
開発中のコードはこちらです。

こちらのコードを参考にしました

以下が画面です。
request…requestWhenInUseAuthorizationを呼びます
start…startUpdatingLocationを呼びます
stop…stopUpdatingLocationを呼びます
現在地…ViewModelのcurrentChangeSubjectをsendします。MapViewで受け取り位置情報を更新します。
2段目…CLAuthorizationStatusを表示します。
3段目…緯度
4段目…経度

MVVMでの実装ですので大まかにクラス設計をします。
とりあえず、Modelが位置情報を取得しViewModelに伝えることを考えてみます。
Model…LocationDataSource.swift。CLLocationManagerを保持し、delegateも受け取る。受け取った位置情報を保持するlocationSubject、authorizationStatusを保持するauthorizationSubjectを持つ。
ViewModel…MapViewModel.swift。Modelを保持しlocationSubject、authorizationSubjectを監視する。authorizationStatusとlocationを保持する。

ここまでで書きたいコードを整理してみます。

// Model
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()
    }
 }

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

     func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
         locationSubject.send(locations)
     }
 }
// ViewModel
import CoreLocation
import Combine
import SwiftUI

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

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

    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)
    }
}

(トラッキング開始とかその辺は置いといて)これでModelからの位置情報をViewModelに通知することができます。

ModelからViewModelに位置情報を更新することができたので、今度はViewModelからViewに位置情報を通知してあげます。
ViewModelのプロパティにlocationを作っています。@PublishedでPublisherを発行していますので、Viewはそれを購読します。

// ViewModel
@Published var location: CLLocation = .init()
// View
        Map(
           // 色々地図の設定
        })
           // ここ
            .onReceive(viewModel.$location) { locations in
                updateReigion(coordinate: CLLocationCoordinate2D(latitude: locations.coordinate.latitude, longitude: locations.coordinate.longitude))
            }

とりあえず位置情報が取得されるたびに、地図を中心に持ってくることができました。
本当は(0,0)の地点から移動する最初の場合だけにしたいですね。(…そのうち条件を付け足しておきます。)

実際にコードを書いていて思ったことは、Data完全にModelであるLocationDataSourceがModelの範囲を逸脱してしまいました。。。
gitでコードを漁ってみましたが、皆さんCLLocationManagerはManagerとして用いているようです。(Managerって名前だし当たり前ですね。)
引き続き試行錯誤してみます。

私はCombine初心者なので、最初なぜPassthroughSubjectをprivateにしてeraseToAnyPublisher()してAnyPublisher型に変換して渡すことが理解できませんでした。
PassthroughSubjectのまま他のクラスから閲覧されると.send()を呼ばれたりしてしまいます。他のクラスでは必要以上のデータ処理を行わないようにしているようです。下記の記事を参考にしました。


そもそもCombineが怪しい・・・う〜んここがもっと勉強しなきゃです。
続きます。

この記事が気に入ったらサポートをしてみませんか?