Lesson 03 / 15
Navigation Controllers
Measuring that when a screen is pushed and popped in a navigation controller, a selection removed from the model stays on the list as leftover, and that the pushed screen matches the model.
Published Updated
In this lesson
- Describes that a navigation controller holds screens in a stack and how push and pop change the stack
- Measures that the screen beneath a popped screen does not update by itself
- Recognizes a value removed from the model staying on the screen as leftover
- Separates the measurement limit that does not count the first write of a pushed screen as a deviation
Contents
In the previous lesson the screen came onto the screen once, stayed beneath another screen, and came back. Where did that other screen come from? In most apps, screens are placed on top of one another with a navigation controller. The navigation controller holds the screens in a stack: a new screen is pushed onto the top of the stack, and when the user goes back, the top screen is popped off the stack and the screen beneath it becomes visible again.
This is a management job; the navigation controller does not manage what the screens contain. What the screen left underneath shows is whatever that screen’s own controller wrote. In this lesson we measure a consequence of that: the list screen has a selected row, the user goes down into the detail and comes back; the selection is gone from the model, but does the list know about it.
Push and Pop
The fragment below shows a contact list and a detail screen. Because it imports the platform framework, it is not run.
import UIKit
final class ContactListController: UIViewController {
let store: ContactStore
private let selectedLabel = UILabel()
private let countLabel = UILabel()
init(store: ContactStore) {
self.store = store
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewDidLoad() {
super.viewDidLoad()
countLabel.text = store.values["count"] ?? ""
}
func select(_ name: String) {
store.update("selected", name)
selectedLabel.text = name
navigationController?.pushViewController(ContactDetailController(store: store), animated: true)
}
}
final class ContactDetailController: UIViewController {
let store: ContactStore
private let selectedLabel = UILabel()
private let phoneLabel = UILabel()
init(store: ContactStore) {
self.store = store
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewDidLoad() {
super.viewDidLoad()
selectedLabel.text = store.values["selected"] ?? ""
phoneLabel.text = store.values["phone"] ?? ""
}
override func didMove(toParent parent: UIViewController?) {
super.didMove(toParent: parent)
if parent == nil { store.remove("selected") }
}
}
select writes the selection to both the model and the list’s label, and pushes the detail. When the detail controller is removed from the stack, didMove(toParent:) receives the parent as nil and removes the selection from the model. There is no code in the list’s own controller that clears the selection label.
The Controller That Manages the Stack
A navigation controller keeps a stack made of child controllers. The topmost controller is visible on the screen; pushViewController puts a controller on top of the stack, and popViewController removes the topmost one. The stack holds the controllers inside it strongly; so a controller waiting in the stack stays in memory and its view is not loaded again.
import UIKit
final class ContactListController: UIViewController {
let store: ContactStore
init(store: ContactStore) {
self.store = store
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
func showDetail(for name: String) {
let detail = ContactDetailController(store: store, name: name)
navigationController?.pushViewController(detail, animated: true)
}
}
The navigationController property gives the navigation controller that the controller sits in; if the controller is not in a stack it returns nil, and the push call silently does nothing. The back button in the navigation bar is connected by the framework itself: a tap pops the topmost controller, and there is no need to write the popViewController call in code.
The Measure
The measure is the same core throughout the course.
// Sync.swift — compares every field shown on screen with the model's current value
final class Screen {
let name: String
private(set) var shown: [String: String] = [:]
init(_ name: String) { self.name = name }
func set(_ key: String, _ value: String) { shown[key] = value }
func clear(_ key: String) { shown[key] = nil }
}
func verdict(model: String?, shown: String?) -> String {
switch (model, shown) {
case (nil, nil): return "out-of-scope"
case (nil, _?): return "leftover"
case (_?, nil): return "missing"
case let (wanted?, displayed?): return wanted == displayed ? "matching" : "stale"
}
}
func check(_ label: String, model: [String: String], screen: Screen, keys: [String]) -> [String] {
keys.map { key in
let result = verdict(model: model[key], shown: screen.shown[key])
print("\(label) | \(screen.name).\(key): model=\(model[key] ?? "-") screen=\(screen.shown[key] ?? "-") -> \(result)")
return result
}
}
func countVerdicts(_ verdicts: [String]) {
for name in ["matching", "stale", "missing", "leftover"] {
print("\(name): \(verdicts.filter { $0 == name }.count)")
}
}
The List After Returning
The model builds the two controllers in the skeleton and the stack in plain Swift. The model builds what the code writes to the screen; it is not the framework itself. The navigation controller keeps only a list of names, because what is measured is not the stack itself but what the screens write when the stack changes. The order of the pop and the didMove(toParent:) call is the lesson’s choice.
The events are, in order: the list loads and a contact is selected, the detail is pushed and loads, the detail is popped.
// main.swift — list, detail, pop
final class ContactStore {
private(set) var values: [String: String] = [:]
func update(_ key: String, _ value: String) { values[key] = value }
func remove(_ key: String) { values[key] = nil }
}
final class ContactNavigation {
private(set) var stack: [String] = []
func push(_ name: String) { stack.append(name) }
func pop() { _ = stack.popLast() }
}
final class ContactListController {
let store: ContactStore
let screen = Screen("ContactListController")
init(store: ContactStore) { self.store = store }
func viewDidLoad() {
screen.set("count", store.values["count"] ?? "")
}
func select(_ name: String) {
store.update("selected", name)
screen.set("selected", name)
}
}
final class ContactDetailController {
let store: ContactStore
let screen = Screen("ContactDetailController")
init(store: ContactStore) { self.store = store }
func viewDidLoad() {
screen.set("selected", store.values["selected"] ?? "")
screen.set("phone", store.values["phone"] ?? "")
}
func didMove(toParent parent: ContactNavigation?) {
if parent == nil { store.remove("selected") }
}
}
let store = ContactStore()
store.update("count", "2")
store.update("phone", "555-0100")
let navigation = ContactNavigation()
let list = ContactListController(store: store)
navigation.push("ContactListController")
list.viewDidLoad()
list.select("Alice")
let chosen = check("selected", model: store.values, screen: list.screen, keys: ["selected", "count"])
let detail = ContactDetailController(store: store)
navigation.push("ContactDetailController")
detail.viewDidLoad()
let pushed = check("detail-pushed", model: store.values, screen: detail.screen, keys: ["selected", "phone"])
navigation.pop()
detail.didMove(toParent: nil)
let popped = check("popped", model: store.values, screen: list.screen, keys: ["selected", "count"])
countVerdicts(chosen + pushed + popped)
selected | ContactListController.selected: model=Alice screen=Alice -> matching selected | ContactListController.count: model=2 screen=2 -> matching detail-pushed | ContactDetailController.selected: model=Alice screen=Alice -> matching detail-pushed | ContactDetailController.phone: model=555-0100 screen=555-0100 -> matching popped | ContactListController.selected: model=- screen=Alice -> leftover popped | ContactListController.count: model=2 screen=2 -> matching matching: 5 stale: 0 missing: 0 leftover: 1
The selection and the detail are matching in the first two events. In the third event the detail controller updated the model correctly: the selection is gone from the model. But the list’s label still says Alice. The line came out leftover: the screen shows a value, and the model does not carry that field. The count field never changed, so it stayed matching in the same list. The problem is not that the detail controller wrote wrongly; it is that the list wrote nothing. Changing the model does not change the screen.
This result occurs even though the list becomes visible again when the detail screen leaves the stack. When the list reappears, the viewWillAppear we saw in the previous lesson is called, but the list has no code that overrides that method and brings the selection level with the model. The fix is to rewrite the selection label from the model in that method, or to clear it if the model is empty. This lesson’s measurement does not measure the fix; it only shows the problem.
VC1. In the model, the call that removes the selection is inside didMove(toParent:), after the pop. The order of these two events in the real framework was not measured; the result does not depend on the order, because the list writes in neither of these calls.
Common Mistakes
- Assuming the list is reloaded. When we return from the detail, the list controller was waiting in its place in the stack;
viewDidLoadis not called again. If the value was written only there, the screen stays old. - Catching the pop with
viewWillDisappear. This method is also called when a new controller is pushed on top. To tell that a controller has really left the stack,isMovingFromParentordidMove(toParent:)is used. - Pushing the same controller instance twice. The framework does not support this and gives an error at run time. A new instance is created for each push.
- Pushing from a controller that is not in a stack. If
navigationControllerisnil, the push is silently not made. - Forgetting to clear in the list the state the detail left behind. The detail screen marks the selected contact, and if the mark is not erased when returning to the list, a leftover value stays in the list. This is the result this lesson measures.
Reading the Measurement
Of the six lines, five are matching and one is leftover. The leftover line is the selected field that stays on the list screen after the pop: the model no longer carries that field, and the screen still shows it. This is not a missing write but a missing deletion; because the list controller kept living in the stack, no one was assigned the clearing call.
The striking side is that this mistake does not come from the detail screen. The detail controller did its own work and removed the value from the model inside didMove(toParent:). The screen agreeing with the model depends on the own write call of every screen on which the model changes; when the model changes, the other controllers in the stack do not hear about it. A list screen waiting in the stack can read the changed model only when it appears again, and if no one wrote the read, it does not read.
Title, Bar Buttons and Rebuilding the Stack
Each controller defines what will appear in the navigation bar through navigationItem: title gives the bar’s title and rightBarButtonItem gives the button on the right. The title of the back button comes from the previous controller’s title. These settings are made inside viewDidLoad; as long as the controller stays in the stack, writing them once is enough.
import UIKit
final class ContactDetailController: UIViewController {
let store: ContactStore
let name: String
init(store: ContactStore, name: String) {
self.store = store
self.name = name
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.title = name
}
override func didMove(toParent parent: UIViewController?) {
super.didMove(toParent: parent)
if parent == nil {
store.remove("selected")
}
}
}
The stack can do more than a single push and pop. popToRootViewController removes everything down to the root controller; the viewControllers array gives the whole stack; setViewControllers rebuilds the stack entirely. Each of these means that the controllers that leave receive lifecycle calls. After a call that removes several controllers at once, it is necessary to make sure that the state left by all of them has been cleared.
For two screens to show the same data, a shared model object is often given: the list and the detail look at the same store. This guarantees that the screens will read from the same source, but it does not guarantee when the read call will be made. The lesson’s measurement shows this gap.
There is one more detail: the pushViewController call adds to the stack, but does not wait for the transition to finish visually. Code written right after the call runs at a moment when the new controller has not yet appeared. For this reason, work that depends on the new screen’s content is left to the new controller’s own lifecycle methods.
The Bounding Measurement
This measurement has two limits. The first: the pushed detail is a new Screen and has not written anything yet. If the core checks this screen before viewDidLoad, it says missing, because the model carries a value. This is not a deviation; it is the screen not having been set up yet. The second: a popped screen leaves the stack and is no longer checked; the measurement looks only at visible screens.
// main.swift — the pushed screen and the popped screen
final class ContactStore {
private(set) var values: [String: String] = [:]
func update(_ key: String, _ value: String) { values[key] = value }
}
final class ContactNavigation {
private(set) var stack: [String] = []
func push(_ name: String) { stack.append(name) }
func pop() { _ = stack.popLast() }
}
final class ContactDetailController {
let store: ContactStore
let screen = Screen("ContactDetailController")
init(store: ContactStore) { self.store = store }
func viewDidLoad() {
screen.set("selected", store.values["selected"] ?? "")
screen.set("phone", store.values["phone"] ?? "")
}
}
let store = ContactStore()
store.update("selected", "Alice")
store.update("phone", "555-0100")
let navigation = ContactNavigation()
navigation.push("ContactListController")
let detail = ContactDetailController(store: store)
navigation.push("ContactDetailController")
let before = check("pushed-before-writing", model: store.values, screen: detail.screen, keys: ["selected", "phone"])
detail.viewDidLoad()
let after = check("pushed-after-writing", model: store.values, screen: detail.screen, keys: ["selected", "phone"])
navigation.pop()
print(navigation.stack.joined(separator: ", "))
countVerdicts(before + after)
pushed-before-writing | ContactDetailController.selected: model=Alice screen=- -> missing pushed-before-writing | ContactDetailController.phone: model=555-0100 screen=- -> missing pushed-after-writing | ContactDetailController.selected: model=Alice screen=Alice -> matching pushed-after-writing | ContactDetailController.phone: model=555-0100 screen=555-0100 -> matching ContactListController matching: 2 stale: 0 missing: 2 leftover: 0
The rule does not know when a screen counts as set up. The pushed screen comes out missing before it writes and matching after it writes; for this reason the lesson checks the screen after the setup call. At the end of the pop, only the list remains in the stack. The popped detail screen is not checked again, because it is no longer a visible screen.
Summary
- A navigation controller holds screens in a stack; push adds to the stack, pop removes the top one.
- The screen left underneath does not update by itself when the screen above changes the model.
- A selection removed from the model is leftover if it is still visible in the list’s label.
- The pushed screen writes matching to the model during the setup call; checking it makes sense after this call.
- The popped screen leaves the stack and stays outside the measurement.
- This lesson’s agreement: leftover.
Next Step
The navigation controller adds the new screen to the stack. Not every screen is added to the stack: some open over the current screen and leave nothing in its place when they close. In the next lesson we will cover presentation styles and measure whether a value entered in a note window reaches the screen left underneath.
To keep your progress and take notes, Log in
My notes
Log in to take notes.