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)
]
}
@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.