This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We begin to refactor the imperative table view code from the last episode, working toward a more declarative approach of defining our form.

00:06 In today's episode, we'll start to refactor last week's form code. The weakest part of that code is the reliance on index paths, because this makes it very difficult to change our form.

So, instead of directly using the table view's data source methods to define our form, we'll create a separate Section struct that holds an array of cells. Using the struct allows us to model a form in a more reusable way without using specific index paths.

Defining Sections

01:06 Looking at our table, we see that each section has a number of static cells and an optional footer text, so that's how we define the Section struct:

struct Section {
    var cells: [UITableViewCell]
    var footerTitle: String?
}

01:33 The table view controller will have a sections array, and when we change any section's footerTitle, that should trigger the footers in the table view to be updated.

01:53 We add a sections property to the view controller, along with a method to build up sections based on the state:

class ViewController: UITableViewController {
    var sections: [Section] = []

    var state = Hotspot() {
        // ...
    }


    func buildSections() {
        sections = [
            Section(cells: [

            ], footerTitle: nil),
            Section(cells: [

            ], footerTitle: nil),
        ]
    }

    // ...
}

02:14 Now we can add our cells to these sections. We copy the code from tableView(_:cellForRowAt:), and this creates the toggle cell:

func buildSections() {
    let toggleCell = UITableViewCell(style: .value1, reuseIdentifier: nil)
    toggleCell.textLabel?.text = "Personal Hotspot"
    toggleCell.contentView.addSubview(toggle)
    toggle.isOn = state.isEnabled
    toggle.translatesAutoresizingMaskIntoConstraints = false
    toggle.addTarget(self, action: #selector(toggleChanged(_:)), for: .valueChanged)
    toggleCell.contentView.addConstraints([
        toggle.centerYAnchor.constraint(equalTo: toggleCell.contentView.centerYAnchor),
        toggle.trailingAnchor.constraint(equalTo: toggleCell.contentView.layoutMarginsGuide.trailingAnchor)
        ])

    sections = [
        Section(cells: [
            toggleCell
        ], footerTitle: nil),
        Section(cells: [

        ], footerTitle: nil),
    ]
}

03:26 The second section should contain the password cell, so we also get the code to create that cell and move it into this method:

func buildSections() {
    let toggleCell = UITableViewCell(style: .value1, reuseIdentifier: nil)
    // ...

    let passwordCell = UITableViewCell(style: .value1, reuseIdentifier: nil)
    passwordCell.textLabel?.text = "Password"
    passwordCell.detailTextLabel?.text = state.password
    passwordCell.accessoryType = .disclosureIndicator

    sections = [
        Section(cells: [
            toggleCell
        ], footerTitle: nil),
        Section(cells: [
            passwordCell
        ], footerTitle: nil),
    ]
}

03:58 Now we have to decide when to call buildSections. The first place to do this is when we initialize the view controller so that we create the initial state of the form:

class ViewController: UITableViewController {
    // ...

    init() {
        super.init(style: .grouped)
        buildSections()
    }

    // ...
}

Updating Sections

04:45 We also call buildSections any time the state changes. And instead of updating a specific footer by its index path, we can now loop over all sections to update the table's footers:

var state = Hotspot() {
    didSet {
        print(state)
        buildSections()

        UIView.setAnimationsEnabled(false)
        tableView.beginUpdates()

        for (index, section) in sections.enumerated() {
            let footer = tableView.footerView(forSection: index)
            footer?.textLabel?.text = tableView(tableView, titleForFooterInSection: index)
            footer?.setNeedsLayout()
        }

        // ...

        tableView.endUpdates()
        UIView.setAnimationsEnabled(true)
    }
}

06:08 The compiler complains about the fact that we don't use the section variable in the for-loop. We could get section.footerTitle to set the footer's text, but it's probably smart to keep relying on the titleForFooterInSection method, because then everything keeps working correctly if we change that method in the future.

To fix the compiler warning, we could replace section with an underscore and make it explicit that we only need the index and not the value:

for (index, _) in sections.enumerated() {
    // ...
}

Or, we could loop through a range of the indices:

for index in sections.startIndex..<sections.endIndex {
    // ...
}

But we land on using the array's indices property:

for index in sections.indices {
    // ...
}

07:03 With the sections array in place, we can clean up the table view data source methods a lot:

override func numberOfSections(in tableView: UITableView) -> Int {
    return sections.count
}

override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return sections[section].cells.count
}

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    return sections[indexPath.section].cells[indexPath.row]
}

override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
    return sections[section].footerTitle
}

08:21 We define the footer title of the toggle section in an extension of the Hotspot struct, and in the buildSections method, we add the footer to the right section:

struct Hotspot {
    var isEnabled: Bool = true
    var password: String = "hello"
}

extension Hotspot {
    var enabledSectionTitle: String? {
        return isEnabled ? "Personal Hotspot Enabled" : nil
    }
}
func buildSections() {
    // ...
    sections = [
        Section(cells: [
            toggleCell
        ], footerTitle: state.enabledSectionTitle),
        Section(cells: [
            passwordCell
        ], footerTitle: nil),
    ]
}

09:21 If we run the app, the initial state of the form looks the same as it did before our changes. The password section also still works, but something strange happens when we toggle the switch: it disappears.

Granular Updates

10:02 By calling buildSections when the state changes, we're recreating all table view cells, which results in moving the stored toggle to the newly created cell. But we're not reloading the table view to show these new cells, so the table view and the sections array get out of sync. Instead of completely rebuilding the sections, we should update just the parts that may have changed. As a matter of fact, this more closely resembles how our forms library will eventually work.

11:00 For now, we'll use our "insider knowledge" to update specific elements of the sections array: the toggle section's footer and the password label. And the rest of the code in the state's didSet — which updates the table view footers — is no longer specific to our form, so we can extract it out to a separate method, reloadSectionFooters:

var state = Hotspot() {
    didSet {
        print(state)
        sections[0].footerTitle = state.enabledSectionTitle
        sections[1].cells[0].detailTextLabel?.text = state.password

        reloadSectionFooters()
    } 
}

func reloadSectionFooters() {
    UIView.setAnimationsEnabled(false)
    tableView.beginUpdates()
    for index in sections.indices {
        let footer = tableView.footerView(forSection: index)
        footer?.textLabel?.text = tableView(tableView, titleForFooterInSection: index)
        footer?.setNeedsLayout()
    }
    tableView.endUpdates()
    UIView.setAnimationsEnabled(true)
}

Highlighting Cells

12:43 Running the app, we see that everything works well. But we're not yet done making our code more declarative; there are still some hardcoded index paths to factor out.

13:20 In the delegate method tableView(_:shouldHighlightRowAt:), we want to be able to look up whether or not a row should be highlighted. We could store this information in the sections array by combining it in a tuple with the cell, but instead we create a UITableViewCell subclass specifically for form cells:

class FormCell: UITableViewCell {
    var shouldHighlight = false
}

14:20 We update the Section struct to hold these FormCells, and we update the code where we create our cells to use the new type as well:

struct Section {
    var cells: [FormCell]
    var footerTitle: String?
}

14:28 Now, in shouldHighlight, we can simply return the information from the FormCell:

override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool {
    return sections[indexPath.section].cells[indexPath.row].shouldHighlight
}

14:56 We keep having to look up a cell by an index path, so we create a helper method that does this for us:

func cell(for indexPath: IndexPath) -> FormCell {
    return sections[indexPath.section].cells[indexPath.row]
}

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    return cell(for: indexPath)
}

override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool {
    return cell(for: indexPath).shouldHighlight
}

15:50 And we still have to set shouldHighlight to true for the password cell:

func buildSections() {
    // ...
    let passwordCell = FormCell(style: .value1, reuseIdentifier: nil)
    passwordCell.shouldHighlight = true
    // ...
}

Selecting Cells

16:15 Finally, let's fix the delegate method tableView(_:didSelectRowAt:). We add another property to FormCell — an optional function that we can use to define what should happen on selection:

class FormCell: UITableViewCell {
    var shouldHighlight = false
    var didSelect: (() -> ())?
}

17:06 We move the code that pushes the password view controller to the buildSections method into the didSelect function of the password cell:

func buildSections() {
    // ...
    let passwordVC = PasswordViewController(password: state.password) { [unowned self] in
        self.state.password = $0
    }

    passwordCell.didSelect = { [unowned self] in
        self.navigationController?.pushViewController(passwordVC, animated: true)
    }
    // ...
}

18:42 So this last delegate method becomes very small too:

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    cell(for: indexPath).didSelect?()
}

19:43 We usually don't want to give table view cells a lot of responsibility, especially if we use a table to display a large amount of data and all cells have the same logic. But in this case, it's a different story. In our form table, each cell has its own logic, so it makes more sense to move the logic into the individual cells.

Discussion

20:23 We've made some good progress; about half of our view controller is still rather specific, but half of it is more generic. Throughout the view controller, we still find a stored toggle element, the toggle's custom target action, and a very specific buildSections method. However, all table view methods are generic already.

21:20 Next time, we'll apply the same system from today to the second view controller. This will lead us to really decouple the specific form information from the view controller, with the goal of building a completely generic form view controller.