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

26. Adding Polish
Written by Eli Ganim

Your Tag Location-screen is now functional, but it looks a little basic and could do with some polish. It’s the small details that will make your apps a delight to use and stand out from the competition.

In this chapter, you will learn the following:

  • How to improve the user experience by adding tiny tweaks to your app which gives it some polish.
  • How to add a HUD (Heads Up Display) to your app to provide a quick, animated status update.
  • How to continue the navigation flow after displaying the HUD.

Improving the user experience

Take a look at the design of the cell with the Description text view:

There is a margin between the text view and the cell border
There is a margin between the text view and the cell border

There is a margin between the text view and the cell border. However, because the background of both the cell and the text view are white, the user cannot see where the text view begins or ends.

It is possible to tap on the cell but just outside the text view area. That is annoying when you want to start typing: You think that you’re tapping in the text view, but the keyboard doesn’t appear.

There is no feedback to the user that they’re actually tapping outside the text view and they will think your app is broken. In my opinion, deservedly so.

Keyboard activation for cells

You’ll have to make the app a little more forgiving. When the user taps anywhere inside that first cell, the text view should activate, even if the tap wasn’t on the text view itself.

➤ Add the following table view delegate methods to LocationDetailsViewController.swift:

// MARK:- Table View Delegates
override func tableView(_ tableView: UITableView,
          willSelectRowAt indexPath: IndexPath) -> IndexPath? {
  if indexPath.section == 0 || indexPath.section == 1 {
    return indexPath
  } else {
    return nil
  }
}

override func tableView(_ tableView: UITableView,
           didSelectRowAt indexPath: IndexPath) {
  if indexPath.section == 0 && indexPath.row == 0 {
    descriptionTextView.becomeFirstResponder()
  }
}

The tableView(_:willSelectRowAt:) method limits taps to just the cells from the first two sections. Recall that || means or. So, if the section number equals 0 or when it equals 1, you accept the tap on the cell. The third section only has read-only labels — it doesn’t need to allow taps.

The tableView(_:didSelectRowAt:) method handles the actual taps on the rows. You don’t need to respond to taps on the Category or Add Photo rows as these cells are connected to segues.

But if the user taps on the first row of the first section — the row with the description text view — then you will give the input focus to the text view. Here you use &&, meaning and, to make sure that the tap is in the first section and also on the first row of that section.

➤ Try it out. Run the app and click or tap somewhere along the edges of the first cell. Any tap inside that first cell should now make the text view active and bring up the keyboard. Remember that on the simulator, you may need to press ⌘+K to make the keyboard visible.

Anything you can do to make screens less frustrating to use is worth putting in the effort!

Speaking of the text view, once you’ve activated it, there’s no way to get rid of the keyboard! And because the keyboard takes up half of the screen, that can be a bit annoying.

Deactivating the keyboard

It would be nice if the keyboard disappeared after you tapped anywhere else on the screen. As it happens, that is not so hard to implement.

➤ Add the following to the end of viewDidLoad() in LocationDetailsViewController.swift:

