Routing With MapKit and Core Location

Learn how to use MapKit and CoreLocation to help users with address completion and route visualization using multiple addresses. By Ryan Ackermann.

Leave a rating/review
Download materials
Save for later
Share
Update note: Ryan Ackermann updated this tutorial for iOS 13, Xcode 11 and Swift 5. Lyndsey Scott wrote the original.

Apple’s been hard at work improving its maps with better land detail, pedestrian data and road coverage, closing the gap between it and its competitors. So hop aboard the Apple Maps bandwagon by getting to know MapKit and CoreLocation.

In this tutorial, you’ll create an app named RWRouter to help you find a round-trip route between your starting point and up to two other locations. To do that, you’ll use CoreLocation and MKLocalSearch to fetch address data and MKDirections to find the quickest route between addresses.

In this tutorial, you’ll learn how to:

  • Handle user location and authorization requests with CoreLocation.
  • Reverse geocode with CLGeocoder to convert a location’s coordinates into a human-readable address.
  • Use MKLocalSearchCompleter to autocomplete an address.
  • Generate routes using MKRoute and display them using MapKit.

Now, you’re ready to dive into your map app!

Getting Started

To get started, click the Download Materials button at the top or bottom of this tutorial. Inside the zip file, you’ll find two folders: final and starter. Open the starter folder.

The first view of the app has three text fields: one for the starting/ending address and two for the in-between stops. The second view has a map view and a table view to show the routes and directions.

The app’s layout is complete, but it’s up to you to add the features.

Using MapKit With CoreLocation

What’s the difference between CoreLocation and MapKit?

  • CoreLocation handles your location data. It uses all available components on the device including the Wi-Fi, GPS, Bluetooth, magnetometer, barometer and cellular hardware.
  • MapKit is all about visual operations, like rendering maps, and user-friendly operations, like address search and route directions. After all, who wants to type latitude and longitude instead of a human-readable address?

In the next steps, you’ll gather the user’s starting location with CoreLocation, use MapKit to handle addresses that the user enters manually, create a round-trip route between them and then, finally, use all that data to show a map with the entire route. Cool!

Getting the User’s Location With CoreLocation

In ViewControllers/RouteSelectionViewController.swift, add the following property to the top of the class:

private let locationManager = CLLocationManager()

You declare locationManager as a property of the class so you can access it as needed.

Next, add the following code to attemptLocationAccess() to set up and instantiate CLLocationManager:

// 1
guard CLLocationManager.locationServicesEnabled() else {
  return
}
// 2
locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters
// 3
locationManager.delegate = self
// 4
if CLLocationManager.authorizationStatus() == .notDetermined {
  locationManager.requestWhenInUseAuthorization()
} else {
  locationManager.requestLocation()
}

Taking each numbered section in turn:

  1. Before using the location manager, it’s good practice to make sure the user has enabled location services.
  2. When you geocode the location’s coordinates, you’ll probably lose some precision. An accuracy of 100 meters is more than adequate.
  3. The location manager’s delegate informs the app when new locations arrive and when the privacy setting changes.
  4. If the user enabled and authorized location services, you request the current location.

Build and run. Did you get an alert asking for your authorization? No.

Confused face

That’s because there’s one more thing left to take care of: You need to tell the user why you’re making the request.

Autocompleting the User’s Location

You want your app to be able to make sensible suggestions about where the user might want to go. To do this, it needs to know where the user currently is. So you need to get permission to access the user’s current location.

Getting Authorization to Access the User’s Location

Open Supporting InfoInfo.plist and follow these steps:

  1. Add NSLocationWhenInUseUsageDescription to Info.plist.
  2. Keep the Type as String.
  3. Set the Value to the message to show users, which explains why you’re asking for their location: Used to autofill the start/end location.

Xcode-plist with NSLocationWhenInUseUsageDescription highlighted

Build and run again; an alert should now pop up, as expected.

Location request alert in an iPhone

Tap Allow While Using App or Allow Once to make the location manager aware of your location.

Note: NSLocationWhenInUseUsageDescription and requestWhenInUseAuthorization() lets the app access the user’s location while the app is running. Starting in iOS 13, when requesting this permission level, there’s a third option: Allow Once. This option sets CLAuthorizationStatus to authorizedWhenInUse while the app is running. The next time the app launches, it resets the authorization to notDetermined.

