Chapters

Hide chapters

iOS Apprentice

Eighth Edition · iOS 13 · Swift 5.2 · Xcode 11

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

My Locations

Section 3: 11 chapters
Show chapters Hide chapters

Store Search

Section 4: 12 chapters
Show chapters Hide chapters

31. Polishing the App
Written by Eli Ganim

Apps with appealing visuals sell better than ugly ones. Now that the app works as it should, it’s time to make it look good!

You’re going to go from this:

To this:

The main screen gets the biggest makeover, but you’ll also tweak the others a little.

You’ll do the following in this chapter:

  • Convert placemarks to strings: Refactor the code to display placemarks as text values so that the code is centralized and easier to use.
  • Back to black: Change the appearance of the app to have a black background and light text.
  • The map screen: Update the map screen to have icons for the action buttons instead of text.
  • Fix the table views: Update all the table views in the app to have black backgrounds with white text.
  • Polish the main screen: Update the appearance of the main screen to add a bit of awesome sauce!
  • Make some noise: Add sound effects to the app.
  • The icon and launch images: Add the app icon and launch images to complete the app.

Converting placemarks to strings

Let’s begin by improving the code. I’m not really happy with the way the reverse geocoded street address gets converted from a CLPlacemark object into a string. It works, but the code is unwieldy and repetitive.

There are three places where this happens:

  • CurrentLocationViewController, the main screen.
  • LocationDetailsViewController, the Tag/Edit Location screen.
  • LocationsViewController, the list of saved locations.

Let’s start with the main screen. CurrentLocationViewController.swift has a method named string(from:) where this conversion happens. It’s supposed to return a string that looks like this:

subThoroughfare thoroughfare
locality administrativeArea postalCode

This string goes into a UILabel that has room for two lines, so you use the \n character sequence to create a line-break between the thoroughfare and locality.

The problem is that any of these properties may be nil. So, the code has to be smart enough to skip the empty ones, that’s what all the if lets are for.

There’s a lot of repetition going on in this method. You can refactor this.

Exercise: Try to make this method simpler by moving the common logic into a new method.

Answer: Here’s a possible solution. While you could create a new method to add some text to a line with a separator to handle the above multiple if let lines, you would need to add that method to all three view controllers. Of course, you could add the method to the Functions.swift file to centralize the method too…

But better still, what if you created a new String extension since this functionality is for adding some text to an existing string? Sounds like a plan?

➤ Add a new file to the project using the Swift File template. Name it String+AddText.

➤ Add the following to String+AddText.swift:

extension String {
  mutating func add(text: String?, 
    separatedBy separator: String) {
    if let text = text {
      if !isEmpty {
        self += separator
      }
      self += text
    }
  }
}

Most of the code should be pretty self-explanatory. You ask the string to add some text to itself, and if the string is currently not empty, you add the specified separator first before adding the new text.

Mutating

Notice the mutating keyword. You haven’t seen this before. Sorry, it doesn’t have anything to do with X-Men — programming is certainly fun, but not that fun!

When a method changes the value of a struct, it must be marked as mutating. Recall that String is a struct, which is a value type, and therefore cannot be modified when declared with let.

The mutating keyword tells Swift that the add(text:separatedBy:) method can only be used on strings that are made with var, but not on strings made with let. If you try to modify self in a method on a struct that is not marked as mutating, Swift considers this an error. You don’t need to use the mutating keyword on methods inside a class because classes are reference types and can always be mutated, even if they are declared with let.

➤ Switch over to CurrentLocationViewController.swift and replace string(from:) with the following:

func string(from placemark: CLPlacemark) -> String {
  var line1 = ""
  line1.add(text: placemark.subThoroughfare, separatedBy: "")
  line1.add(text: placemark.thoroughfare, separatedBy: " ")

  var line2 = ""
  line2.add(text: placemark.locality, separatedBy: "")
  line2.add(text: placemark.administrativeArea, 
     separatedBy: " ")
  line2.add(text: placemark.postalCode, separatedBy: " ")

  line1.add(text: line2, separatedBy: "\n")
  return line1
}

That looks a lot cleaner. The logic that decides whether or not to add a CLPlacemark property to the string now lives in your new String extension, so you no longer need all those if let statements. You also use add(text:separatedBy:) to add line2 to line1 with a newline character in between.

➤ Run the app to see if it works.

There’s still a small thing you can do to improve the new add(text:separatedBy:) method. Remember default parameter values? You can use them here.

➤ In String+AddText.swift, change the line that defines the method to:

