Assigning to @State in Combine's `assign(to:on:)` subject does not result in view update

2.4k Views Asked by At

I have a map view which has a button that, when pressed, should center the map on the user's current location. I am trying to achieve this using Swift's Combine framework. I tried solving this by adding a @State property called mapCenter and assigning to this property in Combine's assign(to:on:) subject, as follows:

struct MapWithButtonView: View {

    // What the current map view center should be.
    @State var mapCenter = CLLocationCoordinate2D(latitude: 42.35843, longitude: -71.05977)

    // A subject whose `send(_:)` method is being called from within the CenterButton view to center the map on the user's location.
    private var centerButtonTappedPublisher = PassthroughSubject<Bool, Never>()

    // A publisher that turns a "center button tapped" event into a coordinate.
    private var centerButtonTappedCoordinatePublisher: AnyPublisher<CLLocationCoordinate2D?, Never> {
        centerButtonTappedPublisher
            .map { _ in LocationManager.default.currentUserCoordinate }
            .eraseToAnyPublisher()
    }

    private var coordinatePublisher: AnyPublisher<CLLocationCoordinate2D, Never> {
        Publishers.Merge(LocationManager.default.$initialUserCoordinate, centerButtonTappedCoordinatePublisher)
            .replaceNil(with: CLLocationCoordinate2D(latitude: 42.35843, longitude: -71.05977))
            .eraseToAnyPublisher()
    }

    private var cancellableSet: Set<AnyCancellable> = []

    init() {
        // This does not result in an update to the view... why not?
        coordinatePublisher
            .receive(on: RunLoop.main)
            .handleEvents(receiveSubscription: { (subscription) in
                    print("Receive subscription")
                }, receiveOutput: { output in
                    print("Received output: \(String(describing: output))")
                }, receiveCompletion: { _ in
                    print("Receive completion")
                }, receiveCancel: {
                    print("Receive cancel")
                }, receiveRequest: { demand in
                    print("Receive request: \(demand)")
                })
            .assign(to: \.mapCenter, on: self)
            .store(in: &cancellableSet)
    }

    var body: some View {
        ZStack {
            MapView(coordinate: mapCenter)
                .edgesIgnoringSafeArea(.all)

            CenterButton(buttonTappedPublisher: centerButtonTappedPublisher)
        }
    }
}

The MapView is a UIViewRepresentable view and looks like this:

struct MapView: UIViewRepresentable {
    // The center of the map.
    var coordinate: CLLocationCoordinate2D

    func makeUIView(context: Context) -> MKMapView {
        let mapView = MKMapView(frame: .zero)
        mapView.showsUserLocation = true
        return mapView
    }

    func updateUIView(_ view: MKMapView, context: Context) {
        let span = MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02)
        let region = MKCoordinateRegion(center: coordinate, span: span)
        view.setRegion(region, animated: true)
    }
}

The CenterButton is a simple button that looks like this:

struct CenterButton: View {
    var buttonTappedPublisher: PassthroughSubject<Bool, Never>

    var body: some View {
        Button(action: {
            self.buttonTappedPublisher.send(true)
        }) {
            Image(systemName: "location.fill")
                .imageScale(.large)
                .accessibility(label: Text("Center map"))
        }
    }
}

And the LocationManager is an ObservableObject which publishes the user's current and initial location:

class LocationManager: NSObject, ObservableObject {

    // The first location reported by the CLLocationManager.
    @Published var initialUserCoordinate: CLLocationCoordinate2D?
    // The latest location reported by the CLLocationManager.
    @Published var currentUserCoordinate: CLLocationCoordinate2D?

    private let locationManager = CLLocationManager()

    static let `default` = LocationManager()

    private override init() {
        super.init()

        locationManager.delegate = self
        locationManager.desiredAccuracy = kCLLocationAccuracyBest
        locationManager.pausesLocationUpdatesAutomatically = true
        locationManager.activityType = .other
        locationManager.requestWhenInUseAuthorization()
    }
}

extension LocationManager: CLLocationManagerDelegate {

