Intermediate iOS 15 Programming with Swift

Chapter 8
How to Get Direction and Draw Route on Maps

Since the release of the iOS 7 SDK, the MapKit framework includes the MKDirections API which allows iOS developers to access the route-based directions data from Apple's server. Typically you create an MKDirections instance with the start and end points of a route. The instance then automatically contacts Apple's server and retrieves the route-based data.

You can use the MKDirections API to get both driving and walking directions depending on your preset transport type. If you like, MKDirections can also provide you alternate routes. On top of all that, the API lets you calculate the travel time of a route.

Again we'll build a demo app to see how to utilize the MKDirections API. After going through the chapter, you will learn the following stuff:

  • How to get the current user's location
  • How to use #available to handle multiple versions of APIs
  • How to compute the route and draw it on the map
  • How to use the segmented control
  • How to retrieve the route steps and display the detailed driving/walking instructions

The Sample Route App

I have covered the basics of the MapKit framework in the Beginning iOS Programming with Swift book, so I expect you have some idea about how MapKit works, and understand how to pin a location on a map. To demonstrate the usage of the MKDirections API, we'll build a simple map app. You can start with this project template (http://www.appcoda.com/resources/swift55/MapKitDirectionStarter.zip).

If you build the template, you should have an app that shows a list of restaurants. By tapping a restaurant, the app brings you to the map view with the location of the restaurant annotated on the map. If you have read our beginner book, that's pretty much the same as what you have implemented in the FoodPin app. We'll enhance the demo app to get the user's current location and display the directions to the selected restaurant.

Figure 8.1. The Food Map app running on iOS 10 and 11
Figure 8.1. The Food Map app running on iOS 10 and 11

There is one thing I want to point out. If you look into the MapViewController extension, you will find these lines of code:

if #available(iOS 11.0, *) {
    var markerAnnotationView: MKMarkerAnnotationView? = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) as? MKMarkerAnnotationView

    if markerAnnotationView == nil {
        markerAnnotationView = MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: identifier)
        markerAnnotationView?.canShowCallout = true
    }

    markerAnnotationView?.glyphText = "😋"
    markerAnnotationView?.markerTintColor = UIColor.orange

    annotationView = markerAnnotationView

} else {

    var pinAnnotationView: MKPinAnnotationView? = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) as? MKPinAnnotationView

    if pinAnnotationView == nil {
        pinAnnotationView = MKPinAnnotationView(annotation: annotation, reuseIdentifier: identifier)
        pinAnnotationView?.canShowCallout = true
        pinAnnotationView?.pinTintColor = UIColor.orange
    }

    annotationView = pinAnnotationView
}

As a demo, the current project is configured to run on iOS 10.0 or up. However, some of the APIs are available for the iOS 11 SDK or later. For example, the MKMarkerAnnotationView class was first introduced in iOS 11. On iOS 10, we will fallback to use the MKPinAnnotationView class. You can run the demo app on iOS 10 simulator and see what you get.

Similar to this project, if your app is going to support both iOS 10 and 11, you will need to check the OS version before calling some newer APIs. Otherwise, this will cause errors when the app runs on older versions of iOS.

Swift has a built-in support for API availability checking. You can easily define an availability condition such that the block of code will only be executed on certain iOS versions. You use the #available keyword in an if statement. In the availability condition, you specify the OS versions (e.g. iOS 10) you want to verify. The asterisk (*) is required and indicates that the if clause is executed on the minimum deployment target and any other versions of OS. In the above example, we will execute the code block only if the device is running on iOS 11 (or up).

Creating an Action Method for the Direction Button

Now, open the Xcode project and go to Main storyboard. The starter project already comes with the direction button, but it is not working yet.

Figure 8.2. The direction button in maps
Figure 8.2. The direction button in maps

What we are going to do is to implement this button. When a user taps the button, it shows the user's current location and displays the directions to the selected restaurant.

The map view controller has been associated with the MapViewController class. Now, create an empty action method named showDirection in the class. We'll provide the implementation in the later section.

