4

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.

kcstricks
  • 1,489
  • 3
  • 17
  • 32
  • Have you checked this? https://stackoverflow.com/questions/57799548/navigationview-and-navigation-link-on-button-click-swift-ui/57799724#57799724 – Sagar Chauhan Mar 26 '20 at 06:47
  • show us your mapview code.... – Chris Mar 26 '20 at 06:50
  • @Chris I've added `MapView` and `CenterButton` code :) – kcstricks Mar 27 '20 at 01:58
  • maybe your locationmanager is defect....we don't know because you do not show us all relevant code, just some code... – Chris Mar 27 '20 at 06:54
  • @Chris, my apologies for leaving out code. I included the code that I thought was where the problem lied. I didn't want to readers like you to have to read through a bunch of other code if they didn't have to. But apparently it's necessary, so I apologize. I have included all code now. Nothing is missing. – kcstricks Mar 27 '20 at 22:39

3 Answers3

1

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)
        }
    }
}
Chris
  • 7,579
  • 3
  • 18
  • 38
  • Thanks @Chris! I've updated my question to provide the full, reproducible example. My apologies again for leaving it out originally. – kcstricks Mar 27 '20 at 22:41
  • Hey @Chris, thanks for taking the time to figure this out! I'm glad you at least learned a lot about Combine in the process :) Your updated solution does indeed work! "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" - do you know why this needs to happen in a separate class? I guess I just don't understand why assigning directly to a @State property in `assign(to:on:)` doesn't work. – kcstricks Mar 28 '20 at 19:12
0

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 ;)

Chris
  • 7,579
  • 3
  • 18
  • 38
  • Hey @Chris! My code works on a real device? I was trying it on the simulator, yes. However in the simulator's `Debug -> Location` menu, I selected "City Run", which simulates the user moving around in Cupertino, CA, and so in theory tapping on the "center map" button should result in the map recentering, even on the simulator. Are you sure I'm doing things correctly? I don't have a developer account at the moment, so I can't try out the code on a real device. – kcstricks Mar 28 '20 at 07:15
  • i don't know - did not test on device. just with my code it worked - this is what i meant. the problem in your code is that mapcenter never changes, although currentuser location changes.....so it never updates the mapview – Chris Mar 28 '20 at 08:36
  • the problem must be somewhere in your publisher chain...the output show the right value (coordinate), but mapcenter doesn't get the new/updated value.... – Chris Mar 28 '20 at 09:07
  • i updated my answer, it works now ;) and i learnt a lot about combine ;) – Chris Mar 28 '20 at 09:36
0

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

malhal
  • 26,330
  • 7
  • 115
  • 133