22.
Navigation Controllers
Written by Eli Ganim
At this point the high scores screen contains a table view displaying a handful of fixed data rows. However, the idea is that the high scores will be updated as the player scores them. Therefore, you need to implement the ability to add items.
In this chapter you’ll expand the app to have a navigation bar at the top. Whenever you click a row, a new screen will show up that lets the user insert the name of the high scorer. When you tap Done, the new item will be added to the list.
This chapter covers the following:
- Navigation controller: Add a navigation controller to the app to allow navigation between screens.
- Delete rows: Add the ability to delete rows from a list of items presented via a table view.
- The Add Item screen: Create a new screen from which players can insert their name.
Navigation controller
First, let’s add the navigation bar. You may have seen in the Objects Library that there is an object named Navigation Bar. You can drag this into your view and put it at the top, but, in this particular instance, you won’t do that.
Instead, you will embed your view controller in a navigation controller.
Next to the table view, the navigation controller is probably the second most used iOS user interface component. It is the thing that lets you go from one screen to another:
The UINavigationController object takes care of most of this navigation stuff for you, which saves a lot of programming effort. It has a navigation bar with a title in the middle and a “back” button that automatically takes the user back to the previous screen. You can put a button (or several buttons) of your own on the right.
Adding a navigation controller
Adding a navigation controller is really easy.
➤ Open Main.storyboard and select the View Controller Scene.
➤ From the menu bar at the top of the screen, choose Editor ▸ Embed In ▸ Navigation Controller.
Several things have happened in Interface Builder now:
- Interface Builder has added a new Navigation Controller Scene and made a relationship between it and the main view controller.
- There’s a navigation bar at the top of the main screen (just like in the SwiftUI version of Bullseye)
- The High Scores View Controller also has a navigation bar with a Back button.
Why was a navigation bar added to the High Scores View Controller Scene, but not to About View Controller Scene? When you connected the main view controller to the About screen a few chapters ago, you chose the Present Modally segue. When you connected the High Scores Screen, you chose Show.
Segue types
What are the possible Segues and what do they mean? Here is a brief explanation of each type of segue:
-
Show: Pushes the new view controller onto the navigation stack so that the new view controller is at the top of the navigation stack. It also provides a back button to return to the previous view controller. If the view controllers are not embedded in a navigation controller, then the new view controller will be presented modally (see Present Modally in the list below as to what this means).
Example: Navigating folders in the Mail app
-
Show Detail: For use in a split view controller (you’ll learn more about those when developing the last app in this book). The new view controller replaces the detail view controller of the split view when in an expanded two-column interface. Otherwise, if in single-column mode, it will push in a navigation controller.
Example: In Messages, tapping a conversation will show the conversation details — replacing the view controller on the right when in a two-column layout, or push the conversation when in a single column layout
-
Present Modally: Presents the new view controller to cover the previous view controller — most commonly used to present a view controller that covers the entire screen on iPhone, or on iPad it’s common to present it as a centered box that darkens the presenting view controller. Usually, if you had a navigation bar at the top or a tab bar at the bottom, those are covered by the modal view controller too.
Example: Selecting Touch ID & Passcode in Settings
-
Present as Popover: When run on an iPad, the new view controller appears in a popover, and tapping anywhere outside of this popover will dismiss it. On an iPhone, will present the new view controller modally over the full screen.
Example: Tapping the + button in Calendar
-
Custom: Allows you to implement your own custom segue and have control over its behavior. (You will learn more about this in a later chapter.)
➤ Run the app and try it out. Navigate to the About screen and then to the High Score screen and witness the difference between the two segue types.
Setting the navigation bar title
➤ Go back to the storyboard, select Navigation Item under View Controller Scene in the Document Outline, switch to the Attributes Inspector on the right-hand pane, and set the value of Title to Bullseye.
What you’re doing here is changing a Navigation Item object that was automatically added to the view controller when you chose the Embed In command.
The Navigation Item object contains the title and buttons that appear in the navigation bar when this view controller becomes active. Each embedded view controller has its own Navigation Item that it uses to configure what shows up in the navigation bar.
If you run the app now, you’ll see that the title in the navigation controller of the main screen is now Bullseye. However, if you open the high scores screen you’ll see it has no title.
When the navigation controller slides a new view controller in, it replaces the contents of the navigation bar with the new view controller’s Navigation Item. You’ll add a Navigation Item to the high scores view controller and set its title.
➤ Go to the storyboard and select the High Scores scene
➤ Drag a Navigation Item from the object library into the scene
➤ Change the Navigation Item’s title to “High Scores”.
Run the app, open the high scores screen and verify the title was indeed updated:
Deleting rows
Imagine you let a friend enjoy the amazing Bullseye game on your iPhone and he reaches a high score you can’t beat. That would be really annoying!
For that purpose you need a way to delete high scores from the list. A common way to do this in iOS apps is “swipe-to-delete.” You swipe your finger over a row and a Delete button slides into view. A tap on the Delete button confirms the removal, tapping anywhere else will cancel.
Swipe-to-delete
Swipe-to-delete is very easy to implement.
➤ Add the following method to HighScoresViewController.swift. You should put this with the other table view delegate methods, to keep things organized.
override func tableView(
_ tableView: UITableView,
commit editingStyle: UITableViewCell.EditingStyle,
forRowAt indexPath: IndexPath) {
// 1
items.remove(at: indexPath.row)
// 2
let indexPaths = [indexPath]
tableView.deleteRows(at: indexPaths, with: .automatic)
}
When the commitEditingStyle method is present in your view controller (it is a method defined by the table view data source protocol), the table view will automatically enable swipe-to-delete. All you have to do is:
- Remove the item from the data model.
- Delete the corresponding row from the table view.
➤ Run the app to try it out!
Adding a navigation button
Now that you can remove items from the list, it would be useful to also have a way to reset the high scores list to its initial state. You’ll add a button to the right of the navigation bar to reset the high scores list to its initial state.
➤ Open the storyboard.
➤ Go to the Objects Library and look for Bar Button Item. Drag it into the right-side slot of the navigation bar. (Be sure to use the navigation bar on the High Scores View Controller, not the one from the navigation controller!)
By default, this new button is named “Item”. Let’s rename it to “Reset”.
➤ In the Attributes inspector for the bar button item, update the title to Reset.
OK, that gives us a button. If you open the high scores screen, the navigation bar should look like this:
Making the navigation button do something
If you tap on your new reset button, it doesn’t actually do anything. That’s because you haven’t hooked it up to an action. You got plenty of exercise with this for Bullseye, so it should be child’s play for you by now.
➤ Add a new action method to HighScoresViewController.swift:
// MARK:- Actions
@IBAction func resetHighScores() {
}
You’re leaving the method empty for the moment, but it needs to be there so you have something to connect the button to.
➤ Open the storyboard and connect the Reset button to this action. To do this, Control-drag from the reset button to the yellow circle in the bar above the view (this circle represents the High Scores View Controller):
Actually, you can Control-drag from the Add button to almost anywhere in the same scene to make the connection.
➤ After dragging, pick resetHighScores from the pop-up (under Sent Actions):
➤ Let’s give resetHighScores() something to do. Back in HighScoresViewController.swift, move all the HighScoreItem initialization code from viewDidLoad() to resetHighScores() and call it from viewDidLoad(). The final code should look like this (some items were removed for brevity:
override func viewDidLoad() {
super.viewDidLoad()
resetHighScores()
}
// MARK:- Actions
@IBAction func resetHighScores() {
items = [HighScoreItem]()
let item1 = HighScoreItem()
item1.name = "The reader of this book"
item1.score = 50000
items.append(item1)
. . .
let item5 = HighScoreItem()
item5.name = "Eli"
item5.score = 500
items.append(item5)
tableView.reloadData()
}
Note that at the beginning you clear the items array by assinging it an empty array. You then add the default 5 items and eventually call reloadData on the table view, so that it will be refreshed.
Saving and loading high scores
You probably noticed that the high scores data resets every time you restart the app. That’s because you’re not saving or loading the data.
First, you need to make HighScoreItem conform to Codable so that you can write it to a file.
➤ Open HighScoreItem.swift and update the class definition to this:
class HighScoreItem : Codable
Next, you’ll create a helper class to save and load the data and use it to fetch items when the high scores screen loads.
➤ Create a new Swift file and name it PersistencyHelper.swift. Put this content in the new file:
class PersistencyHelper {
static func saveHighScores(_ items: [HighScoreItem]) {
let encoder = PropertyListEncoder()
do {
let data = try encoder.encode(items)
try data.write(to: dataFilePath(), options: Data.WritingOptions.atomic)
} catch {
print("Error encoding item array: \(error.localizedDescription)")
}
}
static func loadHighScores() -> [HighScoreItem] {
var items = [HighScoreItem]()
let path = dataFilePath()
if let data = try? Data(contentsOf: path) {
let decoder = PropertyListDecoder()
do {
items = try decoder.decode([HighScoreItem].self, from: data)
} catch {
print("Error decoding item array: \(error.localizedDescription)")
}
}
return items
}
static func dataFilePath() -> URL {
let paths = FileManager.default.urls(for: .documentDirectory,
in: .userDomainMask)
return paths[0].appendingPathComponent("HighScores.plist")
}
}
This should all be familiar to you, since you’ve done exactly the same thing in Checklists. You have one method to save the high scores to file and one that loads them from the file. The third method simply creates the path to the plist as a URL.
Now it’s time to use these methods. First, you want to load the high scores.
➤ Open HighScoresViewController.swift and add loadHighScores() to viewDidLoad(). If there’s no high scores file (or if loading fails for any reason), you fallback to the default list of high scores:
override func viewDidLoad() {
super.viewDidLoad()
items = PersistencyHelper.loadHighScores()
if (items.count == 0) {
resetHighScores()
}
}
Next, you want to save the high scores whenever an item is deleted or the list is reset.
➤ Add PersistencyHelper.saveHighScores(items) at the end of resetHighScores() and tableView(_:commit:forRowAt:).
Adding new high scores
There’s one piece missing: How do you add new high scores to the list? Obviously, it needs to happen when a game ends.
In Bullseye everyone’s a winner. Even if your score is really low - you still make it to the high scores list (albeit at the bottom of the list).
Exercise: Where’s the right place to detect when a game ends, and how would you add the new high score?
The score needs to be added when a game ends, which is right before a new game starts.
➤ Open ViewController.swift and add this at the top of the method startNewGame():
@IBAction func startNewGame() {
addHighScore(score)
. . .
}
Next, you need to implement the new method.
➤ Add this code somewhere in ViewController.swift:
func addHighScore(_ score:Int) {
// 1
guard score > 0 else {
return;
}
// 2
let highscore = HighScoreItem()
highscore.score = score
highscore.name = "Unknown"
// 3
var highScores = PersistencyHelper.loadHighScores()
highScores.append(highscore)
highScores.sort { $0.score > $1.score }
PersistencyHelper.saveHighScores(highScores)
}
Here’s what this piece of code is doing:
- Make sure the score is higher than 0, since you don’t want to store games in which the player didn’t score any points.
- Create a new
HighScoreItemwith the score and set the player name to “Unknown”. - Load the high scores from the file, add the new score, sort the list and save it back to the file.
Run the app and give it a try. Play a game, click on the “Start Over” button to end the game and head over to the high scores screen to see your score.
The Edit High Score screen
You’ve learned how to add new high scores, but all of them contain the same player name - “Unknown”. You will need to provide a way to change the name. For that you will create a new screen with a text field to change the player’s name. It will look like this:
Adding a new view controller to the storyboard
➤ Go to the Objects Library and drag a new Table View Controller (not a regular view controller) on to the storyboard canvas.
You may need to zoom out to fit everything properly. Right-click on the canvas to get a pop-up with zoom options, or use the - 100% + controls at the bottom of the Interface Builder canvas. (You can also double-click on an empty spot in the canvas to zoom in or out. Or, if you have a Trackpad, simply pinch with two fingers to zoom in or out.)
➤ With the new view controller in place, select Table View and change its view’s background to Group Table View Background.
➤ Select the prototype cell from the High Scores View Controller. Control-drag to the new view controller. It might be difficult to capture the correct object here, so instead you can control-drag from HighScoreItem in the outline to the left.
Let go of the mouse and a list of options pops up.
➤ Choose Show from the menu.
The segue is represented by the arrow between the two view controllers:
➤ Run the app to see what it does.
When you press any of the cells, a new empty table view slides in from the right. You can press the back button – the one that says “High Scores” – at the top to go back to the previous screen.
Note: Xcode may be giving you the warning, “Prototype table cells must have reuse identifiers”. You might remember this issue from before — you will fix this issue soon.
Customizing the navigation bar
So now you have a new table view controller that slides into the screen when you press a cell. However, this screen is empty. Data input screens usually have a navigation bar with a Cancel button on the left and a Done button on the right. In some apps the button on the right is called Save or Send. Pressing either of these buttons will close the screen, but only Done will save your changes.
➤ First, drag a Navigation Item from the Objects Library on to the new scene.
➤ Next, drag two Bar Button Items on to the navigation bar, one to the left slot (removing the existing back button) and one to the right slot.
➤ In the Attributes inspector for the left button choose System Item: Cancel.
➤ For the right button choose Done for both System Item and Style attributes.
Don’t type anything into the button’s Title field. The Cancel and Done buttons are built-in button types that automatically use the proper text. If your app runs on an iPhone where the language is set to something other than English, these predefined buttons are automatically translated into the device’s language.
➤ Double-click the navigation bar for the new table view controller to edit its title and change it to Edit High Score. You can also change this via the Attributes inspector as you did before.
➤ Run the app, click on the high scores button, tap any cell and you’ll see that your new screen has Cancel and Done buttons.
Making your own view controller class
You created a custom view controller for the About screen. Do you remember how to do it on your own? If not, here are the steps:
➤ Right-click on the Bullseye group (the yellow folder) in the project navigator and choose New File… Choose the Cocoa Touch Class template.
➤ In the next dialog, set the Class to EditHighScoreViewController and Subclass to UITableViewController (when you change the subclass, the class name will automatically change — so either set the subclass first or change the class name back after the change). Leave the language at Swift (or change it back if it is not set to Swift).
➤ Save the file to your project folder, which should be the default location.
➤ The file should have a lot of source and commented code — this is known as boilerplate code, or code that is generally always needed. In this particular case, you don’t need most of it. So remove everything except for viewDidLoad (and remove the comments from inside viewDidLoad as well) so that your code looks like this:
import UIKit
class EditHighScoreViewController: UITableViewController {
override func viewDidLoad() {
super.viewDidLoad()
}
}
This tells Swift that you have a new object for a table view controller that goes by the name of EditHighScoreViewController. You’ll add the rest of the code soon. First, you have to let the storyboard know about this new view controller.
➤ In the storyboard, select the Edit High Score Scene and go to the Identity inspector. Under Custom Class, type EditHighScoreViewController.
This tells the storyboard that the view controller from this scene is actually your new EditHighScoreViewController object.
Make sure that it is really the view controller that is selected before you change the fields in the Identity inspector (the scene needs to have a blue border). A common mistake is to select the table view and change that.
Making the navigation buttons work
There’s still one issue — the Cancel and Done buttons ought to close the Add Item screen and return the app to the main screen, but tapping them has no effect yet.
Exercise: Do you know why the Cancel and Done buttons do not return you to the main screen?
Answer: Because those buttons have not yet been hooked up to any actions!
You will now implement the necessary action methods in EditHighScoreViewController.swift.
➤ Add these new cancel() and done() action methods:
// MARK:- Actions
@IBAction func cancel() {
navigationController?.popViewController(animated: true)
}
@IBAction func done() {
navigationController?.popViewController(animated: true)
}
This tells the navigation controller to close the Add Item screen with an animation and to go back to the previous screen, which in this case is the main screen.
You still need to hook up the Cancel button to the cancel() action and the Done button to the done() action.
➤ Open the storyboard and find the Add Item View controller. Control-drag from the bar buttons to the yellow circle icon and pick the proper action from the pop-up menu.
➤ Run the app to try it out. The Cancel and Done buttons now return the app to the main screen.
What do you think happens to the EditHighScoreViewController object when you dismiss it? After the view controller disappears from the screen, its object is destroyed and the memory it was using is reclaimed by the system.
Every time the user opens the Edit High Score screen, the app makes a new instance of it. This means a view controller object is only alive for the duration that the user is interacting with it; there is no point in keeping it around afterwards.
Container view controllers
You’ve read that one view controller represents one screen, but here you actually have two view controllers for each screen: a Table View controller that sits inside a navigation controller.
The navigation controller is a special type of view controller that acts as a container for other view controllers. It comes with a navigation bar and has the ability to easily go from one screen to another, by sliding them in and out of sight. The container essentially “wraps around” these screens.
The navigation controller is just the frame that contains the view controllers that do the real work, which are known as the “content” controllers. Here, the HighScoresViewController provides the content for the first screen; the content for the second screen comes from the EditHighScoreViewController.
Another often-used container is the Tab Bar controller, which you’ll see in the next app.
On the iPad, container view controllers are even more commonplace. View controllers on the iPhone are full-screen but on the iPad they often occupy only a portion of the screen, such as the content of a popover or one of the panes in a split-view.
This completes the implementation of the navigation functionality for your app. If at any point you got stuck, you can refer to the project files for the app from the 22-Navigation Controllers folder in the Source Code folder.