Swift Talk #117

Building a Form Library: Showing & Hiding Sections

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

Using a simple key path API, we add the ability to control the visibility of sections by any condition.

00:06 Today we'll continue working on our forms library after receiving some feature requests from users on Twitter. The two most interesting requests were form validation and the conditional visibility of sections.

Validation

00:25 As a matter of fact, the library already gives us some opportunities to incorporate validation. We can dynamically show or hide a section's footer based on a key path, which can be used to show validation feedback.

00:49 Another way to present validation results is to turn the text color of a specific field's label red if the input is invalid. This can be done by assigning a key path to set the color of the label. This key path should then point to a property that returns a color based on the specific validation rules.

01:30 In the end, the code that performs the validation itself doesn't come from the library but is specific to the app. Showing the validation results is a matter of setting properties in the UI that communicate errors or messages — how feedback should be presented depends on the specific design of the app.

Hiding and Showing Sections

01:45 Let's focus on the other popular feature: hiding and showing sections based on the state. In our example app, it would make sense that, if the hotspot is disabled in the first section, the other two sections with more detailed settings disappear.

01:59 Looking at the call site, we'd like to express this behavior with a new isVisible parameter, for which we supply a key path to a Boolean on the state. This should be the only change on the user's side of our library:

let hotspotForm: Form<Hotspot> =
    sections([
        section([
            controlCell(title: "Personal Hotspot", control: uiSwitch(keyPath: \.isEnabled))
        ], footer: \.enabledSectionTitle),
        section([
            detailTextCell(title: "Notification", keyPath: \.showPreview.text, form: showPreviewForm)
        ], isVisible: \.isEnabled),
        section([
            nestedTextField(title: "Password", keyPath: \.password),
            nestedTextField(title: "Network Name", keyPath: \.networkName)
        ], isVisible: \.isEnabled)
    ])

02:37 Let's see what we have to change in the library by working backward from the proposed API. The first thing to do is to add the isVisible parameter to the section function. We don't want to force the user to always supply the isVisible parameter, so we make it optional and we let it default to nil:

func section<State>(_ cells: [Element<FormCell, State>], footer keyPath: KeyPath<State, String?>? = nil, isVisible: KeyPath<State, Bool>? = nil) -> Element<Section, State> {
    return { context in
        // ...
    }
}

03:21 The section function creates a Section instance, and this class needs a property that specifies whether or not it's visible:

class Section: Equatable {
    let cells: [FormCell]
    var footerTitle: String?
    var isVisible: Bool
    init(cells: [FormCell], footerTitle: String?, isVisible: Bool) {
        self.cells = cells
        self.footerTitle = footerTitle
        self.isVisible = isVisible
    }
}

03:55 Now we can first set the visibility to true when we instantiate the Section. If no isVisible key path is passed into the function, the property is never updated, so the section remains visible by default:

func section<State>(_ cells: [Element<FormCell, State>], footer keyPath: KeyPath<State, String?>? = nil, isVisible: KeyPath<State, Bool>? = nil) -> Element<Section, State> {
    return { context in
        // ...
        let section = Section(cells: renderedCells.map { $0.element }, footerTitle: nil, isVisible: true)
        // ...
    }
}

04:08 In the update closure defined by the section function, we update the isVisible property if a key path is provided:

func section<State>(_ cells: [Element<FormCell, State>], footer keyPath: KeyPath<State, String?>? = nil, isVisible: KeyPath<State, Bool>? = nil) -> Element<Section, State> {
    return { context in
        // ...
        let section = Section(cells: renderedCells.map { $0.element }, footerTitle: nil, isVisible: true)
        let update: (State) -> () = { state in
            for c in renderedCells {
                c.update(state)
            }
            if let kp = keyPath {
                section.footerTitle = state[keyPath: kp]
            }
            if let iv = isVisible {
                section.isVisible = state[keyPath: iv]
            }
        }
        return RenderedElement(element: section, strongReferences: strongReferences, update: update)
    }
}

04:57 Now, anytime the state changes, we update the visibility property of the Section instance. But we still have to use this property and actually show or hide the section in the table view.

Updating the Table View

05:34 We add a computed property to the table view controller that returns just the visible sections:

class FormViewController: UITableViewController {
    var sections: [Section] = []
    var visibleSections: [Section] {
        return sections.filter { $0.isVisible }
    }
    // ...
}

06:02 Now we can use the array of visible sections in the data source methods of the table view:

class FormViewController: UITableViewController {
    // ...

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

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

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


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

    // ...
}