    func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
        switch status {
        case .authorizedAlways, .authorizedWhenInUse:
            NSLog("Location authorization status changed to '\(status == .authorizedAlways ? "authorizedAlways" : "authorizedWhenInUse")'")
            enableLocationServices()
        case .denied, .restricted:
            NSLog("Location authorization status changed to '\(status == .denied ? "denied" : "restricted")'")
            disableLocationServices()
        case .notDetermined:
            NSLog("Location authorization status changed to 'notDetermined'")
        default:
            NSLog("Location authorization status changed to unknown status '\(status)'")
        }
    }

    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        // We are only interested in the user's most recent location.
        guard let location = locations.last else { return }
        // Use the location to update the location manager's published state.
        let coordinate = location.coordinate
        if initialUserCoordinate == nil {
            initialUserCoordinate = coordinate
        }
        currentUserCoordinate = coordinate
    }

    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        NSLog("Location manager failed with error: \(error)")
    }

    // MARK: Helpers.

    private func enableLocationServices() {
        locationManager.startUpdatingLocation()
    }

    private func disableLocationServices() {
        locationManager.stopUpdatingLocation()
    }
}

Unfortunately, the above does not work. The view is never updated when the CenterButton is tapped. I ended up solving this problem by using an ObservableObject view model object with a @Published var mapCenter property, however I don't know why my initial solution using @State does not work. What is wrong with updating @State as I have done above?

Note that if trying to reproduce this, you will need to add the NSLocationWhenInUseUsageDescription key with a value such as "This app needs access to your location" in your Info.plist file in order to be able to grant location permissions.

3

There are 3 best solutions below

2
On BEST ANSWER

NEW ANSWER:

ok, i am a combine newbie but it didn't let me go, so i tried and tried...and now it works, even in simulator.

the problem was, that you did all in the struct/Contentview instead of doing the combine things in a seperate class which publishes the value you want to change.

check this out:

class Model : ObservableObject {

    @Published var mapCenter : CLLocationCoordinate2D = CLLocationCoordinate2D(latitude: 0, longitude: 0)


    // A subject whose `send(_:)` method is being called from within the CenterButton view to center the map on the user's location.
      public var centerButtonTappedPublisher = PassthroughSubject<Bool, Never>()

      // A publisher that turns a "center button tapped" event into a coordinate.
      private var centerButtonTappedCoordinatePublisher: AnyPublisher<CLLocationCoordinate2D?, Never> {
          centerButtonTappedPublisher
              .map { _ in
                  print ("new loc in pub: ", LocationManager.default.currentUserCoordinate)
                  return LocationManager.default.currentUserCoordinate

          }
          .eraseToAnyPublisher()
      }

      private var coordinatePublisher: AnyPublisher<CLLocationCoordinate2D, Never> {

          Publishers.Merge(LocationManager.default.$initialUserCoordinate, centerButtonTappedCoordinatePublisher)
              .replaceNil(with: CLLocationCoordinate2D(latitude: 2.0, longitude: 2.0))
          .eraseToAnyPublisher()
      }

      private var cancellableSet: Set<AnyCancellable> = []
      var cancellable: AnyCancellable?

      init() {
            // This does not result in an update to the view... why not?

            coordinatePublisher
                .receive(on: RunLoop.main)
    //            .handleEvents(receiveSubscription: { (subscription) in
    //                print("Receive subscription")
    //            }, receiveOutput: { output in
    //                print("Received output: \(String(describing: output))")
    //
    //            }, receiveCompletion: { _ in
    //                print("Receive completion")
    //            }, receiveCancel: {
    //                print("Receive cancel")
    //            }, receiveRequest: { demand in
    //                print("Receive request: \(demand)")
    //            })
                .assign(to: \.mapCenter, on: self)
                .store(in: &cancellableSet)

            print(cancellableSet)

            self.cancellable = self.coordinatePublisher.receive(on: DispatchQueue.main)
                                                      .assign(to: \.mapCenter, on: self)
        }
}

struct ContentView: View {

    @ObservedObject var model = Model()


    var body: some View {
        VStack {
            MapView(coordinate: model.mapCenter)
                .edgesIgnoringSafeArea(.all)

            CenterButton(buttonTappedPublisher: model.centerButtonTappedPublisher)
        }
    }
}

struct MapView: UIViewRepresentable {
    // The center of the map.
    var coordinate: CLLocationCoordinate2D

    let mapView = MKMapView(frame: .zero)

    func makeUIView(context: Context) -> MKMapView {
        mapView.showsUserLocation = true
        return mapView
    }

