SwiftUI+MVVMの設計で地図を用いた位置情報の表示をしてみる

こんにちは。ママさんエンジニアのトモヨです。
SwiftUIの練習に、地図と位置情報を用いたアプリを作成してみました。
アプリを起動すると位置情報を取得し現在地を中心に地図を表示するアプリです。画面はこんな感じです。

完成したコードです。

簡単なクラス構成です。
LocationManager・・・CLLocationManagerを持つ。shared
ContentView・・・メインのview。Textたちとmapを持つ
MapView・・・mapの基本的な操作をする。MKMapViewDelegateを実装する
MapViewModel・・・LocationManagerとやりとりし、viewに伝える

ContentView

まずはviewから実装します。こちらはTextとMapViewを保持しています。
CurrentLocationCenterButtonをタップすると現在地中心に地図を表示します。

struct ContentView: View {
    @ObservedObject var viewModel: MapViewModel
    var body: some View {
        VStack(spacing: 16) {
            HStack(spacing: 16) {
                Button("request") {
                    viewModel.requestAuthorization()
                }
                Button("start") {
                    viewModel.startTracking()
                }
                Button("stop") {
                    viewModel.stopTracking()
                }
                CurrentLocationCenterButton(action: {
                    // TODO: タップしたら現在地を中心にする
                })
            }
            Text(String(format: "longitude: %f", viewModel.longitude))
            Text(String(format: "latitude: %f", viewModel.latitude))
            MapView(viewModel: viewModel)
        }
    }
}

MapView

主に地図の機能を実装します。
MKMapViewDelegateを実装したいのでMapViewはUIViewRepresentableを継承させます。

struct MapView: UIViewRepresentable {
    @ObservedObject var viewModel: MapViewModel
    private let mapView = MKMapView(frame: .zero)
    
    init(viewModel: MapViewModel) {
        self.viewModel = viewModel
    }
    
    func makeUIView(context: Context) -> MKMapView {
        mapView.showsUserLocation = true
        return mapView
    }

    func updateUIView(_ view: MKMapView, context: Context) {
        // 現在地を中心にする
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    final class Coordinator: NSObject, MKMapViewDelegate {
        // 略
    }
}

困ったこと

ここで困ったのはMapViewはUIViewRepresentableを継承している関係でMapViewで作ったメソッドを呼び出せないことです。最初は現在地を中心にするメソッドを作成し、それをContentViewから呼び出せばいいと思っていました。MapViewはContentViewからはSwiftUIのViewとして扱われているので呼び出せませんでした。

解決方法

combineを用いて解決しました。

受け取り側

ViewModelにmapCenterを作成します。@Publishedを付与して変更があった場合に通知するようにします。

final class MapViewModel: ObservableObject {
   @Published var mapCenter = CLLocationCoordinate2D(latitude: 0, longitude: 0)
}

MapViewはcoordinateを保持します。MapViewModelをObservedObjectで保持しているので@Publishedで変更された情報をキャッチします。

struct MapView: UIViewRepresentable {
    @ObservedObject var viewModel: MapViewModel
    private var coordinate: CLLocationCoordinate2D
    private let mapView = MKMapView(frame: .zero)
    
    init(viewModel: MapViewModel) {
        self.viewModel = viewModel
        self.coordinate = viewModel.mapCenter
    }
}

送信側

ViewModelでPassthroughSubjectを実装します。
送信側のViewが.sendをすればMapViewModelが受け取る形です。

final class MapViewModel: ObservableObject {
    @Published var mapCenter = CLLocationCoordinate2D(latitude: 0, longitude: 0)
 
    private(set) var currentLocationCenterButtonTappedSubject = PassthroughSubject<Void, Never>()
    
    private var currentLocationCenterButtonTappedCoordinatePublisher: AnyPublisher<CLLocationCoordinate2D?, Never> {
        currentLocationCenterButtonTappedSubject
            .map { _ in
                print ("new loc in pub: ", LocationManager.shared.currentUserCoordinate)
                return LocationManager.shared.currentUserCoordinate
            }
            .eraseToAnyPublisher()
    }
    
    private var coordinatePublisher: AnyPublisher<CLLocationCoordinate2D, Never> {
        // nil考慮
        Publishers.Merge(LocationManager.shared.$initialUserCoordinate, currentLocationCenterButtonTappedCoordinatePublisher)
            .replaceNil(with: CLLocationCoordinate2D(latitude: 2.0, longitude: 2.0))
            .eraseToAnyPublisher()
    }
    
    private var cancellableSet = Set<AnyCancellable>()
    
    init() {
        // 受け取った時の処理
        self.coordinatePublisher.receive(on: DispatchQueue.main)
            .assign(to: \.mapCenter, on: self)
            .store(in: &cancellableSet)
    }
}

Viewではタップしたときに送信します。

// ContentView.swift
                CurrentLocationCenterButton(action: {
                    viewModel.currentLocationCenterButtonTappedSubject.send()
                })

考えたこと

一旦は現在地を中心に持ってこれましたが、UIViewRepresentable#updateUIViewの呼び出しタイミングが自分で制御できないため、意図しないタイミングで呼び出された場合、突然現在地中心になってしまうと思いました。
またupdateUIViewで現在地を中心にと固定してしまっているので、例えば指定した地点を中心に表示する場合はupdateUIView内でどう処理するのか悩みました。
UIViewRepresentableで複雑な処理はあまり推奨しないのかもしれません。

今回は緯度経度がModelとなるはずですが、Managerが保持してしまっているので、厳密にはMVVMのModelではないです。Modelとして作るのも微妙だったのでManagerが緯度経度を保持してViewModelが処理する形となりました。

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