[Swift MapKit Tutorial Series] How To Customize the Map Annotations Callout, Request a Transit ETA and Launch the Transit Directions to your Destination

Update February 2017: Fully updated for Xcode 8 and Swift 3.

Since iOS 9, MapKit was enriched with more cool features that worth to try out and use to empower your map project. One of them is the Maps Transit feature and the ability to request a Transit ETA (Estimated Time of Arrival) as well as launching the Transit directions.

Let’s assume you are in London and you want to reach some nearby restaurants. iOS can now calculate the estimated Transit time needed to get to the restaurant. Besides, it allows you to open the iOS maps app with a provided Transit directions.

Here is a quick demo of what you gonna do in this tutorial.

Demo - How To Customize the map annotations Callout, Request for the Transit ETA and Launch the Transit Itinerary to your destination

In addition to the Transit ETA and directions implementation, you will customize the pin view callout by taking advantage of the MKAnnotationView‘s detailCalloutAccessoryView property.

Without further ado, let’s dive right in!

To begin with, download this images pack you will use on the project.

Open up Xcode, select “File\New\Project” from the menu, choose the Single View Application template and name the project “Restaurants Transit”.

Create new Xcode project

Setting up the UI

The user interface is pretty simple, a map view and a button to move from one restaurant to the next one.

Select the Main.storyboard file from the Project navigator view.

Note: Make sure you pick a device model to build on the UI, in my case I am using the iPhone SE screen.

Choose a device model to layout with from the "View as" panel in Xcode 8

Drag a UIToolbar from the Object library and place it at the bottom of the view.

Now let’s apply some Auto Layout constraints to the tool bar in order to keep it at the bottom of the screen.

Make sure the tool bar is selected, then select the Pin menu and set the leading, bottom and trailing constraints to 0, make sure to Uncheck the Constrain to margins checkbox, then hit the Add Constraints button to apply.

Set the leading, bottom and trailing Auto Layout constraints to the UIToolbar

The tool bar came with a default bar button item, let’s change its default text. Select it from the Document Outline view then switch to the Attributes inspector and change the Title property value to “Next Restaurant”.

Change text on button item in the UIToolBar

That was easy 🙂

Another remaining easy step is to set up the map object in the view. To do so, drag an MKMapView object from the Object library and adjust it manually in a way to fill all the rest of the view above the tool bar. Then, set the (leading, bottom, trailing and top) Auto Layout constraints for the map to 0 from the Pin menu, so that it will always stick to the screen margins.

Place the MKMapView object with Auto Layout

To finish up with the UI, you need to hook up an IBOutlet reference for the map view to the code as well as an IBAction for the button.

Let’s quickly do that, switch to the Assistant editor and make sure the ViewController.swift file is loaded in the split editor along with the storyboard. Now, ctrl click on the map view and drag a line to the code, set the outlet name to “mapView” and hit the “Connect” button. Next, ctrl click on the tool bar button and drag a line to the code, this time, set the Connection type to “Action” and name it “showNext”.

Here is a quick animation for more clarity.

Hook up map view and tool bar button to the code

xozlp

Yes, we are done with the UI, don’t forget to import all the images you downloaded previously to the project (drag all images to the Project navigator view and make sure the “Copy items if needed” checkbox is checked).

Final Project navigator view

Time for code now 🙂

First thing to do is to import the MapKit framework. Select the ViewController.swift file from the Project navigator view and add the following import statement at the top of the class:

import MapKit

Next, let’s add some properties. Copy the following declarations right after the class name:

var names:[String]!
var images:[UIImage]!
var descriptions:[String]!
var coordinates:[Any]!
var currentRestaurantIndex: Int = 0
var locationManager: CLLocationManager! // A reference to the location manager

The four array properties will store the relevant data for the restaurants (names, images, descriptions and coordinates). As for the currentRestaurantIndex property, it is important to keep track of the current displayed restaurant and hence to get the right infos for the next restaurant to locate in the map.

Let’s fill the arrays you declared above with some data. Locate the viewDidLoad method and implement the following code inside (right after the super.viewDidLoad() call):

// Some restaurants in London
names = ["Pied a Terre", "Big Ben", "Hawksmoor Seven Dials", "Enoteca Turi", "Wiltons", "Scott's", "The Laughing Gravy", "Restaurant Gordon Ramsay"]

