23.
Use Location Data
Written by Matthijs Hollemans & Fahim Farook
You’ve learnt how to get GPS coordinate information from the device and to display the information on screen.
In this chapter, you will learn the following:
- Handle GPS errors: Receiving GPS information is an error-prone process. How do you handle the errors?
- Improve GPS results: How to improve the accuracy of the GPS results you receive.
- Reverse geocoding: Getting the address for a given set of GPS coordinates.
- Testing on device: Testing on device to ensure that your app handles real-world scenarios.
- Support different screen sizes: Setting up your UI to work on iOS devices with different screen sizes.
Handle GPS errors
Getting GPS coordinates is error-prone. You may be somewhere where there is no clear line-of-sight to the sky — such as inside or in an area with lots of tall buildings — blocking your GPS signal.
There may not be many Wi-Fi routers around you, or they haven’t been catalogued yet, so the Wi-Fi radio isn’t much help getting a location fix either.
And of course your cellular signal might be so weak that triangulating your position doesn’t offer particularly good results either.
All of that is assuming your device actually has a GPS or cellular radio. I just went out with my iPod touch to capture coordinates and get some pictures for this app. In the city center it was unable to obtain a location fix. My iPhone did better, but it still wasn’t ideal.
The moral of this story is that your location-aware apps had better know how to deal with errors and bad readings. There are no guarantees that you’ll be able to get a location fix, and if you do, then it might still take a few seconds.
This is where software meets the real world. You should add some error handling code to the app to let users know about problems getting those coordinates.
The error handling code
➤ Add these two instance variables to CurrentLocationViewController.swift:
var updatingLocation = false
var lastLocationError: Error?
➤ Change locationManager(_:didFailWithError:) to the following:
func locationManager(
_ manager: CLLocationManager,
didFailWithError error: Error
) {
print("didFailWithError \(error.localizedDescription)")
if (error as NSError).code == CLError.locationUnknown.rawValue {
return
}
lastLocationError = error
stopLocationManager()
updateLabels()
}
The location manager may report errors for a variety of scenarios. You can look at the code property of the Error object to find out what type of error you’re dealing with. You do need to cast to NSError first since that is the subclass of Error that actually contains the code property.
Some of the possible Core Location errors:
-
CLError.locationUnknown— the location is currently unknown, but Core Location will keep trying. -
CLError.denied— the user denied the app permission to use location services. -
CLError.network— there was a network-related error.
There are more, but you get the point. Lots of reasons for things to go wrong!
Note: These error codes are defined in the
CLErrorenumeration. Recall that an enumeration, orenum, is a list of values and names for these values.The error codes used by Core Location have simple integer values. Rather than using the values 0, 1, 2 and so on in your program, Core Location has given them symbolic names using the
CLErrorenum. That makes these codes easier to understand and you’re less likely to pick the wrong one.To convert these names back to an integer value you ask for the
rawValue.
In your updated locationManager(_:didFailWithError:), you do:
if (error as NSError).code == CLError.locationUnknown.rawValue {
return
}
The CLError.locationUnknown error means the location manager was unable to obtain a location right now, but that doesn’t mean all is lost. It might just need another second or so to get an uplink to the GPS satellite. In the mean time, it’s letting you know that, for now, it could not get any location information.
When you get this error, you will simply keep trying until you do find a location or receive a more serious error.
In the case of a more serious error, you store the error object into the new instance variable, lastLocationError:
lastLocationError = error
That way, you can look up later what kind of error you were dealing with. This comes in useful in updateLabels(). You’ll be modifying that method shortly to show the error to the user because you don’t want to leave them in the dark about such things.
Exercise. Can you explain why
lastLocationErroris an optional?
Answer: When there is no error, lastLocationError will not have a value. In other words, it can be nil, and variables that can be nil must be optionals in Swift.
Finally, the update to locationManager(_:didFailWithError:) adds a new method call:
stopLocationManager()
Stop location updates
If obtaining a location appears to be impossible for wherever the user currently is on the globe, then you need to tell the location manager to stop. To conserve battery power, the app should power down the iPhone’s radios as soon as it doesn’t need them anymore.
If this was a turn-by-turn navigation app, you’d keep the location manager running even in the case of a network error because who knows, a couple of meters ahead you might get a valid location.
For this app, the user will simply have to press the Get My Location button again if they want to try in another spot.
➤ Add the stopLocationManager() method:
func stopLocationManager() {
if updatingLocation {
locationManager.stopUpdatingLocation()
locationManager.delegate = nil
updatingLocation = false
}
}
There’s an if statement that checks whether the boolean instance variable updatingLocation is true or false. If it is false, then the location manager isn’t currently active and there’s no need to stop it.
The reason for having this updatingLocation variable is that you are going to change the appearance of the Get My Location button and the status message label when the app is trying to obtain a location fix, to let the user know the app is working on it.
➤ Put some extra code in updateLabels() to show the error message:
func updateLabels() {
if let location = location {
. . .
} else {
. . .
// Remove the following line
messageLabel.text = "Tap 'Get My Location' to Start"
// The new code starts here:
let statusMessage: String
if let error = lastLocationError as NSError? {
if error.domain == kCLErrorDomain && error.code == CLError.denied.rawValue {
statusMessage = "Location Services Disabled"
} else {
statusMessage = "Error Getting Location"
}
} else if !CLLocationManager.locationServicesEnabled() {
statusMessage = "Location Services Disabled"
} else if updatingLocation {
statusMessage = "Searching..."
} else {
statusMessage = "Tap 'Get My Location' to Start"
}
messageLabel.text = statusMessage
}
}
The new code determines what to put in the messageLabel at the top of the screen. It uses a bunch of if statements to figure out what the current status of the app is.
If the location manager gave an error, the label will show an error message.
The first error it checks for is CLError.denied in the error domain kCLErrorDomain, which means Core Location errors. In that case, the user has not given this app permission to use the location services. That sort of defeats the purpose of this app but it can happen, and you have to check for it anyway. If the error code is something else, then you simply say “Error Getting Location” as this usually means there was no way of obtaining a location fix.
Even if there was no error, it might still be impossible to get location coordinates if the user disabled Location Services completely on their device, instead of just for this app. You check for that situation with the locationServicesEnabled() method of CLLocationManager.
Suppose there were no errors and everything works fine, then the status label will say “Searching…” before the first location object has been received.
If your device can obtain the location fix quickly, then this text will be visible only for a fraction of a second, but often, it might take a short while to get that first location fix. No one likes waiting, so it’s nice to let the user know that the app is actively looking up their location. That is what you’re using the updatingLocation boolean for.
Note: You put all this logic into a single method because that makes it easy to change the screen when something has changed. Received a location? Simply call
updateLabels()to refresh the contents of the screen. Received an error? LetupdateLabels()sort it out…
Start location updates
➤ Also add a new startLocationManager() method — I suggest you put it right above stopLocationManager(), to keep related functionality together:
func startLocationManager() {
if CLLocationManager.locationServicesEnabled() {
locationManager.delegate = self
locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
locationManager.startUpdatingLocation()
updatingLocation = true
}
}
Starting the location manager used to happen in the getLocation() action method. However, because you now have a stopLocationManager() method, it makes sense to move the start code into a method of its own, just to keep things symmetrical.
The only difference from before is that this checks whether the location services are enabled and you set the variable updatingLocation to true if you did indeed start location updates.
➤ Change getLocation() to:
@IBAction func getLocation() {
. . .
if authStatus == .denied || authStatus == .restricted {
. . .
}
// New code below, replacing existing code after this point
startLocationManager()
updateLabels()
}
There is one more tiny change. Suppose there was an error and no location could be obtained, but then you walk around for a bit and a valid location comes in. In that case, it’s a good idea to remove the old error code.
➤ At the bottom of locationManager(_:didUpdateLocations:), add the following line just before calling updateLabels():
lastLocationError = nil
This clears out the old error state. After receiving a valid coordinate, any previous error you may have encountered is no longer applicable.
➤ Run the app and tap Get My Location. While the app is waiting for incoming coordinates, the label at the top should say “Searching…” until it finds a valid coordinate or encounters a fatal error.
Play around with the Simulator’s location settings for a while and see what happens when you choose different locations.
Note that changing the Simulator’s location to None isn’t an error anymore. This still returns the .locationUnknown error code but you ignore that because it’s not a fatal error.
Tip: You can also simulate locations from within Xcode. If your app uses Core Location, the bar at the top of the debug area gets an arrow icon. Click on that icon to change the simulated location:
Ideally, you should not just test in the Simulator but also on your device, as you’re more likely to encounter real errors that way.
Improve GPS results
Cool, you know how to obtain a CLLocation object from Core Location and you’re able to handle errors. Now what?
Well, here’s the thing: you saw in the Simulator that Core Location keeps giving you new location objects over and over, even though the coordinates may not have changed. That’s because the user could be on the move, in which case their GPS coordinates do change.
However, you’re not building a navigation app. So, for MyLocations you just want to get a location that is accurate enough and then you can tell the location manager to stop sending updates.
This is important because getting location updates costs a lot of battery power as the device needs to keep its GPS/Wi-Fi/cellular radios powered up for this. This app doesn’t need to ask for GPS coordinates all the time, so it should stop when the location is accurate enough.
The problem is that you can’t always get the accuracy you want, so you have to detect this. When the last couple of coordinates you received aren’t increasing in accuracy then that is probably as good as it’s going to get, and you should let the radio power down.
Get results for a specific accuracy level
➤ Change locationManager(_:didUpdateLocations:) to the following:
func locationManager(
_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]
) {
let newLocation = locations.last!
print("didUpdateLocations \(newLocation)")
// 1
if newLocation.timestamp.timeIntervalSinceNow < -5 {
return
}
// 2
if newLocation.horizontalAccuracy < 0 {
return
}
// 3
if location == nil || location!.horizontalAccuracy > newLocation.horizontalAccuracy {
// 4
lastLocationError = nil
location = newLocation
// 5
if newLocation.horizontalAccuracy <= locationManager.desiredAccuracy {
print("*** We're done!")
stopLocationManager()
}
updateLabels()
}
}
Let’s take these changes one-by-one:
-
If the time at which the given location object was determined is too long ago — 5 seconds in this case —, then this is a cached result.
Instead of returning a new location fix, the location manager may initially give you the most recently found location under the assumption that you might not have moved much in the last few seconds — obviously, this does not take into consideration people with jet packs.
You’ll simply ignore these cached locations if they are too old.
-
To determine whether new readings are more accurate than previous ones, you’ll use the
horizontalAccuracyproperty of the location object. However, sometimes locations may have ahorizontalAccuracythat is less than 0. In which case, these measurements are invalid and you should ignore them. -
This is where you determine if the new reading is more useful than the previous one. Generally speaking, Core Location starts out with a fairly inaccurate reading and then gives you more and more accurate ones as time passes. However, there are no guarantees — so, you cannot assume that the next reading truly is always more accurate.
Note that a larger accuracy value means less accurate — after all, accurate up to 100 meters is worse than accurate up to 10 meters. That’s why you check whether the previous reading,
location!.horizontalAccuracy, is greater than the new reading,newLocation.horizontalAccuracy.You also check for
location == nil. Recall thatlocationis an optional instance variable that stores theCLLocationobject that you obtained in a previous call todidUpdateLocations. Iflocationisnil, then this is the very first location update you’re receiving and in that case you should continue.So, if this is the very first location reading (
locationisnil) or the new location is more accurate than the previous reading, you continue to step 4. Otherwise you ignore this location update. -
You’ve seen this part before. It clears out any previous error and stores the new
CLLocationobject into thelocationvariable. -
If the new location’s accuracy is equal to or better than the desired accuracy, you can call it a day and stop asking the location manager for updates. When you started the location manager in
startLocationManager(), you set the desired accuracy to 10 meters (kCLLocationAccuracyNearestTenMeters), which is good enough for this app.
Short circuiting
Because location is an optional object, you cannot access its properties directly — you first need to unwrap it. You could do that with if let, but if you’re sure that the optional is not nil you can also force unwrap it with !.
That’s what you are doing in this line:
if location == nil || location!.horizontalAccuracy > newLocation.horizontalAccuracy {
You wrote location!.horizontalAccuracy with an exclamation point instead of just location.horizontalAccuracy.
But what if location == nil, won’t the force unwrapping fail then? Not in this case, because the force unwrap is never performed.
The || operator (logical or) tests whether either of the two conditions is true. If the first one is true (location is nil), it will not evaluate the second condition. That’s called short circuiting. There is no need for the app to check the second condition if the first one is already true.
So, the app will only look at location!.horizontalAccuracy when location is guaranteed to be non-nil. Blows your mind, eh?
➤ Run the app. First set the Simulator’s location to None, then press Get My Location. The screen now says “Searching…”
➤ Switch the location to Apple but don’t press Get My Location again. After a brief moment, the screen is updated with GPS coordinates as they come in.
If you check the Xcode Console, you’ll get about 10 location updates before it says “*** We’re done!” and the location updates stop.
Note: It’s possible the above steps won’t work for you. If the screen does not say “Searching…” but shows an old set of coordinates instead, then the Simulator is holding on to old location data. This seems to happen when you pick a location from within Xcode using the arrow in the debug area instead of the Simulator’s Features menu.
The quickest way to fix this is to quit the Simulator and run the app again — this launches a new Simulator. If you can’t get it to work, no worries, it’s not that important. Just be aware that the Simulator can be finicky sometimes.
You, as the developer, can tell from the Console when the location updates stop, but obviously, the user won’t see this.
The Tag Location button becomes visible as soon as the first location is received so the user can start saving this location to their library right away, but at this point the location may not be accurate enough yet. So it’s nice to show the user when the app has found the most accurate location.
Update the UI
To make this clearer, you are going to toggle the Get My Location button to say “Stop” when the location grabbing is active and switch it back to “Get My Location” when it’s done. That gives a nice visual clue to the user. Later on, you’ll also show an animated activity spinner that makes this even more obvious.
To change the state of the button, you’ll add a configureGetButton() method.
➤ Add the following method to CurrentLocationViewController.swift:
func configureGetButton() {
if updatingLocation {
getButton.setTitle("Stop", for: .normal)
} else {
getButton.setTitle("Get My Location", for: .normal)
}
}
It’s quite simple: if the app is currently updating the location, then the button’s title becomes Stop, otherwise it is Get My Location.
You need to now call configureGetButton() from several different places in your code. If you look closely, you’ll notice that wherever you call updateLabels(), you also need to call the new method. So might as well call the new method from within updateLabels(), right?
➤ Add a call to configureGetButton() at the end of updateLabels():
func updateLabels() {
. . .
configureGetButton()
}
➤ Run the app again and perform the same test as before. The button changes to Stop when you press it. When there are no more location updates, it switches back.
When a button says “Stop”, you naturally expect to be able to press it so you can interrupt the location updates. This is especially so when you’re not getting any coordinates at all. Eventually Core Location may give an error, but as a user, you may not want to wait for that.
Currently, however, pressing Stop doesn’t stop anything. You have to change getLocation() for this, as any taps on the button call this method.
➤ In getLocation(), replace the line with the call to startLocationManager() with the following:
if updatingLocation {
stopLocationManager()
} else {
location = nil
lastLocationError = nil
startLocationManager()
}
Again, you’re using the updatingLocation flag to determine what state the app is in.
If the button is pressed while the app is already doing the location fetching, you stop the location manager.
Note that you also clear out the old location and error objects before you start looking for a new location.
➤ Run the app. Now pressing the Stop button will put an end to the location updates. You should see no more updates in the Console after you press Stop.
Note: If the Stop button doesn’t appear long enough for you to click it, set the location back to None first, tap Get My Location a few times, and then select the Apple location again.
Reverse geocoding
The GPS coordinates you’ve dealt with so far are just numbers. The coordinates 37.33240904, -122.03051218 don’t really mean that much, but the address 1 Infinite Loop in Cupertino, California does.
Using a process known as reverse geocoding, you can turn a set of coordinates into a human-readable address. Regular or “forward” geocoding does the opposite: it turns an address into GPS coordinates. You can do both with the iOS SDK, but for MyLocations you’ll only do the reverse one.
You’ll use the CLGeocoder object to turn the location data into a human-readable address and then display that address on screen.
It’s quite easy to do this but there are some rules. You’re not supposed to send out a ton of these reverse geocoding requests at the same time. The process of reverse geocoding takes place on a server hosted by Apple and it costs them bandwidth and processor time to handle these requests. If you flood their servers with requests, Apple won’t be happy.
MyLocations is only supposed to be used occasionally. So theoretically, its users won’t be spamming the Apple servers, but you should still limit the geocoding requests to one at a time, and once for every unique location. After all, it makes no sense to reverse geocode the same set of coordinates over and over.
Reverse geocoding needs an active Internet connection and anything you can do to prevent unnecessary use of the iPhone’s radios is a good thing for your users.
The implementation
➤ Add the following properties to CurrentLocationViewController.swift:
let geocoder = CLGeocoder()
var placemark: CLPlacemark?
var performingReverseGeocoding = false
var lastGeocodingError: Error?
These mirror what you did for the location manager. CLGeocoder is the object that will perform the geocoding and CLPlacemark is the object that contains the address results.
The placemark variable needs to be an optional because it will have no value when there is no location yet, or when the location doesn’t correspond to a street address — I don’t think it will respond with “Sahara desert, Africa”, but to be fair, I haven’t had the chance to try.
You set performingReverseGeocoding to true when a geocoding operation is taking place, and lastGeocodingError will contain an Error object if something went wrong, or, nil if there is no error.
➤ You’ll put the geocoder to work in locationManager(didUpdateLocations):
func locationManager(
_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]
) {
. . .
if location == nil || location!.horizontalAccuracy > newLocation.horizontalAccuracy {
. . .
if newLocation.horizontalAccuracy <= locationManager.desiredAccuracy {
. . .
}
updateLabels()
// The new code begins here:
if !performingReverseGeocoding {
print("*** Going to geocode")
performingReverseGeocoding = true
geocoder.reverseGeocodeLocation(newLocation) {placemarks, error in
if let error = error {
print("*** Reverse Geocoding error: \(error.localizedDescription)")
return
}
if let places = placemarks {
print("*** Found places: \(places)")
}
}
}
// End of the new code
}
}
The app should only perform a single reverse geocoding request at a time. So, first you check whether it is busy by looking at the performingReverseGeocoding variable. Then you start the geocoder.
The code looks straightforward enough, right? Do note the closure at the end of the call to reverseGeocodeLocation — it’s a trailing closure, similar to what you’ve seen before a few times.
Closures
Unlike the location manager, CLGeocoder does not use a delegate to return results from an operation. Instead, it uses a closure. Closures are an important Swift feature and you can expect to see them all over the place — for Objective-C programmers, a closure is similar to a “block”.
Closures can have parameters too and here, the parameters for the closure are placemarks and error, both of which are optionals because either one or the other can be nil depending on the situation.
So, while all the code inside the closure does is print out either the list of places or the error, you do have to unwrap each optional before you do that to be sure that you have a value there.
Unlike the rest of the code in locationManager(_:didUpdateLocations:), the code in the closure is not performed right away. After all, you can only print the geocoding results once the geocoding completes, and that may be several seconds later.
The closure is kept for later use by the CLGeocoder object and is only performed after CLGeocoder finds an address or encounters an error.
So why does CLGeocoder use a closure instead of a delegate?
The problem with using a delegate to provide feedback is that you need to write one or more separate methods. For example, for CLLocationManager there are the locationManager(_:didUpdateLocations:) and locationManager(_:didFailWithError:) methods.
By creating separate methods, you move the code that deals with the response away from the code that makes the request. With closures, on the other hand, you can put that handling code in the same place. That makes the code more compact and easier to read. Some APIs do both, and you have a choice between using a closure or becoming a delegate.
So when you write,
geocoder.reverseGeocodeLocation(newLocation) {placemarks, error in
// put your statements here
}
you’re telling the CLGeocoder object that you want to reverse geocode the location, and that the code in the closure should be executed as soon as the geocoding is completed.
The closure itself is:
{ placemarks, error in
// put your statements here
}
The items before the in keyword — placemarks and error — are the parameters for this closure and they work just like parameters for a method or a function.
When the geocoder finds a result for the location object that you gave it, it invokes the closure and executes the statements within. The placemarks parameter will contain an array of CLPlacemark objects that describe the address information, and the error variable contains an error message in case something went wrong.
Closures are basically the same principle as using delegate methods, except you’re not putting the code into a separate method but in a closure.
It’s OK if closures have got you scratching your head right now. You’ll see them used many more times in the upcoming chapters.
➤ Run the app and pick a location. As soon as the first location is found, you can see in the Console that the reverse geocoder has kicked in after a second or two:
didUpdateLocations <+37.33233141,-122.03121860> +/- 5.00m (speed 0.00 mps / course -1.00) @ 8/11/20, 5:01:49 PM Eastern Daylight Time
*** Going to geocode
*** Found places: [Apple Campus, Apple Campus, 1 Infinite Loop, Cupertino, CA 95014, United States @ <+37.33233141,-122.03121860> +/- 100.00m, region CLCircularRegion (identifier:'<+37.33213110,-122.02990105> radius 279.38', center:<+37.33213110,-122.02990105>, radius:279.38m)]
If you choose the Apple location, you’ll see that some location readings are duplicates; the geocoder only does the first of those. Only when the accuracy of the reading improves does the app reverse geocode again. Nice!
Note: Several readers have previously reported that if you are in China and are trying to reverse geocode an address that is outside of China, you may get an error and
placemarkswill benil. If this happens to you, try a location inside China instead.
Handle reverse geocoding errors
➤ Replace the contents of the geocoding closure with the following:
self.lastGeocodingError = error
if error == nil, let places = placemarks, !places.isEmpty {
self.placemark = places.last!
} else {
self.placemark = nil
}
self.performingReverseGeocoding = false
self.updateLabels()
Just as with the location manager, you store the error object so you can refer to it later — you do use a different instance variable this time, lastGeocodingError.
The next line does something you haven’t seen before:
if error == nil, let places = placemarks, !places.isEmpty {
You know that if let is used to unwrap optionals. Here, placemarks is an optional, so it needs be unwrapped before you can use it or you risk crashing the app when placemarks is nil. The unwrapped placemarks array gets the temporary name places.
The !places.isEmpty bit says that we should only enter this if statement if the array of placemark objects is not empty.
You should read this line as:
if there’s no error and the unwrapped placemarks array is not empty {
Of course, Swift doesn’t speak English, so you have to express this in terms that Swift understands.
You could also have written this as three different, nested if statements:
if error == nil {
if let places = placemarks {
if !places.isEmpty {
But it’s just as easy to combine all of these conditions into a single if.
You’re doing a bit of defensive programming here: you specifically check first whether the array has any objects in it. If there is no error, then it should have at least one object, but you’re not going to trust that it always will. Good developers are paranoid!
If all three conditions are met — there is no error, the placemarks array is not nil, and there is at least one CLPlacemark inside this array — then you take the last of those CLPlacemark objects:
self.placemark = places.last!
The last property refers to the last item from an array. It’s an optional because there is no last item if the array is empty. As an alternative, you can also write places[places.count - 1] but that’s not as tidy.
Usually there will be only one CLPlacemark object in the array, but there is the odd situation where one location coordinate may refer to more than one address. This app can only handle one address at a time. So, you’ll just pick the last one, which usually is the only one.
If there was an error during geocoding, you set self.placemark to nil. Note that you did not do that for the locations. If there was an error there, you kept the previous location object because it may actually be correct, or good enough, and it’s better than nothing.
But for the address that makes less sense. You don’t want to show an old address, only the address that corresponds to the current location or no address at all.
In mobile development, nothing is guaranteed. You may get coordinates back or you may not, and if you do, they may not be very accurate. The reverse geocoding will probably succeed if there is some type of network connection available, but you also need to be prepared to handle the case where there is none.
And remember, not all GPS coordinates correspond to actual street addresses — there is no corner of 52nd and Broadway in the Sahara desert.
Note: Did you notice that inside the closure you used
selfto refer to the view controller’s properties and methods? This is a Swift requirement.Closures are said to capture all the variables they use and
selfis one of them. You can forget about that immediately, if you like; just know that Swift requires that all captured variables are explicitly mentioned.As you’ve seen, outside a closure, you can use
selfto refer to properties and methods, but it’s not a requirement. However, you do get a compiler error if you leave outselfinside a closure. So you don’t have much choice there.
Display the address
Let’s show the address to the user.
➤ Change updateLabels() to:
func updateLabels() {
if let location = location {
. . .
// Add this block
if let placemark = placemark {
addressLabel.text = string(from: placemark)
} else if performingReverseGeocoding {
addressLabel.text = "Searching for Address..."
} else if lastGeocodingError != nil {
addressLabel.text = "Error Finding Address"
} else {
addressLabel.text = "No Address Found"
}
// End new code
} else {
. . .
}
}
Because you only do the address lookup after the app has a valid location, you just have to change the code inside the first if branch. If you’ve found an address, you show that to the user, otherwise you show a status message.
The code to format the CLPlacemark object into a string is placed in its own method, just to keep the code readable.
➤ Add the string(from:) method:
func string(from placemark: CLPlacemark) -> String {
// 1
var line1 = ""
// 2
if let tmp = placemark.subThoroughfare {
line1 += tmp + " "
}
// 3
if let tmp = placemark.thoroughfare {
line1 += tmp
}
// 4
var line2 = ""
if let tmp = placemark.locality {
line2 += tmp + " "
}
if let tmp = placemark.administrativeArea {
line2 += tmp + " "
}
if let tmp = placemark.postalCode {
line2 += tmp
}
// 5
return line1 + "\n" + line2
}
Let’s look at this in detail:
-
The address will be two lines of text — create a new string variable for the first line of text.
-
If the placemark has a
subThoroughfare, add it to the string. This is an optional property, so you unwrap it withif letfirst. Just so you know,subThoroughfareis a fancy name for house number. -
Adding the
thoroughfare, or street name, is done similarly. Note that you put a space between it andsubThoroughfareso they don’t get glued together. -
The same logic goes for the second line of text. This adds the locality (the city), administrative area (the state or province), and postal code (or zip code), with spaces between them where appropriate.
-
Finally, the two lines are concatenated, or added together, with a newline character in between. The
\nadds the line break (or newline) to the string.
➤ In getLocation(), clear out the placemark and lastGeocodingError variables to start with a clean slate. Put this just above the call to startLocationManager():
placemark = nil
lastGeocodingError = nil
➤ Run the app again. Seconds after a location is found, the address label should be filled in as well.
It’s fairly common that street numbers or other details are missing from the address. The CLPlacemark object may contain incomplete information, which is why its properties are all optionals. Geocoding is not an exact science!
Exercise. If you pick the City Bicycle Ride or City Run locations from the Simulator’s Features menu, you should see in the Console that the app jumps through a whole bunch of different coordinates — it simulates someone moving from one place to another. However, the coordinates on the screen and the address label don’t change nearly as often. Why is that?
Answer: The logic for MyLocations was designed to find the most accurate set of coordinates for a stationary position. You only update the location variable when a new set of coordinates comes in that is more accurate than previous readings. Any new readings with a higher — or the same — horizontalAccuracy value are simply ignored, regardless of what the actual coordinates are.
With the City Bicycle Ride and City Run options, the app doesn’t receive the same coordinates with increasing accuracy but a series of completely different coordinates. That means this app doesn’t work very well when you’re on the move, unless you press Stop and try again. On the other hand, the app was also not intended for capturing shifting locations.
Note: If you’re playing with different locations in the Simulator or from the Xcode debugger menu and you get stuck, then the quickest way to get unstuck is to reset the Simulator. Sometimes it just doesn’t want to move to a new location even if you tell it to, and then you have to show it who’s the boss!
Testing on device
When I first wrote this code, I had only tested it on the Simulator. It worked fine there. Then, I put it on my iPod touch and guess what? Not so good.
The problem with the iPod touch is that it doesn’t have GPS, so it relies only on Wi-Fi to determine the location. But Wi-Fi might not be able to give you accuracy up to ten meters; I got +/- 100 meters at best.
Right now, you only stop the location updates when the accuracy of the reading falls within the desiredAccuracy setting — something that will never actually happen on my iPod touch.
That goes to show that you can’t always rely on the Simulator to test your apps. You need to put them on your device and test them in the wild, especially when using device-dependent functionality like location-based APIs. If you have more than one device, then test on all of them!
In order to deal with this situation, you will improve upon the didUpdateLocations delegate method.
First fix
➤ Change locationManager(_:didUpdateLocations:) to:
func locationManager(
_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]
) {
. . .
if newLocation.horizontalAccuracy < 0 {
return
}
// New section #1
var distance = CLLocationDistance(Double.greatestFiniteMagnitude)
if let location = location {
distance = newLocation.distance(from: location)
}
// End of new section #1
if location == nil || location!.horizontalAccuracy > newLocation.horizontalAccuracy {
. . .
if newLocation.horizontalAccuracy <= locationManager.desiredAccuracy {
. . .
// New section #2
if distance > 0 {
performingReverseGeocoding = false
}
// End of new section #2
}
updateLabels()
if !performingReverseGeocoding {
. . .
}
// New section #3
} else if distance < 1 {
let timeInterval = newLocation.timestamp.timeIntervalSince(location!.timestamp)
if timeInterval > 10 {
print("*** Force done!")
stopLocationManager()
updateLabels()
}
// End of new sectiton #3
}
}
It’s a pretty long method now, but only the three highlighted sections were added. This is the first one:
var distance = CLLocationDistance(Double.greatestFiniteMagnitude)
if let location = location {
distance = newLocation.distance(from: location)
}
This calculates the distance between the new reading and the previous reading, if there was one. We can use this distance to measure if our location updates are still improving.
If there was no previous reading, then the distance is Double.greatestFiniteMagnitude. That is a built-in constant that represents the maximum value that a Double value can have. This little trick gives it a gigantic distance if this is the very first reading. You’re doing that so any of the following calculations still work even if you weren’t able to calculate a true distance yet.
You also add an if statement later where you stop the location manager:
if distance > 0 {
performingReverseGeocoding = false
}
This forces a reverse geocoding for the final location, even if the app is already currently performing another geocoding request.
You absolutely want the address for that final location, as that is the most accurate location you’ve found. But if some previous location was still being reverse geocoded, this step would normally be skipped. Simply by setting performingReverseGeocoding to false, you always force the geocoding to be done for this final coordinate.
Of course, if distance is 0, then this location is the same as the location from a previous reading, and you don’t need to reverse geocode it anymore.
The real improvement is found in the final new section:
} else if distance < 1 {
let timeInterval = newLocation.timestamp.timeIntervalSince(location!.timestamp)
if timeInterval > 10 {
print("*** Force done!")
stopLocationManager()
updateLabels()
}
}
If the coordinate from this reading is not significantly different from the previous reading and it has been more than 10 seconds since you’ve received that original reading, then it’s a good point to hang up your hat and stop.
It’s safe to assume you’re not going to get a better coordinate than this and you can stop fetching the location.
This is the improvement that was necessary to make my iPod touch stop scanning after some time. It wouldn’t give me a location with better accuracy than +/- 100 meters, but it kept repeating the same one over and over.
I picked a time limit of 10 seconds because that seemed to give good results.
Note that you don’t just say:
} else if distance == 0 {
The distance between subsequent readings is never exactly 0. It may be something like 0.0017632. Rather than checking for equals to 0, it’s better to check for less than a certain distance, in this case one meter.
By the way, did you notice how you used location! to unwrap it before accessing the timestamp property? When you are inside this else-if, the value of location is guaranteed to be non-nil, so its safe to force unwrap the optional.
➤ Run the app and test that everything still works. It may be hard to recreate this situation on the Simulator, but try it on your device inside the house and see what output you see in the Console.
Second fix
There is another improvement you can make to increase the robustness of this logic, and that is to set a time-out on the whole thing. You can tell iOS to perform a method one minute from now. If by that time the app hasn’t found a location yet, you stop the location manager and show an error message.
➤ First add a new instance variable:
var timer: Timer?
➤ Then change startLocationManager() to:
func startLocationManager() {
if CLLocationManager.locationServicesEnabled() {
. . .
timer = Timer.scheduledTimer(
timeInterval: 60,
target: self,
selector: #selector(didTimeOut),
userInfo: nil,
repeats: false)
}
}
The new lines set up a timer object that sends a didTimeOut message to self after 60 seconds; didTimeOut is the name of a method.
A selector is the term that Objective-C uses to describe the name of a method, and the #selector() syntax is how you create a selector in Swift.
➤ Change stopLocationManager() to:
func stopLocationManager() {
if updatingLocation {
. . .
if let timer = timer {
timer.invalidate()
}
}
}
You have to cancel the timer in case the location manager is stopped before the time-out fires. This happens when an accurate enough location is found within one minute after starting, or when the user taps the Stop button.
➤ Finally, add the didTimeOut() method:
@objc func didTimeOut() {
print("*** Time out")
if location == nil {
stopLocationManager()
lastLocationError = NSError(
domain: "MyLocationsErrorDomain",
code: 1,
userInfo: nil)
updateLabels()
}
}
There’s something new about this method — there’s a new @objc attribute before func — whatever could it be?
Remember how how #selector is an Objective-C concept? (How could you forget, it was just a few paragraphs ago, right?) So, when you use #selector to identify a method to call, that method has to be accessible not only from Swift, but from Objective-C as well. The @objc attribute allows you to identify a method — or class, or property, or even enumeration — as being accessible from Objective-C.
So, that’s what you’ve done for didTimeOut — declared it as being accessible from Objective-C.
didTimeOut() is always called after one minute, whether you’ve obtained a valid location or not — unless stopLocationManager() cancels the timer first.
If after that one minute there still is no valid location, you stop the location manager, create your own error code, and update the screen.
By creating your own NSError object and putting it into the lastLocationError instance variable, you don’t have to change any of the logic in updateLabels().
However, you do have to make sure that the error’s domain is not kCLErrorDomain because this error object does not come from Core Location but from within your own app.
An error domain is simply a string, so MyLocationsErrorDomain will do. For the code I picked 1. The value of the code doesn’t really matter at this point because you only have one custom error, but you can imagine that when your app becomes bigger, you might need multiple error codes.
Note that you don’t always have to use an NSError object; there are other ways to let the rest of your code know that an error occurred. In this case updateLabels() was already using an NSError anyway, so having your own error object just made sense.
➤ Run the app. Set the Simulator location to None and press Get My Location.
After a minute, the debug area should say “*** Time out” and the Stop button reverts to Get My Location. There should also be an error message on the screen:
Just getting a simple location from Core Location and finding the corresponding street address turned out to be a lot more complicated than it looked. There are many different situations to handle. Nothing is guaranteed, and everything can go wrong — iOS development sometimes requires nerves of steel!
To recap, the app can either:
- Find a location with the desired accuracy,
- Find a location that is not as accurate as you’d like and doesn’t get any more accurate readings,
- Doesn’t find a location at all,
- Or, takes too long finding a location.
The code now handles all these situations, but I’m sure it’s not perfect yet. No doubt the logic could be tweaked more, but it will do for the purposes of this book.
I hope it’s clear that if you’re releasing a location-based app, you need to do a lot of field testing!
Required device capabilities
The Info.plist file has a key, Required device capabilities, that lists the hardware that your app needs in order to run. This is the key that the App Store uses to determine whether a user can install your app on their device.
The default value is armv7, which is the CPU architecture of the iPhone 3GS and later models. If your app requires additional features, such as Core Location to retrieve the user’s location, you should list them here.
➤ Select Info.plist and add a new item to the Required device capabilities array.
➤ Click the double headed arrow at the end of the value field to get a list of possible values. Select Location Services from the list:
You could also add GPS, so that the app requires a GPS receiver. But if you did, users won’t be able to install the app on an iPod touch or on certain iPads.
P.S. You can now take the print() statements out of the app, or simply comment them out. Personally, I like to keep them in there as they’re handy for debugging. In an app that you plan to upload to the App Store, you’ll definitely want to remove the print() statements when development is complete.
Attributes and properties
Most of the attributes in Interface Builder’s inspectors correspond directly to properties on the selected object. For example, a UILabel has the following attributes:
These are directly related to the following properties:
And so on… As you can see, the names may not always be exactly the same (“Lines” and numberOfLines) but you can easily figure out which property goes with which attribute.
You can find these properties in the documentation for UILabel. From the Xcode Help menu, select Developer Documentation. Type “uilabel” into the search field to bring up the class reference for UILabel:
The documentation for UILabel does not list properties for all of the attributes from the inspectors. For example, in the Attributes inspector there is a section named “View”. The attributes in this section come from UIView, which is the base class of UILabel. So if you can’t find a property in the UILabel class, you may need to check the documentation under the “Inherits From” section which is under the Relationships section of the UILabel documentation.
You can find the project files for this chapter under 23-Use-location-data in the Source Code folder.