Routing with MapKit and Core Location

Lyndsey Scott
Beat the clock with routes so fast, they're diabolical.

Procrastinator’s Revenge: Beat the clock with routes so fast, they’re diabolical.

Apple has included major updates to their MapKit and CoreLocation frameworks in iOS 9; namely, more detailed maps, new transit routing features, and simplified location fetching. Apple’s determination to overtake their competitors (*ahem* Google Maps *ahem*) should be incentive enough to hop aboard the Apple Maps bandwagon if you haven’t already!

In this tutorial, you’ll create an app named Procrastinator’s Revenge to help you find the quickest round-trip route between your starting point, up to two locations and back. The app fetches address data using CoreLocation, then finds the quickest route between those addresses using MapKit.


Note: This tutorial assumes you know the basics of iOS and Swift development, asynchronous blocks and UITableViews. If you’re new to iOS development and Swift, check out our “Learn to Code iOS Apps with Swift Tutorial” series first. If you are new to asynchronous blocks, check out our “How To Use Blocks” series. Finally, if you’re new to UITableViews, check out Apple’s UITableViewDelegate and UITableViewDataSource documentation.

Getting Started

Download the starter project and open ProcrastinatorsRevenge.xcodeproj in Xcode. Build and run; feel free to tap around the app a bit to see how it works.

The starter project

The starter project

The first screen of the app has three text fields – one for the starting/ending address and two for the in-between stops. Tap Route It!, and the app transitions to a second screen with a map.

Using MapKit with CoreLocation

How exactly does MapKit relate to CoreLocation?

The Apple docs simply state, “The Core Location framework lets you determine the current location or heading associated with a device” and though you’ll use this feature of CoreLocation to pre-populate the user’s starting point, CoreLocation's ability to translate coordinates and partial addresses into user-friendly address representations and map item objects using its CLGeocoder class will be paramount to completing the first part of this tutorial.

In the second part of the tutorial, you’ll convert the CLPlacemark returned from your CLGeocoder operations into an MKPlacemark, and in turn, convert that MKPlacemark into an MKMapItem. You’ll then be able to use the MKMapItems to run an MKDirectionsRequest which will finally return the MKRoute info from Apple.

So, to review, that’s CLGeocoder > CLPlacemark > MKPlacemark > MKMapItem > MKDirectionsRequest > MKRoute.

Huh?

Huh?

Might sound a bit complicated, but lucky for you, your long-lost Crazy Aunt Lucy has given you the perfect motivation to get started!

The Hunt for Aunt Lucy’s Millions

After a lifetime of believing that Aunt Lucy was little more than a myth your parents cooked up to warn you of the potential consequences of excessive drug use, you unexpectedly receive a letter in the mail from the legend herself:

Aunt_Lucys_Letter

Uh-oh. You’re the notoriously tardy one amongst your obnoxiously punctual cousins. But this app is your chance to get revenge and claim Aunt Lucy’s millions for yourself!

First things first: you’ll make things a touch easier for the user and pre-populate the start/end field with the user’s current address.

Getting the Current Address with CoreLocation

In ViewController.swift, add the following code, replacing the existing viewDidLoad, to set up and instantiate a CLLocationManager object:

// 1
let locationManager = CLLocationManager()

override func viewDidLoad() {
  super.viewDidLoad()
  originalTopMargin = topMarginConstraint.constant
  // 2
  locationManager.delegate = self
  locationManager.requestWhenInUseAuthorization()
  // 3
  if CLLocationManager.locationServicesEnabled() {
    locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters
    locationManager.requestLocation()
  }
}

Taking each numbered section in turn:

  1. You declare locationManager as a global instance in order to maintain a strong reference.
  2. In viewDidLoad, after setting the location manager’s delegate, you explicitly ask for authorization to access the user’s location when the app is in use. This alert will no longer appear upon subsequent app launches once the user’s selected a response.
  3. Once location services are enabled, set the desired accuracy of CLLocationManager’s location output then request the current location using .requestLocation() (introduced in iOS 9).

Build and run your app; did you get an alert asking for your authorization? No?

reaction_faces___confused_by_awesomesauceuk-d4od42z

That’s because there’s one more thing left to take care of. You need to provide the user with a reason for your request.

Open Supporting Files > info.plist and follow these steps:

  1. Add “NSLocationWhenInUseUsageDescription” as a Key in the Information Property List.
  2. Keep the Type as String.
  3. Set the Value with the message to show the user, explaining why you’re asking for their location: “Allow us to access your current location so we can autofill your start/end point.”
