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 FormCell
s, 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.