見出し画像

UIViewRepresentableを外部から更新したいときに困った

おはようございます。ママさんエンジニアのトモヨです。
現在、SwiftUIで地図を用いたアプリを作成中ですが、表示中の領域の距離を画面に表示する方法に困ったことがあったので記載します。

環境

Xcode13.3
Swift5

やりたいこと

  • 画面に表示されている地図の東西の距離を表示する

  • ズームイン、ズームアウトでリアルタイムで反映させる。

クラス構成

  • MapView ・・・delegate通知を受け取る。画面端から端の東西の距離を計算する。viewModelへ距離を通知する(send)

  • ViewModel・・・距離を受け取る

  • View・・・表示する

// MapView
import SwiftUI
import MapKit
import Combine
import UIKit

struct MapView: UIViewRepresentable {
    @ObservedObject var viewModel: MapViewModel
    private var coordinate: CLLocationCoordinate2D
    private let mapView = MKMapView(frame: .zero)
    private let defaultMeter = CLLocationDistance(TokyoDomeInfo.diameter)
    
    init(viewModel: MapViewModel) {
        self.viewModel = viewModel
        self.coordinate = viewModel.mapCenter
    }
    
    func makeUIView(context: Context) -> MKMapView {
        // 略
        return mapView
    }
    
    func updateUIView(_ view: MKMapView, context: Context) {
        let region = MKCoordinateRegion(center: self.coordinate, latitudinalMeters: defaultMeter, longitudinalMeters: defaultMeter)
            view.setRegion(region, animated: true)
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    final class Coordinator: NSObject, MKMapViewDelegate {
        var parent: MapView
        init(_ parent: MapView) {
            self.parent = parent
        }
        
        func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
            parent.viewModel.distanceSubject.send(mapView.regionInMeter() / 2 as Double)
            parent.viewModel.centerLocation = mapView.centerCoordinate
        }
    }
}
extension MKMapView {
  // 画面上に表示されている東西の距離(m)
  func regionInMeter() -> CLLocationDistance {
      return region.span.latitudeDelta * 111 * 1000
  }
}
// ViewModel
import CoreLocation
import Combine

final class MapViewModel: ObservableObject {
    var eastAndWestDistance: CLLocationDistance
    var centerLocation = CLLocationCoordinate2D()

    private(set) var distanceSubject = PassthroughSubject<Double, Never>()
    private var cancellableSet = Set<AnyCancellable>()
     
    init() {
        distanceSubject
            .throttle(for: .seconds(0.1), scheduler: DispatchQueue.main, latest: true)
            .sink { [weak self] distance in
            guard let self = self else { return }
            eastAndWestDistance = distance 
        }.store(in: &cancellableSet)    }
}
// View
Text("\(viewModel.eastAndWestDistance)")

困ったこと

mapViewDidChangeVisibleRegionが呼ばれたときに内部でupdateUIViewが呼ばれ、mapViewDidChangeVisibleRegionが再度呼ばれ・・・とループする

対応したこと

ViewModelにフラグを持たせる

// ViewModel
import CoreLocation
import Combine

final class MapViewModel: ObservableObject {
    // MapViewのupdateUIViewを呼ばれないようにするフラグ
    // updateUIViewで制御しないとmapViewDidChangeVisibleRegionが呼ばれたときにupdateUIViewが呼ばれループする
    var shouldUpdateView = true

    var eastAndWestDistance: CLLocationDistance
    var centerLocation = CLLocationCoordinate2D()

    private(set) var distanceSubject = PassthroughSubject<Double, Never>()
    private var cancellableSet = Set<AnyCancellable>()
     
    init() {
        distanceSubject
            .throttle(for: .seconds(0.1), scheduler: DispatchQueue.main, latest: true)
            .sink { [weak self] distance in
            guard let self = self else { return }
            eastAndWestDistance = distance 
        }.store(in: &cancellableSet)    }
}
// MapView
import SwiftUI
import MapKit
import Combine
import UIKit

struct MapView: UIViewRepresentable {
    @ObservedObject var viewModel: MapViewModel
    private var coordinate: CLLocationCoordinate2D
    private let mapView = MKMapView(frame: .zero)
    private let defaultMeter = CLLocationDistance(TokyoDomeInfo.diameter)
    
    init(viewModel: MapViewModel) {
        self.viewModel = viewModel
        self.coordinate = viewModel.mapCenter
    }
    
    func makeUIView(context: Context) -> MKMapView {
        // 略
        return mapView
    }
    
    func updateUIView(_ view: MKMapView, context: Context) {
        guard viewModel.shouldUpdateView else {
            viewModel.shouldUpdateView = true
            return
        }

        let region = MKCoordinateRegion(center: self.coordinate, latitudinalMeters: defaultMeter, longitudinalMeters: defaultMeter)
            view.setRegion(region, animated: true)
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    final class Coordinator: NSObject, MKMapViewDelegate {
        var parent: MapView
        init(_ parent: MapView) {
            self.parent = parent
        }
        
        func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
            // updateUIView内の処理をしないようにする
            parent.viewModel.shouldUpdateView = false
            parent.viewModel.distanceSubject.send(mapView.regionInMeter() / 2 as Double)
            parent.viewModel.centerLocation = mapView.centerCoordinate
        }
    }
}

原始的なのですが、今ではこの対応しかなさそうです。

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