Note: NSLocationWhenInUseUsageDescription | .requestWhenInUseAuthorization() lets the app access the user’s location while the app is being used.

NSLocationAlwaysUsageDescription | .requestAlwaysAuthorization() lets the app access the user’s location, even while the app is backgrounded.

plist

Build and run your app again; an alert should now pop up as expected:

LocationAuthorization

Tap Allow; the location manager is now aware of your location.

Next you’ll create a CLGeocoder object to reverse geocode the CLLocationManager's current CLLocation. Reverse geocoding is the process of turning a location’s coordinates into a human-readable address.

Scroll to the end of ViewController.swift and add the following code to locationManager(_:didUpdateLocations:locations:):

CLGeocoder().reverseGeocodeLocation(locations.last!,
  completionHandler: {(placemarks:[CLPlacemark]?, error:NSError?) -> Void in
  if let placemarks = placemarks {
    let placemark = placemarks[0]
  }
})

reverseGeocodeLocation(_:completionHandler:) returns an array of placemarks in its completion handler. For most geocoding results, this array will only contain one element; in rare situations, a single location can return multiple nearby locations. In this case, any of those locations, perhaps placemarks[0], should suffice. You can also stop updating the location now that you’ve found an appropriate placemark.

Now that you’ve found a CLPlacemark representing the user’s current address, you need to associate that location data with its corresponding text field. To do that, use Swift’s tuple data structure to group multiple values into a single compound value.

Right above viewDidLoad, add the following global variable to associate each UITextField with its corresponding MKMapItem:

var locationTuples: [(textField: UITextField!, mapItem: MKMapItem?)]!

You’ll be storing MKMapItems (not CLPlacemarks) for the user’s location, since that’s the object type you’ll eventually use to initialize the MKDirectionsRequest required to calculate the route.

Within viewDidLoad, add:

locationTuples = [(sourceField, nil), (destinationField1, nil), (destinationField2, nil)]

Here you pre-populate the array with tuples, each containing a text field and a nil value in place of the MKMapItem that may eventually be associated with that text field.

That takes care of the location data structure that will serve you well throughout this tutorial.

happy-thumbs-up

Scroll down to locationManager(_:didUpdateLocations:location:) and add the following snippet after the placemark declaration within reverseGeocodeLocation(_:completionHandler:)‘s completion handler:

self.locationTuples[0].mapItem = MKMapItem(placemark:
  MKPlacemark(coordinate: placemark.location!.coordinate,
  addressDictionary: placemark.addressDictionary as! [String:AnyObject]?))

This adds an MKMapItem representation of the user’s current location to the first tuple in locationTuples.

Next, add the following function to the main ViewController class (not an extension) to convert the location data into a readable address:

func formatAddressFromPlacemark(placemark: CLPlacemark) -> String {
  return (placemark.addressDictionary!["FormattedAddressLines"] as! 
    [String]).joinWithSeparator(", ")
}

formatAddressFromPlacemark(_:) takes the line-by-line address array stored at the “FormattedAddressLines” key of CLPlacemark's address dictionary, then concatenates the contents with commas between each element.

Scroll back to locationManager(_:didUpdateLocations:locations:); after the self.locationTuples[0].mapItem initialization add:

self.sourceField.text = self.formatAddressFromPlacemark(placemark)

This sets the source UITextField with the new address.

Within that same if let block, add the following:

self.enterButtonArray.filter{$0.tag == 1}.first!.selected = true

In the starter project, the button’s selected text was pre-set, the field tags and button tags were assigned in numerical order and the Enter buttons were all linked to IBOutletCollection enterButtonArray using the Interface Builder. The above code finds and selects the Enter button with tag 1, i.e. the Enter button next to the source UITextField also with tag 1, so that the button’s text changes to to reflect its selected state.

Build and run your app in the simulator; assuming Apple HQ is the default current location, your text field should soon contain “Apple Inc., 2 Infinite Loop, Cupertino, CA 95014-2083, United States”:

Apple_source

Time to change that to Crazy Aunt Lucy’s address!

Click anywhere within the simulator to reveal the menu bar; select Debug > Location > Custom location…:

Menu_bar

Enter Aunt Lucy’s coordinates:

Latitude: 29.049186, Longitude: -95.45384

Latitude: 29.049186, Longitude: -95.45384

