Skip to content

Latest commit

 

History

History
169 lines (120 loc) · 5.8 KB

Forms.md

File metadata and controls

169 lines (120 loc) · 5.8 KB

Forms - Building table like UIs with mixed row types

It is quite common with UIs laid out and styled as table views. But these tables are sometimes using rows with mixed types. A good example is iOS's general settings. Building tables with mixed row types are often hard to get right, especially if the rows being displayed might differ based on some configuration. If you ever attempted to maintain similar UIs, especially when backed by a UITableView, you are probably aware of the difficulties involved.

To mitigate this, Form provides three helper views; FormView, SectionView and RowView for building table like UIs. These views are backed by UIStackViews, and laid out and styled to look like UITableViews. They were designed for convenience and are best suited for smaller tables. For more performant tables, Form provides TableKit for working with UITableViews and reusable rows.

Building forms using FormView, SectionView and RowView is straightforward:

let form = FormView()
let section = form.appendSection(header: "About")
let row = section.appendRow(title: "Credits")
bag += row.onValue { /* show credits */ } 

Here we can see that we can build our UI more declaratively and directly. This is in sharp contrast to using table views where you have an indirection using indices, data sources and cells.

As you build you table using code it is also simple to make them dynamic based on some configuration parameters:

if hasFeature {
  let section = form.appendSection(header: "Feature") 
  if hasSubFeature {
    let row = section.appendRow(title: "Sub feature")
  }
}

To build this using table view's indirection would require a delicate juggling of section and row indices.

FormView

At the root of a form is the FormView that holds vertically laid out section views:

let form = FormView()
let section = SectionView(header: ..., footer: ...)
form.append(section)

As adding sections to a form is so common there are convenience helpers to write this more succinctly:

let section = form.appendSection(header: ..., footer: ...)

But it is worth pointing out that you can append any view to a form, not only section views, making it easier to build custom UI:

form.append(customView)

SectionView

A SectionView holds an array of vertically laid out row views, optionally starting with header and ending with a footer.

Similar to FormView you can add any view to a section:

let section = form.appendSection()
section.append(customView)

But more commonly, you add row views instead:

let row = RowView(title: ...)
section.append(row)

Or more succinctly:

let row = form.appendRow(title: ...)

By using RowViews we also ensure the layout is updated to use the provided SectionStyle's rowInsets and itemSpacing:

let style = SectionStyle.default.restyle { style in
  style.rowInset.left = 40
  style.itemSpacing = 20
}

let section = form.appendSection(style: style)

RowView

A RowView holds an array of horizontally laid out views. You typically build a row starting out with a title (and optionally subtitle) and then appends (or prepends) more views to it:

let row = RowView(title: "title", subtitle: "subtitle")
  .prepend(iconImage)
  .append("details")
  .append(.chevron)
  
section.append(row)  

Or more conveniently:

let row = section.appendRow(title: "title", subtitle: "subtitle")
  .prepend(iconImage)
  .append("details")
  .append(.chevron)

RowAndProvider

When adding a RowView to a section it returns a RowAndProvider holding both the row view and a Signal<()> for observing selections of the row:

bag += section.appendRow(title: "title")  // -> RowAndProvider<Signal<()>>
  .onValue { /* row tapped */ }

A RowAndProvider behaves much like a standalone RowView, and you can continue appending and prepending views to its row:

let row = section.appendRow(title: "title") // - RowAndProvider
  .prepend(iconImage)  // - RowAndProvider
  .append("details")  // - RowAndProvider
  .onValue { /* row tapped */ }

But as seen above RowAndProvider also takes the role of a signal so you can in the case above call onValue to observe the row being tapped.

RowAndProvider is generic on a Provider type conforming to SignalProvider. As for the example above, the provider was just a basic signal Signal<()> for observing row taps. But if you append a view that conforms to SignalProvider, such as many UIControls, append will return an updated RowAndProvider holding the added view as its provider:

let enabledSwitch = UISwitch(...)
let row = RowView(title: "title") // -> RowView
  .append(enabledSwitch) // -> RowAndProvider<UISwitch>

Now RowAndProvider holds the switch and provides the switch's signal for convenience:

bag += row.onValue { enabled in /* switch updated */ }

If you add another providing view to a RowAndProvider it will change to provide the latest view.

bag += section.appendRow(title: "title") // -> RowAndProvider<Signal<()>>
  .append(enabledSwitch) // -> RowAndProvider<UISwitch>
  .onValue { enabled in ... }

If you want to opt out of changing the provider you can cast the appended provider to a UIView:

bag += section.appendRow(title: "title") // -> RowAndProvider<Signal()>
  .append(enabledSwitch as UIView) // -> RowAndProvider<Signal()>
  .onValue { /* row tapped */ }

bag += enabledSwitch.onValue { enabled in ... }

By using the power of Flow's signals together with forms we can build our UI and logic in a more declarative way:

let feature: ReadWriteSignal<Bool>
bag += section.appendRow(title: "Feature")
  .append(UISwitch()) // -> The providedSignal is ReadWriteSignal<Bool>
  .bidirectionallyBindTo(feature.atOnce())