@IBAction func showDirection(sender: UIButton) {
}

In the storyboard, establish a connection between the Direction button and the action method. Control-drag from the Direction button to the view controller icon in the dock. Select showDirectionWithSender: to connect with the action method.

Figure 8.3. Connecting the direction button with the action method
Figure 8.3. Connecting the direction button with the action method

Displaying the User Location on Maps

Since our app is going to display a route from the user's current location to the selected restaurant, we have to enable the map view to show the user's current location. By default, the MKMapView class doesn't display the user's location on the map. You can set the showsUserLocation property of the MKMapView class to true to enable it. Because the option is set to true, the map view uses the built-in Core Location framework to search for the current location and display it on the map.

In the viewDidLoad method of the MapViewController class, insert the following line of code:

mapView.showsUserLocation = true

If you can't wait to test the app and see how it displays the user location, you can compile and run the app. Select any of the restaurants to bring up the map. Unfortunately, it will not work as expected. The app doesn't show your current location.

Core Location has a feature known as Location Authorization. You have to explicitly ask for a user's permission to grant your app location services. Basically, you need to implement these two things to get the location working:

  • Request a user's authorization by calling the requestWhenInUseAuthorization or requestAlwaysAuthorization method of CLLocationManager.
  • Add a key (NSLocationWhenInUseUsageDescription / NSLocationAlwaysUsageDescription) to your Info.plist.

There are two types of authorization: requestWhenInUseAuthorization and requestAlwaysAuthorization. You use the former if your app only needs location updates when it's in use. The latter is designed for apps that use location services in the background (suspended or terminated). For example, a social app that tracks a user's location requires location updates even if it's not running in the foreground. Obviously, requestWhenInUseAuthorization is good enough for our demo app.

To do that, you will need to add a key to your Info.plist. Depending on the authorization type, you can either add the NSLocationWhenInUseUsageDescription or NSLocationAlwaysUsageDescription key to Info.plist. Both keys contain a message telling a user why your app needs location services.

In this project, let's add the NSLocationWhenInUseUsageDescription key in Info.plist. Select the file and right click any blank. Choose Add Row in the popover menu. For the key, set it to Privacy - Location When in Use Usage Description, which is actually the NSLocationWhenInUseUsageDescription key. For the value, you need to specify the reason (e.g. We need to find out your current location in order to compute the route.)

Figure 8.4. Adding a required key to get the user's approval for accessing the user's location
Figure 8.4. Adding a required key to get the user's approval for accessing the user's location

Now we are ready to modify the code again. First, declare a location manager variable in the MapViewController class:

let locationManager = CLLocationManager()

Insert the following lines of code in the viewDidLoad method right after super.viewDidLoad():

// Request for a user's authorization for location services
locationManager.requestWhenInUseAuthorization()
let status = CLLocationManager.authorizationStatus()

if status == CLAuthorizationStatus.authorizedWhenInUse {
    mapView.showsUserLocation = true
}

The first line of code calls the requestWhenInUseAuthorization method. The method first checks the current authorization status. If the user has not yet been asked to authorize location updates, it automatically prompts the user to authorize the use of location services.

Once the user makes a choice, we check the authorization status to see if the user granted permission. If yes, we enable showsUserLocation in the app.

Now run the app again and have a quick test. When you launch the map view, you'll be prompted to authorize location services. As you can see, the message shown is the one we specified in the NSLocationWhenInUseUsageDescription key. Remember to hit the Allow once or Allow While Using App button to enable the location updates.

Figure 8.5. When the app launches, you'll be prompted to authorize location services
Figure 8.5. When the app launches, you'll be prompted to authorize location services

Testing Location Using the Simulator

Wait! How can we simulate the current location using the built-in simulator? How can you tell the simulator where you are?