Build and run again; the field should now say “105 Any Way St, Lake Jackson, TX, 77566-4198, United States” as your start/end position:

Any_Way_source

Next, you’ll need to correct user input so it reflects full and accurate addresses and create MKMapItems from those addresses to associate with their relevant text fields.

Process User Entries with CoreLocation

Still in ViewController.swift, update addressEntered(_:) as follows:

@IBAction func addressEntered(sender: UIButton) {
  view.endEditing(true)
  // 1
  let currentTextField = locationTuples[sender.tag-1].textField
  // 2
  CLGeocoder().geocodeAddressString(currentTextField.text!,
    completionHandler: {(placemarks: [CLPlacemark]?, error: NSError?) -> Void in
    if let placemarks = placemarks {

    } else {

    }
  })
}

Here’s what you’ve added:

  1. In the interface builder, each Enter button has been given a tag corresponding to its order from top to bottom: 1, 2 and 3, respectively. You use sender.tag to find the corresponding text field.
  2. Forward geocode the address using CLGeocoder's geocodeAddressString(_:completionHandler:).

Unlike reverseGeocodeLocation(_:completionHandler:), geocodeAddressString(_:completionHandler:) can often return more than one CLPlacemark since the text passed in is often not an exact match for a single location. Luckily, we’ve already created a UITableView subclass to let the user select an address from the returned CLPlacemarks.

Take a look at AddressTableView.swift. As should be clear from tableView(_:numberOfRowsInSection:) and tableView(_:cellForRowAtIndexPath:), you’ll use the addresses array declared at the top of the class as a global variable to populate the table.

Add the following function to the main ViewController class:

func showAddressTable(addresses: [String]) {
  let addressTableView = AddressTableView(frame: UIScreen.mainScreen().bounds,
    style: UITableViewStyle.Plain)
  addressTableView.addresses = addresses
  addressTableView.delegate = addressTableView
  addressTableView.dataSource = addressTableView
  view.addSubview(addressTableView)
}

Here you create an AddressTable and set its addresses array using the CLPlacemarks returned by geocodeAddressString(_:completionHandler:).

Back to addressEntered(_:). Within the if let placemarks = placemarks block in geocodeAddressString(_:completionHandler:)‘s completion handler, add:

var addresses = [String]()
for placemark in placemarks {
  addresses.append(self.formatAddressFromPlacemark(placemark))
}
self.showAddressTable(addresses)

You loop though placemarks, populate a new array of address Strings, then pass them along to showAddressTable(_:).

Build and run; take a look at Aunt Lucy’s Clue #1 to figure out what address to enter in the “Stop #1” field:

Clue1

Hmmm… This Way, That Way, and Any Way are all streets in Lake Jackson (confusingly enough). You’re looking for a breakfast sandwich at a drive-through restaurant somewhere in between. This clue sounds a lot like the song “Pop! Goes the Weasel”. “Pop! Goes the Weasel” is often played by jack in the boxes…that’s it! Location #1 is Jack in the Box at 165 Oyster Creek Dr.

Type in “165 Oyster Creek Dr, Lake Jackson, TX”; a table view appears, listing the full address as an option:

OysterCreekTable

What happens when you select the address? Nothing much! :] The table disappears but the address stays the same. Time to change that.

When you select a row containing an address, you want to automatically set the corresponding text field to contain the selected address, update the locations array to contain the relevant MKMapItem and set the corresponding Enter button to its selected state.

Update showAddressTable(_:) in ViewController.swift like so:

func showAddressTable(addresses: [String], textField: UITextField,
  placemarks: [CLPlacemark], sender: UIButton) {

  let addressTableView = AddressTableView(frame: UIScreen.mainScreen().bounds, style: UITableViewStyle.Plain)
  addressTableView.addresses = addresses
  addressTableView.currentTextField = textField
  addressTableView.placemarkArray = placemarks
  addressTableView.mainViewController = self
  addressTableView.sender = sender
  addressTableView.delegate = addressTableView
  addressTableView.dataSource = addressTableView
  view.addSubview(addressTableView)
}

Here you pass AddressTableView the current text field, the placemarks, a pointer to the current instance of ViewController.swift so you can easily change its locationTuples array and Enter buttons.

Within geocodeAddressString(_:completionHandler:), update the call to showAddressTable(_:) to pass the relevant parameters:

self.showAddressTable(addresses, textField: currentTextField,
    placemarks: placemarks, sender: sender)