    func updateUIView(_ view: MKMapView, context: Context) {
        let span = MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02)
        let region = MKCoordinateRegion(center: coordinate, span: span)
        print("map new coordinate", coordinate)
        view.setRegion(region, animated: true)
    }
}

class LocationManager: NSObject, ObservableObject {

    // The first location reported by the CLLocationManager.
    @Published var initialUserCoordinate: CLLocationCoordinate2D?
    // The latest location reported by the CLLocationManager.
    @Published var currentUserCoordinate: CLLocationCoordinate2D?

    private let locationManager = CLLocationManager()

    static let `default` = LocationManager()

    private override init() {
        super.init()

        locationManager.delegate = self
        locationManager.desiredAccuracy = kCLLocationAccuracyBest
        locationManager.pausesLocationUpdatesAutomatically = true
        locationManager.activityType = .other
        locationManager.requestWhenInUseAuthorization()

        enableLocationServices()
    }
}

extension LocationManager: CLLocationManagerDelegate {

    func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
        switch status {
        case .authorizedAlways, .authorizedWhenInUse:
            NSLog("Location authorization status changed to '\(status == .authorizedAlways ? "authorizedAlways" : "authorizedWhenInUse")'")

            ()
        case .denied, .restricted:
            NSLog("Location authorization status changed to '\(status == .denied ? "denied" : "restricted")'")
            disableLocationServices()
        case .notDetermined:
            NSLog("Location authorization status changed to 'notDetermined'")
        default:
            NSLog("Location authorization status changed to unknown status '\(status)'")
        }
    }

    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        // We are only interested in the user's most recent location.
        guard let location = locations.last else { return }
        // Use the location to update the location manager's published state.
        let coordinate = location.coordinate
        if initialUserCoordinate == nil {
            initialUserCoordinate = coordinate
        }
        currentUserCoordinate = coordinate
    }

    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        NSLog("Location manager failed with error: \(error)")
    }

    // MARK: Helpers.

    public func enableLocationServices() {
        locationManager.startUpdatingLocation()
    }

    private func disableLocationServices() {
        locationManager.stopUpdatingLocation()
    }
}

struct CenterButton: View {
    var buttonTappedPublisher: PassthroughSubject<Bool, Never>

    var body: some View {
        Button(action: {
            self.buttonTappedPublisher.send(true)
        }) {
            Image(systemName: "location.fill")
                .imageScale(.large)
                .accessibility(label: Text("Center map"))
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

OLD ANSWER:

ok, because you don't give us a copyable reproducable example, i made an easy example which works. Just feel free to copy and use it for your problem.

struct MapView: UIViewRepresentable {

    var coordinate: CLLocationCoordinate2D

    func makeUIView(context: Context) -> MKMapView {
        let mapView = MKMapView(frame: .zero)
        mapView.showsUserLocation = true
        return mapView
    }

    func updateUIView(_ view: MKMapView, context: Context) {
        let span = MKCoordinateSpan(latitudeDelta: 1.02, longitudeDelta: 1.02)
        let region = MKCoordinateRegion(center: coordinate, span: span)
        view.setRegion(region, animated: true)
    }
}

struct ContentView: View {

    @State var coordinate = CLLocationCoordinate2D(latitude: 0, longitude: 0)

    var body: some View {
        VStack {
            Button(action: {
                self.coordinate.latitude += 10
                self.coordinate.longitude = 30
            }) {
                Text("new coordinate")
            }
            MapView(coordinate: coordinate)
        }
    }
}
0
On

You don't need a CLLocationManager for this, just access the current user annotation like this map.userLocation.location.coordinate in your UIViewRepresentable.

https://developer.apple.com/documentation/mapkit/mkuserlocation/1452415-location?language=objc

4
On

yes, your code works. Try this:

var body: some View {
        ZStack {
            MapView(coordinate: mapCenter)
                .edgesIgnoringSafeArea(.all)

            CenterButton(buttonTappedPublisher: centerButtonTappedPublisher)
        }.onAppear() {

            Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { (timer) in
                       self.mapCenter.latitude += 0.1
                   }
        }
    }

and you will see, that the map is moving constantly. Maybe you tried your code in simulator? there the user location never changes so nothing happens if you tap again on your button...try it on a real device and start moving ;)