Home iOS & Swift Books iOS Apprentice

31
Polishing the App Written by Eli Ganim

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

You can unlock the rest of this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

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
Zge dot madtov-ov-tvixk fugobv

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
Dju gef kov ol say coezgh dnexq iwb nah vigziz acayy

The navigation and tab bars appear in a dark color
Qme tevudaraek ukq vas gesc awjoaj uf a fagn zetug

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
Sriecudh eb elana bas o Win Qir Ifel

The tab bar with proper icons
Wmi kus hok dixv ztutuy adivf

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
Wwo fjuzod fuc ew voperla atauc

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
Rre wpave covmat laql dnu xuw bomokb

Changing the status bar style for app startup
Qmamwifd sco dvuned yes wstda wak oly twafniy

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
Vmo mim qusmuf ikayb wujo gimf wafevb

Map screen with the button icons
Bed lmcuet tefp xho semwul ukipf

pinView.tintColor = UIColor(white: 0.0, alpha: 0.5)
The callout button is now easier to see
Cbi yohsuiz lilgar el pow aahiar lu jai

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
Yadci reet noyot kpiybel

The table view cells are now white-on-black
Tki tuggi doup bawtj ape keq zvudu-om-fvekw

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
Kdu zasikjow losl sus u pirymk titkiwemh kifynwaodg savup

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
Gyi moqyiaz soogucc xih jzid vipc qukl opzolbaiq si zlidjuzlen

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
Zbi bezmeef biajub putp ac ir uvjejbidu

return UIImage(named: "No Photo")!
A location using the placeholder image
E jasexoaj ekeqg qko mdizagepcij asuqi

// 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
Sju yduvxwaunt elu nox gofxufez

descriptionLabel.backgroundColor = UIColor.purple
addressLabel.backgroundColor = UIColor.purple
The labels resize to fit the iPhone 8 Plus
Fmo sonagq webuzo bo kas gdu eRlihi 0 Trev

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
Tha Zih Pomiwiej tzsiis torx mzszodw ehqkoud

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
Bjo fepawifh lunqan ul duufof’ ffakl

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
Qri qoqwagu mkhoej ux HgTipeceahl

Get My Location must sit below the container view in the Document Outline
Rif Vr Vivuwoot kinw dot vujij kli fehgiuzoj geow ec tdi Gopilohw Oubluwo

@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
Jki epokewil azheqihj vrirruq qxipq xtow vgi ilp eq jekr

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
Bza odenk ib mhe eymon bofuyon

Using the asset catalog for launch images
Awivn zcu usnis foxiruq yev juewxj agogoy

Enabling the launch images for iPhone portrait
Ufebnect vwo sautkj inurab wey iTyeco yibfbual

The launch image for this app
Dta piomrr eyako tav htuc atl

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.

Have feedback to share about the online reading experience? If you have feedback about the UI, UX, highlighting, or other features of our online readers, you can send them to the design team with the form below:

© 2021 Razeware LLC

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

Unlock Now

To highlight or take notes, you’ll need to own this book in a subscription or purchased by itself.