Then in the else block immediately following the call to showAddressTable:, add an alert:

  self.showAlert("Address not found.")

If geocodeAddressString(_:completionHandler:) doesn’t return any placemarks, you display an error.

Next, add the following to the top of tableView(_:didSelectRowAtIndexPath:) in AddressTable.swift’s:

// 1
if addresses.count > indexPath.row {
  // 2
  currentTextField.text = addresses[indexPath.row]
  // 3
  let mapItem = MKMapItem(placemark:
    MKPlacemark(coordinate: placemarkArray[indexPath.row].location!.coordinate,
    addressDictionary: placemarkArray[indexPath.row].addressDictionary
    as! [String:AnyObject]?))
  mainViewController.locationTuples[currentTextField.tag-1].mapItem = mapItem
  // 4
  sender.selected = true
}

This is what’s happening, bit by bit:

  1. Since the last row in the table is “None of the above,” you only update the text field and its associated map item when the row is less than the length of the addresses array.
  2. Update the current text field to contain the selected address.
  3. Create a MKMapItem with the placemark corresponding to the selected row and associate the MKMapItem with the current text field in mainViewController's locationTuples array.
  4. Select the current Enter button.

Build and run; input Stop #1, tap Enter, then select the correct address in the table. The text field, location tuple array, and Enter button should update appropriately:

Addresses updated. (Checkmarks et al.)

Addresses updated. (Checkmarks et al.)

You’re not done with ViewController.swift yet though! You still have a few more use cases to handle.

Update textField(_:shouldChangeCharactersInRange:replacementString:):

func textField(textField: UITextField,
  shouldChangeCharactersInRange range: NSRange,
  replacementString string: String) -> Bool {

  enterButtonArray.filter{$0.tag == textField.tag}.first!.selected = false
  locationTuples[textField.tag-1].mapItem = nil
  return true
}

When the user edits a field, you nullify the MKMapItem since it may no longer apply and deselect the corresponding Enter button so the user knows they’ll have to re-select the proper address.

Next, update swapFields(_:) as follows:

@IBAction func swapFields(sender: AnyObject) {
  swap(&destinationField1.text, &destinationField2.text)
  swap(&locationTuples[1].mapItem, &locationTuples[2].mapItem)
  swap(&self.enterButtonArray.filter{$0.tag == 2}.first!.selected, &self.enterButtonArray.filter{$0.tag == 3}.first!.selected)
}

When the user taps “↑↓”, you need to swap the text, the MKMapItems contained in indexes 1 and 2 of locationTuples and the selected states of the 2nd and 3rd Enter buttons.

Finally, you must prepare the app for its transition to DirectionsViewController where you’ll calculate the route by overriding a few NSSeguePerforming methods.

Within the main ViewController class (perhaps underneath getDirections(_:)) override shouldPerformSegueWithIdentifier(_:sender:)

override func shouldPerformSegueWithIdentifier(identifier: String, sender: AnyObject?) -> Bool {
  if locationTuples[0].mapItem == nil ||
    (locationTuples[1].mapItem == nil && locationTuples[2].mapItem == nil) {
    showAlert("Please enter a valid starting point and at least one destination.")
    return false
  } else {
    return true
  }
}

The if-else conditional prevents the segue if a source and at least one destination haven’t been set.

Prepare the locationTuples array for the next view by adding the following read-only computed property above viewDidLoad:

var locationsArray: [(textField: UITextField!, mapItem: MKMapItem?)] {
  var filtered = locationTuples.filter({ $0.mapItem != nil })
  filtered += [filtered.first!]
  return filtered
}

locationsArray filters out the indices of locationTuples containing nil MKMapItems and since the app will fetch a round-trip route, filtered += [filtered.first!] copies the tuple at the first index to the end of the array.

Underneath shouldPerformSegueWithIdentifier(_:sender:), override prepareForSegue(_:sender:):

override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
  var directionsViewController = segue.destinationViewController as! DirectionsViewController
  directionsViewController.locationArray = locationsArray
}

This passes locationsArray to the next view controller.

That’s it for ViewController.swift! Now switch to DirectionsViewController.swift to start routing.

“Calculating Route…” with MapKit

Here’s Aunt Lucy’s Clue #2:

Clue2

