NEW: Join my free 100 Days of SwiftUI challenge today! >>

Advanced MKMapView with SwiftUI

Paul Hudson    @twostraws   

This project is going to be based around a map view, asking users to add places to the map that they want to visit. To make this work we can’t just embed a simple MKMapView in SwiftUI and hope for the best: we need to track the center coordinate, whether or not the user is viewing place details, what annotations they have, and more.

So, we’re going to start with a basic MKMapView wrapper that has a coordinator, then quickly add some extras onto it so that it becomes more useful.

Create a new SwiftUI view called “MapView”, add an import for MapKit, then give it this code:

struct MapView: UIViewRepresentable {
    func makeUIView(context: Context) -> MKMapView {
        let mapView = MKMapView()
        mapView.delegate = context.coordinator
        return mapView
    }

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

    }

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    class Coordinator: NSObject, MKMapViewDelegate {
        var parent: MapView

        init(_ parent: MapView) {
            self.parent = parent
        }
    }
}

There’s nothing special there, so let’s change that immediately by making the map keep track of its center coordinate. As we looked at previously this means implementing the mapViewDidChangeVisibleRegion() method in our coordinator, but this time we’re going to pass that data up to the MapView struct so we can use @Binding to store the value somewhere else. So, the coordinator will receive the value from MapKit and pass it up to the MapView, that MapView puts the value in an @Binding property, which means it’s actually being stored somewhere else – we’ve made a little chain that connects MKMapView to whatever SwiftUI view is embedding the map.

Start by adding this property to MapView:

@Binding var centerCoordinate: CLLocationCoordinate2D

That will immediately break the MapView_Previews struct, because it needs to provide a binding. This preview isn’t really useful because MKMapView doesn’t work outside of the simulator, so I wouldn’t blame you if you just deleted it. However, if you really want to make it work you should add some example data to MKPointAnnotation so that it’s easy to reference:

extension MKPointAnnotation {
    static var example: MKPointAnnotation {
        let annotation = MKPointAnnotation()
        annotation.title = "London"
        annotation.subtitle = "Home to the 2012 Summer Olympics."
        annotation.coordinate = CLLocationCoordinate2D(latitude: 51.5, longitude: -0.13)
        return annotation
    }
}

With that in place it’s easy to fix MapView_Previews, because we can just use that example annotation:

struct MapView_Previews: PreviewProvider {
    static var previews: some View {
        MapView(centerCoordinate: .constant(MKPointAnnotation.example.coordinate))
    }
}

We’re going to add more to that in just a moment, but first I want to put it into ContentView. In this app users are going to be adding places to a map that they want to visit, and we’ll represent that with a full-screen MapView and a translucent circle on top to represent the center point. Although this view will have a binding to track the center coordinate, we don’t need to use that to place the circle – a simple ZStack will make sure the circle always stays in the center of the map.

First, add an extra import line so we get access to MapKit’s data types:

import MapKit

Second, add a property inside ContentView that will store the current center coordinate of the map. Later on we’re going to use this to add a place mark:

@State private var centerCoordinate = CLLocationCoordinate2D()

And now we can fill in the body property

ZStack {
    MapView(centerCoordinate: $centerCoordinate)
        .edgesIgnoringSafeArea(.all)
    Circle()
        .fill(Color.blue)
        .opacity(0.3)
        .frame(width: 32, height: 32)
}

If you run the app now you’ll see you can move the map around freely, but there’s always a blue circle showing exactly where the center is.

Although our blue dot will always be fixed at the center of the map, we still want ContentView to have its centerCoordinate property updated as the map moves around. We’ve connected it to MapView, but we still need to implement the mapViewDidChangeVisibleRegion() method in the map view’s coordinator to kick off that whole chain.

So, add this method to the Coordinator class of MapView now:

func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
    parent.centerCoordinate = mapView.centerCoordinate
}

All this work by itself isn’t terribly interesting, so the next step is to add a button in the bottom-right that lets us add place marks to the map. We’re already inside a ZStack, so the easiest way to align this button is to place it inside a VStack and a HStack with spacers before it each time. Both those spacers end up occupying the full vertical and horizontal space that’s left over, making whatever comes at the end sit comfortably in the bottom-right corner.

We’ll add some functionality for the button soon, but first let’s get it in place and add some basic styling to make it look good.