NSLocationAlwaysUsageDescription and requestAlwaysAuthorization() lets the app access the user’s location, even when the app is running in the background.

Now that you have permission to use the user’s location, it’s time to put that information to work!

Turning the User’s Coordinates Into an Address

The location information that your app receives will be in the form of coordinates — but most users want to enter their routes as an address.

Next, you’ll create a CLGeocoder to reverse geocode the user’s location. Reverse geocoding is the process of turning a location’s coordinates into a human-readable address.

Add a new property to the top of ViewControllers/RouteSelectionViewController.swift:

private var currentPlace: CLPlacemark?

Here, you use currentPlace to store the geocoded information from the current location. You’ll use this information later to generate a route.

Scroll to the end of CLLocationManagerDelegate and replace the placeholder methods with:

func locationManager(
  _ manager: CLLocationManager, 
  didChangeAuthorization status: CLAuthorizationStatus
) {
  // 1
  guard status == .authorizedWhenInUse else {
    return
  }
  manager.requestLocation()
}

func locationManager(
  _ manager: CLLocationManager, 
  didUpdateLocations locations: [CLLocation]
) {
  guard let firstLocation = locations.first else {
    return
  }

  // TODO: Configure MKLocalSearchCompleter here...

  // 2
  CLGeocoder().reverseGeocodeLocation(firstLocation) { places, _ in
    // 3
    guard
      let firstPlace = places?.first, 
      self.originTextField.contents == nil 
      else {
        return
    }
    

    // 4
    self.currentPlace = firstPlace
    self.originTextField.text = firstPlace.abbreviation
  }
}

Here’s what this code does:

  1. Ensure the user has given the app authorization to access location information.
  2. 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 many nearby locations. In this case, places?.first suffices.
  3. Since the user can edit the origin text field, it’s a good idea to make sure that it’s empty before changing it.
  4. Store the current location and update the field.
Note: firstPlace.abbreviation is an extension property that determines an appropriate description for a given CLPlacemark.

By default, the simulator doesn’t have a default location set. To configure the location, open the simulator, if it’s not already running. Under FeaturesLocation, select Apple.

Build and run. Once the geocode finishes, you’ll see Apple Campus appear in the Start / End field.

RWRouter initial screen with Apple Campus text filled in the first text field

Next, you’ll need to handle user input by implementing MKLocalSearchCompleter to suggest a location in real time.

Processing User Input With MKLocalSearchCompleter

Still in ViewControllers/RouteSelectionViewController.swift, add another property to the top of the class:

private let completer = MKLocalSearchCompleter()

Here, you use MKLocalSearchCompleter to guess the final address when the user starts typing in the text field.

Add the following to textFieldDidChange(_:) in Actions:

// 1
if field == originTextField && currentPlace != nil {
  currentPlace = nil
  field.text = ""
}
// 2
guard let query = field.contents else {
  hideSuggestionView(animated: true)
  // 3
  if completer.isSearching {
    completer.cancel()
  }
  return
}
// 4
completer.queryFragment = query

Here’s what you’ve done:

  1. If the user edited the origin field, you remove the current location.
  2. You make sure that the query contains information, because it doesn’t make sense to send an empty query to the completer.
  3. If the field is empty and the completer is currently attempting to find a match, you cancel it.
  4. Finally, you pass the user’s input to the completer’s queryFragment.

MKLocalSearchCompleter uses the delegate pattern to surface its results. There’s one method for retrieving the results and one to handle errors that may occur.

At the bottom of the file, add a new extension for MKLocalSearchCompleterDelegate:

// MARK: - MKLocalSearchCompleterDelegate

extension RouteSelectionViewController: MKLocalSearchCompleterDelegate {
  func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
    guard let firstResult = completer.results.first else {
      return
    }
    
    showSuggestion(firstResult.title)
  }

  func completer(
    _ completer: MKLocalSearchCompleter, 
    didFailWithError error: Error
  ) {
    print("Error suggesting a location: \(error.localizedDescription)")
  }
}

MKLocalSearchCompleter returns many results to completerDidUpdateResults(_:), but for this app, you’ll use only the first one. showSuggestion(_:) is a helper method that takes care of adjusting an auto layout constraint and setting a label’s text property.

