22.
Get Location Data
Written by Eli Ganim
You are going to build MyLocations, an app that uses the Core Location framework to obtain GPS coordinates for the user’s whereabouts, MapKit to show the user’s favorite locations on a map, the iPhone’s camera and photo library to attach photos to these locations, and finally, Core Data to store everything in a database. Phew, that’s a lot of stuff!
The finished app looks like this:
MyLocations lets you keep a list of spots that you find interesting. Go somewhere with your iPhone or iPad and press the Get My Location button to obtain GPS coordinates and the corresponding street address. Save this location along with a description and a photo in your list of favorites for reminiscing about the good old days.
Think of this app as a “location album” instead of a photo album.
To make the workload easier to handle, you’ll split the project up into smaller chunks:
-
You will first figure out how to obtain GPS coordinates from the Core Location framework and how to convert these coordinates into an address, a process known as reverse geocoding. Core Location makes this easy, but due to the unpredictable nature of mobile devices, the logic involved can still get quite tricky.
-
Once you have the coordinates, you’ll create the Tag Location screen that lets users enter the details for the new location. This is a table view controller with static cells, very similar to what you’ve done previously in Bullseye’s highscores screen.
-
You’ll store the location data into a Core Data store. For the last app you saved app data into a .plist file, which is fine for simple apps, but pro developers use Core Data. It’s not as scary as it sounds!
-
Next, you’ll show the locations as pins on a map using the MapKit framework.
-
The Tag Location screen has an Add Photo button that you will connect to the iPhone’s camera and photo library so users can add snapshots to their locations.
-
Finally, you’ll make the app look good using custom graphics. You will also add sound effects and some animations to the mix.
Of course, you are not going to do all of that at once. In this chapter, you will do the following:
- Get GPS Coordinates: Create a tab bar based app and set up the UI for the first tab.
- CoreLocation: Use the CoreLocation framework to get the user’s current location.
- Display coordinates: Display location information on screen.
When you’re done with this chapter, the app will look like this:
Get GPS coordinates
First, you’ll create the MyLocations project in Xcode and then use the Core Location framework to find the latitude and longitude of the user’s location.
Creating the project
➤ Fire up Xcode and make a new project. Choose the Tabbed App template.
➤ Fill in the options as follows:
- Product Name: MyLocations
- Organization Name: Your name or the name of your company
- Organization Identifier: Your own identifier in reverse domain notation
- Language: Swift
- Include Unit Tests and Include UI Tests: unchecked
- Use SwiftUI: unchecked
➤ Save the project.
If you run the app, it looks like this:
The app has a tab bar along the bottom with two tabs: First and Second.
Even though it doesn’t do much yet, the app already employs three view controllers:
- The root controller is a
UITabBarControllerthat contains the tab bar and performs the switching between the different screens. - A view controller for the First tab.
- A view controller for the Second tab.
The two tabs each have their own view controller. By default, the Xcode template names them FirstViewController and SecondViewController.
At this point, the storyboard looks like this:
It’s zoomed out to fit the whole thing on the screen. Storyboards are great, but they sure take up a lot of space!
As before, you’ll be editing the storyboard using the iPhone 8 dimensions. Later, if necessary, you’ll make some adjustments to get the app to work on other screen sizes as well.
➤ In the View as: pane at the bottom, choose iPhone 8.
The first tab
In this chapter, you’ll be working with the first tab only. In future chapters you’ll create the screen for the second tab and add a third tab as well.
Let’s give FirstViewController a better name.
Remember the refactoring trick you learned previously? That’s what you’ll use here since that renames both the file and any references to it anywhere in the project.
➤ Open FirstViewController.swift, hover your mouse cursor over the word FirstViewController in the class line, right-click (or Control-click) and select Refactor > Rename… from the context menu.
➤ Change the name to CurrentLocationViewController. This changes the file name, the class name and the reference to the class in the storyboard, all at once! Nifty, eh?
➤ Go to the Project Settings screen and de-select the Landscape Left and Landscape Right settings under Deployment Info — Device Orientation. Now the app is portrait-only. (You can enable Upside Down at the same time if you like, since this would enable both portrait modes on iPad.)
➤ Run the app again just to make sure everything still works.
Whenever you change how things are hooked up in the storyboard, it’s useful to run the app and verify that the change was successful — it’s way too easy to forget a step and you want to catch such mistakes right away.
And if you are wondering where you changed things in the storyboard, remember how you renamed the FirstViewController? That change modified the storyboard, too.
A view controller that sits inside a navigation controller has a Navigation Item object that allows it to configure the navigation bar. Tab bars work the same way. Each view controller that represents a tab has a Tab Bar Item object.
➤ Open the storyboard, select the Tab Bar Item object from the First Scene (this is the Current Location View Controller) and go to the Attributes inspector. Change the Title to Tag.
Later on, you’ll also set a new image for the Tab Bar Item too; it currently uses the default image from the template.
First tab UI
You will now design the screen for this first tab. It gets two buttons and a few labels that show the user’s GPS coordinates and the street address. To save you some time, you’ll add all the outlets in one go.
➤ Add the following to the class in CurrentLocationViewController.swift, just after the class definition and before viewDidLoad():
@IBOutlet weak var messageLabel: UILabel!
@IBOutlet weak var latitudeLabel: UILabel!
@IBOutlet weak var longitudeLabel: UILabel!
@IBOutlet weak var addressLabel: UILabel!
@IBOutlet weak var tagButton: UIButton!
@IBOutlet weak var getButton: UIButton!
➤ Still in CurrentLocationViewController.swift, add this just before the last curly brackets:
// MARK:- Actions
@IBAction func getLocation() {
// do nothing yet
}
Now open the storyboard, remove the existing labels and design the UI to look something like this — always make use of the positioning guides that Interface Builder provides to place controls since this gives you nice, even spacing:
➤ The (Message Label) at the top should span the whole width of the screen. You’ll use this label for status messages while the app is obtaining the GPS coordinates. Set the Alignment attribute to centered and connect the label to the messageLabel outlet.
➤ Once you’ve positioned the (Message Label), set its Auto Layout constraints for left, top and right so that it aligns with the Safe Area. In case you’re wondering, you don’t have to explicitly select the Safe Area.
If you set up the constraints as below, most of the time the constraints should be set correctly for you.
➤ Make the (Latitude goes here) and (Longitude goes here) labels right-aligned and connect them to the latitudeLabel and longitudeLabel outlets respectively.
➤ Set up left, top, right and bottom Auto Layout constraints for the Latitude: label. You can use your judgement with regards to the top spacing — here 24 points is the value used — or you can use the suggested spacing of 8 points. It’s totally up to you, but feels like there should be a bit more spacing between the message and the latitude, longitude grouping.
➤ Then, set up left, right and bottom constraints for the Longitude: label — you don’t need a top constraint since the bottom constraint of the Latitude: label acts as the top constraint for this one.
Again, you can use your judgement with regards to the bottom spacing since that determines how far away the (Address goes here) label is from the Longitude: label. It probably should have the same amount of spacing as there was at the top to the (Message Label) and so 24 points make sense.
➤ Add top, right and bottom constraints for (Latitude goes here) and right and bottom constraints for (Longitude goes here).
Do note that as you add constraints, the positions of some of the labels might shift. So you might need to adjust positioning again — for example, position the (Longitude goes here) label so that it stretches to the right edge of the screen — to set things up as they originally were.
➤ You will get some Auto Layout constraint issues at this point. This is due to none of the labels in the latitude and longitude grouping having specific widths or heights. It’s hard for Xcode to determine what the actual sizes should be.
We know that the longer of the two left labels is Longitude: So let’s try setting both labels on the left to be the same size as the Longitude: label — Control-drag from the Longitude: label to the Latitude: label and select Equal Widths from the pop-up.
Hmm… that made things worse! Why?
Because you had an existing trailing space from the Latitude: label to the (Latitude goes here) label and that spacing is now incorrect. Select the (Latitude goes here) label, switch to the Size inspector and remove the leading constraint to the Latitude: label.
➤ The (Latitude goes here) label will resize to fit its contents again. Add a leading constraint between it and the Latitude: label, but make the spacing greater than or equal to 8 points to match what’s there for the longitude label set.
Why greater than or equal to when all the other constraints are set to equal to? Because if you set it to equal to, you’ll get another set of red constraints.
➤ The (Address goes here) label spans the whole width of the screen and should be 50 points high so it can fit two lines of text. Set its Lines attribute to 0 (that means it can display a variable number of lines). Connect this label to the addressLabel outlet.
➤ Set left, right and bottom constraints on the (Address goes here) label. Make the bottom constraint be 24 points to match the top spacing previously set, or, whatever value you set/like. It’s your choice.
➤ The Tag Location button doesn’t do anything yet, but should be connected to the tagButton outlet.
➤ Set left and right constraints of 16 points on the Tag Location button so that it stretches from side to side.
➤ Connect the Get My Location button to the getButton outlet and its Touch Up Inside event to the getLocation action.
➤ Set left, right and bottom constraints on the Get My Location button so that it stretches from side to side and is at least 16 points from the bottom of the screen — you can use your judgement as to the actual positioning you think is good.
➤ Run the app to see the new design in action.
If you think the positioning of some element is off, feel free to adjust the Auto Layout constraints till the layout looks right. There is no right or wrong here, it’s all a matter of how it looks to you.
So far, nothing special. With the exception of the tab bar, this is stuff you’ve seen and done before. Time to add something new: Let’s play with Core Location!
Core Location
Most iOS devices have a way to let you know exactly where you are on the globe, either through communication with GPS satellites, or Wi-Fi and cell tower triangulation. The Core Location framework puts that power in your own hands.
An app can ask Core Location for the user’s current latitude and longitude. For devices with a compass, it can also give the heading — you won’t be using that for this app. Core Location can also provide continuous location updates while you’re on the move.
Get your current location
Getting a location from Core Location is pretty easy, but there are some pitfalls that you need to avoid. Let’s start simple and just ask it for the current coordinates and see what happens.
➤ At the top of CurrentLocationViewController.swift, add an import statement:
import CoreLocation
That is all you have to do to add the Core Location framework to your project.
Core Location, like many other parts of the iOS SDK, works via a delegate, so you should make the view controller conform to the CLLocationManagerDelegate protocol.
➤ Add CLLocationManagerDelegate to the view controller’s class line:
class CurrentLocationViewController: UIViewController,
CLLocationManagerDelegate {
➤ Also add a new property:
let locationManager = CLLocationManager()
The CLLocationManager is the object that will give you GPS coordinates. You’re putting the reference to this object in a constant — using let, not a variable (var). Once you have created the location manager object, the value of locationManager will never have to change.
The new CLLocationManager object doesn’t give you GPS coordinates right away. To begin receiving coordinates, you have to call its startUpdatingLocation() method first.
Unless you’re doing turn-by-turn navigation, you don’t want your app to continuously receive GPS coordinates. That requires a lot of power and will quickly drain the battery. For this app, you only turn on the location manager when you want a location fix and turn it off again when you’ve received a usable location.
You’ll implement that logic in a minute — it’s more complex than you’d think. For now, you’re only interested in receiving something from Core Location, just so you know that it works.
➤ Change the getLocation() action method to the following:
@IBAction func getLocation() {
locationManager.delegate = self
locationManager.desiredAccuracy =
kCLLocationAccuracyNearestTenMeters
locationManager.startUpdatingLocation()
}
This method is hooked up to the Get My Location button. It tells the location manager that the view controller is its delegate and that you want to receive locations with an accuracy of up to ten meters. Then you start the location manager. From that moment on, the CLLocationManager object will send location updates to its delegate, i.e., the view controller.
➤ Speaking of the delegate, add the following code:
// MARK: - CLLocationManagerDelegate
func locationManager(_ manager: CLLocationManager,
didFailWithError error: Error) {
print("didFailWithError \(error.localizedDescription)")
}
func locationManager(_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]) {
let newLocation = locations.last!
print("didUpdateLocations \(newLocation)")
}
These are the delegate methods for the location manager. For the time being, you’ll simply output a print() message to the Console. Also, do note the error.localizedDescription bit which, instead of simply printing out the contents of the error variable, outputs a human readable version of the error (if possible) based on the device’s current locale (or language setting).
➤ Run the app in the simulator and press the Get My Location button.
Hmm… nothing seems to be happening. That’s because you need to ask for permission before accessing location information.
Ask for permission
➤ Add the following lines to the top of getLocation():
let authStatus = CLLocationManager.authorizationStatus()
if authStatus == .notDetermined {
locationManager.requestWhenInUseAuthorization()
return
}
This checks the current authorization status. If it is .notDetermined — meaning that this app has not asked for permission yet — then the app will request “When In Use” authorization. That allows the app to get location updates while it is open and the user is interacting with it.
There is also “Always” authorization, which permits the app to check the user’s location even when it is not active. That’s useful for a navigation app, for example. For most apps, including MyLocations, when-in-use is what you want to ask for.
Just adding these lines of code is not enough. You also have to add a special key to the app’s Info.plist.
➤ Open Info.plist file. Right-click somewhere inside Info.plist and choose Add Row from the menu.
➤ For the key, type NSLocationWhenInUseUsageDescription (or choose Privacy — Location When In Use Usage Description from the list).
➤ Type the following text in the Value column:
This app lets you keep track of interesting places. It needs access to the GPS coordinates for your location.
This description tells the user what the app wants to use the location data for.
➤ Run the app again and press the Get My Location button.
Core Location will pop up the following alert, asking the user for permission:
If a user denies the request with the Don’t Allow button, then Core Location will never give your app location coordinates. If the user chooses “Allow Once”, then location services will only be available during this session and the app will request access again next time.
➤ Press the Don’t Allow button. Now press Get My Location again.
Xcode’s debug area should now show the following message (or something similar):
didFailWithError The operation couldn’t be completed. (kCLErrorDomain error 1.)
This comes from the locationManager(_:didFailWithError:) delegate method. It’s telling you that the location manager wasn’t able to obtain a location. The reason why is described by an Error object, which is the standard object that the iOS SDK uses to convey error information. You’ll see it in many other places in the SDK since there are plenty of places where things can go wrong!
This Error object has a domain and a code. The domain in this case is kCLErrorDomain meaning the error came from Core Location (CL). The code is 1, also identified by the symbolic name CLError.denied, which means the user did not allow the app to obtain location information.
Note: The
kprefix is often used by the iOS frameworks to signify that a name represents a constant value — maybe whoever came up with this prefix thought it was spelled “konstant.” This is an old convention and you won’t see it used much in new frameworks or in Swift code, but it still pops up here and there.
➤ Stop the app from within Xcode and run it again.
When you press the Get My Location button, the app does not ask for permission anymore but immediately gives you the same error message.
Let’s make this a bit more user-friendly, because a normal user would never see that print() output.
Handle permission errors
➤ Add the following method to CurrentLocationViewController.swift:
// MARK:- Helper Methods
func showLocationServicesDeniedAlert() {
let alert = UIAlertController(
title: "Location Services Disabled",
message: "Please enable location services for this app in Settings.",
preferredStyle: .alert)
let okAction = UIAlertAction(title: "OK", style: .default,
handler: nil)
alert.addAction(okAction)
present(alert, animated: true, completion: nil)
}
This pops up an alert with a helpful hint. This app is pretty useless without access to the user’s location, so it should encourage the user to enable location services. (It’s not necessarily the user of the app who has denied access to the location data; a systems administrator or parent could also have restricted location access.)
➤ To show this alert, add the following lines to getLocation(), just before you set the locationManager’s delegate:
if authStatus == .denied || authStatus == .restricted {
showLocationServicesDeniedAlert()
return
}
This shows the alert if the authorization status is denied or restricted. Notice the use of || here, the “logical or” operator. showLocationServicesDeniedAlert() will be called if either of those two conditions is true.
➤ Try it out. Run the app and tap Get My Location. You should now get the Location Services Disabled alert:
Fortunately, users can change their minds and enable location services for your app again. This is done from the device’s Settings app.
➤ Open the Settings app in the simulator and go to Privacy ▸ Location Services.
➤ Click MyLocations and then While Using the App to enable location services again. Switch back to the app (or run it again from Xcode) and press the Get My Location button.
If you try it, the following message will appear in Xcode’s debug area:
didFailWithError The operation couldn’t be completed. (kCLErrorDomain error 0.)
Again there is an error message but with a different code, 0. This is “location unknown” which means Core Location was unable to obtain a location for some reason.
That is not so strange, as you’re running this from the simulator, which obviously does not have a real GPS. Your Mac may have a way to obtain location information through Wi-Fi but this is not built into the simulator. Fortunately, there is a way to fake it!
Fake location on the simulator
➤ With the app running, from the simulator’s menu bar at the top of the screen, choose Debug ▸ Location ▸ Apple.
You should now see messages like these in the debug area:
didUpdateLocations <+37.33259552,-122.03031802> +/- 500.00m (speed -1.00 mps / course -1.00) @ 6/30/17, 8:19:11 AM Israel Daylight Time
didUpdateLocations <+37.33241211,-122.03050893> +/- 65.00m (speed -1.00 mps / course -1.00) @ 6/30/17, 8:19:13 AM Israel Daylight Time
didUpdateLocations <+37.33240901,-122.03048800> +/- 65.00m (speed -1.00 mps / course -1.00) @ 6/30/17, 8:19:14 AM Israel Daylight Time
It keeps going on and on, giving the app a new location every second or so. These particular coordinates point at the Apple headquarters in Cupertino, California.
Look carefully at the coordinates the app is receiving. The first one says “+/- 500.00m,” the second one “+/- 65.00m,” a little further on “+/- 50.00m” etc. This number keeps getting smaller and smaller until it stops at about “+/- 5.00m.”
This is the accuracy of the measurement, expressed in meters. What you see is the simulator imitating what happens when you ask for a location on a real device.
If you go out with an iPhone and try to obtain location information, the iPhone uses three different ways to find your coordinates. It has onboard cellular, Wi-Fi and GPS radios that each give it location information at different levels of detail:
-
Cell tower triangulation will always work if there is a signal but it’s not very precise.
-
Wi-Fi positioning works better, but that is only available if there are known Wi-Fi routers nearby. This system uses a big database that contains the locations of wireless networking equipment.
-
The very best results come from the GPS (Global Positioning System), but that needs satellite communication and is therefore is the slowest of the three. It also won’t work very well indoors.
So, your device has several ways of obtaining location data, ranging from fast but inaccurate (cell towers, Wi-Fi) to accurate but slow (GPS). And none of these are guaranteed to work. Some devices don’t even have a GPS or cellular radio at all and have to rely on just Wi-Fi. Suddenly obtaining a location seems a lot trickier.
Fortunately for us, Core Location does all of the hard work of turning the location readings from its various sources into a useful number. Instead of making you wait for the definitive results from the GPS — which may never come — Core Location sends location data to the app as soon as it gets it, and then follows up with more and more accurate readings.
Exercise: If you have an iPhone, iPod touch or iPad nearby, try the app on your device and see what kind of readings it gives you. If you have more than one device, try the app on all of them and note the differences.
Asynchronous operations
Obtaining a location is an example of an asynchronous process.
Sometimes apps need to do things that may take a while. After you start an operation, you have to wait until it gives you the results. If you’re unlucky, those results may never come at all!
In the case of Core Location, it can take a second or two before you get the first location reading and then quite a few seconds more to get coordinates that are accurate enough for your app to use.
Asynchronous means that after you start such an operation, your app will continue on its merry way. The user interface is still responsive, new events are being sent and handled, and the user can still tap on things.
The asynchronous process is said to be operating “in the background.” As soon as the operation is done, the app is notified through a delegate so that it can process the results.
The opposite is synchronous (without the a). If you start an operation that is synchronous, the app won’t continue until that operation is done. In effect, the app freezes up.
In the case of CLLocationManager that would cause a big problem: your app would be totally unresponsive for the couple of seconds that it takes to get a location fix. Those kinds of “blocking” operations are often a bad experience for the user.
For example, MyLocations has a tab bar at the bottom. If the app blocked while getting the location, switching to another tab during that time would have no effect. The user expects to always be able to change tabs, but now it appears that the app is frozen, or worse, has crashed.
The designers of iOS decided that such behavior is unacceptable and therefore operations that take longer than a fraction of a second should be performed in an asynchronous manner.
For the next app, you’ll see more asynchronous processing in action when we talk about network connections and downloading stuff from the Internet.
By the way, iOS has something called a watchdog timer. If your app is unresponsive for too long, then under certain circumstances, the watchdog timer will kill your app without mercy — so don’t do anything that freezes your UI!
The take-away is this: any operation that takes long enough to be noticeable by the user should be done asynchronously, in the background.
Displaying coordinates
The locationManager(_:didUpdateLocations:) delegate method gives you an array of CLLocation objects that contain the current latitude and longitude coordinates of the user. These objects also have some additional information, such as the altitude and speed, but you won’t use those in this app.
You’ll take the last CLLocation object from the array — because that is the most recent update — and display its coordinates in the labels that you added to the screen earlier.
➤ Add a new instance variable to CurrentLocationViewController.swift:
var location: CLLocation?
You will store the user’s current location in this variable. This needs to be an optional, because it is possible to not have a location, for example, when you’re stranded out in the Sahara desert somewhere and there are no cell towers or GPS satellites in sight (it happens).
But even when everything works as it should, the value of location will still be nil until Core Location reports back with a valid CLLocation object, which as you’ve seen, may take a few seconds. So an optional it is.
➤ Change locationManager(_:didUpdateLocations:) to:
func locationManager(_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]) {
let newLocation = locations.last!
print("didUpdateLocations \(newLocation)")
location = newLocation // Add this
updateLabels() // Add this
}
You store the CLLocation object that you get from the location manager into the instance variable and call a new updateLabels() method.
Keep the print() in there because it’s handy for debugging.
➤ Add the updateLabels() method:
func updateLabels() {
if let location = location {
latitudeLabel.text = String(format: "%.8f",
location.coordinate.latitude)
longitudeLabel.text = String(format: "%.8f",
location.coordinate.longitude)
tagButton.isHidden = false
messageLabel.text = ""
} else {
latitudeLabel.text = ""
longitudeLabel.text = ""
addressLabel.text = ""
tagButton.isHidden = true
messageLabel.text = "Tap ’Get My Location’ to Start"
}
}
Because the location instance variable is an optional, you use the if let syntax to unwrap it.
Note the shadowing of the original location variable by the unwrapped variable. Inside the if statement, location now refers to an actual CLLocation object that is not nil.
If there is a valid location object, you convert the latitude and longitude, which are values with type Double, into strings and put them into the labels.
You’ve seen string interpolation before to put values into strings, so why doesn’t this code simply do the following?
latitudeLabel.text = "\(location.coordinate.latitude)"
That would certainly work, but it doesn’t give you any control over how the latitude value appears. For this app, you want both latitude and longitude to be shown with 8 digits behind the decimal point.
For that sort of control, you need to use a format string.
Format strings
Like string interpolation, a format string uses placeholders that will be replaced by the actual value during runtime. These placeholders, or format specifiers, can be quite intricate.
To create the text for the latitude label you do this:
String(format: "%.8f", location.coordinate.latitude)
This creates a new String object using the format string "%.8f" and the value to replace in that string, location.coordinate.latitude.
Placeholders always start with a percent (%) sign. Examples of common placeholders are: %d for integer values, %f for floating-point and %@ for objects.
Format strings are very common in Objective-C code, but less so in Swift because string interpolation is much simpler (but less powerful).
The %.8f format specifier does the same thing as %f: it takes a decimal number and puts it in the string. The .8 means that there should always be 8 digits behind the decimal point.
➤ Run the app, select a location to simulate from the simulator’s Debug menu and tap the Get My Location button. You’ll now see the latitude and longitude appear on the screen.
When the app starts up, it has no location object (location is still nil) and therefore ought to show the “Tap ’Get My Location’ to Start” message at the top as a hint to the user. But it doesn’t do that yet since the app doesn’t call updateLabels() until it receives the first coordinates.
➤ To fix this, also call updateLabels() from viewDidLoad():
override func viewDidLoad() {
super.viewDidLoad()
updateLabels()
}
➤ Run the app. Initially, the screen should now say, Tap ‘Get My Location’ to Start, and the latitude and longitude labels are empty.
You can find the project files for this chapter under 22 - Get Location Data in the Source Code folder.