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!