German references… Something about a pain killer… Another mention of This Way and That… “Not at the best house, but at the ___”… Worst? That seems to fit, especially since it rhymes with “thirst”… Lo and behold, at the corner of This Way and That, there’s a German beer hall called Wurst Haus that serves a drink called the Pain Killer! It’s at 102 This Way Lake Jackson, TX.

Now that you know all the addresses, you’ll have to create an MKDirections object, then call its calculateDirectionsWithCompletionHandler(_:) for the source and destination of each segment in order to calculate the route.

Add the following to DirectionsViewController:

func calculateSegmentDirections(index: Int) {
  // 1
  let request: MKDirectionsRequest = MKDirectionsRequest()
  request.source = locationArray[index].mapItem
  request.destination = locationArray[index+1].mapItem
  // 2
  request.requestsAlternateRoutes = true
  // 3
  request.transportType = .Automobile
  // 4
  let directions = MKDirections(request: request)
  directions.calculateDirectionsWithCompletionHandler ({
    (response: MKDirectionsResponse?, error: NSError?) in
    if let routeResponse = response?.routes {

    } else if let _ = error {

    }
  })
}

Here’s what’s going on above:

  1. Create an MKDirectionsRequest by setting the MKMapItem at a given index of the locationArray as the source and setting the MKMapItem at the next index as the destination.
  2. Set requestsAlternateRoutes to true to fetch all the reasonable routes from the source to destination.
  3. Set the transportation type to .Automobile for this particular scenario. (.Walking and .Any are also valid MKDirectionsTransportTypes.)
  4. Initialize an MKDirections object with the MKDirectionsRequest, then call calculateDirectionsWithCompletionHandler(_:) to get an MKDirectionsResponse containing an array of MKRoutes.

If calculateDirectionsWithCompletionHandler(_:) doesn’t return any routes and instead returns an error, the else if let _ = error block will execute. Add the following to that else if logic block:

let alert = UIAlertController(title: nil,
  message: "Directions not available.", preferredStyle: .Alert)
let okButton = UIAlertAction(title: "OK",
  style: .Cancel) { (alert) -> Void in
  self.navigationController?.popViewControllerAnimated(true)
}
alert.addAction(okButton)
self.presentViewController(alert, animated: true,
  completion: nil)

This displays an error and returns the user to the previous view controller.

Assuming MKRoutes are found, the first if let statement within calculateDirectionsWithCompletionHandler(_:) will execute as true. Within that if let block add:

let quickestRouteForSegment: MKRoute =
  routeResponse.sort({$0.expectedTravelTime <
  $1.expectedTravelTime})[0]

Here you sort the routes from least to greatest expected travel time, then pull out the first index, i.e., the index with the shortest expected travel time. That gives you the fastest route between two points. You're well on your way to victory!

You can practically taste that $500 mill!

You can practically taste that $500 mill!

But you still need to calculate multiple routes between multiple points. You can do this recursively.

First, update calculateSegmentDirections(_:)'s parameters:

