SwiftUI+MVVMで位置情報を使用した地図表示したい〜アプリの設計からやってみる〜
こんにちは。ママさんエンジニアのトモヨです。
SwiftUIではMVVMのアーキテクチャとの相性がいいので、地図を利用した現在位置情報を表示してみます。
開発中のコードはこちらです。
こちらのコードを参考にしました
以下が画面です。
request…requestWhenInUseAuthorizationを呼びます
start…startUpdatingLocationを呼びます
stop…stopUpdatingLocationを呼びます
現在地…ViewModelのcurrentChangeSubjectをsendします。MapViewで受け取り位置情報を更新します。
2段目…CLAuthorizationStatusを表示します。
3段目…緯度
4段目…経度
![](https://assets.st-note.com/img/1648191217662-LzRFqwpkkk.png?width=1200)
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が怪しい・・・う〜んここがもっと勉強しなきゃです。
続きます。