Open Map Location in Multiple Apps from MKMapView
You’ve probably seen in a lot of apps where tapping on a map location or address gives you the option to open that location in one of the map applications installed on your phone. Perhaps you tap on the address for an event in the Facebook app, and it gives you the option to see that location in Apple Maps, Google Maps, or Waze, depending on what apps you have on your phone. I wanted to attach this functionality to an MKMapView
in an app I’m currently working on
in a clean, encapsulated way, and thought I would share that in a post. I took what I had learned from a few other great articles, and combined them to fit my needs.
Requirements
To get started, I had to decide what map applications I wanted to support. Everyone will obviously have Apple Maps on their phone, but may also optionally have Google Maps or Waze as well. I decided to stick with these “big three” and go from there. Next, if there was only one app available on the phone (e.g. Apple Maps), I didn’t want to show a list with only one option, so tapping on the MKMapView
would skip the selection step and directly open in Apple Maps. The final thing to keep in mind for my situation was that the actualy map view was located in a child view controller of the currently displayed top-level controller, so I wanted to offer the map selection pop-up in the parent view controller, even though the prompt would be triggered from the child view controller.
Initial Setup
The first thing we need to do is to “whitelist” Google Maps and Waze in the Info.plist configuration file. Create an array entry with the key of LSApplicationQueriesSchemes. This will allow the app to determine if Waze and Google Maps are installed and available to be used on the device. From the Apple docs:
LSApplicationQueriesSchemes (Array - iOS) Specifies the URL schemes you want the app to be able to use with the canOpenURL: method of the UIApplication class. For each URL scheme you want your app to use with the canOpenURL: method, add it as a string in this array.
These URL schemes, unique to each app, allow for inter-application communcation, and you can read more about them here. For our two map applications, we’ll add the array entries comgooglemaps and waze.
Building Scheme Info
Since Apple Maps already has a nice built-in API for opening itself from another iOS application with a particular location and configuration, I wanted to construct a similar, encapsulating interface for the other third-party map schemes as well. For Google Maps and Waze, we need to build out:
- A mechanism to determine if each app is available to use on the device
- The actual URL for opening the app with the location information
I began this process with a simple protocol, aptly named MapAppScheme
:
import Foundation
import MapKit
protocol MapAppScheme {
var label: String { get } // The label for the option in the list
var scheme: URL? { get } // The URL scheme used to determine if the app is available
var annotation: MKAnnotation { get set } // The coordinates (latitutde and longitude) for the location
var url: URL? { get } // The URL to open the application with the required info
}
From here, it’s pretty straightfoward to “flesh out” the two schemes.
Google Maps
import Foundation
import MapKit
struct GoogleMapsScheme: MapAppScheme {
var label: String = "Google Maps"
var scheme: URL? = URL(string: "comgooglemaps://") // Scheme needs to be wrapped in a URL object
var annotation: MKAnnotation
init(annotation: MKAnnotation) {
self.annotation = annotation
}
var url: URL? {
let stringScheme = scheme?.absoluteString
// Try to use the name of the location, replacing spaces with +, otherwise use the latitude and longitude
guard let destination: String = annotation.title??.replacingOccurrences(of: " ", with: "+") ?? "\(annotation.coordinate.latitude),\(annotation.coordinate.longitude)" else {
return nil
}
return URL(string: "\(String(describing: stringScheme!))?saddr=&daddr=\(destination)¢er=\(annotation.coordinate.latitude),\(annotation.coordinate.longitude)")
}
}
The code speaks for itself, but ideally the url
property will be constructed with either a location name:
comgooglemaps://?saddr=&daddr=Billy+Goat+Hill¢er=37.7415,122.4330
Or the coordinates:
comgooglemaps://?saddr=&daddr=37.7415,122.4330¢er=37.7415,122.4330
The way the URL is constructed will enter the location as the destination for traveling directions, with the starting location left blank. You can learn more about the Google Maps URL scheme here.
Waze
import Foundation
import MapKit
struct WazeScheme: MapAppScheme {
var label: String = "Waze"
var scheme: URL? = URL(string: "waze://")
var annotation: MKAnnotation
init(annotation: MKAnnotation) {
self.annotation = annotation
}
var url: URL? {
// Try to get the location name
let destination = annotation.title??.addingPercentEncoding(withAllowedCharacters: .alphanumerics)
// Build the query params
var searchQuery = [
URLQueryItem(name: "ll", value: "\(annotation.coordinate.latitude),\(annotation.coordinate.longitude)")
]
// Add location name to query params if available
if let dest = destination {
searchQuery.append(URLQueryItem(name: "q", value: dest))
}
var wazeUrl = URLComponents(string: "https://waze.com/ul")
// Attach query params to URL
wazeUrl?.queryItems = searchQuery
return wazeUrl?.url
}
}
The Waze scheme is very similar to Google Maps, although the annotation’s title will be URL escaped instead of using +’s.
https://waze.com/ul?q=66%20Billy%20Goat%20Hill&ll=37.7415,122.4330
Additionally, since the Waze URL scheme is a more traditional URL, I built it using the URLComponent
class instead of interpolating the results into the URL string directly. You can learn more about the Waze URL scheme here.
Consolidating the Results
Now that the schemes are built out, I constructed a helper class to determine which of the two I could use.
import Foundation
import MapKit
class MapAppsHelper {
fileprivate let mapSchemes: [MapAppScheme]
let annotation: MKAnnotation
init(annotation: MKAnnotation) {
self.annotation = annotation
mapSchemes = [
GoogleMapsScheme(annotation: annotation),
WazeScheme(annotation: annotation)
]
}
lazy private(set) var availableMapApps: [String: URL] = {
var availableSchemes: [String: URL] = [:]
for scheme in mapSchemes {
// If the app is available, add the URL to the list of available schemes
if let schemeUrl = scheme.scheme, UIApplication.shared.canOpenURL(schemeUrl), let url = scheme.url {
availableSchemes[scheme.label] = url
}
}
// ["Google Maps": "urlForGoogleMaps", ...]
return availableSchemes
}()
}
We first check if the scheme is available by using the canOpenURL
function, then add our constructed URL to the list of availableSchemes
. I then created a MapActionSheetViewController
, which is a UIAlertController
action sheet.
import UIKit
import MapKit
class MapActionSheetViewController: UIAlertController {
fileprivate let mapOptions: [String: URL]
init(mapOptions: [String: URL], renderAppleMaps: @escaping () -> ()) {
// Pass in the results from our MapAppsHelper
self.mapOptions = mapOptions
super.init(nibName: nil, bundle: nil)
// Pass in the closure containing the logic to render the location inside Apple Maps
buildActions(renderAppleMaps)
}
override var preferredStyle: UIAlertControllerStyle {
return .actionSheet
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
fileprivate func buildActions(_ renderAppleMaps: @escaping () -> ()) {
// Add the Apple Maps option with the closure containing the logic for rendering the location in Apple Maps
addAction(UIAlertAction(title: "Apple Maps", style: .default, handler: { (action) in
renderAppleMaps()
}))
// Add one or more actions for our third-party map applications
mapOptions.forEach { option in
addAction(UIAlertAction(title: option.key, style: .default, handler: { (action) in
UIApplication.shared.open(option.value, options: [:], completionHandler: nil)
}))
}
// Add an option to cancel opening the location in a map application
addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: { (action) in
self.dismiss(animated: true, completion: nil)
}))
}
}
There’s a lot going on here, but hopefully the code with comments will make clear what’s going on. Basically as we add options to the action sheet, we associate the logic for opening each map application with the correct URL (or executing a closure in the case of Apple Maps) to that option. As we wrap up, hopefully this will all make sense. However, for a visual representation, this will render the highlighted section below.
One final thing to note about the renderAppleMaps
closure; we are passing this functionality in rather than declaring it inside the controller since we might have the case where the only map app available is Apple Maps. Therefore, rather than writing the code to open the location in Apple Maps in two places in the codebase, we can write it in one place and pass it in if we need to, or just execute it when the MKMapView
is tapped.
Wiring Up the View
As mentioned at the beginning of the article, the MKMapView
is actually inside the view of a child view controller, and we want to render our UIAlertController
action sheet in the parent controller. The child view controller is called ParkDetailsMapViewController
, and the parent controller is the ParkViewController
.
We can accomplish this by setting up a UITapGestureRecognizer
on the MKMapView
, then setting the ParkViewController
parent as the delegate to a protocol declared in the child ParkDetailsMapViewController
. When the MKMapView
is tapped, trigger the delegate to show the action sheet. First, the ParkDetailsMapViewController
, in which I’m only showing the relevant parts.
import MapKit
protocol ParkDetailsMapViewControllerDelegate: class {
// Define this in the parent ParkViewController
func renderMapSelectionActionSheet(mapOptions: [String: URL], renderAppleMaps: @escaping () -> ())
}
class ParkDetailsMapViewController: UIViewController {
let mapAppsHelper: MapAppsHelper
init(annotation: MKAnnotation) {
// Pass in the MKAnnotation from the MKMapView
mapAppsHelper = MapAppsHelper(annotation: annotation)
...
// mapView variable declaration left out, however just a basic MKMapView setup
mapView.addAnnotationWithRegion(annotation)
}
private var mapTapGesureRecognizer: UITapGestureRecognizer?
weak var delegate: ParkDetailsMapViewControllerDelegate?
override func viewDidLoad() {
super.viewDidLoad()
// When the map view is tapped, call the openInMaps function
mapTapGesureRecognizer = UITapGestureRecognizer(target: self, action: #selector(openInMaps))
mapView.addGestureRecognizer(mapTapGesureRecognizer!)
...
}
I added the openInMaps
function as a private extension to the controller.
fileprivate extension ParkDetailsMapViewController {
@objc func openInMaps(_ sender: UITapGestureRecognizer) {
// If we have more than Apple Maps available, trigger the rendering of the action sheet
if mapAppsHelper.availableMapApps.count > 0 {
delegate?.renderMapSelectionActionSheet(mapOptions: mapAppsHelper.availableMapApps, renderAppleMaps: {
self.mapView.openInMaps(self.mapAppsHelper.annotation)
})
} else {
// Open directly in Apple Maps
mapView.openInMaps(mapAppsHelper.annotation)
}
}
}
If you notice, the two arguments we’re sending to the renderMapSelectionActionSheet
delegate function happen to match the arguments required for the initialization of the MapActionSheetViewController
. You can probably guess where we’ll be creating that controller instance….
Before we jump over to the parent ParkViewController
, I want to point out that the openInMaps
function on the mapView
variable is actually an extension attached to the MKMapView
for opening the location in Apple Maps.
extension MKMapView {
// How far to zoom the map
private var regionRadius: CLLocationDistance {
return 2000
}
func addAnnotationWithRegion(_ annotation: MKAnnotation) {
addAnnotation(annotation)
// Center the map view around the location
let coordinateRegion = MKCoordinateRegionMakeWithDistance(annotation.coordinate, regionRadius * 2.0, regionRadius * 2.0)
setRegion(coordinateRegion, animated: true)
}
func openInMaps(_ annotation: MKAnnotation) {
let options = [
MKLaunchOptionsMapCenterKey: NSValue(mkCoordinate: region.center),
MKLaunchOptionsMapSpanKey: NSValue(mkCoordinateSpan: region.span)
]
// Set up the location marker in the map view
let placemark = MKPlacemark(coordinate: annotation.coordinate, addressDictionary: nil)
let mapItem = MKMapItem(placemark: placemark)
mapItem.name = annotation.title!
// Launch in Apple Maps
mapItem.openInMaps(launchOptions: options)
}
}
Hopefully the code and comments are enough of an explanation, but feel free to play around with different values here to see what kind of results you get in the map view.
Finishing Touches
The last piece is one of the most straightforward, where we actually build out the renderMapSelectionActionSheet
delegate function. At this point, all we need to do is take the arguments passed in, instantiate the MapActionSheetViewController
with those arguments, and present the view controller.
class ParkViewController: UIViewController, ParkDetailsMapViewControllerDelegate, UIScrollViewDelegate {
...
func renderMapSelectionActionSheet(mapOptions: [String: URL], renderAppleMaps: @escaping () -> ()) {
let actionSheet = MapActionSheetViewController(mapOptions: mapOptions, renderAppleMaps: renderAppleMaps)
// Show the action sheet
present(actionSheet, animated: true, completion: nil)
}
...
}
And voilà! Our finished product (sorry for the heavy duty GIF).
Conclusion
In this particular case, we are using an MKMapView
to trigger our map app options, but you could very well do it with a UITableViewCell
or UILabel
location address as well. This is one way I felt worked best for this particular codebase, but there are many other great tutorials on how to do this out scattered about the interwebs. I hope you’ve been able to pick up a few good ideas from this lesson on your iOS journey. Thanks for reading!