// Restaurants' images to show in the pin callout         
images = [UIImage(named: "restaurant-1.jpeg")!, UIImage(named: "restaurant-2.jpeg")!, UIImage(named: "restaurant-3.jpeg.jpg")!, UIImage(named: "restaurant-4.jpeg")!, UIImage(named: "restaurant-5.jpeg")!, UIImage(named: "restaurant-6.jpeg")!, UIImage(named: "restaurant-7.jpeg")!, UIImage(named: "restaurant-8.jpeg")!]
        
// Latitudes, Longitudes
coordinates = [
        [51.519066, -0.135200],
        [51.513446, -0.125787],
        [51.465314, -0.214795],
        [51.507747, -0.139134],
        [51.509878, -0.150952],
        [51.501041, -0.104098],
        [51.485411, -0.162042],
        [51.513117, -0.142319]
]
      
currentRestaurantIndex = 0 // Start with the first Restaurant in the array

Usually you want to calculate the Transit ETA between your current location and a destination point (the restaurant in this case). Hence, let’s locate the user current location in the map.

Always inside the viewDidLoad, copy the following code at the end of the method:

// Ask for user permission to access location infos
locationManager = CLLocationManager()
locationManager.requestWhenInUseAuthorization()
// Show the user current location 
mapView.showsUserLocation = true
mapView.delegate = self

Before accessing the user’s location information, you need to ask for user permission. Once user grants permission, the location manager will be able to get the current location.

Also, you need to add the NSLocationWhenInUseUsageDescription key to the Info.plist file. Remember, this key specifies the reason for accessing the user’s location information and is supported since iOS 8.

Select the Info.plist file from the Project navigator view.

The Info.plist in the Project navigator view

Then add the NSLocationWhenInUseUsageDescription with some message to show along with the alert, something like this:

Add the NSLocationWhenInUseUsageDescription Key to the Info.plist file

Before you run the app, change the class declaration like below, so that it conforms to the MKMapViewDelegate protocol (since the current class is the delegate of the map view).

class ViewController: UIViewController, MKMapViewDelegate {

Also, you need a way to move and center the visible region on the map around the user current location each time it gets changed. Seems like the mapView:didUpdateUserLocation: protocol method is the best place to do so. Go ahead and copy the following code before the closing bracket of the class:

//MARK: MKMapViewDelegate
func mapView(_ mapView: MKMapView, didUpdate userLocation: MKUserLocation) {
        let region = MKCoordinateRegion(center: self.mapView.userLocation.coordinate, span: MKCoordinateSpan(latitudeDelta: 0.1, longitudeDelta: 0.1))
        mapView.setRegion(region, animated: true)
}

So far so good, run the app to check everything is going right. You should get the permission alert like below:

NSLocationWhenInUseUsageDescription, location, permission alert

Click the “Allow” button and change the simulated location to “London, England” from the Location pop-up menu at the top of the Debug area:

Change the simulated location from the Debug area

You will notice the app moves to the selected region in London! Good job so far!

MKMapView current user location

Placing The Restaurants On The Map

Now as the current location is tracked, you gonna show some nearby restaurants in the London area. The code will be implemented in the action method so that it runs each time you press the tool bar button to move to the next restaurant in the list.

Locate the showNext action method and place the following code inside:

         // 1
        if currentRestaurantIndex > names.count - 1{
            currentRestaurantIndex = 0
        }
        // 2
        let coordinate = coordinates[currentRestaurantIndex] as! [Double]
        let latitude: Double   = coordinate[0]
        let longitude: Double  = coordinate[1]
        let locationCoordinates = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
        
        var point = RestaurantAnnotation(coordinate: locationCoordinates)
        point.title = names[currentRestaurantIndex]
        point.image = images[currentRestaurantIndex]
        // 3
        // Calculate Transit ETA Request
        let request = MKDirectionsRequest()
        /* Source MKMapItem */
        let sourceItem = MKMapItem(placemark: MKPlacemark(coordinate: mapView.userLocation.coordinate, addressDictionary: nil))
        request.source = sourceItem
        /* Destination MKMapItem */
        let destinationItem = MKMapItem(placemark: MKPlacemark(coordinate: locationCoordinates, addressDictionary: nil))
        request.destination = destinationItem
        request.requestsAlternateRoutes = false
        // Looking for Transit directions, set the type to Transit
        request.transportType = .transit
        // Center the map region around the restaurant coordinates
        mapView.setCenter(locationCoordinates, animated: true)
        // You use the MKDirectionsRequest object constructed above to initialise an MKDirections object
        let directions = MKDirections(request: request)
        directions.calculateETA { (etaResponse, error) -> Void in
            if let error = error {
                print("Error while requesting ETA : \(error.localizedDescription)")
                point.eta = error.localizedDescription
            }else{
                point.eta = "\(Int((etaResponse?.expectedTravelTime)!/60)) min"
            }
            // 4
            var isExist = false
            for annotation in self.mapView.annotations{
                if annotation.coordinate.longitude == point.coordinate.longitude && annotation.coordinate.latitude == point.coordinate.latitude{
                    isExist = true
                    point = annotation as! RestaurantAnnotation
                }
            }
            if !isExist{
                self.mapView.addAnnotation(point)
            }
            self.mapView.selectAnnotation(point, animated: true)
            self.currentRestaurantIndex += 1
          }

Let’s breakdown the code above to get a deep understanding on how it goes:

// 1: This is a simple test as you click the tool bar button to iterate over the array items. If you reach the last restaurant in the list, it will be reset to 0 to show the first item and goes from there.

// 2: Here you used the currentRestaurantIndex value to get the restaurant coordinates, name and image informations. Then, you constructed a custom annotation object to store those informations for later use in the mapView:viewForAnnotation: protocol method.

// 3: Here is the main part; In order to calculate the Transit ETA (Estimated Time of Arrival) for each restaurant, you need two locations (source and destination). The source point is the user current location coordinates, while the destination is the restaurant we want to reach. After preparing those informations and initializing an MKDirectionsRequest object, it’s time to launch the ETA request. To do this, you invoked the calculateETA API on the directions object.

The calculateETA API runs asynchronously, and its completion handler will run on the main thread, this is where you check for the returned results. If no error occurred, you converted the ETA estimated time to minutes (since it’s returned in seconds) and assign it to the custom annotation eta property.

// 4: This is to check whether the restaurant pin to show was already shown before on the map view. If it’s new annotation, then add it, if not, then reuse the current annotation in the map view annotations array.

The selectAnnotation API will programmatically select the specified annotation and display a callout view for it.

Once a pin is selected, a callout view needs some informations to display. Since you decided to customize the callout, you will need to implement one more MKMapViewDelegate protocol method to take care of the view customization.

Before the closing bracket of the class, implement the following code:

func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
        // If annotation is not of type RestaurantAnnotation (MKUserLocation types for instance), return nil
        if !(annotation is RestaurantAnnotation){
            return nil
        }
        
        var annotationView = self.mapView.dequeueReusableAnnotationView(withIdentifier: "Pin")
        
        if annotationView == nil{
            annotationView = MKPinAnnotationView(annotation: annotation, reuseIdentifier: "Pin")
            annotationView?.canShowCallout = true
        }else{
            annotationView?.annotation = annotation
        }
        
        let restaurantAnnotation = annotation as! RestaurantAnnotation
        annotationView?.detailCalloutAccessoryView = UIImageView(image: restaurantAnnotation.image)
        
        // Left Accessory
        let leftAccessory = UILabel(frame: CGRect(x: 0,y: 0,width: 50,height: 30))
        leftAccessory.text = restaurantAnnotation.eta
        leftAccessory.font = UIFont(name: "Verdana", size: 10)
        annotationView?.leftCalloutAccessoryView = leftAccessory
        
        // Right accessory view
        let image = UIImage(named: "bus.png")
        let button = UIButton(type: .custom)
        button.frame = CGRect(x: 0, y: 0, width: 30, height: 30)
        button.setImage(image, for: UIControlState())
        annotationView?.rightCalloutAccessoryView = button
        return annotationView
}

Since the callout customization is only restricted to “RestaurantAnnotation” instances, you need to skip all other types of annotations (like MKUserLocation types). In such cases, you just returned nil as you did above.

The rest of the code is just for setting up the layout of the callout view. Note the detailCalloutAccessoryView property, introduced in iOS 9 which now allows to natively add custom content to the callout view, such us images and long text views.

Before you run the app, let’s add the famous RestaurantAnnotation class to the project.

Select “File\New\File” from the menu, choose the “Cocoa Touch Class” file type and name it RestaurantAnnotation. Make sure it’s an NSObject subclass.

Add an NSObject subclass to Xcode

Next, select the RestaurantAnnotation.swift file from the Project navigator view and change the class content to the following:

import UIKit
import MapKit

class RestaurantAnnotation: NSObject, MKAnnotation {
    
    var coordinate: CLLocationCoordinate2D
    var title: String?
    var image: UIImage?
    var eta: String?
    
    init(coordinate: CLLocationCoordinate2D) {
        self.coordinate = coordinate
    }
}

xpa0r

