Swift Talk # 96

Building a Form Library: Extracting a Reusable Form View Controller

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 continue refactoring our forms code by creating a form table view controller as the first reusable component.

00:06 In this episode, we're continuing with refactoring our form library, as it's becoming more declarative and we want to push this even further. Today we'll move as much logic as we can out of the table view controller and turn it into a reusable form view controller. The specific forms will then have to be configured from outside the view controller.

00:39 Last time, we refactored the specific and generic code into separate methods of our view controller, so it should be easy to completely pull out the specific code. The parts that are still specific to our form are the buildSections method, the toggle's target action, and the part where we update the sections based on the state. Everything else is non-specific table view code.

Driver and FormViewController

01:51 We're going to create a new class that will drive the view controller, and it will hold all the specific information and functionality for our personal hotspot settings form. This driver class will own both the form data and the view controller, and it'll update the view controller if its state changes. To start, we move all the specific code from the view controller into the driver:

class HotspotDriver {
    var formViewController: FormViewController!
    var sections: [Section] = []
    let toggle = UISwitch()
    
    init() {
        buildSections()
        formViewController = FormViewController(sections: sections, title: "Personal Hotspot Settings")
    }
    
    var state = Hotspot() {
        didSet {
            print(state)
            sections[0].footerTitle = state.enabledSectionTitle
            sections[1].cells[0].detailTextLabel?.text = state.password
            
            formViewController.reloadSectionFooters()
        }
    }
    
    func buildSections() {
        // ...
    }
    
    @objc func toggleChanged(_ sender: Any) {
        state.isEnabled = toggle.isOn
    }
}

04:07 When the driver modifies its sections, we want those changes to propagate to the form view controller. The best way to make this happen is to turn Section into a class. Under the hood, forms are built up with all kinds of objects — cells, switches, text fields — and Section is basically an abstraction layer on top of those objects. By making Section into an object as well, the driver and the view controller can share the same instances that are globally mutated by the driver:

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

05:19 By defining the cells array with let, we guarantee the static nature of the table. If the array were mutable — thereby making it possible to add, remove, and reorder cells — our library would be much harder to implement. We might still add this mutability in the future, but it's better to start out with the limitation that forms consist of static tables.

06:00 The cells themselves, their contents, and the footer titles are still mutable — so we'll be able to change them, and we'll notify the view controller if we do.

06:45 We modify the form view controller's initializer to take an array of sections so that the driver can pass them in as soon as it creates the view controller. Along with the sections, we also pass in the view controller's title:

class HotspotDriver {
    var formViewController: FormViewController!
    var sections: [Section] = []
    let toggle = UISwitch()
    
    init() {
        buildSections()
        formViewController = FormViewController(sections: sections, title: "Personal Hotspot Settings")
    }

    // ...
}

08:16 All that's left in the form view controller now is generic code:

class FormViewController: UITableViewController {
    var sections: [Section] = []
    
    func reloadSectionFooters() {
        // ...
    }
    
    init(sections: [Section], title: String) {
        self.sections = sections
        super.init(style: .grouped)
        navigationItem.title = title
    }
    
    // ...
    
    override func numberOfSections(in tableView: UITableView) -> Int {
        return sections.count
    }
    
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return sections[section].cells.count
    }
    
    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
    }
    
    override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
        return sections[section].footerTitle
    }
    
    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        cell(for: indexPath).didSelect?()
    }
}

08:27 In the app delegate, we create the HotspotDriver and put the driver's form view controller at the root of the navigation stack:

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    // ...
    let navigationController = UINavigationController()
    let driver = HotspotDriver()

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
        // ...
        navigationController.viewControllers = [
            driver.formViewController
        ]
        // ...
        return true
    }
}

09:24 Before we can run the app and see if everything works, we need to fix one thing in the HotspotDriver. Currently, the password cell tries to push another view controller onto a navigation controller, but since we're no longer inside a view controller, we have to use the navigation controller of the contained form view controller:

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

09:39 Given the driver's dependency on the navigation controller, it could be smart to let the navigation controller be owned by the driver as well, instead of assuming it's there. We'll come back to refactor this later.

Reusing FormViewController

10:48 Now that we have a reusable form view controller, we can also use it for the password form. That eliminates the need for the PasswordViewController, so we repurpose it into a PasswordDriver that follows the same pattern as the first driver:

class PasswordDriver {
    let textField = UITextField()
    let onChange: (String) -> ()
    var formViewController: FormViewController!
    var sections: [Section] = []
    
    init(password: String, onChange: @escaping (String) -> ()) {
        self.onChange = onChange
        buildSections()
        self.formViewController = FormViewController(sections: sections, title: "Hotspot Password")
        textField.text = password
    }
    
    func buildSections() {
        let cell = FormCell(style: .value1, reuseIdentifier: nil)
        cell.textLabel?.text = "Password"
        cell.contentView.addSubview(textField)
        // ...

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

    // TODO: textField.becomeFirstResponder()
    
    @objc func editingEnded(_ sender: Any) {
        onChange(textField.text ?? "")
    }
    
    @objc func editingDidEnter(_ sender: Any) {
        onChange(textField.text ?? "")
        formViewController.navigationController?.popViewController(animated: true)
    }
}

14:31 Back in the HotspotDriver, we now create a PasswordDriver instead of another view controller, and when the password cell is selected, we push that driver's form view controller:

class HotspotDriver {
    // ...
    func buildSections() {
        // ...

        let passwordDriver = PasswordDriver(password: state.password) { [unowned self] in
            self.state.password = $0
        }
        
        passwordCell.didSelect = { [unowned self] in
            self.formViewController.navigationController?.pushViewController(passwordDriver.formViewController, animated: true)
        }

        // ...
    }
    // ...
}

First Responder

15:34 We almost made everything work again, but the password text field no longer triggers the keyboard by becoming the first responder. It used to do this in the view controller's viewDidAppear, but the generic form view controller we have now doesn't know about our text field. To address this, we add a property to FormViewController for the driver to pass in an object that should be the first responder:

class FormViewController: UITableViewController {
    var sections: [Section] = []
    var firstResponder: UIResponder?
    
    // ...
    
    init(sections: [Section], title: String, firstResponder: UIResponder? = nil) {
        self.firstResponder = firstResponder
        // ...
    }
    
    // ...
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        firstResponder?.becomeFirstResponder()
    }
    
    // ...
}

class PasswordDriver {
    let textField = UITextField()
    // ...
    init(password: String, onChange: @escaping (String) -> ()) {
        // ...
        self.formViewController = FormViewController(sections: sections, title: "Hotspot Password", firstResponder: textField)
        // ...
    }
    // ...
}

Wrapping Up

17:49 FormViewController, Section, and FormCell no longer have anything to do with our specific form. They've basically become just infrastructure, so we move them into a separate file, which will be the basis for a library.

19:10 At this point, we're almost facing the same problem we had at the beginning of the episode: instead of two view controllers, we have two specific driver classes that are quite similar. They each build and store sections, and they each generate and manage a form view controller.

It should be possible to somehow share this code, for which we'd have to get rid of the driver's custom actions that are defined for specific form controls. Next time, we'll work on a single, reusable driver that only needs to receive a configuration of sections in order to make the form work.

Resources

  • Sample Project

    Written in Swift 4

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes