MapView Annotation in SwiftUI

Implementing MapView in SwiftUI

Page content

SwiftUI is very powerful for building an interactive UI at a fast pace. However, there are a couple of limitations still there such as some native API from UIKit like MKMapView from MapKit or search bar and other UIKit API. I will provide a tutorial for making a MapView in SwiftUI by using UIViewRepresentable as well as putting callback to the SwiftUI if we have clicked the annotation.

Some quick knowledge about several items below:

  • UIViewRepresentable : A wrapper for a UIKit view that you use to integrate that view into your SwiftUI view hierarchy

  • Coordinator : A SwiftUI view that represents a UIKit view controller can define a Coordinator type that SwiftUI manages and provides as part of the representable view’s context

  • MapKit : UIKit API for Map behavior such as MKMapView and Annotation View and other native Map behavior

First of all, we need to make our Model for displaying the item inside the map. The model we can put title and it’s coordinate (latitude and longitude).

CODE

 1final class Checkpoint: NSObject, MKAnnotation {
 2    let title: String?
 3    let countryCode: String?
 4    let coordinate: CLLocationCoordinate2D
 5
 6    init(title: String?, countryCode: String?, coordinate: CLLocationCoordinate2D) {
 7        self.title = title
 8        self.countryCode = countryCode
 9        self.coordinate = coordinate
10    }
11}

In the code above, the Checkpoint class is a class that represent our map point in the view. Once this model has been created let’s move to the creation of MapView

Create a new struct of MapView and make it conform to UIViewRepresentable.

 1import UIKit
 2import MapKit
 3import SwiftUI
 4
 5struct MapView: UIViewRepresentable {
 6
 7    // 1.
 8    var annotationOnTap: (_ title: String) -> Void
 9
10    // 2.   
11    @Binding var checkpoints: [Checkpoint]
12
13    /// 3. Used internally to maintain a reference to a MKMapView
14    /// instance when the view is recreated.
15    let key: String
16
17    private static var mapViewStore = [String : MKMapView]()
18
19    // 4.
20    func makeUIView(context: Context) -> MKMapView {
21        if let mapView = MapView.mapViewStore[key] {
22            mapView.delegate = context.coordinator
23            return mapView
24        }
25        let mapView = MKMapView(frame: .zero)
26        mapView.delegate = context.coordinator
27        MapView.mapViewStore[key] = mapView
28        return mapView
29    }
30
31    // 5.
32    func updateUIView(_ uiView: MKMapView, context: Context) {
33        uiView.addAnnotations(checkpoints)
34    }
35
36    // 6.
37    func makeCoordinator() -> MapCoordinator {
38        MapCoordinator(self)
39    }
40}
  1. AnnotationOnTap is a completion to notify SwiftUI if we have clicked an annotation from MKMapView

  2. @Binding is a property wrapper for checkpoints model that we need for this MapView for displaying each dot of the location

  3. This key is for storing a single MKMapView instance in the memory. Using mapViewStore for handling if there is an existing instance of MKMapView on this particular screen. Why do we need this? There is a bug on MKMapView (UIKit) if we move to another screen and SwiftUI rerendering the struct of the SwiftUI View that contains this MapView it will create new MapView instead of reusing it while the old one still on the memory. It causes some bottleneck on rendering UI part for both SwiftUI and UIKit on the same point and it can cause a crash after several times.

  4. This one overriding function from UIViewRepresentable to return the expected view

  5. This one overriding function from UIViewRepresentable to attach a new view or do some additional layouting. In this case, we add the checkpoint to each annotation

  6. This one also overriding function form UIViewRepresentable for coordinator which for mapping the delegation logic on MKMapViewDelegate

Okay once that view has been set up, now we can make the logic for notifying back to SwiftUI. We can not apply delegate in SwiftUI, so there is a Coordinator to put the business logic layer of pure Swift logic. Let make the MapCoordinator class.

 1final class MapCoordinator: NSObject, MKMapViewDelegate {    // 1.
 2    var parent: MapView
 3
 4    init(_ parent: MapView) {
 5        self.parent = parent
 6    }
 7
 8    deinit {
 9        print("deinit: MapCoordinator")
10    }    // 2.    
11    func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView) {
12        view.canShowCallout = true
13
14        let btn = UIButton(type: .detailDisclosure)
15        view.rightCalloutAccessoryView = btn
16    }
17
18    // 3.    
19    func mapView(_ mapView: MKMapView, annotationView view: MKAnnotationView, calloutAccessoryControlTapped control: UIControl) {
20        guard let capital = view.annotation as? Checkpoint, let placeName = capital.title else { return }
21        parent.annotationOnTap(placeName)
22    }
23
24}
  1. We need a reference to the MKMapView here for the coordinator able to return back the handler/logic we attach on it
  2. This one is the delegate function from MKMapView (Put MKMapViewDelegate on this class as well) for displaying the rightCalloutAccesoryView
  3. This one is for telling if we click on the accessory control and we will return back the placeName through the handler on the MapView

Once this has been set up now we can easily use the MapView on our SwiftUI.

 1struct SearchView: View {
 2
 3    @ObservedObject var viewModel: SearchViewModel = SearchViewModel()    
 4
 5    var body: some View {
 6        VStack {
 7                MapView(annotationOnTap: { title in
 8                    print("Title clicked", title)
 9                }, checkpoints: $viewModel.checkpoints, key: "SearchView")
10                    .frame(height: UIScreen.main.bounds.height)
11                    .offset(x: 0, y: 350)
12            }
13    }
14}

As you can see above we just need to pass the checkpoints model and key (can be anything) and viola, we can get the MapView working.