12

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.

Matteo Pacini
  • 21,796
  • 7
  • 67
  • 74
MRF
  • 455
  • 1
  • 6
  • 13

1 Answers1

16

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

Matteo Pacini
  • 21,796
  • 7
  • 67
  • 74
  • Thank you @Matteo for your detailed guide. It worked well, however, I cannot change a State. As I mentioned in my code. I use a Bool State to add another view into the VStack, kinda like a details card. So when an annotation pressed I can trigger a view update by toggling the State var and add the view to ZStack. How can I trigger update it toggle the State var in the action parameter defied for the MapPin class? – MRF Jun 12 '19 at 20:35
  • init(selectedPin: Binding) { $selectedPin = selectedPin // "Cannot assign to property: '$selectedPin' is immutable"} – Serge Almazov Aug 14 '19 at 07:35
  • Updated for Xcode 6/7 - `$selectedPin` is now `_selectedPin` in the coordinator constructor. – Matteo Pacini Aug 29 '19 at 16:12
  • @MatteoPacini Hello, man! Thank you for example. But what if I would like to get map coordinates. I use UITapGestureRecognizer, but when I try to access coords -> i get relative coordinates of window, but no map. My handler is: `code` @objc func triggerTouchAction(gestureReconizer: UITapGestureRecognizer) { if gestureReconizer.state == .ended { // Window - nil let data = gestureReconizer.location(in: gestureReconizer.view) print("Hello, tap!", data) } } – Serge Almazov Aug 30 '19 at 17:07
  • @SergeAlmazov You may want to have a look at this: https://developer.apple.com/documentation/mapkit/mkmapview/1452503-convert – Matteo Pacini Aug 31 '19 at 10:03
  • @MatteoPacini I don't know how to access view in (Coordinator / makeUIView / updateUIView) structure. I use this structure to access UIKit component via SwiftUI. – Serge Almazov Sep 05 '19 at 17:19
  • @SergeAlmazov you can't (or shouldn't) access it inside the coordinator, but you can in `makeUIView` (look inside the body), and in `updateUIView` it's the first argument of the function. – Matteo Pacini Sep 05 '19 at 23:07
  • @MatteoPacini I just want to handle touch events to get lat/lng coordinates on map (UIKit component) in SwiftUI. That's why I can't access view with map. Touch handler is in Coordinator class. And I can't access view inside it. But I also can't handle touch events in makeUIView/updateUIView functions. – Serge Almazov Sep 06 '19 at 15:52
  • Any idea how to make `MKUserTrackingBarButtonItem` work this way? – Thomas Vos Dec 15 '19 at 12:56
  • @SergeAlmazov did you ever find a solution for accessing map coordinates from a gesture recognizer on a MKMapView using UIViewRepresentable + Coordinator? – dead_can_dance Sep 16 '20 at 10:18
  • Hi @MatteoPacini – how does the function `mapView` work? Why are there two different functions with the same name & parameters? When is `mapView` called? – Alex Fine Nov 01 '20 at 22:04
  • This is an awesome answer. My company is replacing one of our screens with swift UI and we also have to make a map. One of the problems I’ve run into is keeping track of the maps center point. You can use a binding. This works fine if you are updating your map via a state variable. But when you pan the map and update the binding via the map delegate, it causes a state updating loop (triggers a view update which sets the coordinate which triggers another view update). Is there a way around this. – Xaxxus Jan 23 '21 at 07:02