// Hide keyboard
let gestureRecognizer = UITapGestureRecognizer(target: self,
                             action: #selector(hideKeyboard))
gestureRecognizer.cancelsTouchesInView = false
tableView.addGestureRecognizer(gestureRecognizer)

A gesture recognizer is a very handy object that can recognize touch-based actions like taps, swipes, pans and pinches. You simply create the gesture recognizer object, give it a method to call when that particular gesture has been observed to take place and add the recognizer object to a view.

You’re using a UITapGestureRecognizer, which as the name implies, recognizes simple taps.

Notice the #selector() keyword again:

. . . target: self, action: #selector(hideKeyboard)) . .

You use this syntax to tell the UITapGestureRecognizer that it should call the method named by #selector() whenever the gesture happens.

This pattern is known as target-action and you’ve already used it whenever you’ve connected UIButtons, UIBarButtonItems and other controls to action methods.

The “target” is the object receiving the message, which is often self, and “action” is the message to send.

Here, you’ve chosen the message hideKeyboard to be sent when a tap is recognized anywhere in the table view. So, you have to implement the method and respond to that message. Also, remember that selectors have their roots in Objective-C. Therefore, any method which is called via a selector has to be accessible from Objective-C.

➤ Add the hideKeyboard() method to LocationDetailsViewController.swift:

@objc func hideKeyboard(_ gestureRecognizer:
                        UIGestureRecognizer) {
  let point = gestureRecognizer.location(in: tableView)
  let indexPath = tableView.indexPathForRow(at: point)

  if indexPath != nil && indexPath!.section == 0
                      && indexPath!.row == 0 {
    return
  }
  descriptionTextView.resignFirstResponder()
}

Whenever the user taps somewhere in the table view, the gesture recognizer calls this method. Conveniently, it also passes a reference to itself as a parameter, which lets you ask gestureRecognizer where the tap happened.

The gestureRecognizer.location(in:) method returns a CGPoint value indicating the tap position. CGPoint is a common struct that you see all the time in UIKit. It contains two fields, x and y, that describe a position on-screen.

Using this CGPoint, you ask the table view which index-path is currently displayed at that position. This is important because you obviously don’t want to hide the keyboard if the user tapped in the row with the text view! If the user tapped anywhere else, you hide the keyboard.

Exercise: Does the logic in the if statement make sense to you? Explain how this works.

Answer: It is possible that the user tapped inside the table view, but not on a cell. For example, somewhere in between two sections or on the section header. In that case, indexPath will be nil, making this an optional of type IndexPath?. To use an optional, you need to unwrap it somehow, either with if let or with !.

You only want to hide the keyboard if the index-path for the tap is not section 0, row 0, which is the cell with the text view. If the user did tap that particular cell, you bail out of hideKeyboard() with the return statement before the code reaches the call to resignFirstResponder().

Note: You don’t want to force unwrap an optional if there’s a chance it might be nil or you risk crashing the app. Force unwrapping indexPath!.section and indexPath!.row may look dangerous here, but it is guaranteed to work thanks to the short-circuiting behavior of the && operator.

If indexPath equals nil, then everything after the first && is simply ignored. The condition can never become true anymore if one of the terms is false. So, when the app gets to look at indexPath!.section, you know that the value of indexPath is not nil at that point.

An alternative way to write this logic is:

if indexPath == nil ||
          !(indexPath!.section == 0 && indexPath!.row == 0) {
  descriptionTextView.resignFirstResponder()
}

Can you wrap your head around that? Here, the if statement checks for the exact opposite. The && and || operators are each other’s opposite in Boolean logic and you can often flip the meaning of a condition around by turning && into || by introducing the ! not operator. You don’t need to worry about this so early on in your programming career, but at some point, you’ll have to learn these rules of Boolean logic. They can be mind-benders!

Of course, you can also use if let to safely unwrap indexPath. So a third — but more verbose — way to write the if statement is as follows:

if let indexPath = indexPath {
  if indexPath.section != 0 && indexPath.row != 0 {
    descriptionTextView.resignFirstResponder()
  }
} else {
  descriptionTextView.resignFirstResponder()
}

This gives you a brief glimpse of the various ways you can write the conditions in if statements. There’s often more than one way to do something in Swift. So, choose whatever approach you find easiest to understand.

➤ Run the app. Tap in the text view to bring up the keyboard. If the keyboard doesn’t come up, press ⌘+K. Tap anywhere else in the table view to hide the keyboard again.

The table view can also automatically dismiss the keyboard when the user starts scrolling. You can enable this in the storyboard.

➤ Open the storyboard and select the table view in the Tag Location scene. In the Attributes inspector change the Keyboard option to Dismiss on drag. Now, scrolling should also hide the keyboard.

The “Dismiss on drag” option for the keyboard
The “Dismiss on drag” option for the keyboard

If this doesn’t work for you on the simulator, try it on a real device. The keyboard in the simulator can be a bit wonky.

➤ Also, try the Dismiss interactively option. Which one do you like best?

The HUD

There is one more improvement to make to this screen, just to add a little spice. When you tap the Done button to close the screen, the app will show a quick animation to let you know it successfully saved the location:

Before you close the screen it shows an animated checkmark
Before you close the screen it shows an animated checkmark

This type of overlay graphic is often called a HUD, for Heads-Up Display. Apps aren’t quite fighter jets, but HUDs are often used to display a progress bar or spinner while files are downloading or another long-lasting task is taking place.

You’ll show your own HUD view for a brief second before the screen closes. It adds an extra bit of liveliness to the app. If you’re wondering how you can display anything on top of a table, this HUD is simply a UIView subclass. You can add views on top of other views. In fact, that’s what you’ve been doing all along.

The labels are views that are added on top of the cells, which are also views. The cells themselves are added on top of the table view, and the table view, in turn, is added on top of the navigation controller’s content view.

So far, when you’ve made your own objects, they have always been view controllers or data model objects, but it’s also possible to make your own views.

Often, using the standard buttons and labels is sufficient. But when you want to do something that is not available as a standard view, you can always make your own. You either subclass UIView or UIControl and do your own drawing. That’s what you’re going to do for the HUD view as well.

Creating the HUD view

➤ Add a new file to the project using the Swift File template. Name it HudView.

Let’s build a minimal version of this class just so that you can get something on the screen. When that works, you’ll make it look fancy.

➤ Replace the contents of HudView.swift with the following:

import UIKit

class HudView: UIView {
  var text = ""

  class func hud(inView view: UIView,
                    animated: Bool) -> HudView {
    let hudView = HudView(frame: view.bounds)
    hudView.isOpaque = false

    view.addSubview(hudView)
    view.isUserInteractionEnabled = false

    hudView.backgroundColor = UIColor(red: 1, green: 0, blue: 0,
                                    alpha: 0.5)
    return hudView
  }
}

The hud(inView, animated) method is known as a convenience constructor. It creates and returns a new HudView instance.

Normally, you would create a new HudView object by writing:

let hudView = HudView()

But using the convenience constructor you’d write:

let hudView = HudView.hud(inView: parentView, animated: true)

A convenience constructor is generally a class method, i.e. a method that works on the class as a whole and not on any particular instance. You can tell because its declaration begins with class func instead of just func.

When you call HudView.hud(inView: parentView, animated: true) you don’t have an instance of HudView yet. The whole purpose of this method is to create an instance of the HUD view for you — so that you don’t have to do that yourself — and to place it on top of another view.

You can see that making an instance is actually the first thing this method does:

class func hud(inView view: UIView,
                  animated: Bool) -> HudView {
  let hudView = HudView(frame: view.bounds)
  . . .
  return hudView
}

It calls HudView(), or actually HudView(frame:), which is an init method inherited from UIView. At the end of the method, the new instance is returned to the caller.

So why use this convenience constructor? As the name implies, for convenience.

Since there are several steps to set up the view, putting them in the convenience constructor frees you from having to worry about any of that.

One of these additional steps is that this method adds the new HudView object as a subview on top of the “parent” view object. This is the navigation controller’s view, so the HUD will cover the entire screen.

It also sets the parent view’s isUserInteractionEnabled property to false. While the HUD is showing, you don’t want the user to interact with the screen anymore. The user has already tapped the Done button and the screen is in the process of closing.

Most users will leave the screen alone at this point, but there’s always some joker who wants to try and break things. By setting isUserInteractionEnabled to false, the view swallows any touches and all the underlying views become unresponsive.

Just for testing, you set the background color of the HUD to 50% transparent red. That way you can see if it covers the entire screen.

Using the HUD view

Let’s add the code to call this funky new HUD so that you can see it in action.

➤ Change the done() method in LocationDetailsViewController.swift to:

@IBAction func done() {
  let hudView = HudView.hud(inView: navigationController!.view,
                          animated: true)
  hudView.text = "Tagged"
}

This creates a HudView object and adds it to the navigation controller’s view with an animation. You also set the text property on the new object.

Previously, done() sent you back to the previous view controller. For testing purposes, you’re not going to do that anymore. You want to have enough time to see what the HudView looks like as you build it step-by-step. If you immediately close the screen after showing the HUD, it will be hard to see what’s going on — unless you can slow down time somehow… You’ll put back the code that closes the screen later.

➤ Run the app. When you press the Done button, the screen will look like this:

The HUD view covers the whole screen
The HUD view covers the whole screen

The app is now totally unresponsive because user interaction is disabled.

When you’re working with views, it’s a good idea to set the background color to a bright color such as red or blue, so you can see exactly how big a given view is.

Did you, upon looking at the HUD activation code, think: “Hey, how come we are using the navigation controller’s view instead of the view from LocationDetailsViewController?” If you did, good on you! It shows that you are starting to understand the composition of view controllers and views and thinking about how they work.

The answer is simple enough to figure out. Just try it and see what happens. Change the HudView creation line in done() to the following:

let hudView = HudView.hud(inView: view, animated: true)

Here, instead of the navigation controller’s content view, you use the current view controller’s view as the parent for the HUD.

➤ Run the app and try the Done button. You should get a screen like this:

The HUD view does not cover the navigation bar
The HUD view does not cover the navigation bar

Do you see what happened?

The HUD now only covers the screen area for the LocationDetailsViewController’s view — it does not cover the navigation bar. And you know what that means, right? The user can tap on the Cancel or Done buttons and have them respond — even if the rest of the screen has user interactions disabled. That can be a problem in certain situations.

Revert your code back to using the navigation controller’s view before you forget.

Let’s get the HUD view to actually display something on-screen instead of the red background.

Drawing the HUD view

➤ Remove the backgroundColor line from the hud(inView:animated:) method.

➤ Add the following method to HudView.swift:

override func draw(_ rect: CGRect) {
  let boxWidth: CGFloat = 96
  let boxHeight: CGFloat = 96

  let boxRect = CGRect(
    x: round((bounds.size.width - boxWidth) / 2),
    y: round((bounds.size.height - boxHeight) / 2),
    width: boxWidth,
    height: boxHeight)

  let roundedRect = UIBezierPath(roundedRect: boxRect,
                                cornerRadius: 10)
  UIColor(white: 0.3, alpha: 0.8).setFill()
  roundedRect.fill()
}

The draw() method is invoked whenever UIKit wants your view to redraw itself.

Recall that everything in iOS is event-driven. The view doesn’t draw anything on-screen unless UIKit asks it to draw itself. That means you should never call draw() yourself.

Instead, if you want a view to redraw, you should send it the setNeedsDisplay() message. UIKit will then trigger a draw() when it is ready to perform the drawing. This may seem strange if you’re coming from another platform. You may be used to redrawing the screen whenever you feel like it. On iOS, however, UIKit is in charge of who gets to draw when.

The above code draws a filled rectangle with rounded corners in the center of the screen. The rectangle is 96 by 96 points (so it’s really a square):

let boxWidth: CGFloat = 96
let boxHeight: CGFloat = 96

This declares two constants you’ll be using in the calculations that follow. You’re using constants because it’s clearer to refer to the symbolic name boxWidth than the number 96. That number doesn’t mean much by itself, but “box width” is a pretty clear description of its purpose.

Additionally, if you were to later decide to change the size of the HUD box, you only have one place in your code where you need to change the width or the height, instead of going through all of your code trying to figure out where else you had the width or the height value as a number.

Note that you force the type of these constants to be CGFloat, which is the type used by UIKit to represent decimal numbers. When working with UIKit or Core Graphics (CG, get it?) you use CGFloat instead of the regular Float or Double.

let boxRect = CGRect(
  x: round((bounds.size.width - boxWidth) / 2),
  y: round((bounds.size.height - boxHeight) / 2),
  width: boxWidth,
  height: boxHeight)

There’s CGRect again, the struct that represents a rectangle. You use it to calculate the position for the HUD. The HUD rectangle should be centered horizontally and vertically on the screen. The size of the screen is given by bounds.size. This is the size of HudView itself, which spans the entire screen.

The above calculation uses the round() function to make sure the rectangle doesn’t end up on fractional pixel boundaries because that makes the image look fuzzy.

let roundedRect = UIBezierPath(roundedRect: boxRect, cornerRadius: 10)
UIColor(white: 0.3, alpha: 0.8).setFill()
roundedRect.fill()

UIBezierPath is a very handy object for drawing rectangles with rounded corners. You just tell it how large the rectangle is and how round the corners should be. Then you fill the rectangle with an 80% opaque dark gray color.

➤ Run the app. The result should look like this:

The HUD view has a partially transparent background
The HUD view has a partially transparent background

There are two more things to add to the HUD, a checkmark and a text label. The checkmark is an image.

Displaying the HUD checkmark

➤ The Resources folder for the book has two files in the Hud Images folder: Checkmark@2x.png and Checkmark@3x.png. Add these files to the asset catalog, Assets.xcassets.

You can do this with the + button or simply drag them from Finder to the Xcode window with the asset catalog open.

➤ Add the following code to the end of draw():

// Draw checkmark
if let image = UIImage(named: "Checkmark") {
  let imagePoint = CGPoint(
    x: center.x - round(image.size.width / 2),
    y: center.y - round(image.size.height / 2) - boxHeight / 8)
  image.draw(at: imagePoint)
}

This loads the checkmark image into a UIImage object. Then it calculates the position for that image based on the center coordinate of the HUD view (center) and the dimensions of the image (image.size).

Finally, it draws the image at that position.

➤ Run the app to see the HUD view with the image:

The HUD view with the checkmark image
The HUD view with the checkmark image

Note: If you don’t see the checkmark when you run the app and, if you did change the done() method to use the view controller’s view instead of the navigation controller’s content view earlier, make sure that you reverted the code back.

The position calculations are based on the HUD view stretching up to the navigation bar and, if the view size is different, the checkmark will be placed a little above the rounded square. Since the background is mostly white outside the square and the checkmark is white, too, you might not even notice it when it is drawn outside the rounded square.

Failable initializers

To create the UIImage, you used if let to unwrap the resulting object. That’s because UIImage(named:) is a failable initializer.

It is possible that loading the image fails. This could be for one of many different reasons such as there being no image with the specified name, or the file not containing a valid image. You can’t fool UIImage into loading something that isn’t an image!

That’s why UIImage’s init(named:) method is really defined as init?(named:). The question mark indicates that this method returns an optional. If there was a problem loading the image, it returns nil instead of a brand new UIImage object.

You’ll see these failable initializers throughout the iOS frameworks. One that you have encountered before is init?(coder:). Whenever it is possible that creating a new object will fail, the responsible init method will return an optional that you need to unwrap before you can use it.

Displaying the HUD text

Usually, to display text in your own view, you’d add a UILabel object as a subview and let UILabel do all of the hard work. However, for a view as simple as this, you can also do your own text drawing.

➤ Add the following code to the end of draw() to complete the method:

// Draw the text
let attribs = [
    NSAttributedString.Key.font: UIFont.systemFont(ofSize: 16),
	NSAttributedString.Key.foregroundColor: UIColor.white ]

let textSize = text.size(withAttributes: attribs)

let textPoint = CGPoint(
  x: center.x - round(textSize.width / 2),
  y: center.y - round(textSize.height / 2) + boxHeight / 4)

text.draw(at: textPoint, withAttributes: attribs)

When drawing text, you first need to know how big the text is so you can figure out where to position it. String has a bunch of handy methods for doing both.

First, set up a dictionary of attributes for the text that you want to draw, such as the font to be used, the text color, etc. Here, you’ll use a white system font of size 16.

You use these attributes and the string from the text property to calculate how wide and tall the text will be.

The result ends up in the textSize constant, which is of type CGSize. As you’ll notice, CGPoint, CGSize and CGRect are types you use a lot when making your own views.

Finally, you calculate where to draw the text (textPoint), and then draw it. Quite simple, really.

➤ Run the app to try it out. Lookin’ good!

The HUD view with the checkmark and the text
The HUD view with the checkmark and the text

➤ Make sure to test the HUD on different Simulators. No matter the device dimensions, the HUD should always appear centered on the screen.

OK, you’ve now got a rounded box with a checkmark, but it’s still far from spectacular. Time to liven it up a little with some animation!

Adding some animation

You’ve already seen a bit about animations before — they’re really easy to add.

➤ Add the following method to HudView.swift:

// MARK:- Public methods
func show(animated: Bool) {
  if animated {
    // 1
    alpha = 0
    transform = CGAffineTransform(scaleX: 1.3, y: 1.3)
    // 2
    UIView.animate(withDuration: 0.3, animations: {
      // 3
      self.alpha = 1
      self.transform = CGAffineTransform.identity
    })
  }
}

For the Bull’s Eye app, you made a crossfade animation using the Core Animation framework. UIView, however, has its own animation mechanism. It still uses Core Animation behind the scenes, but it’s a little more convenient to use.

The standard steps for doing UIView-based animations are as follows:

  1. Set up the initial state of the view before the animation starts. Here, you set alpha to 0, making the view fully transparent. You also set the transform to a scale factor of 1.3. We’re not going to go into depth on transforms here but this means the view is initially scaled up to be larger than it normally would be.

  2. Call UIView.animate(withDuration:animations:) to set up an animation. You pass the method a closure that describes what happens as part of the animation. Recall that a closure is a piece of inline code that is not executed right away. UIKit will animate the properties that you change inside the closure from their initial state to the final state.

  3. Inside the closure, set up the state of the view as it should be after the animation completes. You set alpha to 1, which means the HudView is now fully opaque. You also set the transform to the “identity” transform, restoring the scale back to normal. Because this code is part of a closure, you need to use self to refer to the HudView instance and its properties. That’s the rule for closures.

The HUD view will quickly fade in as it goes from fully transparent to fully opaque, and it will scale down from 1.3 times its original size to its regular width and height.

This is only a simple animation but it looks quite smart.

➤ Change the hud(inView:animated:) method to call show(animated:) just before it returns:

class func hud(inView view: UIView, animated: Bool) -> HudView {
  . . .
  hudView.show(animated: animated)    // Add this
  return hudView
}

➤ Run the app and marvel at the magic of UIView animation.

Improving the animation

You can actually do one better. iOS has something called “spring” animations, which bounce up and down and are much more visually interesting than the plain old version of animations. Using them is very simple.

➤ Replace the UIView.animate(withDuration:animations:) code in show(animated:) with the following:

UIView.animate(withDuration: 0.3, delay: 0,
     usingSpringWithDamping: 0.7, initialSpringVelocity: 0.5,
                    options: [], animations: {
    self.alpha = 1
    self.transform = CGAffineTransform.identity
  }, completion: nil)

The code in the closure is still the same: It sets alpha to 1 and restores the identity transform. However, this new animation method has a lot more options. Feel free to play with these options to see what they do.

➤ Run the app and watch it bounce. Actually, the effect is very subtle, but subtle is good when it comes to user interfaces. You don’t want your users to get seasick from using the app!

Handling the navigation

Back to LocationDetailsViewController. You still need to close the screen when the user taps Done.

There’s a challenge here: You don’t want to dismiss the screen right away. It won’t look very good if the screen closes before the HUD is finished animating. You didn’t spend all that time writing HudView for nothing — you want to give your users a chance to see it.

You are going to use the Grand Central Dispatch framework, or GCD here. GCD is a very handy but somewhat low-level library for handling asynchronous tasks. Telling the app to wait a few seconds before executing some code is a perfect example of an async task.

➤ Add these lines to the bottom of the done() action method:

let delayInSeconds = 0.6
DispatchQueue.main.asyncAfter(deadline: .now() + delayInSeconds,
                               execute: {
  self.navigationController?.popViewController(animated: true)
})

Believe it or not, these mysterious incantations tell the app to close the Tag Location-screen after 0.6 seconds.

The magic happens in DispatchQueue.main.asyncAfter(). This function takes a closure as its final parameter. Inside that closure, you tell the navigation controller to go back to the previous view controller in the navigation stack. This doesn’t happen right away, though. That’s the exciting thing about closures: Even though this code sits side-by-side with all of the other code in the method, everything inside the closure is ignored for now and kept for a later time.

DispatchQueue.main.asyncAfter() uses the time given by .now() + delayInSeconds to schedule the closure for some point in the future. Until then, the app just sits there twiddling its thumbs. By the way, .now() is a shortcut for DispatchTime.now(). Swift’s type inference already knows that the type of the when: parameter is always a DispatchTime object, so you don’t have to mention DispatchTime explicitly.

After 0.6 seconds, the code from the closure runs and the screen closes.

Note: It takes time finding a suitable value. The HUD view takes 0.3 seconds to fully fade in and then you wait another 0.3 seconds before the screen disappears. You don’t want to close the screen too quickly or the effect from showing the HUD is lost, but it shouldn’t take too long either, or it will annoy the user. Animations are cool but they shouldn’t make the app more frustrating to use!

➤ Run the app. Press the Done button and watch how the screen disappears. This looks pretty smooth!

But wait… the HUD never goes away after the Tag Location-screen closes! It still is there after you navigate back to the parent view. This is not good…

Exercise: Can you explain why this happens?

The reason is simple. You added the HUD to the navigation controller’s content view, not the Tag Location-screen’s view. So, even though you’ve dismissed the Tag Location-screen, you still have the HUD displaying because the navigation controller itself is still in existence.

So what do you think you should do to hide the HUD? Remove it from view, of course!

➤ Add the following method to HudView.swift:

func hide() {
  superview?.isUserInteractionEnabled = true
  removeFromSuperview()
}

This method is rather simple. Remember how you disabled user-interactions when showing the HUD? You first re-enable user-interactions and then remove the HudView instance from its parent view. The only new thing might be superview and that’s a reference to a view’s parent view — all UIView objects and sub-classes of UIVew have a superview property which identifies the view’s parent.

Of course, if you wanted, you could have made the method a bit more complex and interesting by adding some animation to the removal of the HUD. Basically, you’d set up the animation to reverse what you did when you showed the view. It is left for you as an exercise!

Now, you need to call this new method to hide the HUD before you exit the Tag Location-screen.

➤ Modify the DispatchQueue.main.asyncAfter closure for done() in LocationDetailsViewController.swift:

DispatchQueue.main.asyncAfter(deadline: .now() + delayInSeconds,
                               execute: {
  hudView.hide()   // Add this line
  self.navigationController?.popViewController(animated: true)
})

➤ Run the app. Press the Done button and check if the HUD disappears when the Tag Location-screen goes away.

Cleaning up the code

GCD code can be a bit messy. So let’s clean up the code and make it easier to understand.

➤ Add a new file to the project using the Swift File template. Name the file Functions.swift.

➤ Replace the contents of the new file with:

import Foundation

func afterDelay(_ seconds: Double, run: @escaping () -> Void) {
  DispatchQueue.main.asyncAfter(deadline: .now() + seconds,
                                 execute: run)
}

That looks very much like the code you just added to done(), except it now lives in its own function: afterDelay(). This is a free function, not a method inside an object. So, it can be used from anywhere in your code.

Take a good look at afterDelay()‘s second parameter, the one named run. Its type is () -> Void. That’s not some weird emoticon, it is Swift notation for a parameter that takes a closure with no arguments and no return value.

The type for a closure generally looks like this:

(parameter list) -> return type

In this case, both the parameter list and the return value are empty, () and Void. This can also be written as Void -> Void, or even () -> () , but () -> Void is preferred because it looks like a function delcaration.

So, whenever you see a -> in the type annotation for a parameter, you know that parameter is a closure.

afterDelay() simply passes this closure along to DispatchQueue.main.asyncAfter().

The annotation @escaping is necessary for closures that are not performed immediately. This is so that Swift knows that it should hold on to this closure for a while.

You may be wondering why you’re going through all this trouble. No fear! The reason will become apparent after you’ve made the following change.

➤ Go back to LocationDetailsViewController.swift and change done() as follows:

@IBAction func done() {
  let hudView = HudView.hud(inView: navigationController!.view,
                                    animated: true)
  hudView.text = "Tagged"
  afterDelay(0.6, run: {
    hudView.hide()
    self.navigationController?.popViewController(animated: true)
  })
}

Now that’s the power of Swift! It only takes one look at this code to immediately understand what it does. After a delay, some code is executed.

By moving the nasty GCD stuff into a new function, afterDelay(), you have added a new level of abstraction to your code that makes it much easier to follow. Writing good programs is all about finding the right abstractions.

Note: Because the code referring to the navigation controller sits in a closure, it needs to use self. Inside closures, you always need to use self explicitly. But, you didn’t need to use self for the line referring to the hudView since it is a local variable that would be in existence only within the scope of the done() method.

You can make the code even more concise. Change the code to:

afterDelay(0.6) {
  hudView.hide()
  self.navigationController?.popViewController(animated: true)
}

Now the closure sits outside of the call to afterDelay().

Swift has a handy rule that says you can put a closure outside a function call if it’s the last parameter of the function. This is known as trailing closure syntax. You will usually see closures being used in this manner because it reads (and looks) better.

➤ Run the app again to make sure the timing still works. Boo-yah!

You can find the project files for this chapter under 26 – Adding Polish in the Source Code folder.

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.