Search for places and display results using MapKit
By: Pravin Bendre
Tags: Search, Swift, Blog, Maps, iOS

Search for places using MKLocalSearchRequest and display results with UISearchController

Apple provides a powerful native map API called MapKit. You can display a map, show the user’s current location, and drop annotation pins. And you don’t need to rely on any third-party SDKs.

For example, if you’re building an app that shows locations or does anything involving a map, MapKit is the tool for you.

Maps contain a lot of information, so it’s natural for users to search for places. Just check out any popular app with a map like Yelp, Zillow, or Uber and you’ll see a search bar with a results table.

This article will help you to implement location search and display results in table view while user typing for places.

When user search for place and select any result that location would drop on a map.

Get user location

It’s reasonable for an app with a map to ask for the user’s current location. You can conveniently zoom to the local area. And map searches can show results by region.

I will assume you have already created project in Xcode.

CLLocationManager is the object you’ll use for pretty much anything location related. You will use it to prompt the user for permission, and ask the device for the current location. Delegate methods are used to handle any responses.

To set up the Location Manager:

a. Open ViewController.swift

b. Replace the contents of ViewController.swift with the following code:

import UIKit

import MapKit



class ViewController : UIViewController {

let locationManager = CLLocationManager()



override func viewDidLoad() {

super.viewDidLoad()

}

}

The import statement gives you access to classes that begin with MK (Map Kit) and CL (Core Location). The CLLocationManager variable gives you access to the location manager throughout the scope of the controller.

c. Replace viewDidLoad() with the following code:

override func viewDidLoad() {

super.viewDidLoad()

locationManager.delegate = self

locationManager.desiredAccuracy = kCLLocationAccuracyBest

locationManager.requestWhenInUseAuthorization()

locationManager.requestLocation()

}

locationManager.delegate: It could take some time for requested information to come back, so delegate methods are used to handle responses asynchronously. You will implement these delegate methods momentarily.

locationManager.desiredAccuracy: This step is optional, but you might want to override the default accuracy level with an explicit value, like kCLLocationAccuracyBest. Or you could use something less accurate like kCLLocationAccuracyHundredMeters to conserve battery life or if location accuracy does not matter too much.

locationManager.requestWhenInUseAuthorization(): This triggers the location permission dialog. The user will only see the dialog once.

locationManager.requestLocation(): This API call was introduced in iOS 9 and triggers a one-time location request.

Set up the Location Manager Delegate

The Location Manager Delegate methods process Location Manager responses. There will be responses to the authorization and location requests you sent earlier in viewDidLoad.

Add this class extension to the bottom of ViewController.swift, outside of the class body:

extension ViewController : CLLocationManagerDelegate {

func locationManager(manager: CLLocationManager, didChangeAuthorizationStatus status: CLAuthorizationStatus) {

if status == .AuthorizedWhenInUse {

locationManager.requestLocation()

}

}



func locationManager(manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {

if let location = locations.first {

print("location:: (location)")

}

}



func locationManager(manager: CLLocationManager, didFailWithError error: NSError) {

print("error:: (error)")

}

}

extension: You put everything into a class extension rather than the class body. This is just a code organization technique to group related delegate methods.

locationManager(_:didChangeAuthorizationStatus:): This method gets called when the user responds to the permission dialog. If the user chose Allow, the status becomes CLAuthorizationStatus.AuthorizedWhenInUse.

You also trigger another requestLocation() because the first attempt would have suffered a permission failure.

locationManager(_:didUpdateLocations:): This gets called when location information comes back. You get an array of locations, but you’re only interested in the first item. You don’t do anything with it yet, but eventually you will zoom to this location.

locationManager(_:didFailWithError:): For now, just print out the error. Or you could handle by code.

Xcode should automatically generate and wire up the following code for you:

@IBOutlet weak var mapView: MKMapView!

Now that you have a handle to the Map Kit View, you can zoom to the user’s location.

a. Open ViewController.swift

b. Replace the code for locationManager(_:didUpdateLocations:) with the following:

func locationManager(manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {

if let location = locations.first {

let span = MKCoordinateSpanMake(0.05, 0.05)

let region = MKCoordinateRegion(center: location.coordinate, span: span)

mapView.setRegion(region, animated: true)

}

}

locationManager(_:didUpdateLocations:) gives you an array of locations, but you’re only interested in the first one.

In order to zoom to this location, you need to perform a few intermediate steps. You define a map region, which is a combination of the map center (coordinate) and zoom level (span). The coordinate is property from the first location object. And the span is an arbitrary area of 0.05 degrees longitude and latitude. Once you combine the coordinate and span into a region, you can zoom using setRegion(_:animated:).

Use UISearchController to display search results

UISearchController is a special UI element that is optimized for displaying search results. It has a search bar that shows or hides a results table, based on the search bar state. It was introduced in iOS 8, and it replaces the deprecated UISearchDisplayController.

The search bar is accessible from both the current page and the search results page. Typically, you embed the search bar in the navigation bar or table view header. When you tap inside the search bar, a semi-transparent modal appears over the current screen. As you start typing, a table view appears within the modal. The search bar’s cancel button dismisses the modal overlay.

UISearchController is made up of three major parts:

searchBar: A search bar comes included with UISearchController.

searchResultsController: This is a custom View Controller you provide to display search results. In this tutorial, you will use a Table View Controller.

searchResultsUpdater: This is a delegate that responds to search bar text entry. In this tutorial, the Table View Controller will implement this delegate and update the table view data source accordingly.

Now you should have Table View Controller with class LocationSearchTable like this:

import UIKit

class LocationSearchTable : UITableViewController {



}

Don’t forget to give storyboard id we will need later on. Set table view cel style to subtitle. set cell identifier that will also need later on.

In the Storyboard, select the View Controller scene (the one containing the map). Go to EditorEmbed InNavigation Controller.

Now it’s time to write some UISearchController code.

Create a property for the UISearchController

Within the ViewController class body, add the following variable:

var resultSearchController:UISearchController? = nil

This variable has controller-level scope to keep the UISearchController in memory after it’s created.

Set up the search results table

Append the following code to the contents of viewDidLoad:

let locationSearchTable = storyboard!.instantiateViewControllerWithIdentifier("LocationSearchTable") as! LocationSearchTable

resultSearchController = UISearchController(searchResultsController: locationSearchTable)

resultSearchController?.searchResultsUpdater = locationSearchTable

The locationSearchTable is the Table View Controller you set up earlier in the Storyboard. Thanks to the Storyboard ID, you can instantiate it programmatically.

The locationSearchTable will also serve as the searchResultsUpdater delegate.

Set up the UISearchController delegate

In the Project navigator, open LocationSearchTable.swift.

Add the following class extension to the bottom, outside of the class body:

extension LocationSearchTable : UISearchResultsUpdating {

func updateSearchResultsForSearchController(searchController: UISearchController) { }

}

Switch back to ViewController.swift, and append the following code to the contents of viewDidLoad:

let searchBar = resultSearchController!.searchBar

searchBar.sizeToFit()

searchBar.placeholder = "Search for places"

navigationItem.searchController = resultSearchController

This configures the search bar, and embeds it within the navigation bar.

Configure the UISearchController appearance

Add some more code to viewDidLoad:

resultSearchController?.hidesNavigationBarDuringPresentation = false

resultSearchController?.dimsBackgroundDuringPresentation = true

definesPresentationContext = true

hidesNavigationBarDuringPresentation determines whether the Navigation Bar disappears when the search results are shown. Set this to false, since we want the search bar accessible at all times.

dimsBackgroundDuringPresentation gives the modal overlay a semi-transparent background when the search bar is selected.

Setting definesPresentationContext to true is an important but easily overlooked step. By default, the modal overlay will take up the entire screen, covering the search bar. definesPresentationContext limits the overlap area to just the View Controller’s frame instead of the whole Navigation Controller.

Apple provides a location search API for querying places. This API is free, which is one reason you might use native MapKit over a 3rd party SDK like Google Maps. Time to use it.

At the top of LocationSearchTable.swift, add the following line:

import MapKit

You did this in ViewController.swift, but you’ll also need it for LocationSearchTable.swift.

Within the LocationSearchTable class body, add the following variables:

var matchingItems:[MKMapItem] = []

var mapView: MKMapView? = nil

matchingItems: You will use this later on to stash search results for easy access.

mapView: Search queries rely on a map region to prioritize local results. The mapView variable is a handle to the map from the previous screen. You’ll wire this up in the next step.

Switch to ViewController.swift, and append the following line to the contents of viewDidLoad:

locationSearchTable.mapView = mapView

This passes along a handle of the mapView from the main View Controller onto the locationSearchTable.

Switch back again to LocationSearchTable.swift, and replace updateSearchResultsForSearchController(_:) with the following code:

func updateSearchResultsForSearchController(searchController: UISearchController) {

guard let mapView = mapView,

let searchBarText = searchController.searchBar.text else { return }

let request = MKLocalSearchRequest()

request.naturalLanguageQuery = searchBarText

request.region = mapView.region

let search = MKLocalSearch(request: request)

search.startWithCompletionHandler { response, _ in

guard let response = response else {

return

}

self.matchingItems = response.mapItems

self.tableView.reloadData()

}

}

The guard statement unwraps the optional values for mapView and the search bar text.

MKLocalSearchRequest: A search request is comprised of a search string, and a map region that provides location context. The search string comes from the search bar text, and the map region comes from the mapView.

MKLocalSearch performs the actual search on the request object. startWithCompletionHandler() executes the search query and returns a MKLocalSearchResponse object which contains an array of mapItems. You stash these mapItems inside matchingItems, and then reload the table.

Now that data is coming back from the API, it’s time to wire up the UI.

Add another class extension to the bottom of LocationSearchTable.swift:

extension LocationSearchTable {

override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {

return matchingItems.count

} override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {

let cell = tableView.dequeueReusableCellWithIdentifier("cell")!

let selectedItem = matchingItems[indexPath.row].placemark

cell.textLabel?.text = selectedItem.name

let address = "\(selectedItem.thoroughfare ?? ""), \(selectedItem.locality ?? ""), \(selectedItem.subLocality ?? ""), \(selectedItem.administrativeArea ?? ""), \(selectedItem.postalCode ?? ""), \(selectedItem.country ?? "")" cell.detailTextLabel?.text = address

return cell

}

}

This extension groups all the UITableViewDataSource methods together.

The matchingItems array determines the number of table rows.

Each cell was configured with an identifier of cell in a previous section. The cell’s built-in textLabel is set to the placemark name of the Map Item.

The cell’s detailTextLabel is set to an address . You need to make one string from all the available dictionary key values.

At the top of ViewController.swift, add the following protocol underneath the import statements, but above the class body:

protocol HandleMapSearch {

func dropPinZoomIn(placemark:MKPlacemark)

}

Within the ViewController class body, add the following variable:

var selectedPin:MKPlacemark? = nil

At the bottom of ViewController.swift, add another class extension:

extension ViewController: HandleMapSearch {

func dropPinZoomIn(placemark:MKPlacemark){

// cache the pin

selectedPin = placemark

// clear existing pins

mapView.removeAnnotations(mapView.annotations)

let annotation = MKPointAnnotation()

annotation.coordinate = placemark.coordinate

annotation.title = placemark.name

if let city = placemark.locality,

let state = placemark.administrativeArea {

annotation.subtitle = "(city) (state)"

}

mapView.addAnnotation(annotation)

let span = MKCoordinateSpanMake(0.05, 0.05)

let region = MKCoordinateRegionMake(placemark.coordinate, span)

mapView.setRegion(region, animated: true)

}

}

This extension implements the dropPinZoomIn() method in order to adopt the HandleMapSearch protocol.

The incoming placemark is cached in the selectedPin variable. This will be useful later when you create the callout button.

removeAnnotations() clears the map of any existing annotations. This step is to ensure we’re only dealing with one annotation pin on the map at a time.

MKPointAnnotation is a map pin that contains a coordinate, title, and subtitle. The placemark has similar information like a coordinate and address information. Here you populate the title and subtitle with information that makes sense.

Append the following code to the contents of viewDidLoad:

locationSearchTable.handleMapSearchDelegate = self

The parent (ViewController) passes a handle of itself to the child controller (LocationSearchTable). This variable doesn’t exist yet, so you’ll create it in the next step.

Switch to LocationSearchTable.swift, and add the following variable:

var handleMapSearchDelegate:HandleMapSearch? = nil

This should silence the compiler error from the previous step.

At the bottom of LocationSearchTable.swift, add another class extension:

extension LocationSearchTable {

override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {

let selectedItem = matchingItems[indexPath.row].placemark

handleMapSearchDelegate?.dropPinZoomIn(selectedItem)

dismissViewControllerAnimated(true, completion: nil)

}

}

This class extension groups UITableViewDelegate methods together.

When a search result row is selected, you find the appropriate placemark based on the row number. Then you pass the placemark to the map controller via the custom protocol method. Finally, you close the search results modal so the user can see the map.