func calculateSegmentDirections(index: Int,
  time: NSTimeInterval, routes: [MKRoute]) {

calculateSegmentDirections(_:time:routes:) now accepts an array of segment routes and a time variable.

Then within the first if let block and after the quickestRouteForSegment declaration, add:

// 1
var timeVar = time
var routeVar = routes
//2
routesVar.append(quickestRouteForSegment)
// 3
timeVar += quickestRouteForSegment.expectedTravelTime
// 4
if index+2 < self.locationArray.count {
  self.calculateSegmentDirections(index+1, time: timeVar, routes: routesVar)
} else {

}

Here you:

  1. Create mutable versions of time and routes (timeVar and routesVar respectively).
  2. Add the quickest route for this current segment to routesVar.
  3. Add this new route's expected travel time to timeVar.
  4. As long as you haven't reached the final two values of the location array, recursively call calculateSegmentDirections(_:time:routes:) with an incremented index and the updated time and route values.

Now go back to viewDidLoad and add the following:

addActivityIndicator()
calculateSegmentDirections(0, time: 0, routes: [])

This code adds an activity indicator to the view while the route's being calculated, then calls calculateSegmentDirections(_:time:routes:) to calculate the route starting at index 0 of locationArray, with an initial total time of 0 and an initially empty route array.

Go back to calculateDirectionsWithCompletionHandler(_:). Within the else block immediately following if index+2 < self.locationArray.count, add:

self.hideActivityIndicator()

This hides the activity indicator within that else block as you've reached the end of the recursion.

Within that same else block, you'll need to plot the route on the map and print the directions in the table. You'll first want some helper functions for those tasks.

Adding MKRoutes to MKMapView

To plot each MKRoute segment onto the MKMapView, add the following to the main DirectionsViewController class:

func plotPolyline(route: MKRoute) {
  // 1
  mapView.addOverlay(route.polyline)
  // 2
  if mapView.overlays.count == 1 {
    mapView.setVisibleMapRect(route.polyline.boundingMapRect,
      edgePadding: UIEdgeInsetsMake(10.0, 10.0, 10.0, 10.0),
      animated: false)
  }
  // 3
  else {
    let polylineBoundingRect =  MKMapRectUnion(mapView.visibleMapRect,
      route.polyline.boundingMapRect)
    mapView.setVisibleMapRect(polylineBoundingRect,
      edgePadding: UIEdgeInsetsMake(10.0, 10.0, 10.0, 10.0),
      animated: false)
  }
}

plotPolyline(_:) does the following:

  1. Adds the MKRoute's polyline to the map as an overlay.
  2. If the plotted route is the first overlay, sets the map's visible area so it's just big enough to fit the overlay with 10 extra points of padding.
  3. If the plotted route is not the first, set the map's visible area to the union of the new and old visible map areas with 10 extra points of padding.

Next, update mapView(_:rendererForOverlay:) within the MKMapViewDelegate extension:

func mapView(mapView: MKMapView,
  rendererForOverlay overlay: MKOverlay) -> MKOverlayRenderer! {

  let polylineRenderer = MKPolylineRenderer(overlay: overlay)
  if (overlay is MKPolyline) {
    if mapView.overlays.count == 1 {
      polylineRenderer.strokeColor =
        UIColor.blueColor().colorWithAlphaComponent(0.75)
    } else if mapView.overlays.count == 2 {
      polylineRenderer.strokeColor =
        UIColor.greenColor().colorWithAlphaComponent(0.75)
    } else if mapView.overlays.count == 3 {
      polylineRenderer.strokeColor =
        UIColor.redColor().colorWithAlphaComponent(0.75)
    }
    polylineRenderer.lineWidth = 5
  }
  return polylineRenderer
}

This gives each route segment a different color.

Under calculateSegmentDirections(_:time:routes:), add:

func showRoute(routes: [MKRoute]) {
  for i in 0..<routes.count {
    plotPolyline(routes[i])
  }
}

This function loops through each MKRoute and adds its polyline to the map.

Within calculateDirectionsWithCompletionHandler(_:), call showRoute(_:) in the else block in which you called self.hideActivityIndicator():

self.showRoute(routesVar)

Build and run; enter the addresses and tap Route It!. The route should appear on the map:

Map_Route

Looking good! Next you have to print the directions to the DirectionsTable.

Printing MKRoute Directions

DirectionsTable.swift contains the global directionsArray, which is an array of tuples of type (String, String, MKRoute). The two Strings in each tuple will contain the starting and ending addresses of the segment belonging to the MKRoute stored in the third index of the tuple.

Scroll to the UITableViewDataSource extension; as indicated by the return value in numberOfSectionsInTableView(_:), the table will contain a section for each route stored in directionsArray, such that each section of your DirectionsTable will represent a different segment of the route.

The return value of tableView(_:numberOfRowsInSection:) is the number of MKRouteSteps in your MKRoute that correspond to the current section, i.e., directionsArray[section].route.

Complete tableView(_:cellForRowAtIndexPath:) by adding the following directly under cell.userInteractionEnabled = false:

// 1
let steps = directionsArray[indexPath.section].route.steps
// 2
let step = steps[indexPath.row]
// 3
let instructions = step.instructions
// 4
let distance = step.distance.miles()
// 5
cell.textLabel?.text = "\(indexPath.row+1). \(instructions) - \(distance) miles"

On each line, you print the corresponding MKRouteStep instruction and distance as follows:

  1. Get the array of MKRouteSteps belonging to the MKRoute that corresponds to the current section.
  2. From that steps array, access the MKRouteStep object at the index corresponding to the current row.
  3. Get step's instructions String.
  4. Get step's distance and convert it from meters to miles using miles() of the CLLocationDistance extension included in DirectionsTableView.swift.
  5. Set the cell's label to show the step's instructions and distance.

Now you can use the methods in the UITableViewDelegate extension to populate the header of each section with the starting point information and the footer with the ending point information and route summary.

Add this line to tableView(_:viewForHeaderInSection:), right before the return statement:

label.text = "SEGMENT #\(section+1)\n\nStarting point: \(directionsArray[section].startingAddress)\n"

The header now contains the starting address.

In tableView(_:viewForFooterInSection:), add the following right before the return statement:

// 1
let route = directionsArray[section].route
// 2
let time = route.expectedTravelTime.formatted()
// 3
let miles = route.distance.miles()
//4
label.text = "Ending point: \(directionsArray[section].endingAddress)\n\nDistance: \(miles) miles\n\nExpected Travel Time: \(time)"
Almost there...

Almost there...

Step by step:

  1. Get the current section's route.
  2. Format expectedTravelTime using formatted() from the NSTimeInterval extension. formatted() uses an NSDateComponentsFormatter to convert the NSTimeInterval into hours-minutes-seconds format.
  3. Format the distance using the miles method of the CLLocationDistance extension.
  4. Set the cell's label to show the ending address, distance, and expected travel time.

Now return to DirectionsViewController. Add the following method to the main class:

func displayDirections(directionsArray: [(startingAddress: String, 
  endingAddress: String, route: MKRoute)]) {
  directionsTableView.directionsArray = directionsArray
  directionsTableView.delegate = directionsTableView
  directionsTableView.dataSource = directionsTableView
  directionsTableView.reloadData()
}

Here you pass directionsArray to the DirectionsTableView.

Then update showRoute(_:):

func showRoute(routes: [MKRoute]) {
  var directionsArray = [(startingAddress: String, endingAddress: String, route: MKRoute)]()
  for i in 0..<routes.count {
    plotPolyline(routes[i])
    directionsArray += [(locationArray[i].textField.text!,
      endAddress: locationArray[i+1].textField.text!, route: routes[i])]
  }
  displayDirections(directionsArray)
}

For each route, you add the start address, end address and MKRoute to the directionsArray before passing it to displayDirections(_:).

Next, you need to update the totalTimeLabel to display the total expected time of the trip calculated using the mutable time parameter in calculateSegmentDirections(_:time:routes:).

Update the function declaration of showRoute(_:) to include an NSTimeInterval parameter:

func showRoute(routes: [MKRoute], time: NSTimeInterval) {

Within calculateDirectionsWithCompletionHandler(_:), append the time parameter to the showRoute(_:time:) call:

self.showRoute(routesVar, time: timeVar)

Also add the following function to the DirectionsViewController class:

func printTimeToLabel(time: NSTimeInterval) {
  var timeString = time.formatted()
  totalTimeLabel.text = "Total Time: \(timeString)"
}

printTimeToLabel(_:) prints the formatted NSTimeInterval to totalTimeLabel.

Finally, call printTimeToLabel(_:) at the end of showRoute(_:)

printTimeToLabel(time)

Here you pass time as a parameter to print it to totalTimeLabel.

Build and run; enter the addresses, tap Route It!, and the route and directions should appear:

Route_#1

But there's one last thing to do if you want to get Aunt Lucy's breakfast sandwich and Pain Killer faster than your competition. Note the total time at the bottom of the "Procrastinator's Route" screen; tap the Back button in the navigation bar, then tap the ↑↓ button to swap the destination order. Now tap Route it! again. Barring road closures or traffic, this reversed route order should be faster!

Route_#2

Now that you have an edge on your competition and can fully maximize your procrastination, revenge (oh, and $500,000,000...) is yours. HAHAHAHAHAHA!

evil_rage_face

Where to Go From Here?

Download the finished project here.

To improve the app further, try supporting different transportation types and more destination text fields; instead of manually swapping fields to figure out the best route, try auto-permuting the locations and auto-calculating the quickest route order.

Have any questions or comments? Join the forum discussion below!

Lyndsey Scott

Actress, Model, App Developer -- www.LyndseyScott.com

Other Items of Interest

Big Book SaleAll raywenderlich.com iOS 11 books on sale for a limited time!

raywenderlich.com Weekly

Sign up to receive the latest tutorials from raywenderlich.com each week, and receive a free epic-length tutorial as a bonus!

Advertise with Us!

PragmaConf 2016 Come check out Alt U

Our Books

Our Team

Video Team

... 19 total!

iOS Team

... 73 total!

Android Team

... 20 total!

Unity Team

... 10 total!

Articles Team

... 15 total!

Resident Authors Team

... 18 total!

Podcast Team

... 7 total!

Recruitment Team

... 9 total!