29.
Maps
Written by Matthijs Hollemans & Fahim Farook
Showing the locations in a table view is useful, but not very visually appealing. Given that the iOS SDK comes with an awesome map view control, it would be a shame not to use it :]
In this chapter, you will add a third tab to the app that will look like this when you are finished:
This is what you’ll do in this chapter:
- Add a map view: Learn how to add a map view to your app and get it to show the current user location or pins for a given set of locations.
- Make your own pins: Learn to create custom pins to display information about points on a map.
Add a map view
First visit: the storyboard.
➤ From the Objects Library, drag a View Controller on to the canvas.
➤ Control-drag from the Tab Bar Controller to this new View Controller to add it to the tabs – choose Relationship segue – view controllers.
➤ The new view controller now has a Tab Bar Item. Change its title to Map via the Attributes inspector.
➤ Drag a Map Kit View into the view controller. Make it cover the entire area of the screen, so that the lower part of the map view sits under the tab bar – the size of the Map View should be 375 × 667 points.
➤ Add left, top, right, and bottom Auto Layout constraints to the Map View via the Add New Constraints menu, pinning it to the main view.
➤ In the Attributes inspector for the Map View, enable Shows: User Location. That will put a blue dot on the map at the user’s current coordinates.
➤ Select the new view controller and select Editor ▸ Embed In ▸ Navigation Controller. This wraps your view controller in a navigation controller, and makes the new navigation controller the view controller displayed by the Tab Bar Controller.
➤ Change the view controller’s — not the new navigation controller, but its root view controller — Navigation Item title to Map.
➤ Drag a Bar Button Item into the left-hand slot of the navigation bar and set the title to Locations. Drag another into the right-hand slot and set its title to User. Later on you’ll use nice icons for these buttons, but for now these labels will do.
This part of the storyboard should look like this:
➤ Run the app. Choose a location in the Simulator’s Features menu and switch to the Map. The screen should look something like this — the blue dot shows the current location:
Sometimes, the map might show a different location than the current user location and you might not see the blue dot. If that happens, you can pan the map by clicking the mouse and dragging it across the Simulator window. Also, to zoom in or out, hold down the Alt/Option key while dragging the mouse.
Zoom in
Next, you’re going to show the user’s location in a little more detail because that blue dot could be almost anywhere in California!
➤ Add a new Swift source file to the project and name it MapViewController.
➤ Replace the contents of MapViewController.swift with the following:
import UIKit
import MapKit
import CoreData
class MapViewController: UIViewController {
@IBOutlet var mapView: MKMapView!
var managedObjectContext: NSManagedObjectContext!
// MARK: - Actions
@IBAction func showUser() {
let region = MKCoordinateRegion(
center: mapView.userLocation.coordinate,
latitudinalMeters: 1000,
longitudinalMeters: 1000)
mapView.setRegion(
mapView.regionThatFits(region),
animated: true)
}
@IBAction func showLocations() {
}
}
extension MapViewController: MKMapViewDelegate {
}
This is a standard view controller — not one of the specialized types like a table view controller. It has an outlet for the map view and two action methods that will be connected to the buttons in the navigation bar. The view controller is also the delegate of the map view, courtesy of the extension.
➤ In the storyboard, select the Map scene — the one with the view controller, not the one with the navigation controller — and in the Identity inspector set its Class to MapViewController.
➤ Connect the Locations button to the showLocations action and the User button to the showUser action. In case you forgot how, Control-drag from each button to the yellow circle for the view controller.
➤ Connect the Map View with the mapView outlet — Control-drag from the view controller to the Map View —, and its delegate with the view controller — Control-drag the other way around.
Currently the view controller only implements the showUser() action method. When you press the User button, it zooms in the map to a region that is 1000 by 1000 meters, a little more than half a mile in both directions, around the user’s position.
Try it out:
Show pins for locations
The other button, Locations, is going to show the region that contains all the user’s saved locations. Before you can do that, you first have to fetch those locations from the data store.
Even though this screen doesn’t have a table view, you could still use an NSFetchedResultsController object to handle all the fetching and automatic change detection. But this time, we’re going to do this the hard way — you’ll do the fetching by hand.
➤ Add a new array to MapViewController.swift:
var locations = [Location]()
➤ Also add this new method:
// MARK: - Helper methods
func updateLocations() {
mapView.removeAnnotations(locations)
let entity = Location.entity()
let fetchRequest = NSFetchRequest<Location>()
fetchRequest.entity = entity
locations = try! managedObjectContext.fetch(fetchRequest)
mapView.addAnnotations(locations)
}
The fetch request is nothing new, except this time you’re not sorting the Location objects. The order of the Location objects in the array doesn’t really matter to the map view — only their latitude and longitude coordinates.
You’ve already seen how to handle errors with a do-try-catch block. But if you’re certain that a particular method call will never fail, you can dispense with the do and catch and just write try! with an exclamation point. As with other things in Swift that have exclamation points, if it turns out that you were wrong, the app will crash without mercy. But in this case there isn’t much that can go wrong. So, you can choose to live a little more dangerously.
Once you’ve obtained the Location objects, you call mapView.addAnnotations() to add a pin for each location on the map.
The idea is that updateLocations() will be executed every time there is a change in the data store. How you’ll do that is of later concern, but the point is that when this happens, the locations array may already exist and may contain Location objects. If so, you first remove the pins for these old objects with removeAnnotations().
Xcode says the lines with mapView.addAnnotations() and removeAnnotations() have errors. This is to be expected and you’ll fix it in a minute.
➤ First, add the viewDidLoad() method:
override func viewDidLoad() {
super.viewDidLoad()
updateLocations()
}
This fetches the Location objects and shows them on the map when the view loads. Nothing special here.
Before this class can use the managedObjectContext, you have to give it a reference to that object first. As before, that happens in SceneDelegate.
➤ In SceneDelegate.swift, extend scene(_:willConnectTo:options:) to pass the context object to the MapViewController as well. This goes inside the if let statement:
// Third tab
navController = tabViewControllers[2] as! UINavigationController
let controller3 = navController.viewControllers.first as! MapViewController
controller3.managedObjectContext = managedObjectContext
You’re not quite done yet. In updateLocations() you told the map view to add the Location objects as annotations — an annotation is a pin on the map — but MKMapView expects an array of MKAnnotation objects, not your own Location class.
Luckily, MKAnnotation is a protocol. So, you can turn the Location objects into map annotations by making the class conform to that protocol.
➤ Change the class line from Location+CoreDataClass.swift to:
public class Location: NSManagedObject, MKAnnotation {
Just because Location is an object that is managed by Core Data doesn’t mean you can’t add your own stuff to it. It’s still an object!
Exercise. Xcode now says “Use of undeclared type MKAnnotation”. Why is that?
Answer: You still need to import MapKit. Add that line at the top of the file.
Exercise. Xcode still shows an error about the class not conforming to the MKAnnotation protocol. What is wrong now?
Answer: You said Location conforms to the MKAnnotation protocol — you have to provide all the required features from that protocol in the Location class. Xcode makes this easy since it provides a “Fix” option to add protocol stubs.
Note: If you use the “Fix” option, you’ll still get errors since the stubs are just that — empty placeholders. So you still have to actually do some work to flesh things out.
The MKAnnotation protocol requires the class to implement the coordinate property. There are two other properties — title and subtitle — which are optional, but we’ll implement those as well.
The annotation needs to know the coordinate in order to place the pin in the correct place on the map. The title and subtitle are used to display additional information about the location for each pin.
➤ Add the following code to Location+CoreDataClass.swift:
public var coordinate: CLLocationCoordinate2D {
return CLLocationCoordinate2DMake(latitude, longitude)
}
public var title: String? {
if locationDescription.isEmpty {
return "(No Description)"
} else {
return locationDescription
}
}
public var subtitle: String? {
return category
}
Do you notice anything special here? All three items are instance variables — because of var — but they also have a block of source code associated with them.
These variables are read-only computed properties. That means they don’t actually store a value in a memory location. Whenever you access the coordinate, title, or subtitle variables, they perform the logic from their code blocks. That’s why they are computed properties: they compute something.
These properties are read-only because they only return a value — you can’t assign them a new value using the assignment operator.
The following is OK because it reads the value of the property:
let s = location.title
But you cannot do this:
location.title = "Time for a change"
The only way the title property can change is if the locationDescription value changes. You could also have written this as a method:
func title() -> String? {
if locationDescription.isEmpty {
return "(No Description)"
} else {
return locationDescription
}
}
This is equivalent to using the computed property. Whether to use a method or a computed property is often a matter of taste and you’ll see both ways used throughout the iOS frameworks.
By the way, it is also possible to make read-write computed properties that can be changed, but the MKAnnotation protocol doesn’t use those.
One more thing that you might have noticed about the variables above is the fact that they all have a public attribute. Except for the generated code in Location+CoreDataProperties.swift, you’ve never used a public attribute for variables before. So why here?
That’s because the MKAnnotation protocol declares all three properties as public. You have to match the protocol declaration exactly and so your properties must have the public attribute as well. If you don’t, Xcode will start whining :] Try removing the public attribute from one variable and see what happens …
➤ Run the app and switch to the Map screen. It should now show pins for all the saved locations. Below each pin you should see the value of the title property from the MKAnnotation protocol.
If you tap on a pin, the category for the location, which comes from the subtitle property, would be added below the title while the pin itself would scale up to indicate that it is currently selected.
Note: So far, all the protocols you’ve seen were used for making delegates. But that’s not the case here —
Locationis not a delegate of anything.The
MKAnnotationprotocol simply lets you pretend thatLocationis an annotation that can be placed on a map view. You can use this trick with any object you want; as long as the object implements theMKAnnotationprotocol, it can be shown on a map.Protocols let objects wear different hats.
Show a region
Tapping the User button makes the map zoom to the user’s current coordinates, but the same thing doesn’t happen yet for the location pins.
By looking at the highest and lowest values for the latitude and longitude of all the Location objects, you can calculate a region and then tell the map view to zoom to that region.
➤ In MapViewController.swift, add the following new method:
func region(for annotations: [MKAnnotation]) -> MKCoordinateRegion {
let region: MKCoordinateRegion
switch annotations.count {
case 0:
region = MKCoordinateRegion(
center: mapView.userLocation.coordinate,
latitudinalMeters: 1000,
longitudinalMeters: 1000)
case 1:
let annotation = annotations[annotations.count - 1]
region = MKCoordinateRegion(
center: annotation.coordinate,
latitudinalMeters: 1000,
longitudinalMeters: 1000)
default:
var topLeft = CLLocationCoordinate2D(
latitude: -90,
longitude: 180)
var bottomRight = CLLocationCoordinate2D(
latitude: 90,
longitude: -180)
for annotation in annotations {
topLeft.latitude = max(topLeft.latitude,
annotation.coordinate.latitude)
topLeft.longitude = min(topLeft.longitude,
annotation.coordinate.longitude)
bottomRight.latitude = min(bottomRight.latitude,
annotation.coordinate.latitude)
bottomRight.longitude = max(
bottomRight.longitude,
annotation.coordinate.longitude)
}
let center = CLLocationCoordinate2D(
latitude: topLeft.latitude - (topLeft.latitude - bottomRight.latitude) / 2,
longitude: topLeft.longitude - (topLeft.longitude - bottomRight.longitude) / 2)
let extraSpace = 1.1
let span = MKCoordinateSpan(
latitudeDelta: abs(topLeft.latitude - bottomRight.latitude) * extraSpace,
longitudeDelta: abs(topLeft.longitude - bottomRight.longitude) * extraSpace)
region = MKCoordinateRegion(center: center, span: span)
}
return mapView.regionThatFits(region)
}
region(for:) has three situations to handle. It uses a switch statement to look at the number of annotations and then chooses the corresponding case:
- There are no annotations. You center the map on the user’s current position.
- There is only one annotation. You center the map on that one annotation.
- There are two or more annotations. You calculate the extent of their reach and add a little padding. See if you can make sense of those calculations. The
max()function looks at two values and returns the larger of the two;min()returns the smaller;abs()always makes a number positive — absolute value.
Note that this method does not use Location objects for anything. It assumes that all the objects in the array conform to the MKAnnotation protocol and it only looks at that part of the object. As far as region(for:) is concerned, what it deals with are annotations. It just so happens that these annotations are represented by your Location objects.
That is the power of using protocols. It also allows you to use this method in any app that uses Map Kit, without modifications. Pretty neat.
➤ Add the following code to showLocations():
@IBAction func showLocations() {
let theRegion = region(for: locations)
mapView.setRegion(theRegion, animated: true)
}
This calls region(for:) to calculate a reasonable region that fits all the Location objects and then sets that region on the map view.
➤ Finally, change viewDidLoad():
override func viewDidLoad() {
. . .
if !locations.isEmpty {
showLocations()
}
}
It’s a good idea to show the user’s locations the first time you switch to the Map tab. So viewDidLoad() calls showLocations() if the user has any saved locations.
➤ Run the app and switch to the Map tab, the map view should be zoomed in on your saved locations — because you have the code in viewDidLoad, remember? This only works well if the locations aren’t too far apart, of course.
Make your own pins
You made the MapViewController conform to the MKMapViewDelegate protocol, but so far, you haven’t done anything with that.
This delegate is useful for creating your own annotation views. Currently, a default pin is displayed with a title below it, but you can change this to anything you like.
Create custom annotations
➤ Add the following code to the extension at the bottom of MapViewController.swift:
func mapView(
_ mapView: MKMapView,
viewFor annotation: MKAnnotation
) -> MKAnnotationView? {
// 1
guard annotation is Location else {
return nil
}
// 2
let identifier = "Location"
var annotationView = mapView.dequeueReusableAnnotationView(
withIdentifier: identifier)
if annotationView == nil {
let pinView = MKPinAnnotationView(
annotation: annotation,
reuseIdentifier: identifier)
// 3
pinView.isEnabled = true
pinView.canShowCallout = true
pinView.animatesDrop = false
pinView.pinTintColor = UIColor(
red: 0.32,
green: 0.82,
blue: 0.4,
alpha: 1)
// 4
let rightButton = UIButton(type: .detailDisclosure)
rightButton.addTarget(
self,
action: #selector(showLocationDetails(_:)),
for: .touchUpInside)
pinView.rightCalloutAccessoryView = rightButton
annotationView = pinView
}
if let annotationView = annotationView {
annotationView.annotation = annotation
// 5
let button = annotationView.rightCalloutAccessoryView as! UIButton
if let index = locations.firstIndex(of: annotation as! Location) {
button.tag = index
}
}
return annotationView
}
This is very similar to what a table view data source does in cellForRowAt, except that you’re not dealing with table view cells here but with MKAnnotationView objects. This is what happens step-by-step :
-
Because
MKAnnotationis a protocol, there may be other objects apart from theLocationobject that want to be annotations on the map. An example is the blue dot that represents the user’s current location.You should leave such annotations alone. So, you use the special
istype check operator to determine whether the annotation is really aLocationobject. If it isn’t, you returnnilto signal that you’re not making an annotation for this other kind of object. -
This is similar to creating a table view cell. You ask the map view to re-use an annotation view object. If it cannot find a recyclable annotation view, then you create a new one.
Note that you’re not limited to using
MKPinAnnotationViewfor your annotations. This is the standard annotation view class, but you can also create your ownMKAnnotationViewsubclass and make it look like anything you want. Pins are only one option. -
This sets some properties to configure the look and feel of the annotation view. Previously the pins were red, but you make them green here.
-
This is where it gets interesting. You create a new
UIButtonobject that looks like a detail disclosure button — ⓘ. You use the target-action pattern to hook up the button’s “Touch Up Inside” event with a new methodshowLocationDetails(), and add the button to the annotation view’s accessory view. -
Once the annotation view is constructed and configured, you obtain a reference to that detail disclosure button again and set its
tagto the index of theLocationobject in thelocationsarray. That way, you can find theLocationobject later inshowLocationDetails()when the button is pressed.
➤ Add the showLocationDetails() method but leave it empty for now. Put it in the main class, not the extension.
@objc func showLocationDetails(_ sender: UIButton) {
}
Because you’ve told the button its #selector is showLocationDetails, the app won’t compile unless you add at least an empty version of this method.
This method takes one parameter, sender, that refers to the control that sent the action message. In this case, the sender will be the ⓘ button. That’s why the type of the sender parameter is UIButton.
➤ Run the app. The pins don’t look the same as the standard pins from before, and are green. There’s no title below each pin, but there’s a callout when you tap a pin, and the callout has a custom button.
If the pins don’t change, then make sure you connected the view controller as the delegate of the map view in the storyboard.
Guard
In the map view delegate method, you wrote the following:
guard annotation is Location else {
return nil
}
As you’ve seen before, the guard statement lets you try something. If the result is nil or false, the code from the else block is performed.
If everything works like it’s supposed to, the code simply skips the else block and continues.
You could also have written it as follows:
if annotation is Location {
// do all the other things
. . .
} else {
return nil
}
This uses the familiar if statement. But notice how the code that handles the situation when annotation is not a Location is now all the way at the bottom of the method. If you have several of these if statements, your code ends up looking like this:
if condition1 {
if condition2 {
if condition3 {
. . .
} else {
return nil // condition3 is false
}
} else {
return nil // condition2 is false
}
} else {
return nil // condition1 is false
}
This kind of structure is known as the “Pyramid of Doom”. There’s nothing wrong with it per se, but it can make the program flow harder to decipher. With guard you can write this as:
guard condition1 else {
return nil // condition1 is false
}
guard condition2 else {
return nil // condition2 is false
}
guard condition3 else {
return nil // condition3 is false
}
. . .
Now all the conditions are checked first and any errors or unexpected situations are handled straight away. Many programmers find this easier to read.
Add annotation actions
Tapping a pin on the map now brings up a callout with a blue ⓘ button. What should this button do? Show the Edit Location screen, of course!
➤ Open the storyboard. Find the Map View Controller, and Control-drag from the yellow circle at the top to the Tag Location scene, which is the Location Details View Controller.
Make this a new Show segue and set its Identifier to EditLocation.
Tip: If making this connection gives you problems because the storyboard won’t fit on your screen, then try Control-dragging from (or to) the Document Outline. You can also zoom out to show more of the storyboard.
The storyboard should now look something like this:
I had to zoom out the Storyboard in order to make the screen capture. Not sure if you can see very clearly at this level, but you should see that there are now three segues going to the Tag Location scene.
➤ Back in MapViewController.swift, change showLocationDetails(_:) to trigger the segue:
func showLocationDetails(sender: UIButton) {
performSegue(withIdentifier: "EditLocation", sender: sender)
}
Because the segue isn’t connected to any particular control in the view controller, you have to perform the segue manually. You pass along the button object as the sender, so you can read its tag property later.
➤ Add the prepare(for:sender:) method:
// MARK: - Navigation
override func prepare(
for segue: UIStoryboardSegue,
sender: Any?
) {
if segue.identifier == "EditLocation" {
let controller = segue.destination as! LocationDetailsViewController
controller.managedObjectContext = managedObjectContext
let button = sender as! UIButton
let location = locations[button.tag]
controller.locationToEdit = location
}
}
This is very similar to what you did in the Locations screen, except that now you get the Location object to edit from the locations array, using the tag property of the sender button as the index in that array.
➤ Run the app, tap on a pin and edit the location.
It works, except … the annotation’s callout doesn’t change until you tap the pin again. Likewise, changes on the other screens, such as adding or deleting a location, have no effect on the map.
This is the same problem you had earlier with the Locations screen. Because the list of Location objects is only fetched once in viewDidLoad(), any changes that happen afterwards are overlooked.
Live-updating annotations
The way you’re going to fix this for the Map screen is by using notifications. Recall that you have already put NotificationCenter to use for dealing with Core Data save errors.
As it happens, Core Data also sends out a bunch of notifications when changes are made to the data store. You can subscribe to these notifications and update the map view when you receive them.
➤ In MapViewController.swift, change the managedObjectContext property declaration to:
var managedObjectContext: NSManagedObjectContext! {
didSet {
NotificationCenter.default.addObserver(
forName: Notification.Name.NSManagedObjectContextObjectsDidChange,
object: managedObjectContext,
queue: OperationQueue.main
) { _ in
if self.isViewLoaded {
self.updateLocations()
}
}
}
}
This is another example of a property observer put to good use. As soon as managedObjectContext is given a value — which happens in AppDelegate during app startup — the didSet block tells the NotificationCenter to add an observer for the NSManagedObjectContextObjectsDidChange notification.
This notification with the very long name is sent out by the managedObjectContext whenever the data store changes. In response, call a closure which executes this code:
if self.isViewLoaded {
self.updateLocations()
}
This couldn’t be simpler: you just call updateLocations() to fetch all the Location objects again. This throws away all the old pins and it makes new pins for all the newly fetched Location objects. Granted, it’s not a very efficient method if there are hundreds of annotation objects, but for now it gets the job done.
Note: You use
isViewLoadedto make sureupdateLocations()only gets called when the map view is loaded. Because this screen sits in a tab, the view fromMapViewControllerdoes not actually get loaded from the storyboard until the user switches to the Map tab. So the view may not be loaded yet when the user tags a new location. In that case, it makes no sense to callupdateLocations()— it could even crash the app since theMKMapViewobject doesn’t exist at that point!
➤ Run the app. First go to the Map screen to see your existing location pins. Then tag a new location. The map should have added a new pin for it, although you may have to press the Locations bar button to make the new pin appear if it’s outside the visible range.
Wildcard
Have another look at that closure. The _ in bit is the parameter list for the closure. Like functions and methods, closures can take parameters.
Because this particular closure gets called by NotificationCenter, you’re given a Notification object as the parameter. Since you’re not using this notification object anywhere in the closure, you use the _ to stand in for the parameter name.
You’ve already seen the _ underscore used in a few places in the code. This symbol is called the wildcard and you can use it whenever a name is expected but you don’t really care about it.
Here, the _ tells Swift you’re not interested in the closure’s parameter. It also helps to reduce visual clutter in the source code; it’s obvious at a glance that this parameter — whatever it may be — isn’t being used in the closure.
So whenever you see the _ used in Swift source code it just means, “there’s something here but the programmer has chosen to ignore it”.
Exercise. The
Notificationobject that we ignored above has auserInfodictionary. From that dictionary it is possible to figure out which objects were inserted/deleted/updated. For example, use the followingprint()s to examine this dictionary:
if let dictionary = notification.userInfo {
print(dictionary[NSInsertedObjectsKey])
print(dictionary[NSUpdatedObjectsKey])
print(dictionary[NSDeletedObjectsKey])
}
This will print out an (optional) collection of
Locationobjects ornilif there were no changes. Your mission, should you choose to accept it: try to make the reloading of the locations more efficient by only inserting or deleting the items that have changed. Good luck! If you get stuck, you can find the solutions from other readers on the raywenderlich.com forums.
That’s it for the Map screen.
You can find the project files for this chapter under 29-Maps in the Source Code folder.