There is no way for the simulator to get the current location of your computer. However, the simulator allows you to fake its location. By default, the simulator doesn't simulate the location. You have to enable it manually. While running the app, you can use the Simulate location button (arrow button) in the toolbar of the debug area. Xcode comes with a number of preset locations. Just change it to your preferred location (e.g. New York). Alternatively, you can set the default location of your simulator. Just click your scheme > Edit Scheme to bring up the scheme editor. Select the Options tab and set the default location.

Once you set the location, the simulator will display a blue dot on the map which indicates the current user location. If you can't find the blue dot on the map, simply zoom out. In the simulator, you can hold down the option key to simulate the pinch-in and pinch-out gestures. For details, you can refer to Apple's official document.

Figure 8.6. Simulating the user's current location
Figure 8.6. Simulating the user's current location

Using MKDirections API to Get the Route info

With the user location enabled, we move on to compute the route between the current location and the location of the restaurant. First, declare a placemark variable in the MapViewController class:

var currentPlacemark: CLPlacemark?

This variable is used to save the current placemark. In other words, it is the placemark object of the selected restaurant. A placemark in iOS stores information such as country, state, city and street address for a specific latitude and longitude.

In the starter project, we already retrieve the placemark object of the selected restaurant. In the viewDidLoad method, you should be able to locate the following line:

let placemark = placemarks[0]

Next, add the following code right below it to set the value of currentPlacemark:

self.currentPlacemark = placemark

Next, we'll implement the showDirection method and use the MKDirections API to get the route data. Update the method by using the following code snippet:

@IBAction func showDirection(sender: AnyObject) {

    guard let currentPlacemark = currentPlacemark else {
        return
    }

    let directionRequest = MKDirections.Request()

    // Set the source and destination of the route
    directionRequest.source = MKMapItem.forCurrentLocation()
    let destinationPlacemark = MKPlacemark(placemark: currentPlacemark)
    directionRequest.destination = MKMapItem(placemark: destinationPlacemark)
    directionRequest.transportType = MKDirectionsTransportType.automobile

    // Calculate the direction
    let directions = MKDirections(request: directionRequest)

    directions.calculate { (routeResponse, routeError) -> Void in

        guard let routeResponse = routeResponse else {
            if let routeError = routeError {
                print("Error: \(routeError)")
            }

            return
        }

        let route = routeResponse.routes[0]
        self.mapView.addOverlay(route.polyline, level: MKOverlayLevel.aboveRoads)

    }
}

At the beginning of the method, we make sure if currentPlacemark contains a value using a guard statement. Otherwise, we just skip everything.

To request directions, we first create an instance of MKDirections.Request. The class is used to store the source and destination of a route. There are a few optional parameters you can configure such as transport type, alternate routes, etc. In the above code, we just set the source, destination and transport type while using default values for the rest of the options. The starting point is set to the user's current location. We use MKMapItem.mapItemForCurrentLocation to retrieve the current location. The end point of the route is set to the destination of the selected restaurant. The transport type is set to automobile.

With the MKDirectionsRequest object created, we instantiate an MKDirections object and call the calculate(completionHandler:) method. The method initiates an asynchronous request for directions and calls your completion handler when the request is completed. The MKDirections object simply passes your request to the Apple servers and asks for route-based directions data. Once the request completes, the completion handler is called. The route information returned by the Apple servers is returned as an MKDirectionsResponse object. MKDirectionsResponse provides a container for saving the route information so that the routes are saved in the routes property.

In the completion handler block, we first check if the route response contains a value. Otherwise, we just print the error. If we can successfully get the route response, we retrieve the first MKRoute object. By default, only one route is returned. Apple may return multiple routes if the requestsAlternateRoutes property of the MKDirectionsRequest object is enabled. Because we didn't enable the alternate route option, we just pick the first route.

With the route, we add it to the map by calling the addOverlay(_:level:) method of the MKMapView class. The detailed route geometry (i.e. route.polyline) is represented by an MKPolyline object. The addOverlay(_:level:) method is used to add an MKPolyline object to the existing map view. Optionally, we configure the map view to overlay the route above roadways but below map labels or point-of-interest icons.

