Chapters

Hide chapters

iOS Apprentice

Eighth Edition · iOS 13 · Swift 5.2 · Xcode 11

My Locations

Section 4: 11 chapters
Show chapters Hide chapters

Store Search

Section 5: 13 chapters
Show chapters Hide chapters

34. Maps
Written by Eli Ganim

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:

The completed Map screen
The completed Map screen

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.

Adding 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 320 × 568 points.)

➤ Add left, top, right, and bottom Auto Layout consraints 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.

Enable show user location for the Map View
Enable show user location for the Map View

➤ 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:

The design of the Map screen
The design of the Map screen

In older versions of Xcode, the app would compile without any problems at this point, but would crash when you switched to the Map tab. This does not appear to be the case with the latest version of Xcode, but if you do run into this issue, here’s what you need to do.

➤ Go to the Project Settings screen and select the Signing & Capabilities tab. Click on the + Capability button. Search for maps and double click the Maps capability to add it.

Enabling the app to use maps
Enabling the app to use maps

chan ➤ Run the app. Choose a location in Simulator’s Debug menu and switch to the Map. The screen should look something like this – the blue dot shows the current location:

The map shows the user’s location
The map shows the user’s 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.

Zooming 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 weak 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 the 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:

Pressing the User button zooms in to the user’s location
Pressing the User button zooms in to the user’s location

Showing 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 matters.

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 AppDelegate.

➤ In AppDelegate.swift, extend application(_:didFinishLaunchingWithOptions:) 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. You’ve never used a public attribute for variables before. So why here?

That’s because the MKAnnotation protocol delcares 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.

The map shows pins for the saved locations
The map shows pins for the saved locations

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 — Location is not a delegate of anything.

The MKAnnotation protocol simply lets you pretend that Location is 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 the MKAnnotation protocol, it can be shown on a map.

Protocols let objects wear different hats.

Showing 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:

  1. There are no annotations. You center the map on the user’s current position.
  2. There is only one annotation. You center the map on that one annotation.
  3. 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.)

The map view zooms in to fit all your saved locations
The map view zooms in to fit all your saved locations

Making 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.

Creating 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 :

  1. Because MKAnnotation is a protocol, there may be other objects apart from the Location object 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 is type check operator to determine whether the annotation is really a Location object. If it isn’t, you return nil to signal that you’re not making an annotation for this other kind of object. The guard statement you’re using here works like an if: it only continues if the condition — annotation is Location — is true.

  2. 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 MKPinAnnotationView for your annotations. This is the standard annotation view class, but you can also create your own MKAnnotationView subclass and make it look like anything you want. Pins are only one option.

  3. 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.

  4. This is where it gets interesting. You create a new UIButton object 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 method showLocationDetails(), and add the button to the annotation view’s accessory view.

  5. Once the annotation view is constructed and configured, you obtain a reference to that detail disclosure button again and set its tag to the index of the Location object in the locations array. That way, you can find the Location object later in showLocationDetails() 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.

The annotations use your own view
The annotations use your own view

Guard

In the map view delegate method, you wrote the following:

guard annotation is Location else {
  return nil
}

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 hard 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.

Adding 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 cirlce at the top to the Tag Location scene, which is the Location Details View Controller.

Make this a new Show segue named 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:

The Location Details screen is connected to all three screens
The Location Details screen is connected to all three screens

It’s hard to see clearly at this level of zoom, 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) { notification 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 you would like the following closure to be called. For clarity, here’s what happens in the closure:

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 isViewLoaded to make sure updateLocations() only gets called when the map view is loaded. Because this screen sits in a tab, the view from MapViewController does 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 call updateLocations() — it could even crash the app since the MKMapView object 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.

Have another look at that closure. The notification in bit is the parameter 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 in the notification parameter. Since you’re not using this notification object anywhere in the closure, you could also write it like this:

{ _ in
  . . .
}

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 Notification object has a userInfo dictionary. From that dictionary it is possible to figure out which objects were inserted/deleted/updated. For example, use the following print()s to examine this dictionary:

if let dictionary = notification.userInfo {
  print(dictionary[NSInsertedObjectsKey])
  print(dictionary[NSUpdatedObjectsKey])
  print(dictionary[NSDeletedObjectsKey])
}

Note: This will print out an (optional) collection of Location objects or nil if 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 34 – Maps in the Source Code folder.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.