Swift Talk # 113

Building a Form Library: Text Fields, Multi-Select, and Nested Forms

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 return to the form library project and add several features to simplify common tasks.

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> = // todo
    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
            // todo
        }, 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.

Resources

  • Sample Project

    Written in Swift 4.1

  • 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