This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We wrap an MKMapView, using a diff on the annotations to properly animate insertions and removals.

00:06 In this episode, we'll wrap an MKMapView in a SwiftUI view. In expanding upon Apple's tutorial on the same topic, we will add the ability to provide an array of annotations, and changes to this array should result in annotations being added and removed with animations.

Map View with Placemarks

00:43 We start by creating a MapView that conforms to UIViewRepresentable. For this, we have to implement the methods makeUIView and updateUIView. In the former, we create the UIView, and for demo purposes, we set its visible frame to a predefined region:

struct MapView: UIViewRepresentable {
    func makeUIView(context: UIViewRepresentableContext<MapView>) -> MKMapView {
        let v = MKMapView()
        v.visibleMapRect = .laufpark
        return v
    }
    
    func updateUIView(_ mapView: MKMapView, context: UIViewRepresentableContext<MapView>) {
        
    }
}

struct ContentView: View {
    var body: some View {
        MapView()
    }
}

02:09 That's all we need to do to integrate a map view into SwiftUI. In order to include some annotations, we add a property that holds an array of point annotations, which we then pass to the MKMapView:

struct MapView: UIViewRepresentable {
    var placemarks: [MKPointAnnotation]
    
    func makeUIView(context: UIViewRepresentableContext<MapView>) -> MKMapView {
        let v = MKMapView()
        v.visibleMapRect = .laufpark
        v.addAnnotations(placemarks)
        return v
    }
    
    func updateUIView(_ mapView: MKMapView, context: UIViewRepresentableContext<MapView>) {
        
    }
}

struct ContentView: View {
    var body: some View {
        MapView(placemarks: placemarks)
    }
}

02:58 The next step is handling updates to the annotations array.

Stepper

03:12 To quickly play around with updating the map view, we add a stepper that lets us grow and shrink the slice of the annotations array that's sent to the map view:

struct ContentView: View {
    @State var count = 0
    var body: some View {
        VStack {
            MapView(placemarks: Array(placemarks.prefix(count)))
            Stepper("Count \(count)", value: $count)
        }
    }
}

04:08 When we run the app, the map starts with zero annotations. And the map view doesn't yet update when we increase the count by using the stepper. That's because we never update the annotations of the MKMapView in the updateUIView method:

struct MapView: UIViewRepresentable {
    var placemarks: [MKPointAnnotation]
    
    func makeUIView(context: UIViewRepresentableContext<MapView>) -> MKMapView {
        let v = MKMapView()
        v.visibleMapRect = .laufpark
        return v
    }
    
    func updateUIView(_ mapView: MKMapView, context: UIViewRepresentableContext<MapView>) {
        mapView.removeAnnotations(mapView.annotations)
        mapView.addAnnotations(placemarks)
    }
}

04:58 When we increment and decrement the number of annotations with the stepper, we can see that all visible annotations flash because they all get removed and then added in every update.

Diffing

05:23 In order to make our updates smarter, we have to look at the current annotations in the MKMapView and compare them with the annotations in the placemarks array. By using the difference API on Array, we can find out which annotations have been added and removed.

06:00 The map view may have different types of annotations, so we have to first filter out any annotation that isn't an MKPointAnnotation:

struct MapView: UIViewRepresentable {
    // ...
    func updateUIView(_ mapView: MKMapView, context: UIViewRepresentableContext<MapView>) {
        let existing = mapView.annotations.compactMap { $0 as? MKPointAnnotation }
        // ...
    }
}

06:48 Now we can find the differences between the new placemarks annotations and the old annotations from the map view. For this, we need to tell the difference API whether or not two given annotations are equal, and since annotations are objects, we can use pointer equality to do so:

struct MapView: UIViewRepresentable {
    // ...
    func updateUIView(_ mapView: MKMapView, context: UIViewRepresentableContext<MapView>) {
        let existing = mapView.annotations.compactMap { $0 as? MKPointAnnotation }
        let diff = placemarks.difference(from: existing) { $0 === $1 }
        // ...
    }
}

07:50 Then we process the differences by adding and removing annotations as needed:

struct MapView: UIViewRepresentable {
    // ...
    func updateUIView(_ mapView: MKMapView, context: UIViewRepresentableContext<MapView>) {
        let existing = mapView.annotations.compactMap { $0 as? MKPointAnnotation }
        let diff = placemarks.difference(from: existing) { $0 === $1 }
        for change in diff {
            switch change {
            case .insert(_, let element, _): mapView.addAnnotation(element)
            case .remove(_, let element, _): mapView.removeAnnotation(element)
            }
        }
    }
}

09:01 We run the app and we tap the stepper a few times to add and remove some annotations. We see that most of the annotations — and not only the one that is being added or removed — are still being animated. This is caused by differences in how each of the two annotation arrays is sorted. When we get the annotations back from the map view, they can be in a different order than they were when we first passed them in.

10:02 We should sort the annotations before calculating the differences between the two arrays. And it doesn't really matter how we sort them, as long as the old and new array are both sorted in the same, stable way. The easiest way to achieve this is by sorting the annotations by latitude:

struct MapView: UIViewRepresentable {
    // ...
    func updateUIView(_ mapView: MKMapView, context: UIViewRepresentableContext<MapView>) {
        let existing = mapView.annotations.compactMap { $0 as? MKPointAnnotation }.sorted { (lhs, rhs) -> Bool in
            lhs.coordinate.latitude < rhs.coordinate.latitude
        }
        // ...
    }
}

11:05 In order to reuse the same sort function for the placemarks array, we pull the function:

extension MKPointAnnotation {
    static func <(lhs: MKPointAnnotation, rhs: MKPointAnnotation) -> Bool {
        lhs.coordinate.latitude < rhs.coordinate.latitude
    }
}

12:04 It's a bad idea to conform MKPointAnnotation to Comparable because we don't own the type, so instead we explicitly use the < function to sort both arrays:

struct MapView: UIViewRepresentable {
    // ...
    func updateUIView(_ mapView: MKMapView, context: UIViewRepresentableContext<MapView>) {
        let existing = mapView.annotations.compactMap { $0 as? MKPointAnnotation }.sorted(by: <)
        let diff = placemarks.sorted(by: <).difference(from: existing) { $0 === $1 }
        for change in diff {
            switch change {
            case .insert(_, let element, _): mapView.addAnnotation(element)
            case .remove(_, let element, _): mapView.removeAnnotation(element)
            }
        }
    }
}

12:16 Now we have a stable order, and the result from difference actually makes sense. This is proven by the fact that the annotation that is being added or removed is the only one that gets animated.

Set Difference

12:40 In this case, we could've used a Set to store the placemarks because their presentation doesn't rely on the order in which they're stored. In other cases where order does matter, such as in a list of table cells, the difference API is powerful because it allows us to animate changes in order.

13:28 There are lots of things we could do to further improve the integration between UIKit and SwiftUI. Instead of the hardcoded map region, we could use a binding for the map's visible rectangle and respond to the user panning the map. This would require us to set up a map view delegate via a coordinator in the UIViewRepresentable's context.

14:03 Another cool thing, which wouldn't be very difficult to do, is to use the UIViewRepresentable coordinator for drawing custom SwiftUI views that replace the built-in point annotations. We will return to this in a future episode.

Resources

  • Sample Code

    Written in Swift 5

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

135 Episodes · 48h28min

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes