---
title: 'The View Controller Concept'
source: 'https://academia.sh/en/courses/uikit/the-view-controller-concept'
course: 'UIKit and Bridging'
language: en
updated: '2026-10-21T05:03:16+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Identifies the view controller as the object that carries the model to the screen and the events on the screen back to the model'
    - 'Classifies whether a screen agrees with the model using four outcomes'
    - 'Reads which field stayed matching, stale, missing or leftover across five events'
    - 'States the limit of the measurement'
---

# The View Controller Concept

How a view controller carries a screen between the model and the view, and an introduction to the measurement that classifies whether a screen agrees with the model as matching, stale, missing or leftover.

In an earlier course (M30/K03), a declarative framework had us read why a view was recomputed through a trigger; when a value changed, the framework updated the screen by itself. This course gives that work back to the programmer. In an imperative framework the model changes, and updating every label and every row is left to the code that is written. Forgetting to write an update raises no error; the screen keeps showing the old thing.

For that reason the question of the course is not a trigger but an outcome: **does this screen agree with the model**. After the code has run, what does the model say, what does the screen show, and are the two the same. The cause is not the measure; the outcome is.

## What a View Controller Does

In an imperative framework the whole screen is tied to one **view controller** object. The controller does three jobs. It reads the model and writes it into the views. It carries an event from the user, for example a button press, to the model. It also receives the calls that announce when the screen opens and closes. There is no other bridge between the model and the view: if the controller did not write a value to the screen, the screen does not know it.

The fragment below is the skeleton of an inbox screen. Because it imports the platform framework, it is not run in this lesson.

```swift
import UIKit

final class InboxController: UIViewController, FolderPickerDelegate {
    let store: MailStore
    private let titleLabel = UILabel()
    private let unreadLabel = UILabel()

    init(store: MailStore) {
        self.store = store
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }

    override func viewDidLoad() {
        super.viewDidLoad()
        titleLabel.text = store.values["title"] ?? ""
        unreadLabel.text = store.values["unread"] ?? ""
    }

    func markAllRead() {
        store.update("unread", "0")
    }

    func folderPicker(_ picker: FolderPicker, didChoose folder: String) {
        titleLabel.text = folder
    }
}
```

The three methods show three separate paths. `viewDidLoad` writes the model to the screen. `markAllRead` only changes the model and does not touch the screen. `folderPicker(_:didChoose:)` writes to the screen but does not change the model; the folder picker updates the model itself. At first glance all three look reasonable. We will find out which of them leaves the screen equal to the model by measuring, not by reading.

## Owning the View

Every view controller has a `view` property, and this property is loaded lazily: the view is created the first time it is requested. A controller that wants to build its view in code does so in `loadView`; in a controller that takes its view from a file, the framework does this work itself. In both cases the first method called after the view has loaded is `viewDidLoad`, and it is the usual place for adding subviews.

```swift
import UIKit

final class ProfileController: UIViewController {
    private let nameLabel = UILabel()

    override func loadView() {
        view = UIView()
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        nameLabel.frame = CGRect(x: 16, y: 80, width: 240, height: 24)
        view.addSubview(nameLabel)
    }
}
```

In this fragment `loadView` only assigns the root view; adding and positioning the label happen in `viewDidLoad`. `super.loadView()` is not called inside `loadView`, because the method that assigns the root view is `loadView` itself. A call that touches the `view` property while the view has not yet loaded starts the loading; in code written in the wrong order, this leads to surprising results.

## Common Mistakes

The most common mistakes in an imperative framework arise from a write-to-screen call being left out somewhere, and most of them run without an error:

- **Changing the model without writing the screen.** When the button is pressed the model is updated and the label stays the same. The screen stays stale and no error appears.
- **Writing a new field in only one place.** A field is added to the model and `viewDidLoad` writes it to the screen, but the place that changes the field does not refresh the screen. A screen that looks right at first launch falls behind on later changes.
- **Forgetting the clearing call.** When a cell is reused or the folder changes, the old content is not cleared; the screen is left holding a value that the model no longer carries.
- **Not connecting the delegate.** If the weakly held delegate is `nil`, the call is skipped silently.
- **Holding the delegate strongly.** The controller and the helper object hold each other strongly, and neither is ever released.

Every item on this list has the same shape: the code runs, raises no error, and the screen stays wrong. That is why the course measures the outcome rather than the call itself, that is, whether the screen agrees with the model.

## The Measure: Four Outcomes

The measurement compares every field a screen shows with the model's current value and returns one of four outcomes.

- **matching:** the model and the screen carry the same value.
- **stale:** both carry a value but the values differ; the screen has stayed old.
- **missing:** the model carries a value and the screen shows nothing.
- **leftover:** the screen shows a value and the model does not carry that field.

A field that is absent on both sides is written `out-of-scope` and enters no count. The core is a separate file, and it is used unchanged in every lesson of the course.

```swift
// 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)")
    }
}
```

`Screen` is the record of the fields a controller shows on screen; `set` writes a field and `clear` removes it. `verdict` is the rule itself, and it does not look at the cause: it looks only at two values. `check` inspects the given keys after an event and prints each line, and `countVerdicts` counts the four outcomes.

## Five Events

The model is a plain Swift counterpart of the controller and the objects around it. The code below does not run the framework; it is a copy that carries the type and method names of the fragment above, but whose calls are made by `main.swift`. In other words, the model builds what the code writes to the screen; it is not the framework itself. The folder picker notifies the controller through a **delegate** protocol, and the delegate reference is held **weakly**, because if the controller holds the picker strongly and the picker holds the controller strongly, the two objects cannot release each other: this is a **retain cycle**.

The five events run in order: the screen loads, all messages are marked read, the folder changes, two new fields arrive in the model, and a cell is reused with new content.

```swift
// main.swift — five events, nine fields, four agreements
final class MailStore {
    private(set) var values: [String: String] = [:]
    func update(_ key: String, _ value: String) { values[key] = value }
}

protocol FolderPickerDelegate: AnyObject {
    func folderPicker(_ picker: FolderPicker, didChoose folder: String)
}

final class FolderPicker {
    weak var delegate: FolderPickerDelegate?
    let store: MailStore

    init(store: MailStore) { self.store = store }

    func choose(_ folder: String) {
        store.update("title", folder)
        delegate?.folderPicker(self, didChoose: folder)
    }
}

final class InboxController: FolderPickerDelegate {
    let store: MailStore
    let screen = Screen("InboxController")

    init(store: MailStore) { self.store = store }

    func viewDidLoad() {
        screen.set("title", store.values["title"] ?? "")
        screen.set("unread", store.values["unread"] ?? "")
    }

    func markAllRead() {
        store.update("unread", "0")
    }

    func folderPicker(_ picker: FolderPicker, didChoose folder: String) {
        screen.set("title", folder)
    }
}

final class MessageCell {
    let screen = Screen("MessageCell")

    func configure(_ message: [String: String]) {
        for key in message.keys.sorted() {
            screen.set(key, message[key] ?? "")
        }
    }
}

let store = MailStore()
store.update("title", "Inbox")
store.update("unread", "3")
let inbox = InboxController(store: store)
inbox.viewDidLoad()
let loaded = check("loaded", model: store.values, screen: inbox.screen, keys: ["title", "unread"])

inbox.markAllRead()
let marked = check("marked-read", model: store.values, screen: inbox.screen, keys: ["unread"])

let picker = FolderPicker(store: store)
picker.choose("Archive")
let moved = check("folder-changed", model: store.values, screen: inbox.screen, keys: ["title"])

store.update("drafts", "2")
store.update("flagged", "1")
let drafted = check("drafts-arrived", model: store.values, screen: inbox.screen, keys: ["drafts", "flagged"])

let cell = MessageCell()
cell.configure(["subject": "Invoice", "badge": "new", "attachment": "pdf"])
let next = ["subject": "Meeting"]
cell.configure(next)
let reused = check("cell-reused", model: next, screen: cell.screen, keys: ["subject", "badge", "attachment"])

countVerdicts(loaded + marked + moved + drafted + reused)
```

```
loaded | InboxController.title: model=Inbox screen=Inbox -> matching
loaded | InboxController.unread: model=3 screen=3 -> matching
marked-read | InboxController.unread: model=0 screen=3 -> stale
folder-changed | InboxController.title: model=Archive screen=Inbox -> stale
drafts-arrived | InboxController.drafts: model=2 screen=- -> missing
drafts-arrived | InboxController.flagged: model=1 screen=- -> missing
cell-reused | MessageCell.subject: model=Meeting screen=Meeting -> matching
cell-reused | MessageCell.badge: model=- screen=new -> leftover
cell-reused | MessageCell.attachment: model=- screen=pdf -> leftover
matching: 3
stale: 2
missing: 2
leftover: 2
```