06:32 When we run the app, the form is presented correctly at first, but we get a crash when we toggle the hotspot switch. The crash is caused by the table view and the underlying model getting out of sync because we're not updating the table view along with our changes to the sections model. In other words, we need to delete and insert the actual table view sections along with our changes to the isVisible properties of the Section instances.

07:24 We can repurpose the reloadSectionFooters method — which is already called any time the state updates — to perform all changes to the table sections, so we give it a more general name, reloadSections. For each section in our model, we have to figure out whether the table view should insert or delete it.

We look up the section's index in the visibleSections array to know which table section we're dealing with. For this lookup, Section needs to be Equatable, which we establish with an identity check:

extension Section: Equatable {
    static func ==(lhs: Section, rhs: Section) -> Bool {
        return lhs === rhs
    }
}

class FormViewController: UITableViewController {
    // ...

    func reloadSections() {
        UIView.setAnimationsEnabled(false)
        tableView.beginUpdates()
        for index in sections.indices {
            let section = sections[index]
            let newIndex = visibleSections.index(of: section)
            // ...

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

    // ...
}

09:10 We also need to know whether or not the section was visible before we process the updates we're currently processing. So we need to store the previous state of the visible sections array:

class FormViewController: UITableViewController {
    var sections: [Section] = []
    var previouslyVisibleSections: [Section] = []
    var visibleSections: [Section] {
        return sections.filter { $0.isVisible }
    }
    // ...
}

10:12 In the initializer of the form view controller, we assign the visible sections as the initial value of previouslyVisibleSections. Then, each time we're done reloading our sections, we update the value so that it's ready to be used for the next reload:

class FormViewController: UITableViewController {
    // ...

    init(sections: [Section], title: String, firstResponder: UIResponder? = nil) {
        self.firstResponder = firstResponder
        self.sections = sections
        super.init(style: .grouped)
        previouslyVisibleSections = visibleSections
        navigationItem.title = title
    }

    func reloadSections() {
        // ...
        previouslyVisibleSections = visibleSections
    }

    // ...
}

10:52 Now we can try to find each section in the array of previously visible sections, as well as in the array of currently visible sections:

func reloadSections() {
    UIView.setAnimationsEnabled(false)
    tableView.beginUpdates()
    for index in sections.indices {
        let section = sections[index]
        let newIndex = visibleSections.index(of: section)
        let oldIndex = previouslyVisibleSections.index(of: section)

        // ...
    }
    tableView.endUpdates()
    UIView.setAnimationsEnabled(true)
    previouslyVisibleSections = visibleSections
}

11:13 By switching over the new and old index, we can go through all possible scenarios: if both indices are nil, it means the section was previously invisible and it stays invisible; if both indices are not nil, the section was and remains visible. In both cases, we don't have to do anything:

func reloadSections() {
    UIView.setAnimationsEnabled(false)
    tableView.beginUpdates()
    for index in sections.indices {
        let section = sections[index]
        let newIndex = visibleSections.index(of: section)
        let oldIndex = previouslyVisibleSections.index(of: section)
        switch (newIndex, oldIndex) {
        case (nil, nil), (.some, .some): break
        // ...
        }
        // ...
    }
    tableView.endUpdates()
    UIView.setAnimationsEnabled(true)
    previouslyVisibleSections = visibleSections
}

12:17 In the other two cases, we have either an old or a new index. If we have a new index but no old index, we have to tell the table view to insert the section. If it's the other way around, the table view has to delete the section:

func reloadSections() {
    UIView.setAnimationsEnabled(false)
    tableView.beginUpdates()
    for index in sections.indices {
        let section = sections[index]
        let newIndex = visibleSections.index(of: section)
        let oldIndex = previouslyVisibleSections.index(of: section)
        switch (newIndex, oldIndex) {
        case (nil, nil), (.some, .some): break
        case let (newIndex?, nil):
            tableView.insertSections([newIndex], with: .automatic)
        case let (nil, oldIndex?):
            tableView.deleteSections([oldIndex], with: .automatic)
        }
        // ...
    }
    tableView.endUpdates()
    UIView.setAnimationsEnabled(true)
    previouslyVisibleSections = visibleSections
}

13:50 Running the app, we encounter a crash because we forgot to update one data source method to use the visibleSections array:

class FormViewController: UITableViewController {
    // ...

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

    // ...
}

14:17 Now it works: the second and third sections of the form hide when we switch off the personal hotspot, and they reappear when we switch it on.

14:22 We like how simple the API is for the library user. By setting a single parameter, we can dynamically show/hide a section. And by using a key path, we can implement any logic we need to update the section's visibility.

14:41 It would be cool if we could update the visibility of individual cells as well. Perhaps this is a nice homework challenge!