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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Routing With MapKit and Core Location
25 mins
- Getting Started
- Using MapKit With CoreLocation
- Getting the User’s Location With CoreLocation
- Autocompleting the User’s Location
- Getting Authorization to Access the User’s Location
- Turning the User’s Coordinates Into an Address
- Processing User Input With MKLocalSearchCompleter
- Improving MKLocalSearchCompleter’s Accuracy
- Finishing the Address’ Autocomplete
- Calculating Routes With MapKit
- Requesting MKRoute Directions
- Rendering Routes Into the Map
- Walking Through Each MKRoute.Step
- Where to Go From Here?
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 usingMapKit
.
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:
- Before using the location manager, it’s good practice to make sure the user has enabled location services.
- When you geocode the location’s coordinates, you’ll probably lose some precision. An accuracy of 100 meters is more than adequate.
- The location manager’s delegate informs the app when new locations arrive and when the privacy setting changes.
- 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.
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 Info ▸ Info.plist and follow these steps:
- Add NSLocationWhenInUseUsageDescription to Info.plist.
- Keep the Type as String.
- 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.
Build and run again; an alert should now pop up, as expected.
Tap Allow While Using App or Allow Once to make the location manager aware of your location.
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:
- Ensure the user has given the app authorization to access location information.
-
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. - Since the user can edit the origin text field, it’s a good idea to make sure that it’s empty before changing it.
- Store the current location and update the field.
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 Features ▸ Location, select Apple.
Build and run. Once the geocode finishes, you’ll see Apple Campus appear in the Start / End 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:
- If the user edited the origin field, you remove the current location.
- You make sure that the query contains information, because it doesn’t make sense to send an empty query to the completer.
- If the field is empty and the completer is currently attempting to find a match, you cancel it.
- 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.
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:
-
commonDelta
refers to the zoom level you want. Increase the value for broader map coverage. - This is the region you created using the coordinates obtained via
CoreLocation
‘s delegate.
Now, build and run.
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.
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:
- Dismiss the keyboard and disable the Calculate Route button.
- Here’s where the current location information comes in handy.
RouteBuilder
handleslocation
andtext
types in different ways.MKLocalSearch
handles the user’s input andCLGeocoder
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. - You map the remaining fields as
text
segments. - Ensure that there’s enough information before showing
DirectionsViewController
. - Build the route with the segments and current region.
- After the helper finishes, re-enable the Calculate Route button.
- 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.
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 aMKRoute
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.
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:
- This is the condition that breaks out of the recursive loop. Without this, the app will suffer the fate of an infinite loop.
- 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. - You configure the request to use the selected tuple value for the source and destination.
- Once you configure the request, you use an instance of
MKDirections
to calculate the directions. - 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!
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:
- Update the class properties
totalDistance
andtotalTravelTime
to reflect the total distance and time of the whole route. - Apply that information to
informationLabel
at the top of the view. - 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.
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!
All videos. All books.
One low price.
A Kodeco subscription is the best way to learn and master mobile development — plans start at just $19.99/month! Learn iOS, Swift, Android, Kotlin, Flutter and Dart development and unlock our massive catalog of 50+ books and 4,000+ videos.
Learn more