Please add this VStack below the Circle:

VStack {
    Spacer()
    HStack {
        Spacer()
        Button(action: {
            // create a new location
        }) {
            Image(systemName: "plus")
        }
        .padding()
        .background(Color.black.opacity(0.75))
        .foregroundColor(.white)
        .font(.title)
        .clipShape(Circle())
        .padding(.trailing)
    }
}

Notice how I added the padding() modifier twice there – once is to make sure the button is bigger before we add a background color, and the second time to push it away from the trailing edge.

Where things get interesting is how we place pins on the map. We’ve bound the center coordinate of the map to a property in our map view, but now we need to send data the other way – we need to make an array of locations in ContentView, and send those to the MKMapView to be displayed.

Solving this is best done by breaking the problem down into several smaller, simpler parts. The first part is obvious: we need an array of locations in ContentView, which stores all the places the user wants to visit.

So, start by adding this property to ContentView:

@State private var locations = [MKPointAnnotation]()

Next, we want to add a location to that whenever the + button is tapped. We aren’t going to add a title and subtitle yet, so for now this is just as simple as creating an MKPointAnnotation using the current value of centerCoordinate.

Replace the // create a new location comment with this:

let newLocation = MKPointAnnotation()
newLocation.coordinate = self.centerCoordinate
self.locations.append(newLocation)

Now for the challenging part: how can we synchronize that with the map view? Remember, we don’t want ContentView to even know that MapKit is being used – we want to isolate all that functionality inside MapView, so that we keep our SwiftUI code nice and clean.

This is where updateUIView() comes in: SwiftUI will automatically call it when any of the values being sent into the UIViewRepresentable struct have changed. This method is then responsible for synchronizing both the view and its coordinator to the latest configuration from the parent view.

In our case, we’re sending the centerCoordinate binding into MapView, which means every time the user moves the map that value changes, which in turn means updateUIView() is being called all the time. This has been happening quietly all this time because updateUIView() is empty, but if you add a simple print() call in there you’ll see it come to life:

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

Now as you move the map around you’ll see “Updating” printing again and again.

Anyway, all this matters because we can also pass into MapView the locations array we just made, and have it use that array to insert annotations for us.

So, start by adding this new property to MapView to hold all the locations we’ll pass to it:

var annotations: [MKPointAnnotation]

Second, we need to update MapView_Previews so that it sends in our example annotation, although again I wouldn’t blame you if you had already deleted the preview because it really isn’t useful at this time! Anyway, if you still have it then adjust it to this:

MapView(centerCoordinate: .constant(MKPointAnnotation.example.coordinate), annotations: [MKPointAnnotation.example])

Third, we need to implement updateUIView() inside MapView so that it compares the current annotations to the latest annotations, and if they aren’t the same then it replaces them. Now, we could compare each item in the annotations to see whether they are the same, but there isn’t any point – we can’t add and remove items at the same time, so all we need to do is check whether the two arrays contain the same number of items, and if they don’t remove all existing annotations and add them again.

Replace your current updateUIView() method with this:

func updateUIView(_ view: MKMapView, context: Context) {
    if annotations.count != view.annotations.count {
        view.removeAnnotations(view.annotations)
        view.addAnnotations(annotations)
    }
}

Finally, update ContentView so that it sends in the locations array to be converted into annotations:

MapView(centerCoordinate: $centerCoordinate, annotations: locations)

That’s enough map work for now, so go ahead and run your app again – you should be able to move around as much as you need, then press the + button to add pins.

One thing you might notice is how iOS automatically coalesces pins when they are placed close together. For example, if you place a few pins in a one kilometer region then zoom out, iOS will hide some of them to avoid making the map hard to read.

LEARN SWIFTUI FOR FREE I have a massive, free SwiftUI video collection on YouTube teaching you how to build complete apps with SwiftUI – check it out!

BUY OUR BOOKS
Buy Pro Swift Buy Swift Design Patterns Buy Testing Swift Buy Hacking with iOS Buy Swift Coding Challenges Buy Swift on Sundays Volume One Buy Server-Side Swift (Vapor Edition) Buy Advanced iOS Volume One Buy Advanced iOS Volume Two Buy Advanced iOS Volume Three Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with macOS Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Server-Side Swift (Kitura Edition) Buy Beyond Code

Was this page useful? Let us know!

Average rating: 3.5/5