00:06 We're back with another episode about our forms library, where we'll
improve the way we work with nested forms. The first helper we'll create builds
a nested form for editing a text cell's value. Another helper will create a form
to choose an option from a list, which is powered by an enum. Finally, we'll
look at how to turn an existing form into a nested form for use as a section
within a larger form.
00:33 Since the last episode of this
series, we've done
some renaming of types in our library to improve consistency and clarity.
Before, we had a strangely named type alias, Rendered
, and its generic
parameters were the wrong way around compared to the other library components.
We renamed it to Element
and flipped its parameters so that the first one is
the type of element it stores and the second one is the type of state the form
operates on:
typealias Element<El, A> = (RenderingContext<A>) -> RenderedElement<El, A>
typealias Form<A> = Element<[Section], A>
Nested Text Fields
01:07 We created a form that lets us edit a Hotspot
struct, which
contains settings for a personal hotspot. When we select the form's password
cell, we go to a nested form for editing the password value:
struct Hotspot {
var isEnabled: Bool = true
var password: String = "hello"
}
let hotspotForm: Form<Hotspot> =
sections([
section([
controlCell(title: "Personal Hotspot", control: uiSwitch(keyPath: \.isEnabled))
], footer: \.enabledSectionTitle),
section([
detailTextCell(title: "Password", keyPath: \.password, form: passwordForm)
])
])
let passwordForm: Form<Hotspot> =
sections([
section([
controlCell(title: "Password", control: textField(keyPath: \.password), leftAligned: true)
])
])
01:37 Editing a cell's value in a nested form is such a common pattern
that it's worth writing a helper to create cells with nested forms. Instead of
supplying a form-building function to detailTextCell
, we call the
nestedTextField
helper, which builds the nested form automatically:
func nestedTextField<State>(title: String, keyPath: WritableKeyPath<State, String>) -> Element<FormCell, State> {
let nested: Form<State> = return detailTextCell(title: title, keyPath: keyPath, form: nested)
}
03:01 We take the function that builds the password form and turn it
into the generic nested form:
func nestedTextField<State>(title: String, keyPath: WritableKeyPath<State, String>) -> Element<FormCell, State> {
let nested: Form<State> =
sections([
section([
controlCell(title: title, control: textField(keyPath: keyPath), leftAligned: true)
])
])
return detailTextCell(title: title, keyPath: keyPath, form: nested)
}
03:25 Using this helper makes it super easy to add a second nested form
— for example, one to edit our hotspot's network name:
struct Hotspot {
var isEnabled: Bool = true
var password: String = "hello"
var networkName: String = "my network"
}
let hotspotForm: Form<Hotspot> =
sections([
section([
controlCell(title: "Personal Hotspot", control: uiSwitch(keyPath: \.isEnabled))
], footer: \.enabledSectionTitle),
section([
nestedTextField(title: "Password", keyPath: \.password),
nestedTextField(title: "Network Name", keyPath: \.networkName)
])
])
The nestedTextField
helper now works for any type of form, so we move the code
into our forms library file.
Nested Option Cells
04:34 Next, we want to create a cell with a nested options form. This
cell looks a lot like the nested text field cell: it has a title and a detail
label displaying the current value, and when we select the cell, another form
opens to edit the value. But in this case, the nested form lets us choose a
value from a list of options.
05:28 We add an enum property to Hotspot
, which we'll use for storing
a preference about how notifications are to be shown. In addition to the enum's
three cases, we've added a property exposing all cases, along with one that
returns the enum value's name as a string:
enum ShowPreview {
case always
case never
case whenUnlocked
static let all: [ShowPreview] = [.always, .whenUnlocked, .never]
var text: String {
switch self {
case .always: return "Always"
case .whenUnlocked: return "When Unlocked"
case .never: return "Never"
}
}
}
struct Hotspot {
var isEnabled: Bool = true
var password: String = "hello"
var networkName: String = "my network"
var showPreview: ShowPreview = .always
}
07:17 We add a new section to the hotspot form for showPreview
.
Similar to how we built the nested text field, we start out with a detail text
cell, which takes a nested form-building function:
let hotspotForm: Form<Hotspot> =
sections([
section([
detailTextCell(title: "Notification", keyPath: \.showPreview.text, form: showPreviewForm)
]),
])
07:57 The most minimal version of the nested form builder already works
— the notification cell is added to the main form, showing "Always" as the
current value — except the nested form doesn't have any cells yet:
let showPreviewForm: Form<Hotspot> =
sections([
section([
])
])
08:50 We need to create the option cells to put in the nested form.
These cells need a value (e.g. an enum case), a title describing the value, and
the key path to assign the value to if the cell gets selected.
09:23 The helper that creates an option cell is generic over an input
parameter, which, in our case, will be the enum type that describes the options:
func optionCell<Input, State>(title: String, option: Input, keyPath: WritableKeyPath<State, Input>) -> Element<FormCell, State> {
}
10:21 The helper has to return an Element
, which is a function that
takes a rendering context and returns a rendered element. Inside the function,
we create a form cell and put the passed-in title in the cell's text label:
func optionCell<Input, State>(title: String, option: Input, keyPath: WritableKeyPath<State, Input>) -> Element<FormCell, State> {
return { context in
let cell = FormCell(style: .value1, reuseIdentifier: nil)
cell.textLabel?.text = title
}
}
10:52 In the rendered element's update function, we set the cell's
accessory view to be a checkmark if the cell's option is equal to the currently
selected one — we have to constrain the input type to be Equatable
:
func optionCell<Input: Equatable, State>(title: String, option: Input, keyPath: WritableKeyPath<State, Input>) -> Element<FormCell, State> {
return { context in
let cell = FormCell(style: .value1, reuseIdentifier: nil)
cell.textLabel?.text = title
return RenderedElement(element: cell, strongReferences: [], update: { state in
cell.accessoryType = state[keyPath: keyPath] == option ? .checkmark : .none
})
}
}
12:14 We map over the enum's cases to create an array of option cells:
let showPreviewForm: Form<Hotspot> =
sections([
section(
ShowPreview.all.map { option in
optionCell(title: option.text, option: option, keyPath: \.showPreview)
}
)
])
12:58 The nested form is now populated with option cells — one for each
enum case. The "Always" cell correctly has a checkmark because it contains the
default value we defined in Hotspot
. But we can't yet select a different
option.
13:09 In order to change the selected option, we need to define the
cell's didSelect
callback. In this callback, we use the context's change
function to assign the cell's option to the key path. We also have to change the
cell's shouldHighlight
property to true
— we made it default to false
in
the FormCell
class — in order to make the cell selectable:
func optionCell<Input: Equatable, State>(title: String, option: Input, keyPath: WritableKeyPath<State, Input>) -> Element<FormCell, State> {
return { context in
let cell = FormCell(style: .value1, reuseIdentifier: nil)
cell.textLabel?.text = title
cell.shouldHighlight = true
cell.didSelect = {
context.change { $0[keyPath: keyPath] = option }
}
return RenderedElement(element: cell, strongReferences: [], update: { state in
cell.accessoryType = state[keyPath: keyPath] == option ? .checkmark : .none
})
}
}
14:33 The optionCell
helper now works. And because we wrote it as a
generic function, we can immediately move it from our app's code to the forms
library.
14:54 If it turns out we need nested option forms in multiple places, we
could also add a helper that creates a cell with a nested form.
Reusing Forms
15:14 It would be nice if we could take our existing hotspot form and
incorporate it into a general settings form — and if we could do so without
having to manually change the form's state type, because that would force us to
change key paths throughout the hotspot form. In other words, we need a smart
way to take a Form<A>
and create a Form<B>
out of it, in order to use it as
a nested form inside another Form<B>
.
16:04 Let's see how we can make this work. We extend our example app
with a separate state struct that holds general settings:
struct Settings {
var hotspot: Hotspot
}
16:18 We start writing a function that turns a hotspot form into a
settings form. The returned Form
is a function that takes a
RenderingContext<Settings>
:
func bind(form: Form<Hotspot>) -> Form<Settings> {
return { context in
}
}
16:50 However, the type of context the passed-in form
function wants
is RenderingContext<Hotspot>
. We have to create a new nested context, which
applies only to the hotspot part of our Settings
struct. As the Hotspot
state for the nested context, we use the hotspot
property of the main
context's Settings
state. We also need to define the nested context's change
function — which we'll do in a bit — along with two navigation functions, which
we pass on from the main context:
func bind(form: Form<Hotspot>) -> Form<Settings> {
return { context in
let nestedContext = RenderingContext<Hotspot>(state: context.state.hotspot, change: { nestedChange in
}, pushViewController: context.pushViewController, popViewController: context.popViewController)
}
}
18:03 The nested context's change
parameter is a function that takes
a function, and this is where we have to do the actual work of binding the
Hotspot
context to the Settings
context. We first call the outer context's
change function to receive the mutable Settings
state. Then, the nested change
function expects an inout Hotspot
, which we get from the mutable Settings
state:
func bind(form: Form<Hotspot>) -> Form<Settings> {
return { context in
let nestedContext = RenderingContext<Hotspot>(state: context.state.hotspot, change: { nestedChange in
context.change { state in
nestedChange(&state.hotspot)
}
}, pushViewController: context.pushViewController, popViewController: context.popViewController)
}
}
18:56 Now that we've created a nested context, we can call the hotspot
form-building function with it to get a rendered form. To return the rendered
form as a RenderedElement
, we have to supply a function that updates the form
elements with new Settings
values. It does this by calling the nested form's
update function, passing in the settings' hotspot
property:
func bind(form: Form<Hotspot>) -> Form<Settings> {
return { context in
let nestedContext = RenderingContext<Hotspot>()
let sections = form(nestedContext)
return RenderedElement(element: sections.element, strongReferences: sections.strongReferences, update: { state in
sections.update(state.hotspot)
})
}
}
20:41 This code seems very tricky to write, but the type system really
helps us out to the extent that it's almost impossible to make mistakes.
Let's try using the bind
function. We keep the hotspotForm
builder as it is,
and we write a new settingsForm
with a single detail text cell, which displays
whether or not the hotspot is enabled. For the nested form, we call bind
to
create a Form<Settings>
from the hotspot form:
struct Settings {
var hotspot = Hotspot()
var hotspotEnabled: String {
return hotspot.isEnabled ? "On" : "Off"
}
}
let settingsForm: Form<Settings> =
sections([
section([
detailTextCell(title: "Personal Hotspot", keyPath: \Settings.hotspotEnabled, form: bind(hotspotForm))
])
])
23:07 In the app delegate, we update the form driver to now use an
initial Settings
value and the new settings form:
let driver = FormDriver(initial: Settings(), build: settingsForm)
23:35 We run the app and see our settings form, consisting of a single
cell that says the hotspot is enabled. When we select that cell, we go to the
hotspot form. Here we can disable the hotspot. Going back to the main settings
form, we see the label is updated to show that the hotspot is now disabled — it
works!
Fixing a Bug
23:53 There's one little problem with the hotspot form though: the
toggle section footer doesn't update along with the toggle's state. We fix this
in the detail text cell helper; the nested form's update function now asks the
nested view controller to reload its section footers:
func detailTextCell<State>(title: String, keyPath: KeyPath<State, String>, form: @escaping Form<State>) -> Element<FormCell, State> {
return { context in
let cell = FormCell(style: .value1, reuseIdentifier: nil)
let rendered = form(context)
let nested = FormViewController(sections: rendered.element, title: title)
return RenderedElement(element: cell, strongReferences: rendered.strongReferences, update: { state in
cell.detailTextLabel?.text = state[keyPath: keyPath]
rendered.update(state)
nested.reloadSectionFooters()
})
}
}
Moving bind(form:to:) into the Library
24:48 The last thing we do before we finish is make the bind
function
more generic. Instead of specifically working with the Settings
and Hotspot
types, we use the generic parameters State
and NestedState
, respectively. We
add a key path parameter that defines how a nested state relates to the main
state:
func bind<State, NestedState>(form: @escaping Form<NestedState>, to keyPath: WritableKeyPath<State, NestedState>) -> Form<State> {
return { context in
let nestedContext = RenderingContext<NestedState>(state: context.state[keyPath: keyPath], change: { nestedChange in
context.change { state in
nestedChange(&state[keyPath: keyPath])
}
}, pushViewController: context.pushViewController, popViewController: context.popViewController)
let sections = form(nestedContext)
return RenderedElement<[Section], State>(element: sections.element, strongReferences: sections.strongReferences, update: { state in
sections.update(state[keyPath: keyPath])
})
}
}
26:26 And in the settings form builder, we pass in the key path to the
hotspot
property:
let settingsForm: Form<Settings> =
sections([
section([
detailTextCell(title: "Personal Hotspot", keyPath: \Settings.hotspotEnabled, form: bind(form: hotspotForm, to: \.hotspot))
])
])
26:49 Now that the bind
function is no longer specific to our use
case, we can move it into our library. With this function, it's easy to create
richly structured forms or to divide a large form into logical, separate
screens.
27:20 It's great that all form elements are linked together in a
reactive way so that we don't need observers to keep values in sync; changing a
setting somewhere down the nested forms hierarchy actually updates the root
struct. And the forms library hides the complexity it takes to make this work.