mutating func add(text: String?, 
                  separatedBy separator: String = "") {

Now, instead of:

line1.add(text: placemark.subThoroughfare, separatedBy: "")

You can write:

line1.add(text: placemark.subThoroughfare)

The default value for separator is an empty string. If the separatedBy parameter is left out, separator will be set to "".

➤ Make these changes in CurrentLocationViewController.swift:

func string(from placemark: CLPlacemark) -> String {
  . . .
  line1.add(text: placemark.subThoroughfare)
  . . .
  line2.add(text: placemark.locality)
  . . .

Where the separator is an empty string, you leave out the separatedBy: "" part of the method call. Note that the other instances of add(text:separatedBy:) in the method don’t have empty strings as the separator but instead, have a space.

Now you have a pretty clean solution that you can re-use in the other two view controllers.

➤ In LocationDetailsViewController.swift, replace the string(from:) code with:

func string(from placemark: CLPlacemark) -> String {
  var line = ""
  line.add(text: placemark.subThoroughfare)
  line.add(text: placemark.thoroughfare, separatedBy: " ")
  line.add(text: placemark.locality, separatedBy: ", ")
  line.add(text: placemark.administrativeArea, 
    separatedBy: ", ")
  line.add(text: placemark.postalCode, separatedBy: " ")
  line.add(text: placemark.country, separatedBy: ", ")
  return line
}

It’s slightly different from how the main screen does it. There are no newline characters and some of the elements are separated by commas instead of just spaces. Newlines aren’t necessary here because the label will wrap.

The final place where placemarks are shown is LocationsViewController. However, this class doesn’t have a string(from:) method. Instead, the logic for formatting the address lives in LocationCell.

➤ Go to LocationCell.swift. Change the relevant part of configure(for:):

func configure(for location: Location) {
  . . .
  if let placemark = location.placemark {
    var text = ""
    text.add(text: placemark.subThoroughfare)
    text.add(text: placemark.thoroughfare, separatedBy: " ")
    text.add(text: placemark.locality, separatedBy: ", ")
    addressLabel.text = text
  } else {
    . . .

You only show the street and the city, so the conversion is simpler.

And that’s it for placemarks.

Back to black

Right now the app looks like a typical iOS app: lots of white, gray tab bar, blue tint color. Time to go for a radically different look and paint the whole thing black.

In iOS 13, Apple added support for Dark Mode, which lets you switch the entire UI from light to dark. You’ll learn how easy it is to support Dark Mode in the next app you build, however you still need to know how to customize the app’s look for the cases where you want the color scheme to be something other than light or dark.

➤ Open the storyboard and go to the Current Location View Controller. Select the top-level view and change its Background Color to Black Color.

➤ Select all the labels (probably easiest from the Document Outline since they are now invisible) and set their Color to White Color.

➤ Change the Font of the (Latitude/Longitude goes here) labels to System Bold 17.

➤ Select the two buttons and change their Font to System Bold 20, to make them slightly larger. You may need to resize their frames to make the text fit (remember, ⌘= is the magic keyboard shortcut).

➤ In the File inspector, change Global Tint to the color Red: 255, Green: 238, Blue: 136. That makes the buttons and other interactive elements yellow, which stands out nicely against the black background.

➤ Select the Get My Location button and change its Text Color to White Color. This provides some contrast between the two buttons.

The storyboard should look like this:

The new yellow-on-black design
The new yellow-on-black design

When you run the app, there are two obvious problems:

  1. The status bar text has become invisible — it is black text on a black background.
  2. The grey tab bar sticks out like a sore thumb. Also, the yellow tint color doesn’t get applied to the tab bar icons.

To fix this, you can use the UIAppearance API — this is a set of methods that lets you customize the look of the standard UIKit controls.

Using UIAppearance

When customizing the UI, you can customize your app on a per-control basis, as you’ve done up to this point, or you can use the “appearance proxy” to change the look of all of the controls of a particular type at once. That’s what you’re going to do here.

➤ Add the following method to SceneDelegate.swift:

func customizeAppearance() {
  UINavigationBar.appearance().barTintColor = UIColor.black
  UINavigationBar.appearance().titleTextAttributes = [ 
    NSAttributedString.Key.foregroundColor: 
    UIColor.white ]
  
  UITabBar.appearance().barTintColor = UIColor.black
  
  let tintColor = UIColor(red: 255/255.0, green: 238/255.0, 
                         blue: 136/255.0, alpha: 1.0)
  UITabBar.appearance().tintColor = tintColor
}

This changes the “bar tint” or background color of all navigation bars and tab bars in the app to black in one fell swoop. It also sets the color of the navigation bar’s title label to white and applies the tint color to the tab bar.

➤ Call this method from the top of scene(_:willConnectTo:options):

func scene(_ scene: UIScene,
           willConnectTo session: UISceneSession,
           options connectionOptions: UIScene.ConnectionOptions) {
  customizeAppearance()
  . . .
}

This looks better already.

The tab bar is now nearly black and has yellow icons
The tab bar is now nearly black and has yellow icons

On the Locations and Map screens you can clearly see that the bars now have a dark tint:

The navigation and tab bars appear in a dark color
The navigation and tab bars appear in a dark color

Keep in mind that the bar tint is not the true background color. The bars are still translucent, which is why they appear as a medium gray rather than pure black.

Tab bar icons

The icons in the tab bar could also do with some improvement. The Xcode Tabbed Application template put a bunch of cruft in the app that you’re no longer using — let’s get rid of it all.

➤ Remove the SecondViewController.swift file from the project.

➤ Remove the first and second images from the asset catalog (Assets.xcassets).

Tab bar images should be basic grayscale images of up to 30 × 30 points — that is 60 × 60 pixels for Retina and 90 × 90 pixels for Retina HD. You don’t have to tint the images; iOS will automatically draw them in the proper color.

➤ The resources for this tutorial include an Images directory. Add the files from this folder to the asset catalog.

➤ Go to the storyboard. Select the Tab Bar Item of the navigation controller embedding the Current Location screen. In the Attributes inspector, under Image choose Tag — this is the name of one of the images you’ve just added.

Choosing an image for a Tab Bar Item
Choosing an image for a Tab Bar Item

➤ For the Tab Bar Item of the navigation controller attached to the Locations screen, choose the Locations image.

➤ For the Tab Bar Item of the navigation controller embedding the Map View Controller, choose the Map image.

Now the tab bar looks a lot more appealing:

The tab bar with proper icons
The tab bar with proper icons

The status bar

The status bar is currently invisible on the Tag screen and appears as black text on dark gray on the other two screens. It would look better if the status bar text was white instead.

To do this, you need to override the preferredStatusBarStyle property in your view controllers and make it return the value .lightContent.

The simplest way to make the status bar white for all your view controllers in the entire app is to replace the UITabBarController with your own subclass.

➤ Add a new source file to the project and name it MyTabBarController.swift.

➤ Replace the contents of MyTabBarController.swift with:

import UIKit

class MyTabBarController: UITabBarController {
  override var preferredStatusBarStyle: UIStatusBarStyle {
    return .lightContent
  }
  
  override var childForStatusBarStyle: UIViewController? {
    return nil
  }
}

By returning nil from childForStatusBarStyle, the tab bar controller will look at its own preferredStatusBarStyle property instead of those from the other view controllers.

➤ In the storyboard, select the Tab Bar Controller and in the Identity inspector change its Class to MyTabBarController. This tells the storyboard that it should now create an instance of your subclass when the app starts up.

That’s right, you can replace standard UIKit components with your own subclasses!

Subclassing lets you change what the built-in UIKit objects do — that’s the power of object-oriented programming. But don’t get carried away and alter their behavior too much — before you know it, your app ends up with an identity crisis!

MyTabBarController still does everything that the standard UITabBarController does. You only override preferredStatusBarStyle to change the status bar color.

You can plug this MyTabBarController class into any app that uses a tab bar controller, and from then on, all its view controllers will have a white status bar.

Now, the status bar is white everywhere:

The status bar is visible again
The status bar is visible again

Well, almost everywhere… When you open the photo picker, the status bar fades to black again. Subclasses to the rescue again!

➤ Add a new file to the project and name it MyImagePickerController.swift. (Getting a sense of déjà vu?)

➤ Replace the contents of MyImagePickerController.swift with:

import UIKit

class MyImagePickerController: UIImagePickerController {
  override var preferredStatusBarStyle: UIStatusBarStyle {
    return .lightContent
  }
}

Now, instead of instantiating the standard UIImagePickerController to pick a photo, you should use this new subclass.

➤ Go to LocationDetailsViewController.swift. In takePhotoWithCamera() and choosePhotoFromLibrary(), change the line that creates the image picker to:

let imagePicker = MyImagePickerController()

This is allowed because MyImagePickerController is a subclass of the standard UIImagePickerController — it has the same properties and methods. As far as UIKit is concerned, the two are interchangeable. So, you can use your subclass anywhere you’d use UIImagePickerController.

While you’re at it, the photo picker still uses the standard blue tint color. That makes its navigation bar buttons hard to read. The fix is simple: set the tint color on the Image Picker Controller just before you present it.

➤ Add the following line to the two methods:

imagePicker.view.tintColor = view.tintColor

Now, the Cancel button appears in yellow instead of blue.

The photo picker with the new colors
The photo picker with the new colors

There is one more thing to change. When the app starts up, iOS looks in the Info.plist file to determine whether it should show a status bar while the app launches, and if so, what color that status bar should be.

Right now, it’s set to Default, which is the black status bar.

➤ Just to be thorough, go to the Project Settings screen. In the General tab, under Deployment Info is a Status Bar Style option. Change this to Light.

Changing the status bar style for app startup
Changing the status bar style for app startup

And now the status bar really is white everywhere!

The map screen

The Map screen currently has a somewhat busy navigation bar with three pieces of text in it: the title and the two buttons.

The bar button items have text labels
The bar button items have text labels

The design advice that Apple gives is to prefer text to icons because icons tend to be harder to understand. The disadvantage of using text is that it makes your navigation bar more crowded.

There are two possible solutions:

  1. Remove the title. If the purpose of the screen is obvious, which it is in this case, then the title “Map” is superfluous. You might as well remove it.

  2. Keep the title but replace the button labels with icons.

For this app, you’ll choose the second option.

➤ Go to the Map scene in the storyboard and select the Locations bar button item. In the Attributes inspector, under Image choose Pin. This will remove the text from the button.

➤ For the User bar button item, choose the User image.

The Map screen now looks like this:

Map screen with the button icons
Map screen with the button icons

Notice that the dot for the user’s current location is drawn in the yellow tint color (it was a blue dot before).

The ⓘ button on the map annotations also appears in yellow, making it hard to see on the white callout. Fortunately, you can override the tint color on a per-view basis. There’s no rule that says the tint color has to be the same everywhere!

➤ In MapViewController.swift, in the method mapView(_:viewFor:), add this below the line that sets pinView.pinTintColor:

pinView.tintColor = UIColor(white: 0.0, alpha: 0.5)

This sets the annotation’s tint color to half-opaque black:

The callout button is now easier to see
The callout button is now easier to see

Fixing the table views

The app is starting to shape up, but there are still some details to take care of. The table views, for example, are still very white.

Unfortunately, what UIAppearance can do for table views is very limited. So, you’ll have to customize each of the table views individually.

This can be done either via code, or via storyboard. The advantage of using storyboards is that you can see the actual changes such as color, spacing, font etc. and be sure how a change affects the rest of the UI. So, let’s make these changes via storyboards as much as possible.

Storyboard changes for the Locations scene

➤ Open the storyboard and select the table view for the Locations scene. Set Table View - Separator color to white with 20% Opacity, Scroll View - Indicators to white, and View - Background to black.

Table view color changes
Table view color changes

This makes the table view itself black but does not alter the cells.

➤ Select the prototype cell in the table view and set its View - Background to black.

➤ Next, select the Description label in the cell and set its Label - Color and Label - Highlighted color to white.

➤ Select the Address label and set its Label - Color and Label - Highlighted color to white with 60% Opacity.

➤ Run the app. That’s starting to look pretty good already:

The table view cells are now white-on-black
The table view cells are now white-on-black

That’s as far as you can get with customization via storyboard. But there are a couple of small issues still.

Code changes for the Locations view

The first, when you tap a cell it still lights up in a bright color, which is a little jarring. It would look better if the selection color was more subdued.

Unfortunately, there is no “selectionColor” property on UITableViewCell, but you can give it a different view to display when it is selected via a UITableViewCell’s selectedBackgroundView property.

➤ In LocationCell.swift, replace awakeFromNib() with the following:

override func awakeFromNib() {
  super.awakeFromNib()
  let selection = UIView(frame: CGRect.zero)
  selection.backgroundColor = UIColor(white: 1.0, alpha: 0.3)
  selectedBackgroundView = selection
}

Every object that comes from a storyboard has the awakeFromNib() method. This method is invoked when UIKit loads the object from the storyboard. It’s the ideal place to customize its looks.

Here, you create a new UIView filled with a dark gray color. This new view is placed on top of the cell’s background when the user taps on the cell. It will look like this:

The selected cell has a subtly different background color
The selected cell has a subtly different background color

The second issue is that the section headers are a bit on the heavy side. There is no easy way to customize the existing headers, but you can replace them with a view of your own.

➤ Go to LocationsViewController.swift and add the following table view delegate method:

override func tableView(_ tableView: UITableView, 
     viewForHeaderInSection section: Int) -> UIView? {

  let labelRect = CGRect(x: 15, 
                         y: tableView.sectionHeaderHeight - 14, 
                         width: 300, height: 14)
  let label = UILabel(frame: labelRect)
  label.font = UIFont.boldSystemFont(ofSize: 11)
  
  label.text = tableView.dataSource!.tableView!(
                 tableView, titleForHeaderInSection: section)
  
  label.textColor = UIColor(white: 1.0, alpha: 0.6)
  label.backgroundColor = UIColor.clear
  
  let separatorRect = CGRect(
          x: 15, y: tableView.sectionHeaderHeight - 0.5, 
          width: tableView.bounds.size.width - 15, height: 0.5)
  let separator = UIView(frame: separatorRect)
  separator.backgroundColor = tableView.separatorColor
  
  let viewRect = CGRect(x: 0, y: 0, 
                    width: tableView.bounds.size.width, 
                   height: tableView.sectionHeaderHeight)
  let view = UIView(frame: viewRect)
  view.backgroundColor = UIColor(white: 0, alpha: 0.85)
  view.addSubview(label)
  view.addSubview(separator)
  return view
}

This method gets called once for each section in the table view. Here, you create a label for the section name, a 1-pixel high view that functions as a separator line, and a container view to hold these two subviews.

It looks like this:

The section headers now draw much less attention to themselves
The section headers now draw much less attention to themselves

Note: Did you notice anything special about the following line?

label.text = tableView.dataSource!.tableView!(tableView, titleForHeaderInSection: section)

This asks the table view’s data source for the text to put in the header. The dataSource property is an optional so you’re using ! to unwrap it. But that’s not the only ! in this line…

You’re calling the tableView(_:titleForHeaderInSection:) method on the table view’s data source, which is of course the LocationsViewController itself.

But this method is an optional method — not all data sources need to implement it. Because of that you have to unwrap the method with the exclamation mark in order to use it. Unwrapping methods… does it get any crazier than that?

By the way, you can also write this as:

label.text = self.tableView(tableView, titleForHeaderInSection: section)

Here you use self to directly access that method on LocationsViewController. Both ways achieve exactly the same thing, since the view controller happens to be the table view’s data source.

Another small improvement you can make is to always put the section headers in uppercase.

➤ Change tableView(_:titleForHeaderInSection:) to:

override func tableView(_ tableView: UITableView, 
    titleForHeaderInSection section: Int) -> String? {
  let sectionInfo = fetchedResultsController.sections![section]
  return sectionInfo.name.uppercased()
}

Now the section headers look even better:

The section header text is in uppercase
The section header text is in uppercase

Currently, if a location does not have a photo, there is a black gap where the thumbnail is supposed to be. It’s better to show a placeholder image. You already added one to the asset catalog when you imported the Images folder.

➤ In LocationCell.swift’s thumbnail(for:), replace the last line that returns an empty UIImage with:

return UIImage(named: "No Photo")!

Recall that UIImage(named:) is a failable initializer, so it returns an optional. Don’t forget the exclamation point at the end to unwrap the optional.

Now locations without photos appear like so:

A location using the placeholder image
A location using the placeholder image

That makes it a lot clearer to the user that the photo is missing. (As opposed to, say, being a photo of a black hole.) The placeholder image is round. That’s the fashion for thumbnail images on iOS these days, and it’s pretty easy to make the other thumbnails rounded too.

➤ Still in LocationCell.swift, add the following lines to the end of awakeFromNib():

// Rounded corners for images
photoImageView.layer.cornerRadius = 
                     photoImageView.bounds.size.width / 2
photoImageView.clipsToBounds = true
separatorInset = UIEdgeInsets(top: 0, left: 82, bottom: 0, 
                                                 right: 0)

This gives the image view rounded corners with a radius that is equal to half the width of the image, which makes it a perfect circle. The clipsToBounds setting makes sure that the image view respects these rounded corners and does not draw outside them. The separatorInset moves the separator lines between the cells a bit to the right so there are no lines between the thumbnail images.

The thumbnails are now circular
The thumbnails are now circular

Note: As you’ll notice from the above image, the rounded thumbnails don’t look very good if the original photo isn’t square. You may want to change the Mode of the image view back to Aspect Fill or Scale to Fill so that the thumbnail always fills up the entire image view.

At this point, you probably want to make sure that the labels in this screen extend to cover the full width of larger screens — remember that while you’ve been designing for 320 point wide screens, you’ve been setting up Auto Layout constraints so that all screen sizes will be automatically supported.

Tip: To verify that the labels now take advantage of all the available screen space on larger screens, give them a non-transparent background color. Do you like bright purple?

➤ Add these lines to awakeFromNib() (in LocationCell.swift, of course) and run the app:

descriptionLabel.backgroundColor = UIColor.purple
addressLabel.backgroundColor = UIColor.purple

This is how it looks on an iPhone 8 Plus screen:

The labels resize to fit the iPhone 8 Plus
The labels resize to fit the iPhone 8 Plus

When you’re done testing, don’t forget to remove the lines that set the background color. It’s useful as a debugging tool, but not particularly pretty to look at.

There are two other table views in the app and they require similar changes.

Table view changes for Tag Location screen

➤ Open the storyboard and select the table view for the Tag Location scene. Set Table View - Separator color to white with 20% Opacity, Scroll View - Indicators to white, and View - Background to black.

➤ Select all the static cells in the table view and set their View - Background to black.

➤ Select the Description text view and set its Text View - Color to white, and View - Background to black.

➤ Select the Add Photo label and set its Label - Color and Label - Highlighted color to white.

➤ Select the main label from all the cells with the Right Detail style and set their Label - Color and Label - Highlighted color to white.

➤ Select the detail label from all the cells with the Right Detail style and set their Label - Color and Label - Highlighted color to white with 60% Opacity.

➤ Select the Address label and set its Label - Color and Label - Highlighted color to white.

➤ Select the Address detail label and set its Label - Color and Label - Highlighted color to white with 60% Opacity.

That completes all the storyboard changes but there are a few code changes left.

Previously, you modified the cell’s subclasss to add the selection highlighting. However, you have static table view cells here and don’t have a subclasss to modify. Don’t despair yet though, the table view delegate has a handy method that comes in useful here.

➤ Open LocationDetailsViewController.swift and add the following method:

override func tableView(_ tableView: UITableView, 
                   willDisplay cell: UITableViewCell, 
                 forRowAt indexPath: IndexPath) {
  let selection = UIView(frame: CGRect.zero)
  selection.backgroundColor = UIColor(white: 1.0, alpha: 0.3)
  cell.selectedBackgroundView = selection
}

The willDisplay delegate method is called just before a cell becomes visible. So, you can do some last-minute customizations on the cell and its contents in this method.

➤ Run the app. The Tag Location screen should now looks like this:

The Tag Location screen with styling applied
The Tag Location screen with styling applied

Table view changes for the Category Picker screen

The final table view is the category picker. There’s nothing new here, the changes are basically the same as before.

➤ Open the storyboard and select the table view for the Category Picker view controller. Set Table View - Separator color to white with 20% Opacity, Scroll View - Indicators to white, and View - Background to black.

➤ Select the prototype cell in the table view and set its View - Background to black.

➤ Select the label in the prototype cell and set its Label - Color and Label - Highlighted color to white.

All that’s left is to set the cell background for highlighted cells. Since there is no subclass for the cell, it’s possible to use the table view delegate’s willDisplay method again.

However, rememember that you are dealing with a prototype cell here. That means that it already is being set up in code via cellForRowAt. So why not simply use the existing method to do the extra work? Remember, there’s often multiple ways to do the same thing.

➤ Open CategoryPickerViewController.swift and add the following code to cellForRowAt, just before the return:

override func tableView(_ tableView: UITableView, 
             cellForRowAt indexPath: IndexPath) -> 
             UITableViewCell {
  . . .
  let selection = UIView(frame: CGRect.zero)
  selection.backgroundColor = UIColor(white: 1.0, alpha: 0.3)
  cell.selectedBackgroundView = selection
  // End new code
  return cell
}

Now the category picker is dressed in black as well. It’s a bit of work to change the visuals of all these table views by hand, but it’s worth it.

The category picker is lookin’ sharp
The category picker is lookin’ sharp

Polishing the main screen

I’m pretty happy with all the other screens, but the main screen needs a bit more work to be presentable.

Here’s what you’ll do:

  • Show a logo when the app starts up. Normally, such splash screens are bad for the user experience, but here you can get away with it.
  • Make the logo disappear with an animation when the user taps Get My Location.
  • While the app is fetching the coordinates, show an animated activity spinner to make it even clearer to the user that something is going on.
  • Hide the Latitude: and Longitude: labels until the app has found coordinates.

You will first hide the text labels from the screen until the app actually has some coordinates to display. The only label that will be visible until then is the one at the top and it will say “Searching…” or give some kind of error message.

In order to do this, you must have outlets for the labels.

➤ Add the following properties to CurrentLocationViewController.swift:

@IBOutlet weak var latitudeTextLabel: UILabel!
@IBOutlet weak var longitudeTextLabel: UILabel!

You’ll put the logic for updating these labels in a single place, updateLabels(), so that hiding and showing them is pretty straightforward.

➤ Change updateLabels() in CurrentLocationViewController.swift:

func updateLabels() {
  if let location = location {
    . . .
    latitudeTextLabel.isHidden = false
    longitudeTextLabel.isHidden = false
  } else {
    . . .
    latitudeTextLabel.isHidden = true
    longitudeTextLabel.isHidden = true
  }
}

➤ Connect the Latitude: and Longitude: labels in the storyboard to the latitudeTextLabel and longitudeTextLabel outlets.

➤ Run the app and verify that the Latitude: and Longitude: labels only appear when you have obtained GPS coordinates.

The first impression

The main screen looks decent and is completely functional, but it could do with more pizzazz. It lacks the “Wow!” factor. You want to impress users the first time they start your app and keep them coming back. To pull this off, you’ll add a logo and a cool animation.

When the user hasn’t yet pressed the Get My Location button, there are no GPS coordinates and the Tag Location button is hidden.

Instead of showing a completely blank upper panel, you can show a large version of the app’s icon.

The welcome screen of MyLocations
The welcome screen of MyLocations

When the user taps the Get My Location button, the icon rolls out of the screen — it’s round so that kinda makes sense — while a panel with the GPS status will slide in.

This is pretty easy to program thanks to the power of Core Animation and it makes the app a whole lot more impressive for first-time users.

First, you need to move the labels into a new container subview.

➤ Open the storyboard and go to the Current Location View Controller. In the Document Outline, select the six labels and the Tag Location button. With these seven views selected, choose Editor ▸ Embed In ▸ View Without Inset from the Xcode menu bar.

This creates a blank, white UIView and puts these labels and the button inside that new view.

Note: The “View Without Inset” option is new in Xcode 10. Previously, you only had the “View” option which created a view with some padding around the controls that you enclosed in the view. This new option does not add any extra padding and keeps your enclosed controls at their original locations.

➤ Change the Background color of this new container view to Clear Color, so that everything becomes visible again. The layout of the screen hasn’t changed; you have simply reorganized the view hierarchy so that you can easily manipulate and animate this group of views as a whole. Grouping views in a container view is a common technique for building complex layouts.

➤ To avoid problems on smaller screens, make sure that the Get My Location button sits higher up in the view hierarchy than the container view. If the button sits under another view you cannot tap it anymore.

Non-intuitively, in the Document Outline, the button must sit below the container view. If it doesn’t, drag to rearrange:

Get My Location must sit below the container view in the Document Outline
Get My Location must sit below the container view in the Document Outline

Note: When you drag the Get My Location button, make sure you’re not dropping it into the container view. The view you just added and the Get My Location button should sit at the same level in the view hierarchy.

When you embedded the six labels and the button in the container view, the Auto Layout constraints that those seven controls had to the main view were broken. Makes sense, right? Because those controls are now inside a different view.

You have to fix a few Auto Layout constraints so that the controls are laid out correctly within the container view.

➤ Select the Container View and set its Auto Layout constraints as follows: left=16, top=0, and right=16.

➤ Select the (Message Label) at the top and set its Auto Layout Constraints to: left=0, top=0, and right=0.

➤ Select the Latitude:, Longitude:, and (Address goes here) labels and set their Auto Layout Constraints to: left=0.

➤ Select the (Latitude goes here), (Longitude goes here), and (Address goes here) labels and set their Auto Layout Constraints to: right=0.

➤ Finally, set the Tag Location button’s Auto Layout Constraints to: left=0, bottom=0, and right=0.

➤ Add the following outlet to CurrentLocationViewController.swift:

@IBOutlet weak var containerView: UIView!

➤ In the storyboard, connect the new container UIView to the containerView outlet.

Now on to the good stuff!

➤ Add the following instance variables to CurrentLocationViewController.swift:

var logoVisible = false

lazy var logoButton: UIButton = {
  let button = UIButton(type: .custom)
  button.setBackgroundImage(UIImage(named: "Logo"), 
                            for: .normal)
  button.sizeToFit()
  button.addTarget(self, action: #selector(getLocation), 
                   for: .touchUpInside)
  button.center.x = self.view.bounds.midX
  button.center.y = 220
  return button
}()

The logo image is actually a button, so that you can tap the logo to get started. The app will show this button when it starts up, and when it doesn’t have anything better to display — for example, after you press Stop and there are no coordinates and no error. To orchestrate this, you’ll use the boolean logoVisible.

The button is a “custom” type UIButton, meaning that it has no title text or other frills. It draws the Logo.png image and calls the getLocation() method when tapped. This is another one of those lazily loaded properties; It’s nicer because it keeps all the initialization logic inline with the declaration of the property.

➤ Add the following method:

func showLogoView() {
  if !logoVisible {
    logoVisible = true
    containerView.isHidden = true
    view.addSubview(logoButton)
  }
}

This hides the container view so the labels disappear, and puts the logoButton object on the screen. This is the first time logoButton is accessed, so at this point the lazy loading kicks in.

➤ In updateLabels(), change the line that says:

statusMessage = "Tap ’Get My Location’ to Start"

To:

statusMessage = ""
showLogoView()

This new logic makes the logo appear when there are no coordinates or error messages to display. That’s also the state at startup time, so when you run the app now, you should be greeted by the logo.

➤ Run the app to check it out.

When you tap the logo (or Get My Location), the logo should disappear and the panel with the labels ought to show up. That doesn’t happen yet, so let’s add some more code to do that.

➤ Add the following method:

func hideLogoView() {
  logoVisible = false
  containerView.isHidden = false
  logoButton.removeFromSuperview()
}

This is the counterpart to showLogoView(). For now, it simply removes the button with the logo and un-hides the container view with the GPS coordinates.

➤ Add the following to getLocation(), right after the authorization status checks:

if logoVisible {
  hideLogoView()
}

Before it starts the location manager, this first removes the logo from the screen if it was visible. Currently, there is no animation code to be seen. When doing complicated layout stuff such as this, it’s better to first make sure the basics work. If they do, you can make it look fancy with an animation afterwards.

➤ Run the app. You should see the screen with the logo. Press the Get My Location button and the logo is replaced by the coordinate labels.

Great! Now you can add the animation. The only method you have to change is hideLogoView().

➤ First, give CurrentLocationViewController the ability to handle animation events by making it the CAAnimationDelegate:

class CurrentLocationViewController: UIViewController, 
              CLLocationManagerDelegate, CAAnimationDelegate {

➤ Then replace hideLogoView() with:

func hideLogoView() {
  if !logoVisible { return }
  
  logoVisible = false
  containerView.isHidden = false
  containerView.center.x = view.bounds.size.width * 2
  containerView.center.y = 40 + 
     containerView.bounds.size.height / 2
  
  let centerX = view.bounds.midX
  
  let panelMover = CABasicAnimation(keyPath: "position")
  panelMover.isRemovedOnCompletion = false
  panelMover.fillMode = CAMediaTimingFillMode.forwards
  panelMover.duration = 0.6
  panelMover.fromValue = NSValue(cgPoint: containerView.center)
  panelMover.toValue = NSValue(cgPoint: 
       CGPoint(x: centerX, y: containerView.center.y))
  panelMover.timingFunction = CAMediaTimingFunction(
                name: CAMediaTimingFunctionName.easeOut)
  panelMover.delegate = self
  containerView.layer.add(panelMover, forKey: "panelMover")
  
  let logoMover = CABasicAnimation(keyPath: "position")
  logoMover.isRemovedOnCompletion = false
  logoMover.fillMode = CAMediaTimingFillMode.forwards
  logoMover.duration = 0.5
  logoMover.fromValue = NSValue(cgPoint: logoButton.center)
  logoMover.toValue = NSValue(cgPoint:
      CGPoint(x: -centerX, y: logoButton.center.y))
  logoMover.timingFunction = CAMediaTimingFunction(
                 name: CAMediaTimingFunctionName.easeIn)
  logoButton.layer.add(logoMover, forKey: "logoMover")
  
  let logoRotator = CABasicAnimation(keyPath: 
                       "transform.rotation.z")
  logoRotator.isRemovedOnCompletion = false
  logoRotator.fillMode = CAMediaTimingFillMode.forwards
  logoRotator.duration = 0.5
  logoRotator.fromValue = 0.0
  logoRotator.toValue = -2 * Double.pi
  logoRotator.timingFunction = CAMediaTimingFunction(
                  name: CAMediaTimingFunctionName.easeIn)
  logoButton.layer.add(logoRotator, forKey: "logoRotator")
}

This creates three animations that are played at the same time:

  1. The containerView is placed outside the screen (somewhere on the right) and moved to the center.
  2. The logo image view slides out of the screen.
  3. The logo image also rotates around its center, giving the impression that it’s rolling away.

Because the “panelMover” animation takes longest, you set a delegate on it so that you will be notified when the entire animation is over.

➤ Now add the necessary CAAnimationDelegate method:

// MARK:- Animation Delegate Methods
func animationDidStop(_ anim: CAAnimation, 
               finished flag: Bool) {
  containerView.layer.removeAllAnimations()
  containerView.center.x = view.bounds.size.width / 2
  containerView.center.y = 40 + 
                containerView.bounds.size.height / 2
  logoButton.layer.removeAllAnimations()
  logoButton.removeFromSuperview()
}

This cleans up after the animations and removes the logo button, as you no longer need it.

➤ Run the app. Tap on Get My Location to make the logo disappear.

Tip: To get the logo back so you can try again, first choose Location ▸ None from the Simulator’s Debug menu. Then tap Get My Location followed by Stop to make the logo reappear.

Apple says that good apps should “surprise and delight,” and modest animations such as these really make your apps more interesting to use —ß as long as you don’t overdo it!

Adding an activity indicator

When the user taps the Get My Location button, you currently change the button’s text to say Stop to indicate the change of state. You can make it even clearer to the user that something is going on by adding an animated activity “spinner.”

It will look like this:

The animated activity spinner shows that the app is busy
The animated activity spinner shows that the app is busy

UIKit comes with a standard control for this, UIActivityIndicatorView. You could add the spinner to the storyboard. However, it’s good to learn diffrent techniques and so you’ll create the spinner in code this time.

The code to change the appearance of the Get My Location button sits in the configureGetButton() method. That’s also a good place to show and hide the spinner.

➤ Replace configureGetButton() with the following:

func configureGetButton() {
  let spinnerTag = 1000
  
  if updatingLocation {
    getButton.setTitle("Stop", for: .normal)
    
    if view.viewWithTag(spinnerTag) == nil {
      let spinner = UIActivityIndicatorView(style: .white)
      spinner.center = messageLabel.center
      spinner.center.y += spinner.bounds.size.height/2 + 25
      spinner.startAnimating()
      spinner.tag = spinnerTag
      containerView.addSubview(spinner)
    }
  } else {
    getButton.setTitle("Get My Location", for: .normal)
    
    if let spinner = view.viewWithTag(spinnerTag) {
      spinner.removeFromSuperview()
    }
  }
}

In addition to changing the button text to “Stop,” you create a new instance of UIActivityIndicatorView. Then you do some calculations to position the spinner view below the message label at the top of the screen. The call to addSubview() actually adds the spinner to the container view and makes it visible.

To keep track of this spinner view, you give it a tag of 1000. You could use an instance variable but this is just as easy and it keeps everything local to the configureGetButton() method. It’s nice to have everything in one place.

When it’s time to revert the button to its old state, you call removeFromSuperview() to remove the activity indicator view from the screen.

And that’s all you need to do.

➤ Run the app. There should now be a cool little animation while the app is busy talking to the GPS satellites.

Making some noise

Visual feedback is important, but you can’t expect users to keep their eyes glued to the screen all the time, especially if an operation might take a few seconds or more.

Emitting an unobtrusive sound is a good way to alert the user that a task is complete — for example, when your iPhone sends an email, you hear a soft “whoosh” sound.

You’re going to add a sound effect to the app too, which is to be played when the first reverse geocoding successfully completes. That seems like a reasonable moment to alert the user that GPS and address information has been captured.

There are many ways to play sounds on iOS, but you’re going to use one of the simplest: system sounds. The System Sound API is intended for short beeps and other notification sounds, which is exactly the type of sound that you want to play here.

➤ Add an import for AudioToolbox, the framework for playing system sounds, to the top of CurrentLocationViewController.swift:

import AudioToolbox

➤ Add a soundID instance variable:

var soundID: SystemSoundID = 0

Because writing just 0 would normally give you a variable of type Int, you explicitly mention the type that you want it to be: SystemSoundID. This is a numeric identifier — sometimes called a “handle” — that refers to a system sound object. 0 means no sound has been loaded yet.

➤ Add the following methods to the class:

// MARK:- Sound effects
func loadSoundEffect(_ name: String) {
  if let path = Bundle.main.path(forResource: name, 
                                      ofType: nil) {
    let fileURL = URL(fileURLWithPath: path, isDirectory: false)
    let error = AudioServicesCreateSystemSoundID(
                      fileURL as CFURL, &soundID)
    if error != kAudioServicesNoError {
      print("Error code \(error) loading sound: \(path)")
    }
  }
}

func unloadSoundEffect() {
  AudioServicesDisposeSystemSoundID(soundID)
  soundID = 0
}

func playSoundEffect() {
  AudioServicesPlaySystemSound(soundID)
}

The loadSoundEffect() method loads the sound file and puts it into a new sound object. The specifics don’t really matter, but you end up with a reference to that object in the soundID instance variable.

➤ Call loadSoundEffect() from viewDidLoad():

loadSoundEffect("Sound.caf")

➤ In locationManager(_:didUpdateLocations:), in the geocoder’s completion closure, change the following code:

if error == nil, let p = placemarks, !p.isEmpty {
  // New code block
  if self.placemark == nil {               
    print("FIRST TIME!")
    self.playSoundEffect()
  }
  // End new code
  self.placemark = p.last!
} else {
  . . .

The new if statement simply checks whether the self.placemark instance variable is nil, in which case this is the first time you’ve reverse geocoded an address. It then plays a sound using the playSoundEffect() method.

Of course, you shouldn’t forget to add the actual sound effect to the project!

➤ Add the Sound folder from this app’s Resources to the project. Make sure Copy items if needed is selected — click the Options button in the file open panel to reveal this option.

➤ Run the app and see if it makes some noise. The sound should only be played for the first address it finds — when you see the FIRST TIME! log message — even if more precise locations keep coming in afterwards.

Note: If you don’t hear the sound on the Simulator, try the app on a device. Sometimes system sounds will not play on the simulators.

CAF audio files

The Sound folder contains a single file, Sound.caf. The caf extension stands for Core Audio Format, and it’s the preferred file format for these kinds of short audio files on iOS.

If you want to use your own sound file but it is in a different format than CAF and your audio software can’t save CAF files, then you can use the afconvert utility to convert the audio file. You need to run it from the Terminal:

$ /usr/bin/afconvert -f caff -d LEI16 Sound.wav Sound.caf

This converts the Sound.wav file into Sound.caf. You don’t need to do this for the audio file from this app’s Sound folder because that file is already in the correct format. But if you want to experiment with your own audio files, then knowing how to use afconvert might be useful.

By the way, iOS can play .wav files just fine, but .caf is more optimal.

The icon and launch images

The Resources folder for this app contains an Icon folder with the app icons.

➤ Import the icon images into the asset catalog — you can simply drag them from Finder into the AppIcon group. It’s best to drag them one-by-one into their respective slots — if you drag the whole set of icons into the group at once, Xcode can get confused. You can see the icons of the asset catalog, here:

The icons in the asset catalog
The icons in the asset catalog

The app currently also has a launch file, LaunchScreen.storyboard, that provides the splash image for when the app is still loading.

Instead of using a storyboard for the launch screen, you can also supply a set of images. Let’s do that for this app.

➤ In the Project Settings screen, in the General tab, find the App Icons and Launch Images section. Click the Use Asset Catalog button next to Launch Images Source:

Using the asset catalog for launch images
Using the asset catalog for launch images

Xcode now asks if you want to migrate the launch images. Click Migrate.

➤ Clear the Launch Screen File text field.

➤ Also remove LaunchScreen.storyboard from the project. It’s also a good idea to delete the app from the Simulator, or even reset it, so that there is no trace of the old launch screen.

➤ Open Assets.xcassets. There is now a LaunchImage item in the list. Select it and go to the Attributes inspector. Under both iOS 8.0 and Later and iOS 7.0 and Later, put checkmark by iPhone Portrait:

Enabling the launch images for iPhone portrait
Enabling the launch images for iPhone portrait

You should now have five slots for dropping the launch images into — if you have any slots that say “Unassigned,” then select and remove them by pressing the delete key. The Resources folder for this app contains a Launch Images folder. Let’s take a look at one of those images, Launch Image Retina 4.png:

The launch image for this app
The launch image for this app

The launch image only has the tab bar and the logo button, but no status bar or any buttons. The reason it has no “Get My Location” button is that you don’t want users to try and tap it while the app is still loading since it’s not really a button! To make this launch image, you can ran the app in the Simulator and choose File ▸ Save Screen Shot. This puts a new PNG file on the Desktop. You then open the image in Photoshop and blank out any text and the status bar portion of the image. The iPhone will draw its own status bar on top anyway.

➤ Drag the files from the Launch Images folder into the asset catalog, one at a time. The slot for each image should be pretty obvious.

Done. That was easy. And with that, MyLocations is complete! Woohoo! You can find the final project files for the app under 31 - Polishing the App in the Source Code folder.

Congrats on making it this far! It has been a long and winding road with a lot of theory to boot.

The final storyboard for MyLocations looks like this:

Where to go from here?

In this section you took a more detailed look at Swift, but there’s still plenty to discover. To learn more about the Swift programming language, you can read the following books:

  • The Swift Programming Language by Apple. This is a free download on the iBooks Store. If you don’t want to read the whole thing, at least take the Swift tour. It’s a great introduction to the language.

  • Swift Apprentice by the raywenderlich.com tutorial Team. This is a book that teaches you everything you need to know about Swift, from beginning to advanced topics. This is a sister book to the iOS Appentice; the iOS Apprentice focuses more on making apps, while the Swift Apprentice focuses more on the Swift language itself. https://store.raywenderlich.com/products/swift-apprentice

There are several good Core Data beginner books on the market. Here are two recommendations:

  • Core Data by Tutorials by the raywenderlich.com tutorial Team. One of the few Core Data books that is completely up-to-date with the lastest iOS and Swift versions. This book is for intermediate iOS developers who already know the basics of iOS and Swift development, but want to learn how to use Core Data to save data in their apps. https://store.raywenderlich.com/products/core-data-by-tutorials

  • Core Data Programming Guide by Apple. If you want to get into the nitty gritty, then Apple’s official guide is a must-read. You can learn a ton from this guide. apple.co/2wNgiRu

Credits for this tutorial:

Are you ready for the final UIKit app? Then continue on to the next chapter, where you’ll make an app that communicates with a web service over the network!

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.