预计阅读本页时间:-
Chapter 9
Exploring Maps and Local Search
WHAT YOU LEARN IN THIS CHAPTER:
- Displaying a map in an app
- Getting and showing the user’s current location
- Using Apple’s local search to display points of interest on a map
WROX.COM CODE DOWNLOADS FOR THIS CHAPTER
广告:个人专属 VPN,独立 IP,无限流量,多机房切换,还可以屏蔽广告和恶意软件,每月最低仅 5 美元
You can find the wrox.com code downloads for this chapter at www.wrox.com/go/begiosprogramming on the Download Code tab. The code is in the chapter 09 download and individually named according to the names throughout the chapter.
The availability of maps and showing a user’s location are very handy features of mobile devices. Apple’s Maps app has been part of iOS since the launch of the iPhone. When the iOS SDK became public, Apple included its Map Kit framework for developers to build their own location-based apps, and they’ve been popular ever since.
Location-based search has become a popular feature in iOS apps as well. Urban Spoon was one of the first to use this type of location awareness by enabling you to view restaurants near your current location. Urban Spoon had spent a great deal of time building its database and search infrastructure to support its app and make it one of the most popular apps ever released.
Apple saw how popular location-based search had become. It partnered with the popular local search service Yelp and created new search classes and protocols that it released with iOS 6. With the new additions to Map Kit, developers can search and show local search results with just a few lines of code.
In this chapter you add the Find Local Record Stores feature to the Bands app. It displays a map with pins showing record stores around the user’s current location.
LEARNING ABOUT MAP VIEWS
Adding an interactive map to an iOS app is done using the MKMapView. It is similar to the UIWebView you added in the previous chapter in that it’s a standalone subview you can add to any other UIView. Though it’s available in the Object library in Interface Builder, it does require adding the MapKit.framework to the project. The Bands project will compile just fine without the framework, but running the app causes a crash on launch. To start on the Find Record Store feature, you first add a new scene to the Bands app to present the MKMapView, as in the following Try It Out
TRY IT OUT: Adding a Map View
- Select the Project in the Project Navigator.
- On the General tab in the Linked Frameworks and Libraries section, add the MapKit.framework as you did in Chapter 7, “Integrating Social Media.”
- From the Xcode menu, select File ⇒ New ⇒ File, and create a new UIViewController subclass named WBAMapSearchViewController.
- Select the WBAMapSearchViewController.h file in the Project Navigator.
- Add the MapKit.h file to the imports with the following code:
#import <UIKit/UIKit.h>
#import <MapKit/MapKit.h>
@interface WBAMapSearchViewController : UIViewController
@end - Add an IBOutlet for an MKMapView with the following code:
@interface WBAMapSearchViewController : UIViewController
@property (nonatomic, assign) IBOutlet MKMapView *mapView;
@end - Select the Main.storyboard from the Project Navigator.
- Drag a new View Controller from the Object library onto the storyboard.
- Select the new View Controller, and set its class in the Identity Inspector to the WBAMapSearchViewController class. This is now the Map Search scene.
- Select the Band Details scene, and add a manual push segue from it to the new Map Search scene.
- Select the new segue, and set its identifier to mapViewSegue in the Attributes Inspector.
- Select the UINavigationItem of the Map Search scene, and set its title to Record Stores in the Attributes Inspector.
- Drag a Map View from the Objects library onto the WBAMapSearchViewController.
- Connect the MKMapView to the mapView IBOutlet in the WBAMapSearchViewController.
- Select the WBABandDetailsViewController.h file from the Project Navigator.
- Add a new value to the WBAActivityButtonIndex using the following code:
typedef enum {
// WBAActivityButtonIndexEmail,
// WBAActivityButtonIndexMessage,
WBAActivityButtonIndexShare,
WBAActivityButtonIndexWebSearch,
WBAActivityButtonIndexFindLocalRecordStores,
} WBAActivityButtonIndex; - Select the WBABandDetailsViewController.m file from the Project Navigator and modify the activityButtonTouched: method with the following code:
- (IBAction)activityButtonTouched:(id)sender
{
UIActionSheet *activityActionSheet = nil;
activityActionSheet = [[UIActionSheet alloc] initWithTitle:nil delegate:self
cancelButtonTitle:@"Cancel" destructiveButtonTitle:nil otherButtonTitles:
@"Share", @"Search the Web", @"Find Local Record Stores", nil];
activityActionSheet.tag = WBAActionSheetTagActivity;
[activityActionSheet showInView:self.view];
} - Modify the actionSheet:clickedButtonAtIndex: method with the following code:
- (void)actionSheet:(UIActionSheet *)actionSheet
clickedButtonAtIndex:(NSInteger)buttonIndex
{
if(actionSheet.tag == WBAActionSheetTagActivity)
{
if(buttonIndex == WBAActivityButtonIndexShare)
{
[self shareBandInfo];
}
else if (buttonIndex == WBAActivityButtonIndexWebSearch)
{
[self performSegueWithIdentifier:@"webViewSegue" sender:nil];
}
else if (buttonIndex == WBAActivityButtonIndexFindLocalRecordStores)
{
[self performSegueWithIdentifier:@"mapViewSegue" sender:nil];
}
}
// the rest of this method is available in the sample code
} - Run the app in the iPhone 4-inch simulator. When you select the Search Map option, you now see the MKMapView, as shown in Figure 9-1.
FIGURE 9-1
How It Works
The first thing you did was to add the MapKit.framework to the project. Next you created a new UIViewController subclass called WBAMapSearchViewController. The class is quite simple with just an IBOutlet for an MKMapView, which requires importing the MKMapKit.h file.
In the Storyboard you added a new view controller and set its class to the new WBAMapSearchViewController. This is now the Map Search scene. You then created a new manual push segue to the Map Search scene from the Band Details scene. After that you added the MKMapView to the Map Search scene and set its IBOutlet to the mapView in the WBAMapSearchViewController.
In the WBABandDetailsViewController you added a new WBAActivityButtonIndexFindLocalRecordStores value to the WBAActivityButtonIndex enumeration. In the actionSheet:clickedButtonAtIndex: method you looked for the new WBAActivityButtonIndexFindLocalRecordStores value and called performSegueWithIdentifier:sender: method, using the mapViewSeque identifier to show the new Map Search scene.
Getting the User’s Location
To perform a local search, you need to get the user’s location. There are a few ways of doing this depending on the needs of the app, but all use the Location Service of the system. The Location Service is part of the CoreLocation.framework and uses hardware on the device to get an approximate location. For iPod touches and Wi-Fi-only iPads, the service can look up the location using the Wi-Fi hotspot to which the device is connected. iPads and iPhones that have a cellular connection can use the location of the cell tower. iPhones also have a GPS antenna for the most accurate location information.
DESIGN YOUR APPS TO CONSERVE BATTERY POWER
When adding location-aware features to an app, you need to keep battery life in mind. All these methods require the system to power on one of the antennas of the device. When an antenna is on, it consumes a good amount of battery power until the antenna gets powered back off. For location-aware apps you need to decide how accurate the location data needs to be and how often your app needs to be updated about changes.
Apps that need greater control over how accurate the location information is and how often it’s delivered can use an instance of the CLLocationManager class in the Core Location framework and the CLLocationManagerDelegate. Using these you can set how accurate the location data needs to be and control how often the Location Service sends updates to the app. Developers using this method need to choose their settings wisely so that they can implement the feature they want while conserving as much battery life as possible.
Apps that use the MKMapView and need location data only when the device moves a significant distance can skip using the CLLocationManagerDelegate and instead use the MKMapViewDelegate. This method shifts the burden of conserving battery life back to the system. It still uses Core Location, but you as the developer don’t need to worry about the details. This is the approach you take with the Bands app.
Location information also brings with it privacy concerns. Some apps in the past have passed the user’s location on to other services without notifying the user. Because of this Apple added the Location Service to the privacy section of the Settings app. Users can turn off Location Services for either the entire device or per app. Some users may turn them off simply to boost their battery life. As the developer you need to keep this in mind. The CLLocationManager class has a static method called locationServicesEnabled that you can call to determine if Location Services are indeed available to your app. If they are not, you should at least tell the user why the location feature is not available. You do this in the following Try It Out using a UIAlertView.
For the Find Local Record Stores feature, showing the user’s location on the map is useful only when it’s zoomed in. As the developer, you can set the region of the map to show, as you see in the following Try It Out.
TRY IT OUT: Displaying the User’s Current Location
- Select the Project from the Project Navigator.
- On the General tab in the Linked Frameworks and Libraries section, add the CoreLocation.framework.
- Select the WBAMapSearchViewController.h file from the Project Navigator.
- Declare that the class implements the MKMapViewDelegate and also add a property for the MKUserLocation using the following code:
@interface WBAMapSearchViewController : UIViewController <MKMapViewDelegate>
@property (nonatomic, assign) IBOutlet MKMapView *mapView;
@property (nonatomic, strong) MKUserLocation *userLocation;
@end - Select the WBAMapSearchViewController.m file from the Project Navigator.
- Add the viewDidAppear: method of the UIViewControllerDelegate using the following code:
- (void)viewDidAppear:(BOOL)animated
{
[super viewDidAppear:animated];
if(![CLLocationManager locationServicesEnabled])
{
UIAlertView *noLocationServicesAlert = [[UIAlertView alloc]
initWithTitle:@"The Find Local Record Stores feature is not available"
message:@"Location Services are not enabled" delegate:nil
cancelButtonTitle:@"OK" otherButtonTitles:nil];
[noLocationServicesAlert show];
}
else
{
self.mapView.showsUserLocation = YES;
}
} - Add the mapView:didUpdateUserLocation method of the MKMapViewDelegate using the following code:
- (void)mapView:(MKMapView *)mapView
didUpdateUserLocation:(MKUserLocation *)userLocation
{
self.userLocation = userLocation;
MKCoordinateSpan coordinateSpan;
coordinateSpan.latitudeDelta = 0.3f;
coordinateSpan.longitudeDelta = 0.3f;
MKCoordinateRegion regionToShow;
regionToShow.center = userLocation.coordinate;
regionToShow.span = coordinateSpan;
[self.mapView setRegion:regionToShow animated:YES];
} - From the Xcode menu select Xcode ⇒ Open Developer Tool ⇒ iOS Simulator.
- From the iOS Simulator menu, select Debug ⇒ Location ⇒ Apple.
- Run the app in the iPhone 4-inch simulator. When you select the Find Local Record Stores option, you can now zoom into the San Francisco area with the user location annotation near Cupertino, as shown in Figure 9-2.
FIGURE 9-2
How It Works
The first thing you did was to declare that the WBAMapSearchViewController implements the MKMapViewDelegate protocol as well as add a property for the MKUserLocation. Locations are shown on an MKMapView using a class that implements the MKAnnotation protocol. It’s a simple protocol that has three required properties. The coordinate property is a CLLocationCoordinate2d struct that holds the latitude and longitude for the location. The title and subtitle properties are NSStrings that describe the location. The MKUserLocation is shown as a pulsing dot that automatically gets added to the map when the user’s location is determined. You want to store the MKUserLocation in its own property so that your code knows that the location has been determined.
In the Storyboard you connected the delegate of the MKMapView to the WBAMapSearchViewController. In the WBAMapSearchViewController implementation you added the viewDidAppear:animated: method. It calls the locationServicesEnable static method of the CLLocationManager class to check if Location Services are enabled. If they are not, you show a UIAlertView to users letting them know that the feature is not available.
If they are available, you set the showUserLocation property of the mapView to YES. This tells the MKMapView to use Core Location to get the current location. When it determines the location, the mapView:didUpdateUserLocation: method of the MKMapViewDelegate is called.
In your implementation of the mapView:didUpdateUserLocation:, you first save the MKUserLocation in the userLocation property of the WBAMapSearchViewController. Next, you create an MKCoordinateSpan and an MKCoordinateRegion. The coordinate region determines the region of the map to show while the span determines how big of an area is visible. The latitude and longitude deltas are measured in degrees, with 1 degree equaling approximately 69 miles. In this implementation the map shows approximately 20 miles around the user’s location.
Finally, you call the setRegion:animated: method of the MKMapView using the MKCoordinateRegion you created for the region parameter and pass YES for the animated parameter. With the animated parameter set to YES the user will see the MKMapView zoom in to their location.
NOTE The iOS Simulator may reset its debug location setting back to None. If you run the app in the simulator and no location is found, check to make sure that the location debug setting is still set to Apple.
Changing the Map Type
If you have used Apple’s Maps app, you have probably noticed the three different view types. Maps can be shown as the standard map with all the roads, highways, cities, and towns labeled, as a satellite view showing just satellite images, or as a hybrid with satellite images and all the labels. Some users may prefer the hybrid view when searching for record stores, so the Bands app you build gives them the option to change how the map displays.
TRY IT OUT: Showing the Satellite and Hybrid Map Types
- Select the WBAMapSearchViewController.h file from the Project Navigator.
- Add a new enumeration named WBAMapViewActionButtonIndex using the following code:
typedef enum {
WBAMapViewActionButtonIndexMapType,
WBAMapViewActionButtonIndexSatelliteType,
WBAMapViewActionButtonIndexHybridType,
} WBAMapViewActionButtonIndex; - Declare the class implements the UIActionSheetDelegate using the following code:
@interface WBAMapSearchViewController : UIViewController <MKMapViewDelegate,
UIActionSheetDelegate> - Add the following IBAction:
- (IBAction)actionButtonTouched:(id)sender;
- Select the Main.storyboard from the Project Navigator.
- Drag a new Bar Button Item from the Object library to the right side of the UINavigationItem in the Map View scene.
- Select the UIBarButtonItem and set its Identifier to Action in the Attributes Inspector.
- Connect the UIBarButtonItem to the actionButtonTouched: method in the WBAMapSearchViewController.
- Select the WBAMapSearchViewController.m file from the Project Navigator.
- Add the actionButtonTouched: method to the implementation using the following code:
- (IBAction)actionButtonTouched:(id)sender
{
UIActionSheet *actionSheet = [[UIActionSheet alloc] initWithTitle:nil
delegate:self cancelButtonTitle:@"Cancel" destructiveButtonTitle:nil
otherButtonTitles:@"Map View", @"Satellite View", @"Hybrid View", nil];
[actionSheet showInView:self.view];
} - Add the actionSheet:clickedButtonAtIndex: with the following code:
- (void)actionSheet:(UIActionSheet *)actionSheet
clickedButtonAtIndex:(NSInteger)buttonIndex
{
if(buttonIndex == WBAMapViewActionButtonIndexMapType)
{
self.mapView.mapType = MKMapTypeStandard;
}
else if (buttonIndex == WBAMapViewActionButtonIndexSatelliteType)
{
self.mapView.mapType = MKMapTypeSatellite;
}
else if (buttonIndex == WBAMapViewActionButtonIndexHybridType)
{
self.mapView.mapType = MKMapTypeHybrid;
}
} - Run the app in the iPhone 4-inch simulator. You can now switch the Map view between the map, satellite, or hybrid views, as shown in Figure 9-3.
FIGURE 9-3
How It Works
The first thing you did was to declare that the WBAMapSearchViewController implements the UIActionSheetDelegate as well as add an IBAction called actionButtonTouched:. In the Storyboard you added a new UIBarButtonItem to the UINavigationItem of the Map View scene, set its identity to Action, and connected it to the actionButtonTouched: method.
In the WBAMapSearchViewController you added a new enumeration named WMAMapViewActionButtonIndex with three values: WBAMapViewActionButtonIndexMapType, WBAMapViewActionButtonSatelliteType, and WBAMapViewActionButtonHybridType. Next, you implemented the actionButtonTouched: method, which shows a UIActionSheet with the three map types as options in the same order of the WBAMapViewActionButtonIndex enumeration. Finally, you implemented the actionSheet:clickedButtonAtIndex: method, which changes the mapType property of the MKMapView using one of the three map type constants. The MKMapTypeStandard shows the standard map, the MKMapTypeSatellite shows the satellite view, and the MKMapTypeHybrid shows the satellite view with the road and city labels.
PERFORMING A LOCAL SEARCH
Now that you have the MKMapView added to the app and the user location shown, you can add the code to actually search for record stores. The search is done by first creating an MKLocalSearchRequest. This class has two properties. The naturalLanguageQuery property is a string of what you want to search for. The region property is the region of the world you would like to search. For the Bands app the query is simply “Record Store,” and the region is whatever the region of the map is set to when the search is performed.
To send the request to Apple, you initialize a new instance of the MKLocalSearch class with an MKLocalSearchRequest, as you will implement in the following Try It Out.
TRY IT OUT: Sending a Local Search Request to Apple
- Select the WBAMapSearchViewController.h file from the Project Navigator.
- Declare the following method in the interface:
- (void)searchForRecordStores;
- Modify the mapView:didUpdateUserLocation: with the following code:
- (void)mapView:(MKMapView *)mapView
didUpdateUserLocation:(MKUserLocation *)userLocation
{
self.userLocation = userLocation;
MKCoordinateSpan coordinateSpan;
coordinateSpan.latitudeDelta = 0.3f;
coordinateSpan.longitudeDelta = 0.3f;
MKCoordinateRegion regionToShow;
regionToShow.center = userLocation.coordinate;
regionToShow.span = coordinateSpan;
[self.mapView setRegion:regionToShow animated:YES];
[self searchForRecordStores];
} - Add the searchForRecordStores method to the implementation using the following code:
- (void)searchForRecordStores
{
if(!self.userLocation)
return;
MKLocalSearchRequest *localSearchRequest = [[MKLocalSearchRequest alloc] init];
localSearchRequest.naturalLanguageQuery = @"Record Store";
localSearchRequest.region = self.mapView.region;
MKLocalSearch *localSearch = [[MKLocalSearch alloc]
initWithRequest: localSearchRequest];
[localSearch startWithCompletionHandler:nil];
}
How It Works
You first declared the searchForRecordStores method in the WBAMapSearchViewController interface. In the implementation of the WBAMapSearchViewController you modified the mapView:didUpdateUserLocation: to call the searchForRecordStores method when the user’s location is determined.
The searchForRecordStores method is where the actual search is performed. The code first makes sure the userLocation property of the WBAMapSearchViewController is set. Without the userLocation set, the MKMapView is most likely showing the entire United States. You can perform a search of the entire United States, but it would not be very useful.
If the userLocation property is set, then the region of the MKMapView has been set to the area around the user’s location. Next you initialized the MKLocalSearchRequest class instance. You set its naturalLanguageQuery property to “Record Store” and the region property to the region property of the MKMapView. This sets up the search to look for record stores within the visible region of the MKMapView. Next you initialized the MKLocalSearch class instance using the MKLocalSearchRequest. Finally you called the startWithCompletionHandler: method of the MKLocalSearch class with a nil completion handler to send the request to Apple.
Unlike most of what you’ve coded up to this point, the MKLocalSearch class does not have a delegate protocol associated with it. Instead it uses a block passed into the startWithCompletionHandler: method.
Blocks, as you may recall from Chapter 2, “Introduction to Objective-C,” are a way of passing a chunk of code into a method like any other parameter. The implementation of that method can then execute the code when it’s appropriate. With the local search implementation you define an inline block as the completion handler to the startWithCompletionHandler: method. When the search is complete and the results have been retrieved, the block will be executed. The following Try It Out shows how this is implemented.
TRY IT OUT: Implementing a Completion Handler Using an Inline Block
- Select the WBAMapSearchViewController.m file from the Project Navigator.
- Modify the call to startWithCompletionHandler: in the searchForRecordStores method using the following code:
[localSearch startWithCompletionHandler:
^(MKLocalSearchResponse *response, NSError *error)
{
if(error)
{
NSLog(@"An error occured while performing the local search");
}
else
{
NSLog(@"The local search found %d record stores", [response.mapItems count]);
}
}]; - Run the app in the iPhone 4-inch simulator. You will see the count from the local search result printed in the Xcode debugger console.
How It Works
The startWithCompletionHandler: method takes only one parameter, which is an inline block. You declare the start of the block with the ^ character. This block has two parameters: The first is an MKLocalSearchResponse object named response and the second is an NSError named error. You can think of the inline block as a method that gets called when the search completes. Instead of declaring a separate method and adding its implementation somewhere else in the file, you define the implementation inline. The implementation gets passed in the MKLocalSearchResponse and the NSError. In this Try It Out the code first checks to see if the NSError parameter is set. If it is, then the search results in an error. If the search was successful the NSError will be nil and the response parameter will have the search results stored in its mapItems property. This code writes the count of mapItems to the console.
COMPLETION HANDLERS AND THREADING
The MKLocalSearch completion handler allows for hiding the lower-level networking code that actually sends the request to Apple and receives the results. That lower-level networking may not be performed on the main thread, however.
Threads are a programming construct that enables multiple execution paths to be performed in parallel. The main thread in an app is almost always responsible for the user interface. When an app needs to perform a method that takes some time to complete, such as a networking call in this case, it’s better to call it on a background thread to keep the user interface from freezing up while the task is performed. If the task results in the user interface needing an update, that update must be performed back on the main thread.
When using completion handlers you should assume that the code will not be executed on the main thread unless specifically stated in the Apple documentation. The documentation for the startSearchWithCompletionHandler: method of the MKLocalSearch class, found at https://developer.apple.com/library/ios/documentation/MapKit/Reference/MKLocalSearch/Reference/Reference.html, includes the sentence “The provided completion handler is always executed on your app’s main thread.” This is very important, as you do not need to add your own code to make sure the completion handler is called on the main thread.
The Bands app can now perform a local search and get the results in the inline block. The next step is showing the record stores on the MKMapView. The actual search results are returned as MKMapItems. These items hold data about each record store found, including their name, a URL, and a phone number if available. To show them on the map, you need to create a class that implements the MKAnnotation protocol. For the Bands app you use an MKPointAnnotation, as demonstrated in the following Try It Out.
TRY IT OUT: Implementing Local Search
- Select the WBAMapSearchViewController.h file from the Project Navigator.
- Add the following property to the interface:
@property (nonatomic, strong) NSMutableArray *searchResultMapItems;
- Select the WBAMapSearchViewController.m file from the Project Navigator.
- Modify the viewDidLoad method with the following code:
- (void)viewDidLoad
{
[super viewDidLoad];
self.searchResultMapItems = [NSMutableArray array];
} - Modify the searchForRecordStores method to the implementation using the following code:
- (void)searchForRecordStores
{
if(!self.userLocation)
return;
MKLocalSearchRequest *localSearchRequest = [[MKLocalSearchRequest alloc] init];
localSearchRequest.naturalLanguageQuery = @"Record Store";
localSearchRequest.region = self.mapView.region;
MKLocalSearch *localSearch = [[MKLocalSearch alloc]
initWithRequest: localSearchRequest];
[UIApplication sharedApplication].networkActivityIndicatorVisible = YES;
[localSearch startWithCompletionHandler:
^(MKLocalSearchResponse *response, NSError *error)
{
[UIApplication sharedApplication].networkActivityIndicatorVisible = NO;
if (error != nil)
{
UIAlertView *mapErrorAlert = [[UIAlertView alloc]
initWithTitle:@"Error" message:[error localizedDescription] delegate:nil
cancelButtonTitle:@"OK" otherButtonTitles:nil];
[mapErrorAlert show];
}
else
{
NSMutableArray *searchAnnotations = [NSMutableArray array];
for(MKMapItem *mapItem in response.mapItems)
{
if(![self.searchResultMapItems containsObject:mapItem])
{
[self.searchResultMapItems addObject:mapItem];
MKPointAnnotation *point = [[MKPointAnnotation alloc] init];
point.coordinate = mapItem.placemark.coordinate;
point.title = mapItem.name;
[searchAnnotations addObject:point];
}
}
[self.mapView addAnnotations:searchAnnotations];
}
}];
} - Run the app in the iPhone 4-inch simulator. When you select Find Record Stores, you see pins representing the search results for the record stores, as shown in Figure 9-4.
FIGURE 9-4
How It Works
In order to keep track of what record stores are found and added to the MKMapView you first declared an NSMutableArray property named searchResultMapItems in the WBAMapSearchViewController interface. You initialize it in the viewDidLoad method. The reason you do this in viewDidLoad and not in viewDidAppear:animated: is to make sure it is only initialized once. The viewDidAppear:animated: method will be called whenever the WBAWebSearchViewController becomes visible. This will be important in the Interacting with Annotations section on this chapter.
Next you modified the searchForRecordStores method. Before starting the local search you first set the Network Activity Indicator visible using the sharedApplication and networkActivityIndicatorVisible property. It is important to do this because the search is a network call. You then hide the Network Activity Indicator in the completion handler, because it gets invoked when the search has completed. Next the code looks at the error parameter, which when set means an error occurred during the search. The code now uses a UIAlertView to tell users what happened using the localizedDescription property of the NSError class.
If there is no error, you create an NSMutableArray that will hold all the new record store locations you will add to the MKMapView. You then use a for-loop to go through each MKMapItem returned in the mapItems property of the response parameter. You check to see if the MKMapItem is already in searchResultMapItems, which tells you if the search result has already been added to the MKMapView. If not, you add the MKMapItem to the searchResultMapItems.
To show the record stores on the MKMapView you need to use a class that implements the MKAnnotation protocol. The MKMapItem class has an MKPlacemark property named placemark that implements the MKAnnotation protocol. You could add the MKPlacemark to the MKMapView. It is displayed as a pin that shows the title in a callout when tapped by users. The title, however, is read-only and is set to the street address of the record store. The name of the record store is more useful, so instead you create an MKPointAnnotation.
The MKPointAnnotation also implements the MKAnnotation protocol and is also displayed as a pin. The title property, however, is not read-only. You set its coordinate property using coordinate from the MKPlacemark. You set its title to the name property of the MKMapItem, which is the actual name of the record store. Now when a user taps on the pin the callout will show the record store name. Finally you add all the new MKPointAnnotations to the MKMapView using the addAnnotations: method.
As the app is coded now, the local search is performed only when the user’s location changes. If user is physically moving around, they see new results loaded. If they are staying still but panning around in the map, the search isn’t triggered again, so no new search results display. To fix this you implement the mapView:regionDidChangeAnimated delegate method in the following Try It Out.
TRY IT OUT: Updating Search Results After Panning
- Select the WBAMapSearchViewController.m file from the Project Navigator.
- Add the mapView:regionDidChangeAnimated method to the implementation with the following code:
- (void)mapView:(MKMapView *)mapView regionDidChangeAnimated:(BOOL)animated
{
[self searchForRecordStores];
} - Modify the mapView:didUpdateUserLocation with the following code:
- (void)mapView:(MKMapView *)mapView
didUpdateUserLocation:(MKUserLocation *)userLocation
{
self.userLocation = userLocation;
MKCoordinateSpan coordinateSpan;
coordinateSpan.latitudeDelta = 0.3f;
coordinateSpan.longitudeDelta = 0.3f;
MKCoordinateRegion regionToShow;
regionToShow.center = userLocation.coordinate;
regionToShow.span = coordinateSpan;
[self.mapView setRegion:regionToShow animated:YES];
//[self searchForRecordStores];
} - Run the app in the iPhone 4-inch simulator. As you pan around the map view or zoom in and out, you should see new pins added to the map.
How It Works
First, you implemented the mapView:regionDidChangeAnimated: delegate method of the MKMapViewDelegate protocol. This method gets called when users pan the MKMapView or zoom in and out. The implementation simply calls the searchForRecordStores method, which performs the local search again using the new region of the MKMapView. You then modified the mapView:didUpdateUserLocation: method to no longer call searchForRecordStores. Its implementation calls setRegion:animated: on the MKMapView, which triggers the mapView:regionDidChangeAnimated: delegate method. If you do not remove the call to searchForRecordStores, the local search will be performed twice.
Animating Annotations
Animations can add some polish to an app and help make the user interface more aesthetically pleasing. With map pin annotations, it’s common to see the pins fall into place from the top of the screen rather than just showing them, as the Bands app now does. To animate the pins you need to change how you are adding them to the MKMapView.
You can recall from earlier in this chapter that all locations on a map are represented using a class that implements the MKAnnotation protocol. Both the MKUserLocation object used to show the user’s location and the MKPointAnnotation objects you create to show the results of the record store search implement this protocol.
These annotations are represented visually on an MKMapView using an MKAnnotationView. Up to this point the MKMapView has used the default MKAnnotationView for both the user’s location and record store locations. The default MKAnnotationView for the MKUserLocation annotation is the pulsing dot. This MKAnnotationView is private and not made available to you. The default MKAnnotationView for an MKPointAnnotation is an MKPinAnnotationView that you can use.
The MKPinAnnotationView has a property named animatesDrop that when set to true performs the drop animation. The default MKPinAnnotationView sets this property to false. In order to set it, you need to create and supply your own MKPinAnnotationView for the MKMapView to display. You do this using the mapView:viewForAnnotation: method of the MKMapViewDelegate protocol.
Before any annotation is displayed on an MKMapView it calls the mapView:viewForAnnotation: method of its delegate. If this method is not implemented or if it returns nil, the MKMapView uses the default MKAnnotationView for the annotation. This is important for the MKUserLocation annotation, because you want the default pulsing dot to be shown. For an MKPointAnnotation used for a record store location, you want to return your own MKPinAnnotationView with the animatesDrop property set to true.
Creating, adding, and deallocating subviews is expensive and can cause jittery animations when a user pans around an MKMapView. To combat this, the MKMapView attempts to reuse MKAnnotationViews that are already created and added to the MKMapView but are no longer visible. This is the same approach you learned about with UITableViewCells in Chapter 5, “Using Table Views.” Before creating a new MKPinAnnotationView, you first try to dequeue one from the MKMapView using a reuse identifier. Only if none are available do you create a new one.
This approach may sound difficult, but the code to implement it is fairly straightforward, as you will see in the following Try It Out.
TRY IT OUT: Animating Pin Drops
- Select the WBAMapSearchViewController.m file from the Project Navigator.
- Add the mapView:viewForAnnotation: method to the implementation using the following code:
- (MKAnnotationView *)mapView:(MKMapView *)mapView
viewForAnnotation:(id<MKAnnotation>)annotation
{
if(annotation == self.userLocationAnnotation)
return nil;
MKPinAnnotationView *pinAnnotationView = (MKPinAnnotationView *)
[mapView dequeueReusableAnnotationViewWithIdentifier: @"pinAnnotiationView"];
if (pinAnnotationView)
{
pinAnnotationView.annotation = annotation;
}
else
{
pinAnnotationView = [[MKPinAnnotationView alloc]
initWithAnnotation:annotation reuseIdentifier: @"pinAnnotiationView"];
pinAnnotationView.canShowCallout = YES;
pinAnnotationView.animatesDrop = YES;
}
return pinAnnotationView;
} - Run the app in the iPhone 4-inch simulator. When results are retrieved, their pins will be animated onto the map.
How It Works
When the mapView:viewForAnnotation: method gets called you first check to see if the annotation being passed in is the MKUserLocation annotation. If it is you return nil, which tells the MKMapView to use the default pulsing dot.
If it is not, you can assume the annotation is an MKPointAnnotation for a record store location that needs an MKPinAnnotationView. Before creating a new MKPinAnnotationView, you attempt to reuse one by calling the dequeueReusableAnnotationViewWithIdentifier: method of the MKMapView. If an MKPinAnnotationView is available, you only need to associate it with the MKPointAnnotation by setting its annotation property using the annotation passed into mapView:viewForAnnotation: method. The other properties will remain set as they were when the MKPinAnnotationView was created.
If no reusable MKPinAnnotationView is found, you initialize a new one using the initWithAnnotation:reuseIdentifier: method. Next you set the canShowCallout property to YES so that the callout with the record store name is displayed when the pin is tapped. You also set the animatesDrop property to YES so that the drop animation is performed. Finally you return the MKPinAnnotationView to be displayed on the MKMapView.
Interacting with Annotations
One of the properties returned by the local search is a URL of the record store, if it is available. In the previous chapter, you learned how to display web pages, so to round out the record store search feature, you can also show the web page of record stores found.
When the callout of an MKPinAnnotation is shown, you can add accessory views to the left and right side. You can then implement the mapView:annotationView:calloutAccessoryControlTapped: method of the MKMapKitDelegate protocol to know when the user taps the accessory. In the Bands app, you add an info button to the left side of the callout if the record store search result contains a URL. When the user taps the button, the app pushes the WBAWebViewController into view and displays that record store’s web page.
TRY IT OUT: Displaying Local Search Result Web Pages
- Select the Main.storyboard from the Project Navigator.
- Select the Map Search scene, and add a new manual push segue to the Web View scene as shown in Figure 9-5.
FIGURE 9-5
- Select the new segue and set its identifier to recordStoreWebSearchSegue in the Attributes Inspector.
- Select the WBAWebViewController.h file from the Project Navigator, and add the following property to the interface:
@property (nonatomic, strong) NSString *recordStoreUrlString;
- Select the WBAWebViewController.m file from the Project Navigator, and modify the viewDidAppear: method with the following code:
- (void)viewDidAppear:(BOOL)animated
{
[super viewDidAppear:animated];
if(self.bandName)
{
NSString *urlEncodedBandName = (NSString *)
CFBridgingRelease(CFURLCreateStringByAddingPercentEscapes(NULL,
(CFStringRef)self.bandName, NULL, (CFStringRef)@"!*’();:@&=+$,/?%#[]",
kCFStringEncodingUTF8 ));
NSString *yahooSearchString = [NSString
stringWithFormat:@"http://search.yahoo.com/search?p=%@", urlEncodedBandName];
NSURL *yahooSearchUrl = [NSURL URLWithString:yahooSearchString];
NSURLRequest *yahooSearchUrlRequest =
[NSURLRequest requestWithURL:yahooSearchUrl];
[self.webView loadRequest:yahooSearchUrlRequest];
}
else if (self.recordStoreUrlString)
{
NSURL *recordStoreUrl = [NSURL URLWithString:self.recordStoreUrlString];
NSURLRequest *recordStoreUrlRequest =
[NSURLRequest requestWithURL:recordStoreUrl];
[self.webView loadRequest:recordStoreUrlRequest];
}
} - Select the WBAMapSearchViewController.m file from the Project Navigator.
- Modify the inline block for the startWithCompletionHandler: in the searchForRecordStores method with the following code:
[localSearch startWithCompletionHandler:^(MKLocalSearchResponse *response,
NSError *error)
{
[UIApplication sharedApplication].networkActivityIndicatorVisible = NO;
if (error != nil)
{
UIAlertView *mapErrorAlert = [[UIAlertView alloc]
initWithTitle:@"Error" message:[error localizedDescription]
delegate:nil cancelButtonTitle:@"OK" otherButtonTitles:nil];
[mapErrorAlert show];
}
else
{
NSMutableArray *searchAnnotations = [NSMutableArray array];
for(MKMapItem *mapItem in response.mapItems)
{
if(![self.searchResultMapItems containsObject:mapItem])
{
[self.searchResultMapItems addObject:mapItem];
MKPointAnnotation *point = [[MKPointAnnotation alloc] init];
point.coordinate = mapItem.placemark.coordinate;
point.title = mapItem.name;
if(mapItem.url)
{
point.subtitle = mapItem.url.absoluteString;
}
[searchAnnotations addObject:point];
}
}
[self.mapView addAnnotations:searchAnnotations];
}
}]; - Modify the mapView:viewForAnnotation: method with the following code:
- (MKAnnotationView *)mapView:(MKMapView *)mapView
viewForAnnotation:(id<MKAnnotation>)annotation
{
if(annotation == self.userLocationAnnotation)
return nil;
MKPinAnnotationView *pinAnnotationView = (MKPinAnnotationView *)
[mapView dequeueReusableAnnotationViewWithIdentifier: @"pinAnnotiationView"];
if (pinAnnotationView)
{
pinAnnotationView.annotation = annotation;
}
else
{
pinAnnotationView = [[MKPinAnnotationView alloc]
initWithAnnotation:annotation reuseIdentifier: @"pinAnnotiationView"];
pinAnnotationView.canShowCallout = YES;
pinAnnotationView.animatesDrop = YES;
}
if(((MKPointAnnotation *)annotation).subtitle)
{
pinAnnotationView.leftCalloutAccessoryView =
[UIButton buttonWithType:UIButtonTypeDetailDisclosure];
}
else
{
pinAnnotationView.leftCalloutAccessoryView = nil;
}
return pinAnnotationView;
} - Add the mapView:annotationView:calloutAccessoryControlTapped: method of the MKMapViewDelegate using the following code:
- (void)mapView:(MKMapView *)mapView annotationView:(MKAnnotationView *)view
calloutAccessoryControlTapped:(UIControl *)control
{
[self performSegueWithIdentifier:@"recordStoreWebSearchSegue" sender:view];
} - Add the prepareForSegue:sender: method to the implementation using the following code:
- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender
{
MKAnnotationView *annotiationView = sender;
MKPointAnnotation *pointAnnotation =
(MKPointAnnotation *)annotationView.annotation;
WebViewController *webViewController =
(WebViewController *)segue.destinationViewController;
webViewController.recordStoreUrlString = pointAnnotation.subtitle;
} - Run the app in the iPhone 4-inch simulator. When a local search result has a URL associated with it, you see the URL and the info button added to the callout, as shown in Figure 9-6. Tapping the info button loads the URL in the Web Search view.
FIGURE 9-6
How It Works
The first thing you did was add a new manual segue from the Map Search scene to the Web View scene and set its identifier to recordStoreWebSearchSegue. In the WBAWebViewController interface you declared a new recordStoreUrlString property. You then added code in the viewDidAppear:animated: method of the WBAWebViewController to create a new NSURL and NSURLRequest using the recordStoreUrlString and then asked the webView to load the new request.
In the WBAMapSearchViewController, you added code to the local search completion handler to look for a URL in the MKMapItems returned by the search. If one is found you set the subtitle property of the MKPointAnnotation using the URL string.
In the mapView:viewForAnnotation: method, you check to see if the annotation has its subtitle property set. If it does, you create a new UIButton with a button type of UIButtonTypeDetailDisclosure and set the leftCalloutAccessoryView of the MKPinAnnotationView. If it does not, you need to set the leftCalloutAccessoryView to nil in case the MKPinAnnotationView was reused.
Next you implemented the mapView:annotationView:calloutAccessoryControlTapped: method which calls performSegueWithIdentifier:sender using the recordStoreWebSearchSegue identifier. Finally, you added the prepareForSegue:sender method. It gets the MKPinAnnotationView as its sender. The MKPinAnnotatoinView has its subtitle property set to the URL string of the record store, which you then use to set the recordStoreUrlString of the WBAWebViewController.
SUMMARY
Local search is a powerful feature that can be used in many apps. Although location-based searches used to require your own backend service and low-level networking code, new versions of the iOS SDK make performing these searches much easier. With Apple Maps you can show users points of interest around them that relate to your app. In the Bands app, users can now search for record stores near their current locations as well as browse each store’s web page while never needing to leave the app.
EXERCISES
- What framework is required to use an MKMapView in an app?
- What framework is used to get the current location of an iOS device?
- What delegate method of the MKMapViewDelegate protocol is called when a user’s location is determined?
- What are the two classes used to perform a local search?
- What type of object is returned in local search results?
- What character is used to denote the beginning of a block?
- What subclass of MKAnnotation can you use to show a pin on an MKMapView?
- What property of an MKPinAnnotationView do you set to animate the pin onto the MKMapView?
WHAT YOU LEARNED IN THIS CHAPTER
TOPIC KEY CONCEPTS
Map Kit The MapKit.framework is used in iOS apps to display a map using an MKMapView. You can use this framework in conjunction with the CoreLocation.framework to get the current location of an iOS device.
Local Search The iOS SDK includes two classes you can use to search for locations in a given region. The MKLocalSearchRequest class is used to build the request, while the MKLocalSearch class is used to send the request to Apple.
Completion Handlers Some of the newer classes being added to the iOS SDK use completion handlers instead of delegates and protocols. A completion handler is an inline block of code that gets passed to a method like any other parameter. When the method completes, it will invoke the block of code. You use them to process the results returned from a local search.
Map Annotations To mark locations on an MKMapView you use an instance of the MKAnnotation class. The MKPointAnnotation, which is the most common, shows a pin on the MKMapView. When users tap the pin it shows a callout with more information.