That's how you construct a direction request and overlay a route on a map. If you run the app now, you will not see a route when the Direction button is tapped. There is still one thing left. We need to implement the mapView(_:rendererFor:) method which actually draws the route. Insert the following code in the MapViewController extension:

func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
    let renderer = MKPolylineRenderer(overlay: overlay)
    renderer.strokeColor = UIColor.blue
    renderer.lineWidth = 3.0

    return renderer
}

In the method, we create an MKPolylineRenderer object which provides the visual representation for the specified MKPolyline overlay object. Here the overlay object is the one we added earlier. The renderer object provides various properties to control the appearance of the route path. We simply change the stroke color and line width.

Okay, let's run the app again and you should be able to see the route after pressing the Direction button. If you can't view the path, remember to check if you set the simulated location to New York.

Figure 8.7. Tapping the direction button now shows the route
Figure 8.7. Tapping the direction button now shows the route

Scale the Map to Make the Route Fit Perfectly

You may notice a problem with the current implementation. The demo app does indeed draw the route on the map, but you may need to zoom out manually in order to show the route. Can we scale the map automatically?

You can use the boundingMapRect property of the polyline to determine the smallest rectangle that completely encompasses the overlay and changes the visible region of the map view.

Insert the following lines of code in the showDirection method:

let rect = route.polyline.boundingMapRect
self.mapView.setRegion(MKCoordinateRegion(rect), animated: true)

And place them right after the following line of code:

self.mapView.addOverlay(route.polyline, level: MKOverlayLevel.aboveRoads)

Compile and run the app again. The map should now scale automatically to display the route within the screen real estate.

Figure 8.8. Display the route with auto scaling
Figure 8.8. Display the route with auto scaling

Using Segmented control

Presently, the app only provides route information for automobile. Wouldn't it be great if the app supported walking directions? We'll add a segmented control in the app such that users can choose between driving and walking directions. A segmented control is a horizontal control made of multiple segments. Each segment of the control functions like a button.

Now go to the storyboard. Drag a segmented control from the Object library to the navigation bar of the map view controller. Place it at the lower corner. Select the segmented control and go to the Attributes inspector. Change the title of the first item to Car and the second item to Walking. Next, click the Pin button to add a couple of auto layout constraints. Your UI should look similar to figure 8.9.

Figure 8.9. Adding the segment control to the map view controller
Figure 8.9. Adding the segment control to the map view controller

Next, go to MapViewController.swift. Declare an outlet variable for the segmented control:

@IBOutlet var segmentedControl: UISegmentedControl!

Go back to the storyboard and connect the segmented control with the outlet variable. In the viewDidLoad method of MapViewController.swift, put this line of code right after super.viewDidLoad():

segmentedControl.isHidden = true

We only want to display the control when a user taps the Direction button. This is why we hide it when the view controller is first loaded up.

Next, declare a new instance variable in the MapViewController class:

var currentTransportType = MKDirectionsTransportType.automobile

The variable indicates the selected transport type. By default, it is set to automobile (i.e. car). Due to the introduction of this variable, we have to change the following line of code in the showDirection method:

directionRequest.transportType = MKDirectionsTransportType.automobile

And replace MKDirectionsTransportType.automobile with currentTransportType like this:

directionRequest.transportType = currentTransportType

Okay, you've got everything in place. But how can you detect the user's selection of a segmented control? When a user presses one of the segments, the control sends a ValueChanged event. So all you need to do is register the event and perform the corresponding action when the event is triggered.

You can register the event by control-dragging the segmented control's Value Changed event from the Connections inspector to the action method. But since you're now an intermediate programmer, let's see how you can register the event by writing code.

Typically, you register the target-action methods for a segmented control like below. You can put the line of code in the viewDidLoad method:

