Changes to @Published array does not appear on Map() in MapKit

72 Views Asked by At

I am trying to implement map functionality into my app. I have run into issues with MapKit when using either UIKit and SwiftUI. Here's my problem. I have an @Published array called events inside a class called EventViewModel. I pass this view model as an EnvironmentObject as I use it in many views. In one view I take in user input and add an Event to the array. This is working properly, my Events render in a list design. However, I am also using the events array as annotationItems in my Map() as an event contains coordinates for a location. My problem is when I add a new event, a new annotation does not render on the map. However, if I have preloaded data in the @Published events array, the annotations load fine.

Here's the SwiftUI interfaced with UIKit

import SwiftUI

struct MapView: View {
    @EnvironmentObject var eventViewModel: EventViewModel
    
    var body: some View {
        MapViewController(eventViewModel: eventViewModel)
            .ignoresSafeArea()
    }
}

struct MapViewController: UIViewControllerRepresentable {
    typealias UIViewControllerType = ViewController
    
    let eventViewModel: EventViewModel
    
    func makeUIViewController(context: Context) -> ViewController {
        let vc = ViewController()
        vc.eventViewModel = eventViewModel
        return vc
    }
    
    func updateUIViewController(_ uiViewController: ViewController, context: Context) {
       
    }
}

class ViewController: UIViewController {
    var locationManager: CLLocationManager?
    var eventViewModel: EventViewModel? {
        didSet {
            subscribeToEventChanges()
        }
    }

    private var cancellables: Set<AnyCancellable> = []

    private func subscribeToEventChanges() {
           eventViewModel?.$events
               .receive(on: DispatchQueue.main)
               .sink { [weak self] _ in
                   self?.updateMapAnnotations()
               }
               .store(in: &cancellables)
    }

    private func updateMapAnnotations() {
       // Clear existing annotations on the map
       mapView.removeAnnotations(mapView.annotations)

       // Add new annotations based on the updated eventViewModel.events
       addEventPins()
        
       print("Update Map Annotations") // Get's into this function when an event is added
    }
    
    lazy var mapView: MKMapView = {
        let map = MKMapView()
        map.showsUserLocation = true
        map.isRotateEnabled = false
        map.translatesAutoresizingMaskIntoConstraints = false
        return map
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        locationManager = CLLocationManager()
        locationManager?.delegate = self
        locationManager?.requestWhenInUseAuthorization()
        locationManager?.requestLocation()
        
        mapView.delegate = self
        
        createMap()
        addEventPins()
    }
    
    private func createMap() {
        view.addSubview(mapView)
        
        mapView.widthAnchor.constraint(equalTo: view.widthAnchor).isActive = true
        mapView.heightAnchor.constraint(equalTo: view.heightAnchor).isActive = true
        mapView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        mapView.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
    }
    
    private func checkLocationAuthorization() {
        guard let locationManager = locationManager,
              let location = locationManager.location else { return }
        
        switch locationManager.authorizationStatus {
            case .authorizedAlways, .authorizedWhenInUse:
                let region = MKCoordinateRegion(
                    center: location.coordinate,
                    latitudinalMeters: 750,
                    longitudinalMeters: 750
                )
                mapView.setRegion(region, animated: true)
            case .notDetermined, .restricted:
                print("Location cannot be determined or is restricted")
            case .denied:
                print("Location services have been denied.")
            @unknown default:
                print("Unknown error occurred. Unable to get location.")
        }
    }
    
    private func addEventPins() {
        guard let eventViewModel = eventViewModel else { return }
        
        for event in eventViewModel.events {
            let annotation = MKPointAnnotation()
            annotation.coordinate = event.coordinate
            annotation.title = event.name
            annotation.subtitle = event.host
            
            let _ = LocationAnnotationView(annotation: annotation, reuseIdentifier: "eventAnnotation")
            
            mapView.addAnnotation(annotation)
            print(mapView.annotations) // Properly adds the annotation when an event is added
        }
    }
}

final class LocationAnnotationView: MKAnnotationView {
    override init(annotation: MKAnnotation?, reuseIdentifier: String?) {
       super.init(annotation: annotation, reuseIdentifier: reuseIdentifier)

       centerOffset = CGPoint(x: -125, y: -125)
        
       canShowCallout = true
       setupUI(name: annotation?.title ?? "")
   }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupUI(name: String?) {
        backgroundColor = .clear
        
        let hostingController = UIHostingController(rootView: Pin(name: name ?? ""))
        hostingController.view.frame = CGRect(x: 0, y: 0, width: 250, height: 250)
       
        hostingController.view.transform = CGAffineTransform(scaleX: 0.75, y: 0.75)
        
        addSubview(hostingController.view)
        
        hostingController.view.backgroundColor = .clear
    }
}

extension ViewController: MKMapViewDelegate {
    func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
        if annotation is MKUserLocation {
            return nil
        }

        var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: "locationAnnotation")
        
        if annotationView == nil {
            annotationView = LocationAnnotationView(annotation: annotation, reuseIdentifier: "locationAnnotation")
        } else {
            annotationView?.annotation = annotation
        }

        return annotationView
    }
}

extension ViewController: CLLocationManagerDelegate {
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        
    }
    
    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
        checkLocationAuthorization()
    }
    
    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        print(error)
    }
}


Lastly here is the createEvent() function I have in another view, it also uses

@EnvironmentObject var eventViewModel: EventViewModel

private func createEvent() {
        var coordinates = CLLocationCoordinate2D()
        let geocoder = CLGeocoder()
        geocoder.geocodeAddressString(self.location) {
            placemarks, error in
            let placemark = placemarks?.first
            let lat = placemark?.location?.coordinate.latitude
            let lon = placemark?.location?.coordinate.longitude
            
            // Format is Address, City, State Zipcode
            coordinates = CLLocationCoordinate2D(latitude: lon ?? CLLocationDegrees(), longitude: lat ?? CLLocationDegrees())
            print(coordinates)
            let event = Event(name: self.eventName, host: self.hosts.first ?? "No Host", date: self.date, coordinate: coordinates)
            
            let date = Calendar.current.startOfDay(for: event.date)
            
            eventViewModel.groupedEvents[date, default: []].append(event)
            eventViewModel.events.append(event)
            
            print(eventViewModel.events)
        }
    }

At first I started completely with the SwiftUI version of MapKit, but found the same issue of annotations not updating when an event is appended to the array. Made the switch to UIKit as many people said it gives more flexibility as the current version of MapKit is still fairly limited.

1

There are 1 best solutions below

1
On

Just change:

let eventViewModel: EventViewModel

to

let events: [Event]

Then implement:

func updateUIViewController(...

This will be called whenever the events change. Remove any from the map that are no longer in the array and add any new ones.

We don't use view model objects in SwiftUI, the View struct is the view model already. Also, a Combine pipeline going down the sink is usually a design flaw, if you implement update then you don't need any Combine.