The last thing to do before the completer will work is to hook up the delegate. Inside viewDidLoad() add:

completer.delegate = self

Build and run. Now, as you type in any of the text fields, a little view will slide in from the bottom with a suggestion.

RWRouter initial screen with suggestion view

Improving MKLocalSearchCompleter’s Accuracy

You may notice that the results from the completer don’t make sense given your location. This is because you never told the completer what your current location is.

Before connecting the completer to the current location, add the final few properties to the top of the class:

private var editingTextField: UITextField?
private var currentRegion: MKCoordinateRegion?

You’ll use the text field property to manage which field is currently active when the user taps a suggestion. You store a reference to the current region that MKLocalSearch will use later to determine where the user currently is.

Toward the bottom of the file, replace // TODO: Configure MKLocalSearchCompleter here… with the following:

//1
let commonDelta: CLLocationDegrees = 25 / 111
let span = MKCoordinateSpan(
  latitudeDelta: commonDelta, 
  longitudeDelta: commonDelta)
//2
let region = MKCoordinateRegion(center: firstLocation.coordinate, span: span)
currentRegion = region
completer.region = region

Here’s what this code does:

  1. commonDelta refers to the zoom level you want. Increase the value for broader map coverage.
  2. This is the region you created using the coordinates obtained via CoreLocation‘s delegate.
Note: 1 degree of latitude is approximately equal to 111 kilometers.

Now, build and run.

RWRouter initial screen with a more precise suggestion view

Finishing the Address’ Autocomplete

Although you can get localized results based on your input, wouldn’t it be nice to select that suggestion? Of course it would — and there’s already a method defined that lets you do so!

Add the following inside suggestionTapped(_:):

hideSuggestionView(animated: true)

editingTextField?.text = suggestionLabel.text
editingTextField = nil

When the user taps the suggestion label, the view collapses and its text is set to the active text field. Before this will work, however, you need to set editingTextField.

To do that, add these few lines to textFieldDidBeginEditing(_:):

hideSuggestionView(animated: true)

if completer.isSearching {
  completer.cancel()
}

editingTextField = textField

This code makes sure that when the user activates a text field, the previous suggestion no longer applies. This also applies to the completer, which you reset if it’s currently searching. Finally, you set the text field property that you defined earlier.

Build and run.

Animation of a user tapping the suggestion view

Suggesting locations and requesting the user’s location both work. Great job!

The final two pieces to this puzzle are calculating a route and displaying that route’s information.

Calculating Routes With MapKit

There’s one thing left to do in ViewControllers/RouteSelectionViewController.swift: You need a way to manage the user’s input and pass it to ViewControllers/DirectionsViewController.swift.

Fortunately, the struct in Models/Route.swift already has that ability. It has two properties: one for the origin and another for the stops along the way.

Instead of creating a Route directly, you’ll use the aids in Helpers/RouteBuilder.swift. This does the busy work of transforming the user’s input for you. For brevity, this tutorial won’t dive into the inner workings of this file. If you’re interested in how it works, however, discuss it in the comments below!

Back in ViewControllers/RouteSelectionViewController.swift, add the following to calculateButtonTapped():

// 1
view.endEditing(true)

calculateButton.isEnabled = false
activityIndicatorView.startAnimating()

// 2
let segment: RouteBuilder.Segment?
if let currentLocation = currentPlace?.location {
  segment = .location(currentLocation)
} else if let originValue = originTextField.contents {
  segment = .text(originValue)
} else {
  segment = nil
}

// 3
let stopSegments: [RouteBuilder.Segment] = [
  stopTextField.contents,
  extraStopTextField.contents
]
.compactMap { contents in
  if let value = contents {
    return .text(value)
  } else {
    return nil
  }
}

// 4
guard 
  let originSegment = segment, 
  !stopSegments.isEmpty 
  else {
    presentAlert(message: "Please select an origin and at least 1 stop.")
    activityIndicatorView.stopAnimating()
    calculateButton.isEnabled = true
    return
}