The measurement checked nine fields: 3 matching, 2 stale, 2 missing, 2 leftover. The number of events (five) and the number of fields (nine) are not the same thing; the event in which the cell is reused checks three fields on its own.

Reading the lines in order: `loaded` left both fields matching, because `viewDidLoad` writes both. `marked-read` is stale: the **target-action** link changed the model but did not update the label; the model says 0 and the screen still says 3. `folder-changed` is also stale, and its cause is more hidden: because the delegate was never connected, `weak var delegate` stayed `nil`, and the `delegate?.folderPicker` call silently did nothing. The model became Archive and the screen stayed Inbox. `drafts-arrived` is two missing: the screen never wrote the `drafts` and `flagged` fields that arrived in the model. `cell-reused` is one matching and two leftover: the cell wrote the subject of the new message but did not clear the badge and attachment left from the previous message.

All four outcomes come from the same root: writing to the screen is a call, and if there is no call there is no write. Stale appears where the call was never made or never reached its target; missing, where the call was never written; leftover, where the clearing call was never written. This distribution is not itself a finding. We chose the five events; this measurement does not say how often each of these outcomes will occur in a framework.

**VC1.** In this lesson's model, the calls that write to the screen and the calls that write to the model are kept separate, and the model is always taken to be correct. Whether a real label later draws the value the controller wrote differently was not measured.

## Reading the Measurement

The numbers above are not a checklist; they are the result of those five events. Which outcome appears on a screen, and how often, depends on the choice of events. What the measurement shows is that all four outcomes can sit side by side in a single realistic controller; it does not say which is more frequent. Stale and missing appear where the screen was not written; leftover, where it was not cleared. Matching lines are the only evidence that the screen is right, and that is why they must be counted too.

The question to ask when reading a line's outcome should be "what remained on the screen", not "what did the code do". It is possible for the screen to stay wrong where the code looks right; it is also possible for the code to look wrong where the screen is right. The measurement looks only at what remains on the screen.

## The Bounding Measurement

The rule compares values character by character. This shows two limits together: formatted text is not counted as equal, and a field absent on both sides is not counted.

```swift
let label = "3 messages"
let screen = Screen("InboxController")
screen.set("unread", label)
let result = check("formatted-text", model: ["unread": "3"], screen: screen, keys: ["unread"])
countVerdicts(result)
screen.clear("unread")
let cleared = check("cleared", model: [:], screen: screen, keys: ["unread"])
countVerdicts(cleared)
```

```
formatted-text | InboxController.unread: model=3 screen=3 messages -> stale
matching: 0
stale: 1
missing: 0
leftover: 0
cleared | InboxController.unread: model=- screen=- -> out-of-scope
matching: 0
stale: 0
missing: 0
leftover: 0
```

The model says `3` and the screen shows `3 messages`. To a human eye the two agree, but the rule counts this as stale, because the rule does not look at the cause and does not know about formatting. When the screen is emptied with `clear` and the model has no such field either, the line prints `out-of-scope` and all four counts stay zero. Because the field is on neither the model nor the screen, there is nothing to compare.

These two limits hold in later lessons as well. A lesson that measures a formatted label either writes the formatted text to the model too, or does the comparison on the unformatted value and says so explicitly.

## Summary

- The view controller is the object that carries the model to the screen and the events on the screen to the model; the screen does not know a value that was not written to it.
- The measure is "does this screen agree with the model" and gives four outcomes: matching, stale, missing, leftover.
- Across five events, nine fields were checked: 3 matching, 2 stale, 2 missing, 2 leftover.
- An unconnected weak delegate raises no error; the call is silently not made and the screen stays stale.
- The rule does not count formatted text as equal and does not count a field absent on both sides.
- This lesson's agreement: **all four** (3/2/2/2).

## Next Step

In this lesson the call that wrote the screen was `viewDidLoad`, and we read it as a setup that runs once. In reality a controller is loaded, appears, disappears and appears again, and each stage has its own call. In the next lesson we will measure how the place where we write a value among the lifecycle methods can separate the screen from the model.