Alright, run the app and make sure to point your simulated location to “London, England”. Then click the tool bar button to start showing up the restaurants!

Show restaurants pin on the map - Demo

You are almost done, the view callout has all the informations you set up earlier in the mapView:annotation: protocol method. One last thing to do is to call the iOS maps app in order to launch the Transit directions to the restaurant.

To do so, select the ViewController.swift file to load it in the editor, and implement the following protocol method before the closing bracket of the class:

func mapView(_ mapView: MKMapView, annotationView view: MKAnnotationView, calloutAccessoryControlTapped control: UIControl) {
        let placemark = MKPlacemark(coordinate: view.annotation!.coordinate, addressDictionary: nil)
        // The map item is the restaurant location
        let mapItem = MKMapItem(placemark: placemark)
        
        let launchOptions = [MKLaunchOptionsDirectionsModeKey:MKLaunchOptionsDirectionsModeTransit]
        mapItem.openInMaps(launchOptions: launchOptions)
}

The above method is also an MKMapViewDelegate protocol method, it runs each time you click one of the annotation view’s accessory buttons (in our case the bus button on the right accessory view). This is the correct place and time to launch the iOS maps app to draw the Transit directions to your destination.

Also note that specifying the MKLaunchOptionsDirectionsModeKey option in the launchOptions dictionary above will make the Maps app interprets that as an attempt to map from the user’s current location to the location specified by the map item constant (which is the restaurant location). So in such context, the maps app is clever enough and doesn’t need further specifications.

That’s it, run the app, browse some of Londons’ restaurants and click the bus button on the right of the callout view to get you some nice Transit directions 🙂

As usual, you can download the final project here.

Feel free to leave a comment below and tell me whether you consider supporting Transit directions on your next maps app? I’d love to hear from you!

Malek
iOS developer with over than 11 years of extensive experience working on several projects with different sized startups and corporates.

15 Comments

  1. Thank you for presenting your superb solution to customizing annotation callouts!
    One of the many reasons I am so pleased about your tutorial is because it works with the newest features of IOS9. It is not making things complicated and awkward by using XIB files or .h / .m classes like other tutorials/code templates do. I encourage everyone who is interested in developing custom callouts to study your tutorial!

  2. Great tutorial! I am trying to implement the part where you calculate the ETA to a restaurant from the user’s current location, in my code. I have placed the ETA algorithm of the tutorial in a function called ‘calculateETA() -> Object’ which returns an object. However, when I call calculateETA(), the

    directions.calculateETAWithCompletionHandler

    line is being bypassed the first time the program encounters the line, and the function returns, then the line gets executed after the function has returned. This is really funky behavior and I have verified this with ‘print()’ statements throughout my code and calculateETA(). I assume I don’t fully understand how ‘calculateETAWithCompletion’ works, so I was reading Apple’s documentation on the function and there’s a function ‘calculating’ which returns ‘true’ if an MKDirections process is still executing. So, I’ve placed a print statement right before calculateETAWithCompletionHandler that prints whether MKDirections is still calculating the ETA, but it always returns ‘false’.

    Any help is greatly appreciated.

  3. Hi Ivan 🙂
    You are getting the obvious behavior. Since calculateETAWithCompletion is an asynchronous API, it will run on the background and only executes its completion handler from the app’s main thread. That explains why the function you wrote (calculateETA) returns before the calculateETAWithCompletion finish its work.
    Solution: Define your calculateETA function with a completion callback parameter then – inside the calculateETAWithCompletion completion handler – call the callback you just defined as parameter (in calculateETA).

    Happy coding!

  4. When I try to run it to my Ipad or Iphone i get the error Ambiguous use of subscript. (let latitude: Double = coordianate [0] as! Double)

  5. Hi Steve, please use the following (I will update the tutorial for iOS 10):
    let coordinate:[Double] = coordinates[currentRestaurantIndex] as! [Double]
    let latitude: Double = coordinate[0]

  6. Hello, I did the exercise and it works. How do I show the pins of restaurants with annotation at the same time as my location? Thanks for your feedback

  7. How I can show distance on annotation from my current location to a point. I want to use an array of coordinates for annotation points. Can you help me please ??

  8. Hey, Malek_T ,Very Happy to see u Helping many peoples, I am having a Problem Regarding That How to Show Directions from “Current Location” to” Destination”? in the Mapkit i want to Draw the Directions From Current Location to the Destination Can u Please Make a Video or Mail me the following Code or Program to me
    My Email id: shaikbaji1911@gmail.com, Thank u in Advance

Comments are closed.