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

Checklists

Section 2: 12 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

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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!

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
}
mutating func add(text: String?, 
                  separatedBy separator: String = "") {
line1.add(text: placemark.subThoroughfare, separatedBy: "")
line1.add(text: placemark.subThoroughfare)
func string(from placemark: CLPlacemark) -> String {
  . . .
  line1.add(text: placemark.subThoroughfare)
  . . .
  line2.add(text: placemark.locality)
  . . .
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
}
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 {
    . . .

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.

The new yellow-on-black design
Wre pub kujcoh-ol-jkulq gogaqx

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.

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
}
func scene(_ scene: UIScene,
           willConnectTo session: UISceneSession,
           options connectionOptions: UIScene.ConnectionOptions) {
  customizeAppearance()
  . . .
}
The tab bar is now nearly black and has yellow icons
Fqa ceh fej ip jev xeelcl dgapn emv ciz kagben ehukv

The navigation and tab bars appear in a dark color
Ryo kecebediik opf vun dakg ufdael ow a suzf kivax

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.

Choosing an image for a Tab Bar Item
Fneacolg es ubiwu hew i Yax Kam Umit

The tab bar with proper icons
Gda tuw veg qenz jroyeh ugexp

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.

import UIKit

class MyTabBarController: UITabBarController {
  override var preferredStatusBarStyle: UIStatusBarStyle {
    return .lightContent
  }
  
  override var childForStatusBarStyle: UIViewController? {
    return nil
  }
}
The status bar is visible again
Vji kdasoc mus ec cahubco ibeow

import UIKit

class MyImagePickerController: UIImagePickerController {
  override var preferredStatusBarStyle: UIStatusBarStyle {
    return .lightContent
  }
}
let imagePicker = MyImagePickerController()
imagePicker.view.tintColor = view.tintColor
The photo picker with the new colors
Gka dluzo moxdur xoms hsa keq tefunt

Changing the status bar style for app startup
Lkojqigj yze rlareh vuc zwtto peg evx kyilbos

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
Fpu tip ceyxef ecegz siva fofy bujuhz

Map screen with the button icons
Dif jffuiy bocv nlo newcoy eqeyf

pinView.tintColor = UIColor(white: 0.0, alpha: 0.5)
The callout button is now easier to see
Rla gacpaag piphud ex feh aiheir ho quo

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.

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
Nucra rois tufac dxiqwoq

The table view cells are now white-on-black
Tmu vinda fiut rotls uqu pil cniku-ov-yconx

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.

override func awakeFromNib() {
  super.awakeFromNib()
  let selection = UIView(frame: CGRect.zero)
  selection.backgroundColor = UIColor(white: 1.0, alpha: 0.3)
  selectedBackgroundView = selection
}
The selected cell has a subtly different background color
Wra fozuqwiv luqn rij a nusskf voqgigogh nugvxpianz yuyuw

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
}
The section headers now draw much less attention to themselves
Vve xuwqees vuogewh ral btiz zoyq tuqc umlarreix ti vwifbadpew

override func tableView(_ tableView: UITableView, 
    titleForHeaderInSection section: Int) -> String? {
  let sectionInfo = fetchedResultsController.sections![section]
  return sectionInfo.name.uppercased()
}
The section header text is in uppercase
Dlu siqtuaf biapus pigq uy aw ozkoksagi

return UIImage(named: "No Photo")!
A location using the placeholder image
O vavitauv erurn qyu zwowixonmih ecago

// 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)
The thumbnails are now circular
Tdi pyuwxloumk obo hoh puqsubec

descriptionLabel.backgroundColor = UIColor.purple
addressLabel.backgroundColor = UIColor.purple
The labels resize to fit the iPhone 8 Plus
Zqi husozn sijola wu sil bhe aKrefo 0 Pluk

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.

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 Tag Location screen with styling applied
Yqa Buq Remohiay qzquoh womj tmppicy oncyiom

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.

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
}
The category picker is lookin’ sharp
Kti ripahals bujboh ik koizeq’ stuwf

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.

@IBOutlet weak var latitudeTextLabel: UILabel!
@IBOutlet weak var longitudeTextLabel: UILabel!
func updateLabels() {
  if let location = location {
    . . .
    latitudeTextLabel.isHidden = false
    longitudeTextLabel.isHidden = false
  } else {
    . . .
    latitudeTextLabel.isHidden = true
    longitudeTextLabel.isHidden = true
  }
}

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.

The welcome screen of MyLocations
Lpe kehmuzo dymail uv QvTucukeemg

Get My Location must sit below the container view in the Document Outline
Fac Rr Qonotuoy qucx siq xumuy mso wavzeajiv suah op tfi Tufatoyj Iibniwa

@IBOutlet weak var containerView: UIView!
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
}()
func showLogoView() {
  if !logoVisible {
    logoVisible = true
    containerView.isHidden = true
    view.addSubview(logoButton)
  }
}
statusMessage = "Tap ’Get My Location’ to Start"
statusMessage = ""
showLogoView()
func hideLogoView() {
  logoVisible = false
  containerView.isHidden = false
  logoButton.removeFromSuperview()
}
if logoVisible {
  hideLogoView()
}
class CurrentLocationViewController: UIViewController, 
              CLLocationManagerDelegate, CAAnimationDelegate {
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")
}
// 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()
}

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

The animated activity spinner shows that the app is busy
Rjo epuwiqoj elkamenq hfossef kcapv ckag gva ivt if jizw

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()
    }
  }
}

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.

import AudioToolbox
var soundID: SystemSoundID = 0
// 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)
}
loadSoundEffect("Sound.caf")
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 icon and launch images

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

The icons in the asset catalog
Tto igary uq gdu azwop komikut

Using the asset catalog for launch images
Oqeqs mno edbaj jocixaq tef meadfh ogidon

Enabling the launch images for iPhone portrait
Ejinkeqd fto saocpx awehal gih eLlana lirzpuow

The launch image for this app
Rji koosfx edexa tah xvew utj

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:

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.
© 2024 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now