segmentedControl.addTarget(self, action: #selector(showDirection), for: .valueChanged)

Here, we use the addTarget method to register the .valueChanged event. When the event is triggered, we instruct the control to call the showDirection method of the current object (i.e. MapViewController). The #selector syntax checks and ensures the method actually exists. In other words, if you do not have the showDirection method in your code, Xcode will warn you.

Since we need to check the selected segment, insert the following code snippet at the very beginning of the showDirection method:

switch segmentedControl.selectedSegmentIndex {
case 0: currentTransportType = .automobile
case 1: currentTransportType = .walking
default: break
}

segmentedControl.isHidden = false

The selectedSegmentedIndex property of the segmented control indicates the index of the selected segment. If the first segment (i.e. Car) is selected, we set the current transport type to automobile. Otherwise, it is set to walking. We also unhide the segmented control.

Lastly, insert the following line of code in the calculate(completionHandler:) closure:

self.mapView.removeOverlays(self.mapView.overlays)

Place the line of code right before calling the add(_:level:) method. Your closure should look like this:

directions.calculate { (routeResponse, routeError) -> Void in

    guard let routeResponse = routeResponse else {
        if let routeError = routeError {
            print("Error: \(routeError)")
        }

        return
    }

    let route = routeResponse.routes[0]
    self.mapView.removeOverlays(self.mapView.overlays)
    self.mapView.addOverlay(route.polyline, level: MKOverlayLevel.aboveRoads)

    let rect = route.polyline.boundingMapRect
    self.mapView.setRegion(MKCoordinateRegion(rect), animated: true)
}

The line of code simply asks the map view to remove all the overlays. This is to avoid both Car and Walk routes overlapping with each other.

You can now test the app. In the map view, tap the Direction button and the segmented control should appear. You're free to select the Walking segment to display the walking directions.

For now, both types of routes are shown in blue. You can make a minor change in the mapView(_:rendererFor:) method of the MapViewController class to display a different color. Simply change this line of code:

renderer.strokeColor = (currentTransportType == .automobile) ? UIColor.systemBlue : UIColor.systemOrange

We use blue color for the Car route and orange color for the Walking route. After the change, run the app again. When walking is selected, the route is displayed in orange.

Figure 8.10. Showing the walking direction
Figure 8.10. Showing the walking direction

Showing Route Steps

Now that you know how to display a route on a map, wouldn't it be great if you can provide detailed driving (or walking) directions for your users? The MKRoute object provides a property called steps, which contains an array of MKRoute.Step objects. An MKRoute.Step object represents one part of an overall route. Each step in a route corresponds to a single instruction that would need to be followed by the user.

Okay, let's tweak the demo. When someone taps the annotation, the app will display the detailed driving/walking instructions.

First, add a table view controller to the storyboard and set the identifier of the prototype cell as to Cell. Next, embed the table view controller in a navigation controller, and change the title of the navigation bar to "Steps". Also, add a bar button item to the navigation bar. In the Attributes inspector, change the system item option to Done.

Next, connect the map view controller with the new navigation controller using a segue. In the Document Outline of Interface Builder, control-drag the map view controller to the navigation controller. Select present modally for the segue type and set the segue's identifier to showSteps.

Figure 8.11. Connecting the map view controller with the navigation controller using a segue
Figure 8.11. Connecting the map view controller with the navigation controller using a segue

The UI design is ready. Now create a new class file using the Cocoa Touch class template. Name it RouteTableViewController and make it a subclass of UITableViewController. Once the class is created, go back to the storyboard. Select the Steps table view controller. Under the Identity inspector, set the custom class to RouteTableViewController.

You may have these two questions in your head:

  • How can we get the detailed steps from the route?
  • How do we know if a user touches the annotation in a map?

As I mentioned earlier, the steps property of an MKRoute object contains an array of MKRoute.Step objects. Each MKRoute.Step object comes with an instructions property that stores the written instructions (e.g. Turn right onto Charles St) for following the path of a particular step. So all we need to do is to loop through all the MKRoute.Step objects to display the written instructions in the Steps table view.

Similar to a table view, MKAnnotationView provides an optional accessory view displayed on the right side of a standard callout bubble. Once you create the accessory view, the following method of your map view's delegate will be called when a user taps the accessory view:

optional func mapView(_ mapView: MKMapView, annotationView view: MKAnnotationView, calloutAccessoryControlTapped control: UIControl)

Now that you should have a better idea of the implementation, let's continue to develop the app. First, open the RouteTableViewController.swift file and import MapKit:

import MapKit

Next, declare an instance variable:

var routeSteps = [MKRoute.Step]()

This variable is used for storing an array of MKRoute.Step object of a selected route. Replace the method of table view data source with the following:

override func numberOfSections(in tableView: UITableView) -> Int {
    // Return the number of sections
    return 1
}

override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    // Return the number of rows
    return routeSteps.count
}

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)

    // Configure the cell...
    cell.textLabel?.text = routeSteps[indexPath.row].instructions

    return cell
}