// 5
RouteBuilder.buildRoute(
  origin: originSegment,
  stops: stopSegments,
  within: currentRegion
) { result in
  // 6
  self.calculateButton.isEnabled = true
  self.activityIndicatorView.stopAnimating()

  // 7
  switch result {
  case .success(let route):
    let viewController = DirectionsViewController(route: route)
    self.present(viewController, animated: true)
    
  case .failure(let error):
    let errorMessage: String
  
    switch error {
    case .invalidSegment(let reason):
      errorMessage = "There was an error with: \(reason)."
    }
    
    self.presentAlert(message: errorMessage)
  }
}

This is what’s happening, step by step:

  1. Dismiss the keyboard and disable the Calculate Route button.
  2. Here’s where the current location information comes in handy. RouteBuilder handles location and text types in different ways. MKLocalSearch handles the user’s input and CLGeocoder handles the location’s coordinates. You only use the current location for the origin of the route. This makes the first segment the one that needs special treatment.
  3. You map the remaining fields as text segments.
  4. Ensure that there’s enough information before showing DirectionsViewController.
  5. Build the route with the segments and current region.
  6. After the helper finishes, re-enable the Calculate Route button.
  7. Show DirectionsViewController if everything went as planned. If something went wrong, show the user an alert explaining what happened.

A lot is going on here. Each part accomplishes something small.

Build and run, then try out a few stops. MKLocalSearch makes this fun on your phone, too, since it works based on what’s around you.

Location pins on a map

That’s it for ViewControllers/RouteSelectionViewController.swift!

Now, switch to ViewControllers/DirectionsViewController.swift to finish routing.

Requesting MKRoute Directions

You can already see a map with the locations you picked, but the routes and directions are missing. You still need to do the following things:

  • Group the Route segments to link them together.
  • For each group, create a MKDirections.Request to get a MKRoute to display.
  • As each request finishes, refresh the map and table views to reflect the new data.

To start chipping away at this list, add this property to the top of the class:

private var groupedRoutes: [(startItem: MKMapItem, endItem: MKMapItem)] = []

This array holds the routes that you request and display in the view. To populate this array with content, add the following to groupAndRequestDirections():

guard let firstStop = route.stops.first else {
  return
}

groupedRoutes.append((route.origin, firstStop))

if route.stops.count == 2 {
  let secondStop = route.stops[1]

  groupedRoutes.append((firstStop, secondStop))
  groupedRoutes.append((secondStop, route.origin))
}

fetchNextRoute()

This method is specific to this app. It creates an array of tuples that hold a start and end MKMapItem. After double-checking the data is valid and there’s more than one stop, you add the origin and first stop. Then, if there’s an extra stop, you add two more groups. The last group is the return trip, ending up back at the start.

Now that you’ve grouped the routes into distinct start and end points, they’re ready for you to feed them into a directions request.

Since there may be many routes to request, you’ll use recursion to iterate through the routes. If you’re unfamiliar with this concept, it’s like a while loop. The main difference is that the recursive method will call itself when it finishes its task.

Note: If you’re interested in learning more, read weheartswift.com’s recursion article.

To see this in action, add the following to fetchNextRoute():

// 1
guard !groupedRoutes.isEmpty else {
  activityIndicatorView.stopAnimating()
  return
}

// 2
let nextGroup = groupedRoutes.removeFirst()
let request = MKDirections.Request()

// 3
request.source = nextGroup.startItem
request.destination = nextGroup.endItem

let directions = MKDirections(request: request)

// 4
directions.calculate { response, error in
  guard let mapRoute = response?.routes.first else {
    self.informationLabel.text = error?.localizedDescription
    self.activityIndicatorView.stopAnimating()
    return
  }

  // 5
  self.updateView(with: mapRoute)
  self.fetchNextRoute()
}

Here’s what this code does:

  1. This is the condition that breaks out of the recursive loop. Without this, the app will suffer the fate of an infinite loop.
  2. To work toward the break-out condition, you need to mutate groupedRoutes. Here, the group that you’ll request is the first in the array.
  3. You configure the request to use the selected tuple value for the source and destination.
  4. Once you configure the request, you use an instance of MKDirections to calculate the directions.
  5. If all went well with the request, you update the view with the new route information and request the next segment of the route.

fetchNextRoute() will continue to call itself after calculate(completionHandler:) finishes. This allows the app to show new information after the user requests each part of the route.

Rendering Routes Into the Map

To begin showing this new information, add the following to updateView(with:):

