Working with MapKit Local Search in iOS 11
This chapter will explore the use of the iOS MapKit MKLocalSearchRequest class to search for map locations within an iOS 11 application. The example application created in the chapter entitled Working with Maps on iOS 11 with MapKit and the MKMapView Class will then be extended to demonstrate local search in action.
An Overview of iOS 11 Local Search
Local search is implemented using the MKLocalSearch class. The purpose of this class is to allow users to search for map locations using natural language strings. Once the search has completed, the class returns a list of locations within a specified region that match the search string. A search for “Pizza”, for example, will return a list of locations for any pizza restaurants within a specified area. Search requests are encapsulated in instances of the MKLocalSearchRequest class and results are returned within an MKLocalSearchResponse object which, in turn, contains an MKMapItem object for each matching location.
Local searches are performed asynchronously and a completion handler called when the search is complete. It is also important to note that the search is performed remotely on Apple’s servers as opposed to locally on the device. Local search is, therefore, only available when the device has an active internet connection and is able to communicate with the search server.
The following code fragment, for example, searches for pizza locations within the currently displayed region of an MKMapView instance named mapView. Having performed the search, the code iterates through the results and outputs the name and phone number of each matching location to the console:
let request = MKLocalSearchRequest() request.naturalLanguageQuery = "Pizza" request.region = mapView.region let search = MKLocalSearch(request: request) search.start(completionHandler: {(response, error) in if error != nil { print("Error occurred in search: \(error!.localizedDescription)") } else if response!.mapItems.count == 0 { print("No matches found") } else { print("Matches found") for item in response!.mapItems { print("Name = \(item.name)") print("Phone = \(item.phoneNumber)") } } })
The above code begins by creating an MKLocalSearchRequest request instance initialized with the search string (in this case “Pizza”). The region of the request is then set to the currently displayed region of the map view instance.
let request = MKLocalSearchRequest() request.naturalLanguageQuery = "Pizza" request.region = mapView.region
An MKLocalSearch instance is then created and initialized with a reference to the search request instance and the search then initiated via a call to the object’s start(completionHandler:) method. search.start(completionHandler: {(response, error) in
The code in the completion handler checks the response to make sure that matches were found and then accesses the mapItems property of the response which contains an array of mapItem instances for the matching locations. The name and phoneNumber properties of each mapItem instance are then displayed in the console:
if error != nil { print("Error occurred in search: \(error!.localizedDescription)") } else if response!.mapItems.count == 0 { print("No matches found") } else { print("Matches found") for item in response!.mapItems { print("Name = \(item.name)") print("Phone = \(item.phoneNumber)") } } })
Adding Local Search to the MapSample Application
In the remainder of this chapter, the MapSample application will be extended so that the user can perform a local search. The first step in this process involves adding a text field to the first storyboard scene. Begin by launching Xcode and opening the MapSample project created in the previous chapter.
Adding the Local Search Text Field
With the project loaded into Xcode, select the Main.storyboard file and modify the user interface to add a Text Field object to the user interface layout (reducing the height of the map view object accordingly to make room for the new field). With the new Text Field selected, display the Attributes Inspector and enter Local Search into the Placeholder property field. When completed, the layout should resemble that of Figure 81-1:
[[File:]]
Figure 81-1
Select the Map Sample view controller by clicking on the toolbar at the top of the scene so that the scene is highlighted in blue. Select the Resolve Auto Layout Issues menu from the toolbar in the lower right-hand corner of the storyboard canvas and select the Reset to Suggested Constraints menu option located beneath All Views in View Controller.
When the user touches the text field, the keyboard will appear. By default this will display a “Return” key. For the purposes of this application, however, a “Search” key would be more appropriate. To make this modification, select the new Text Field object, display the Attributes Inspector and change the Return Key setting from Default to Search.
Next, display the Assistant Editor panel and make sure that it is displaying the content of the ViewController.swift file. Ctrl-click on the Text Field object and drag the resulting line to the Assistant Editor panel and establish an outlet named searchText.
Repeat the above step, this time setting up an Action for the Text Field to call a method named textFieldReturn for the Did End on Exit event. Be sure to set the Type menu to UITextField as shown in Figure 81 2 before clicking on the Connect button:
[[File:]]
Figure 81-2
The textFieldReturn method will be required to perform three tasks when triggered. In the first instance it will be required to hide the keyboard from view. When matches are found for the search results, an annotation will be added to the map for each location. The second task to be performed by this method is to remove any annotations created as a result of a previous search.
Finally, the textFieldReturn method will initiate the search using the string entered into the text field by the user. Select the ViewController.swift file, locate the template textFieldReturn method and implement it so that it reads as follows:
@IBAction func textFieldReturn(_ sender: UITextField) { _ = sender.resignFirstResponder() mapView.removeAnnotations(mapView.annotations) self.performSearch() }
Performing the Local Search
The next task is to write the code to perform the search. When the user touches the keyboard Search key, the above textFieldReturn method is called which, in turn, has been written such that it makes a call to a method named performSearch. Remaining within the ViewController.swift file, this method may now be implemented as follows:
func performSearch() { matchingItems.removeAll() let request = MKLocalSearchRequest() request.naturalLanguageQuery = searchText.text request.region = mapView.region let search = MKLocalSearch(request: request) search.start(completionHandler: {(response, error) in if let results = response { if let err = error { print("Error occurred in search: \(err.localizedDescription)") } else if results.mapItems.count == 0 { print("No matches found") } else { print("Matches found") for item in results.mapItems { print("Name = \(item.name ?? "No match")") print("Phone = \(item.phoneNumber ?? "No Match")") self.matchingItems.append(item as MKMapItem) print("Matching items = \(self.matchingItems.count)") let annotation = MKPointAnnotation() annotation.coordinate = item.placemark.coordinate annotation.title = item.name self.mapView.addAnnotation(annotation) } } } }) }
Next, edit the ViewController.swift file to add the declaration for the matchingItems array referenced in the above method. This array is used to store the current search matches and will be used later in the tutorial:
import UIKit import MapKit class ViewController: UIViewController, MKMapViewDelegate { @IBOutlet weak var mapView: MKMapView! @IBOutlet weak var searchText: UITextField! var matchingItems: [MKMapItem] = [MKMapItem]() . .
The code in the performSearch method is largely the same as that outlined earlier in the chapter, the major difference being the addition of code to add an annotation to the map for each matching location:
let annotation = MKPointAnnotation() annotation.coordinate = item.placemark.coordinate annotation.title = item.name self.mapView.addAnnotation(annotation)
Annotations are represented by instances of the MKPointAnnotation class and are, by default, represented by red pin markers on the map view (though custom icons may be specified). The coordinates of each match are obtained by accessing the placemark instance within each item. The title of the annotation is also set in the above code using the item’s name property.
Testing the Application
Compile and run the application on an iOS device and, once running, select the zoom button before entering the name of a type of business into the local search field such as “pizza”, “library” or “coffee”. Touch the keyboard “Search” button and, assuming such businesses exist within the currently displayed map region, an annotation marker will appear for each matching location (Figure 81-3):
[[File:]]
Figure 81-3
Local searches are not limited to business locations. It can also be used, for example, as an alternative to geocoding for finding local addresses.
Customized Annotation Markers
By default the annotation markers appear with a red background and a white push pin icon (referred to as the glyph). To change the appearance of the annotations the first step is to implement the mapView(_:viewFor:) delegate method within the ViewController.swift file. When implemented, this method will be called each time an annotation is added to the map. The method is passed the MKAnnotation object to be added and needs to return an MKMarkerAnnotationView object configured with appropriate settings ready to be displayed on the map. In the same way that the UITableView class reuses table cells, the MapView class also maintains a queue of MKMarkerAnnotationView objects ready to be used. This dramatically increases map performance when working with large volumes of annotations.
Within the ViewController.swift file, implement a basic form of this method as follows:
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { let identifier = "marker" var view: MKMarkerAnnotationView if let dequeuedView = mapView.dequeueReusableAnnotationView( withIdentifier: identifier) as? MKMarkerAnnotationView { dequeuedView.annotation = annotation view = dequeuedView } else { view = MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: identifier) } return view }
The method begins by specifying an identifier for the annotation type. If a map is to display different categories of annotation, each category will need a unique identifier. The code then checks to see if an existing annotation view with the specified identifier is available to be reused. If one is available it is returned and displayed on the map. If no reusable annotation views are available, a new one is created consisting of the annotation object passed to the method and with the identifier string.
Run the app now, zoom in on the current location and perform a search that will result in annotations appearing. Since no customizations have been made to the MKMarkerAnnotationView object, the location markers appear as before.
Modify the mapView(_:viewFor:) method as follows to change the color of the marker and to display text instead of the glyph icon:
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { . . } else { view = MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: identifier) view.markerTintColor = UIColor.blue view.glyphText = "Here" } return view }
When the app is now tested, the markers appear with a blue background and display text which reads “Here” as shown in Figure 81-4:
[[File:]]
Figure 81-4
The default glyph icon may also be replaced by a different image. Ideally two images should be assigned, one sized at 20x20px to be displayed on the standard marker and a larger one (40x40px) to be displayed when the marker is selected. To try this, open a Finder window and navigate to the map_glyphs folder of the sample code available from the following URL:
https://1drv.ms/u/s!Ar8dYVWceqjbkQF_Dzvclw-LcTCp
This folder contains two image files named small-business-20.png and small-business-40.png. Within Xcode, select the Assets.xcassets entry in the project navigator panel and drag and drop the two image files from the Finder window onto the asset panel as indicated in Figure 81-5:
[[File:]]
Figure 81-5
With the glyphs added, modify the code in the mapView(_:viewFor:) method to use these images instead of the text:
. . } else { view = MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: identifier) view.markerTintColor = UIColor.blue view.glyphText = "Here" view.glyphImage = UIImage(named: "small-business-20") view.selectedGlyphImage = UIImage(named: "small-business-40") } . .
The markers will now display the smaller glyph when a search is performed within the app. Selecting a marker on the map will display the larger glyph image:
[[FIle:]]
Figure 81-6
Another option for customizing annotation markers involves the addition of callout information which appears when a marker is selected within the app. Modify the code once again, this time adding code to add a callout to each marker:
} else { view = MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: identifier) view.markerTintColor = UIColor.blue view.glyphImage = UIImage(named: "small-business-20") view.selectedGlyphImage = UIImage(named: "small-business-40") view.canShowCallout = true view.calloutOffset = CGPoint(x: -5, y: 5) view.rightCalloutAccessoryView = UIButton(type: .detailDisclosure) }
The new code begins by indicating that the marker is able to display a callout before specifying the position of the callout in relation to the corresponding marker. The final line of code declares a view to appear to the left of the callout text, in this case a UIButton view configured to display the standard information icon. Since UIButton is derived from UIControl, the app can receive notifications of the button being tapped by implementing the mapView(_: calloutAccessoryControlTapped:) delegate method. The following example implementation of this method simply outputs a message to the console when the button is tapped:
func mapView(_: MKMapView, annotationView: MKAnnotationView, calloutAccessoryControlTapped: UIControl) { print("Control tapped") }
Run the app again, zoom in and perform a business search. When the result appear, select one of the annotations and note that the callout appears as is the case in Figure 81-7:
[[File:]]
Figure 81-7
Click on the information button and verify that the message appears in the console window.
Annotation Marker Clustering
When too many annotations appear close together in a map it can be difficult to identify one marker from another without zooming into the area so that the markers move apart. MapKit resolves this issue by providing support for clustering of annotation markers. Clustering is enabled by assigning cluster identifiers to the MKMarkerAnnotationView objects. When a group of annotations belonging to the same cluster are grouped too closely together, a single marker appears displaying the number of annotations in the cluster.
To see clusters in action, modify the mapView(_:viewFor:) delegate method one last time to assign a cluster identifier to each annotation marker as follows:
. . } else { view = MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: identifier) view.clusteringIdentifier = "myCluster" view.markerTintColor = UIColor.blue . .
After building and relaunching the app, enter a search term without first zooming into the map. Because the map is zoomed out the markers should be too close together to display, causing the cluster count (Figure 81-8) to appear instead:
[[File:]]
Figure 81-8
Summary
The iOS MapKit Local Search feature allows map searches to be performed using free-form natural language strings. Once initiated, a local search will return a response containing map item objects for matching locations within a specified map region.
In this chapter the MapSample application was extended to allow the user to perform local searches and to use and customize annotations to mark matching locations on the map view in addition to marker clustering.
In the next chapter, the example will be further extended to cover the use of the Map Kit directions API, both to generate turn-by-turn directions and to draw the corresponding route on a map view.