The above code is very straightforward. We simply display the written instructions of the route steps in the table view.

Next, open MapViewController.swift. We're going to add a few lines of code to handle the touch of an annotation.

At the very beginning of the class, declare a new variable to store the current route:

var currentRoute: MKRoute?

In the mapView(_:viewFor:) method, insert the following line of code before return annotationView:

annotationView?.rightCalloutAccessoryView = UIButton(type: .detailDisclosure)

Here we add a detail disclosure button to the right side of an annotation. To handle a touch, we implement the mapView(_:annotationView:calloutAccessoryControlTapped:) method like this:

func mapView(_ mapView: MKMapView, annotationView view: MKAnnotationView, calloutAccessoryControlTapped control: UIControl) {

    performSegue(withIdentifier: "showSteps", sender: view)
}

In iOS, you're allowed to trigger a segue programmatically by calling the performSegue(withIdentifier:sender:) method. Earlier we created a segue between the map view controller and the navigation controller and set the segue's identifier to showSteps. The app will bring up the Steps table view controller when the above performSegue(withIdentifier:sender:) method is called.

Lastly, we have to pass the current route steps to the RouteTableViewController class.

In the body of the calculate(completionHandler:) closure, insert a line of code to update the current route:

self.currentRoute = route

It should be placed right before calling the removeOverlays method. The closure should look like this after the modification:

directions.calculate { (routeResponse, routeError) -> Void in

    guard let routeResponse = routeResponse else {
        if let routeError = routeError {
            print("Error: \(routeError)")
        }

        return
    }

    let route = routeResponse.routes[0]
    self.currentRoute = route
    self.mapView.removeOverlays(self.mapView.overlays)
    self.mapView.addOverlay(route.polyline, level: MKOverlayLevel.aboveRoads)

    let rect = route.polyline.boundingMapRect
    self.mapView.setRegion(MKCoordinateRegion(rect), animated: true)
}

To pass the route steps to RouteTableViewController, implement the performSegue(withIdentifier:sender:) method like this:

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    // Get the new view controller using segue.destinationViewController.
    // Pass the selected object to the new view controller.
    if segue.identifier == "showSteps" {
        let routeTableViewController = segue.destination.children[0] as! RouteTableViewController
        if let steps = currentRoute?.steps {
            routeTableViewController.routeSteps = steps
        }
    }
}

The above code snippet should be very familiar to you. We first get the destination controller, which is the RouteTableViewController object, and then pass it the route steps to the controller.

The app is now ready to run. When you tap the annotation on the map, the app shows you a list of steps to follow.

Figure 8.12. Tapping the annotation now shows you the list of steps to follow
Figure 8.12. Tapping the annotation now shows you the list of steps to follow

But we still miss one thing. When you tap the Done button in the route table view controller, it doesn't dismiss the controller. To make it work, create an action method in the RouteTableViewController class:

@IBAction func close() {
    dismiss(animated: true, completion: nil)
}

Then connect the Done button with the close() method in the storyboard.

Figure 8.13. Connecting the Done button with the action method
Figure 8.13. Connecting the Done button with the action method

That's it. You can test the app again. Now you should be able to dismiss the route table controller when you tap the Done button.

For reference, you can download the complete Xcode project from http://www.appcoda.com/resources/swift55/MapKitDirection.zip.