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

Customizing MKMapView annotations

Paul Hudson    @twostraws   

Adding annotations to an MKMapView is just a matter of dropping pins at the correct location, but in this app we want users to be able to tap the locations for more information, then tap again to edit that location. Making all this happen takes a little SwiftUI, a little UIKit, and a little MapKit, all rolled into one – it’s an interesting challenge!

The first step is to implement the mapView(_:viewFor:) method, which will be called if we want to provide a custom view to represent our map pin. We looked at this previously, but this time we’re going to use a more advanced solution that re-uses views for performance, and also adds a button that can be tapped for more information. MapKit handles that button press in a curious way, but it’s not hard, just a bit odd at first.

Anyway, the main thing here is reusing views for performance. Remember, creating views is expensive, so it’s best to create a handful and just recycle them as needed – just changing the text labels rather than destroying and recreating them each time.

MapKit gives us a nice and simple API for handling view reuse: we create a string identifier of our choosing, then call dequeueReusableAnnotationView(withIdentifier:) on the map view, passing in that identifier. If there is a view waiting to be recycled we’ll get it back and can reconfigure it as needed; if not, we’ll get back nil and need to create the view ourselves.

If we do get back nil it means we need to create the view, which means instantiating a new MKPinAnnotationView and giving it our annotation to display. However, we’re also going to set a property called rightCalloutAccessoryView, which is where we’ll place a button to show more information.

We’re not in SwiftUI land here, which means we can’t use the Button view. Instead, we need to use the UIKit equivalent, UIButton. I could spend a few hours teaching you about the intricacies of working with UIButton, but fortunately I don’t need to: when used with MapKit it’s only one line of code because we can use a built-in button style called .detailDisclosure – it looks like an “I” with a circle around it.

Like all delegate methods from UIKit and MapKit, this next one has a long name. So, the best thing to do is go inside the Coordinator class in MapView, and type “viewfor” to have Xcode’s code completion pop up. Hopefully the correct MKMapView method should pop up and you can press return to make it fill in the full method.

Once that’s done, edit it to this:

func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
    // this is our unique identifier for view reuse
    let identifier = "Placemark"

    // attempt to find a cell we can recycle
    var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier)

    if annotationView == nil {
        // we didn't find one; make a new one
        annotationView = MKPinAnnotationView(annotation: annotation, reuseIdentifier: identifier)

        // allow this to show pop up information
        annotationView?.canShowCallout = true

        // attach an information button to the view
        annotationView?.rightCalloutAccessoryView = UIButton(type: .detailDisclosure)
    } else {
        // we have a view to reuse, so give it the new annotation
        annotationView?.annotation = annotation
    }

    // whether it's a new view or a recycled one, send it back
    return annotationView
}

That won’t quite work yet, because even though we set canShowCallout to true MapKit won’t show call outs for annotations without a title. We don’t have a way of entering titles just yet, so for now we’ll just hard-code one – go back to ContentView.swift and add this line where we create the MKPointAnnotation for the locations array:

newLocation.title = "Example location"

If you run the app now you’ll find you can drop pins by pressing the + button, and you can then tap a pin to bring up the title – along with the little "i" button on the right. Making that button do something is where the fun comes in, albeit for a very specific definition of “fun”.

Things start off straightforward: we’re going to add two properties to our MapView that track whether we should show place details or not, and what place was actually selected. These will form another bridge between MKMapView and SwiftUI, so we’re going to mark them with @Binding.

Add these two properties now:

@Binding var selectedPlace: MKPointAnnotation?
@Binding var showingPlaceDetails: Bool

It’s down to you, but I prefer to place all my @Binding properties together, which affects how Swift creates its memberwise initializers.

Having those extra properties in place means we need to adjust the MapView_Previews struct to include them, like this:

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

Remember to adjust the order of those parameters based on how you arranged the properties in MapView!

Over in ContentView.swift we need to do much the same, although first we need some @State properties to pass in. So, start by adding these to ContentView:

@State private var selectedPlace: MKPointAnnotation?
@State private var showingPlaceDetails = false

We can now update its MapView line to pass those values in:

MapView(centerCoordinate: $centerCoordinate, selectedPlace: $selectedPlace, showingPlaceDetails: $showingPlaceDetails, annotations: locations)

When that showingPlaceDetails Boolean becomes true, we want to show an alert with the title and subtitle of the currently selected place, along with a button that lets users edit the place. We don’t have editing ready yet, but we can at least show the alert and connect it up to MapKit.

Start by adding this alert() modifier to the ZStack in ContentView:

.alert(isPresented: $showingPlaceDetails) {
    Alert(title: Text(selectedPlace?.title ?? "Unknown"), message: Text(selectedPlace?.subtitle ?? "Missing place information."), primaryButton: .default(Text("OK")), secondaryButton: .default(Text("Edit")) {
        // edit this place
    })
}

Finally, we need to update MapView so that tapping the "i" button for an annotation sets the selectedPlace and showingPlaceDetails properties. This is done by implementing a method with an even longer name than before, so the best thing to do is go inside the Coordinator class and type “mapviewcall” – Xcode’s code completion should offer a recommendation, and you can press return to fill it in.

This method, the important part of which is called calloutAccessoryControlTapped, gets called when the button is tapped, and it’s down to us to decide what should happen. In this instance, we’re going to start by checking we have an MKAnnotationView, and if so use that to set the selectedPlace property of the parent MapView. We can then also set showingPlaceDetails to true, which will in turn trigger the alert in ContentView – it’s another chain, this time connecting map pin taps to our alert.

Add this method to the Coordinator class now:

func mapView(_ mapView: MKMapView, annotationView view: MKAnnotationView, calloutAccessoryControlTapped control: UIControl) {
    guard let placemark = view.annotation as? MKPointAnnotation else { return }

    parent.selectedPlace = placemark
    parent.showingPlaceDetails = true
}

With that in place the next step of our project is complete, so please run it now – you should be able to drop a pin, tap on it to reveal more information, then press the "i" button to show an alert. This is coming together!

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: 4.0/5