let padding: CGFloat = 8
mapView.addOverlay(mapRoute.polyline)
mapView.setVisibleMapRect(
  mapView.visibleMapRect.union(
    mapRoute.polyline.boundingMapRect
  ),
  edgePadding: UIEdgeInsets(
    top: 0,
    left: padding,
    bottom: padding,
    right: padding
  ),
  animated: true
)

// TODO: Update the header and table view...

MKRoute provides some interesting information to work with. polyline contains the points along the route that are ready to display in a map view. You can add this directly to the map view because polyline inherits from MKOverlay.

Next, you update the visible region of the map view to make sure that the new information you’ve added to the map is in view.

This isn’t enough to get the route to show up on the map view. You need MKMapViewDelegate to configure how the map view will draw the line displaying the route.

Add the this to the bottom of the file:

// MARK: - MKMapViewDelegate

extension DirectionsViewController: MKMapViewDelegate {
  func mapView(
    _ mapView: MKMapView, 
    rendererFor overlay: MKOverlay
  ) -> MKOverlayRenderer {
    let renderer = MKPolylineRenderer(overlay: overlay)

    renderer.strokeColor = .systemBlue
    renderer.lineWidth = 3
    
    return renderer
  }
}

This delegate method allows you to precisely define how the rendered line will look. For this app, you use the familiar blue color to represent the route.

Finally, to let the map view use this delegate implementation, add this line to viewDidLoad():

mapView.delegate = self

Build and run. You’ll now see lines drawn between the points along with your destinations. Looking good!

Animation showing routes between locations

You almost have a complete app. In the next section, you’ll complete the final steps to make your app fun and effective to use by adding directions.

Walking Through Each MKRoute.Step

At the point, you’ve already implemented most of the table view’s data source. However, the tableView(_:cellForRowAt:) is only stubbed out. You’ll complete it next

Replace the current contents of tableView(_:cellForRowAt:) with:

let cell = { () -> UITableViewCell in
  guard let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) 
  else {
    let cell = UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier)
    cell.selectionStyle = .none
    return cell
  }
  return cell
}()

let route = mapRoutes[indexPath.section]
let step = route.steps[indexPath.row + 1]

cell.textLabel?.text = "\(indexPath.row + 1): \(step.notice ?? step.instructions)"
cell.detailTextLabel?.text = distanceFormatter.string(
  fromDistance: step.distance
)

return cell

Two main things are going on here. First, you set the cell’s textLabel using a MKRoute.Step. Each step has instructions on where to go as well as occasional notices to warn a user about hazards along the way.

Additionally, the distance of a step is useful when reading through a route. This makes it possible to tell the driver of a car: “Turn right on Main Street in two miles”. You format the distance using MKDistanceFormatter, which you declare at the top of this class.

Add the final bit of code, replacing // TODO: Update the header and table view… in updateView(with:):

// 1
totalDistance += mapRoute.distance
totalTravelTime += mapRoute.expectedTravelTime

// 2
let informationComponents = [
  totalTravelTime.formatted,
  "• \(distanceFormatter.string(fromDistance: totalDistance))"
]
informationLabel.text = informationComponents.joined(separator: " ")

// 3
mapRoutes.append(mapRoute)
tableView.reloadData()

With this code, you:

  1. Update the class properties totalDistance and totalTravelTime to reflect the total distance and time of the whole route.
  2. Apply that information to informationLabel at the top of the view.
  3. After you add the route to the array, reload the table view to reflect the new information.

Build and run. Awesome! You can now see a detailed map view with turn-by-turn directions between each stop.

Full app running

Congratulations on completing your app. At this point, you should have a good foundation of how a map-based app works.

Where to Go From Here?

Download the completed version of the project using the Download Materials button at the top or bottom of this tutorial.

To recap what you learned:

  • Leveraging the current location provides context to your app.
  • MKLocalSearchCompleter provides a rich user experience when searching for an address.
  • MKDirections offers the ability to showcase the steps it takes to get from one place to another.

To improve the app further, try allowing the user to select different transportation types or add more destination text fields. Another fun idea could be highlighting the selected step in the map view when the users tap a row in the table view.

MapKit is a powerful framework. You can display static snapshots of areas, show indoor maps and much more. Take a look a the MapKit documentation to see everything you have to work with.

I hope you enjoyed this tutorial. If you have any questions or comments, please join the discussion below!