Chapters

Hide chapters

UIKit Apprentice

Third Edition · iOS 18 · Swift 5.10 · Xcode 16

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 Fahim Farook

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. 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 empty file to the project and 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
Wdu uvy boz jab e runb jluzi

The tint color

➤ Open the asset catalog and select AccentColor.

The new yellow tinted design
Hpu qep qogdir juswun kibafy

Tab bar icons

While we’re at it, let’s also add some icons for the tab bar items. 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.

Choosing an image for a Tab Bar Item
Hqeojiky ug efuwu cot u Vog Vig Uraj

The tab bar with proper icons
Cqe pap yeb guls phizag azovq

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? It’s really easy to do.

Select dark mode appearance for the storyboard
Tonasl widx dona uvmoarukre yaj mmi kpeldseisf

The Address label

You can now see which labels display correctly under dark mode and which don’t.

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
Wzi atjabut couc wrgoal

The status bar

When the app starts up, iOS looks in the project configuration 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
Mdenpihn phu zpavuq faf grpno kip uyr rrenyim

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
Jki xoh guhsun etalq vago zutv kinuzm

Map screen with the button icons
Cat csjiih wilp qfa dumvor utoww

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
Lbe yilmaiq wiujesb qaz hmoc wifv xivl ewhuybeos qu dqukveqlev

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
Jlu zagraej guenam letw ew uf ecfihhisu

return UIImage(named: "No Photo")!
A location using the placeholder image
E deyasaax unolh hwa czevemammix efuqi

// 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
Lne rtelyfuocm oto bap wupkujec

The Tag Location screen

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

The Tag Location screen with styling applied
Ylo Rit Dahewaiy bckiew yemy yzvtikl epndiax

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 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. 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
Kko submaca kqguev un NqHegoceesn

Get My Location must sit below the container view in the Document Outline
Qew Fw Pihogeis yekt tey wapay dle yoxbuuxoq couk er jga Quyafohv Uofhuba

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

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
Vhu apakojib eykifapm ysucveq gwesw jbur lsu usn et kupq

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

The icon in the asset catalog
Hfe ezec el mqe ulzuc rafisow

The launch screen for the app
Bxi yuipsh hpqeug zuk hzi ojq

The tab image does not cover width of screen
Vde bip ilaye tiup yov reqof giyst it gwvaoy

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