Search code examples
swiftmapkitswiftuixcode11

Accessing MKMapView elements as UIViewRepresentable in the main (ContentView) SwiftUI view


I am using SwiftUI to display a map and if user tapped on an annotation, it pops up a detail view in the VStack. I have made the map view and inserted annotations in another SwiftUI file. I also made the detail view.

How can I access the annotations of that map in the main view file to define a .tapaction for them to use it for the detailed view?

I tried defining the view as MKMapView but it is not possible to do it for a UIViewRepresentable inside another SwiftUI view.

The main view (ContentView) code is:

struct ContentView: View {
    @State private var chosen = false

    var body: some View {

        VStack {
            MapView()
            .edgesIgnoringSafeArea(.top)
            .frame(height: chosen ? 600:nil)
            .tapAction {
            withAnimation{ self.chosen.toggle()}
            }

    if chosen {
        ExtractedView()
            }
        }
    }
}

The MapView code is:

struct MapView : UIViewRepresentable {
    @State private var userLocationIsEnabled = false
    var locationManager = CLLocationManager()
    func makeUIView(context: Context) -> MKMapView {
    MKMapView(frame: .zero)

    }
    func updateUIView(_ view: MKMapView, context: Context) {

        view.showsUserLocation = true

        .
        .
        .

            let sampleCoordinates = [
                CLLocation(latitude: xx.xxx, longitude: xx.xxx),
                CLLocation(latitude: xx.xxx, longitude: xx.xxx),
                CLLocation(latitude: xx.xxx, longitude: xx.xxx)
                ]
            addAnnotations(coords: sampleCoordinates, view: view)

        }
    }

}

I expect to be able to access map view annotations and define tapaction in another view.


Solution

  • In SwiftUI DSL you don't access views.

    Instead, you combine "representations" of them to create views.

    A pin can be represented by an object - manipulating the pin will also update the map.

    This is our pin object:

    class MapPin: NSObject, MKAnnotation {
    
        let coordinate: CLLocationCoordinate2D
        let title: String?
        let subtitle: String?
        let action: (() -> Void)?
    
        init(coordinate: CLLocationCoordinate2D,
             title: String? = nil,
             subtitle: String? = nil,
             action: (() -> Void)? = nil) {
            self.coordinate = coordinate
            self.title = title
            self.subtitle = subtitle
            self.action = action
        }
    
    }
    

    Here's my Map, which is not just UIViewRepresentable, but also makes use of a Coordinator.

    (More about UIViewRepresentable and coordinators can be found in the excellent WWDC 2019 talk - Integrating SwiftUI)

    struct Map : UIViewRepresentable {
    
        class Coordinator: NSObject, MKMapViewDelegate {
    
            @Binding var selectedPin: MapPin?
    
            init(selectedPin: Binding<MapPin?>) {
                _selectedPin = selectedPin
            }
    
            func mapView(_ mapView: MKMapView,
                         didSelect view: MKAnnotationView) {
                guard let pin = view.annotation as? MapPin else {
                    return
                }
                pin.action?()
                selectedPin = pin
            }
    
            func mapView(_ mapView: MKMapView, didDeselect view: MKAnnotationView) {
                guard (view.annotation as? MapPin) != nil else {
                    return
                }
                selectedPin = nil
            }
        }
    
        @Binding var pins: [MapPin]
        @Binding var selectedPin: MapPin?
    
        func makeCoordinator() -> Coordinator {
            return Coordinator(selectedPin: $selectedPin)
        }
    
        func makeUIView(context: Context) -> MKMapView {
            let view = MKMapView(frame: .zero)
            view.delegate = context.coordinator
            return view
        }
    
        func updateUIView(_ uiView: MKMapView, context: Context) {
    
            uiView.removeAnnotations(uiView.annotations)
            uiView.addAnnotations(pins)
            if let selectedPin = selectedPin {
                uiView.selectAnnotation(selectedPin, animated: false)
            }
    
        }
    
    }
    

    The idea is:

    • The pins are a @State on the view containing the map, and are passed down as a binding.
    • Each time a pin is added or removed, it will trigger a UI update - all the pins will be removed, then added again (not very efficient, but that's beyond the scope of this answer)
    • The Coordinator is the map delegate - I can retrieve the touched MapPin from the delegate methods.

    To test it:

    struct ContentView: View {
    
        @State var pins: [MapPin] = [
            MapPin(coordinate: CLLocationCoordinate2D(latitude: 51.509865,
                                                      longitude: -0.118092),
                   title: "London",
                   subtitle: "Big Smoke",
                   action: { print("Hey mate!") } )
        ]
        @State var selectedPin: MapPin?
    
        var body: some View {
            NavigationView {
                VStack {
                    Map(pins: $pins, selectedPin: $selectedPin)
                        .frame(width: 300, height: 300)
                    if selectedPin != nil {
                        Text(verbatim: "Welcome to \(selectedPin?.title ?? "???")!")
                    }
                }
            }
    
        }
    
    }
    

    ...and try zooming/tapping the pin on London, UK :)

    enter image description here