4
votes

I have made a simple UIViewRepresentable from MKMapView. You can scroll the mapview, and the screen will be updated with the coordinates in the middle.

Here's the ContentView:

import SwiftUI
import CoreLocation

let london = CLLocationCoordinate2D(latitude: 51.50722, longitude: -0.1275)

struct ContentView: View {
    @State private var center = london

    var body: some View {
        VStack {
            MapView(center: self.$center)
            HStack {
                VStack {
                    Text(String(format: "Lat: %.4f", self.center.latitude))
                    Text(String(format: "Long: %.4f", self.center.longitude))
                }
                Spacer()
                Button("Reset") {
                    self.center = london
                }
            }.padding(.horizontal)
        }
    }
}

Here's the MapView:

struct MapView: UIViewRepresentable {
    @Binding var center: CLLocationCoordinate2D

    func makeUIView(context: Context) -> MKMapView {
        let mapView = MKMapView()
        mapView.delegate = context.coordinator
        return mapView
    }

    func updateUIView(_ uiView: MKMapView, context: Context) {
        uiView.centerCoordinate = self.center
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    class Coordinator: NSObject, MKMapViewDelegate {
        var parent: MapView

        func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
            parent.center = mapView.centerCoordinate
        }

        init(_ parent: MapView) {
            self.parent = parent
        }
    }
}

Tapping the reset button should simply set mapView.center to london. The current method will make the map scrolling super slow, and when the button is tapped, cause the error "Modifying state during view update, this will cause undefined behavior."

How should resetting the coordinates be communicated to the MKMapView, such that the map scrolling is fast again, and the error is fixed?

1
Updated with question stated more clearly.BaronSharktooth

1 Answers

3
votes

The above solution with an ObservedObject will not work. While you wont see the warning message anymore, the problem is still occurring. Xcode just isn't able to warn you its happening anymore.

Published properties in ObservableObjects behave almost identically to @State and @Binding. That is, they trigger a view update any time their objectWillUpdate publisher is triggered. This happens automatically when an @Published property is updated. You can also trigger it manually yourself with objectWillChange.send()

Because of this, it is possible to make properties that do not automatically cause view state to update. And we can leverage this to prevent unwanted state updates for UIViewRepresentable and UIViewControllerRepresentable structs.

Here is an implementation that will not loop when you update its view model from the MKMapViewDelegate methods:

struct MapView: UIViewRepresentable {

    @ObservedObject var viewModel: Self.ViewModel

    func makeUIView(context: Context) -> MKMapView{
        let mapview = MKMapView()
        mapview.delegate = context.coordinator
        return mapview
    }

    func updateUIView(_ uiView: MKMapView, context: Context) {
        // Stop update loop when delegate methods update state.
        guard viewModel.shouldUpdateView else {
            viewModel.shouldUpdateView = true
            return
        }
   
        uiView.centerCoordinate = viewModel.centralCoordinate
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    class Coordinator: NSObject, MKMapViewDelegate {
        private var parent: MapView
    
        init(_ parent: MapView) {
            self.parent = parent
        }
    
        func mapViewDidChangeVisibleRegion(_ mapView: MKMapView){
            // Prevent the below viewModel update from calling itself endlessly.
            parent.viewModel.shouldUpdateView = false
            parent.viewModel.centralCoordinate = mapView.centerCoordinate
        }
    }

    class ViewModel: ObservableObject {
        @Published var centerCoordinate: CLLocationCoordinate2D = .init(latitude: 0, longitude: 0)
        var shouldUpdateView: Bool = true
    }
}

If you really dont want to use an ObservableObject, the alternative is to put the shouldUpdateView property into your coordinator. Although I still prefer to use a viewModel because it keeps your UIViewRepresentable free of multiple @Bindings. You can also use the ViewModel externally and listen to it via combine.

Honestly, I'm surprised apple didn't consider this exact issue when they created UIViewRepresentable.

Almost all UIKit views will have this exact problem if you need to keep your SwiftUI state in sync with view changes.