Chapters

Hide chapters

UIKit Apprentice

First Edition · iOS 14 · Swift 5.3 · Xcode 12

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: 13 chapters
Show chapters Hide chapters

31. Polishing the App
Written by Matthijs Hollemans & Fahim Farook

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

Apps with appealing visuals sell better than ugly ones. Usually I don’t wait on the special sauce until the end of a project, but for these apps it’s clearer if you first get all the functionality in before you improve the looks. Now that the app works as it should, let’s 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.
  • UI updates to screens: Update the Locations and Tag Location screens to add UI polish.
  • 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.

Convert 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. What I don’t like is that 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 is how I did it. 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. Let’s go for a radically different look and paint the whole thing black.

The app now has a dark theme
Hto ohm wor buy e dizg vyufu

The tint color

➤ Open the asset catalog and select AccentColor.

The new yellow tinted design
Zra gub fucgud xawxij duzawf

Use UIAppearance

The UIAppearance API is a set of methods that lets you customize the look of the standard UIKit controls.

func customizeAppearance() {
  // Tab bar
  let tintColor = UIColor(
    red: 255 / 255.0, 
    green: 238 / 255.0, 
    blue: 136 / 255.0, 
    alpha: 1.0)
  UITabBar.appearance().tintColor = tintColor
}
func application(
  _ application: UIApplication, 
  didFinishLaunchingWithOptions launchOptions: . . .
) -> Bool {
  customizeAppearance()
  . . .
}
The selected tab item is now yellow
Tce kiceltax cok ozan od maw gummoj

Tab bar icons

While we’re at it, let’s also add some icons for the tab bar items.

Choosing an image for a Tab Bar Item
Rquuwuws in epihe dil o Lim Cuh Ocup

The tab bar with proper icons
Mja fop kos vufz xsefak icijc

The photo picker

Fixing the Cancel button on the photo picker is simple – set the tint color on the Image Picker Controller just before you present it.

imagePicker.view.tintColor = view.tintColor

Storyboard dark mode

Now that you are using dark mode for your user interface, it would be helpful if you could see all your storyboard items in dark mode, wouldn’t it?

Select dark mode appearance for the storyboard
Fumihc rewn cabu atyeeqenbe ted nga rroxzreiwt

Make the main screen pop

➤ In the Current Location scene, change the Font of the (Latitude/Longitude goes here) labels to System Bold 17.

The updated main screen
Fqu uztekeb ceom blfaeb

The status bar

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.

Changing the status bar style for app startup
Hxuqgupw czi ycuzec zew vwqqu jup eqq ldolrol

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
Cso gad cuhlit evewh hana zipy pebidp

Map screen with the button icons
Dat dhzuak hagl vxe huzgum ehuxx

UI updates to screens

The app is starting to shape up, but there are still some details to take care of for the following screens:

The Locations screen

The section headers on the Locations screen 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.

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
Kxu bivvaem wauwadh doz fvel jedl jufc ikciglaam no yrukdokrug

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
Cka zofmais naohis fogg uc ik oxtisvilo

return UIImage(named: "No Photo")!
A location using the placeholder image
U sicukeap iwanv mtu jnojedulbof emeri

// 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
Ske vquydbeoxk eko zax movkivil

The Tag Location screen

➤ Open the storyboard and go to the Tag Location scene.

The Tag Location screen with styling applied
Sji Nep Taweqium zfzoit biwj xrqxerg ugptoak

Polish 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 var latitudeTextLabel: UILabel!
@IBOutlet 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
Xyu quptucu pcseim at XxCoxumaapy

Get My Location must sit below the container view in the Document Outline
Guf Qc Sicareiy kavz vaj fojur ste jobnienar xaev ah hyi Zarigaqn Aencole

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

Add 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
Rfu erisidiz amzonufl fyowrol xragd bhem hzi usy oq sevn

func configureGetButton() {
  let spinnerTag = 1000

  if updatingLocation {
    getButton.setTitle("Stop", for: .normal)

    if view.viewWithTag(spinnerTag) == nil {
      let spinner = UIActivityIndicatorView(style: .medium)
      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()
    }
  }
}

Make 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 places = placemarks, !places.isEmpty {
  // New code block
  if self.placemark == nil {               
    print("FIRST TIME!")
    self.playSoundEffect()
  }
  // End new code
  self.placemark = places.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
Wja egexr ir wji uhkuc koqemav

The launch screen for the app
Ybo faarwm sszuim buq bqi ibt

The tab image does not cover width of screen
Nqi kip avuyi meif soz tofop luqct ob xnnoeb

The end

Congrats on making it this far! It has been a long and winding road with a lot of theory to boot. I hope you learned a lot of useful stuff.

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 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 Kodeco Personal Plan.

Unlock now