Skip to content

Instantly share code, notes, and snippets.

@chriseidhof
Last active January 4, 2018 12:51
Show Gist options
  • Save chriseidhof/c719fe57807429a33a21c17396c3b0be to your computer and use it in GitHub Desktop.
Save chriseidhof/c719fe57807429a33a21c17396c3b0be to your computer and use it in GitHub Desktop.
forms.swift
//
// ViewController.swift
// Forms
// By Brandon Kase and Chris Eidhof
import UIKit
protocol Element {
associatedtype Input
associatedtype Action
static func textField(value: @escaping (Input) -> String, onChange: @escaping (String) -> Action) -> Self
static func label(_: @escaping (Input) -> String) -> Self
static func hidden(_: @escaping (Input) -> Bool, _ wrapped: Self) -> Self
static func button(title: @escaping (Input) -> String, onTap: Action) -> Self
}
// Helper class
public class TargetAction: NSObject {
let callback: () -> ()
public init(_ callback: @escaping () -> ()) {
self.callback = callback
}
@objc public func action(_ sender: AnyObject) {
callback()
}
}
indirect enum ElementEnum<I, A> {
case _textField(value: (I) -> String, onChange: (String) -> A)
case _label((I) -> String)
case _hidden((I) -> Bool, ElementEnum<I, A>)
case _button(title: (I) -> String, onTap: A)
// todo:
// case validate((Input) -> Bool, Element<Input, Action>)
// validate should wrap the element in a view which gets a red border
}
extension ElementEnum: Element {
typealias Input = I
typealias Action = A
static func textField(value: @escaping (ElementEnum<Input, Action>.Input) -> String, onChange: @escaping(String) -> ElementEnum<Input, Action>.Action) -> ElementEnum<Input, Action> {
return ._textField(value: value, onChange: onChange)
}
static func label(_ convert: @escaping (ElementEnum<Input, Action>.Input) -> String) -> ElementEnum<Input, Action> {
return ._label(convert)
}
static func button(title: @escaping (I) -> String, onTap: A) -> ElementEnum<I, A> {
return ._button(title: title, onTap: onTap)
}
static func hidden(_ condition: @escaping (I) -> Bool, _ wrapped: ElementEnum<I, A>) -> ElementEnum<I, A> {
return ._hidden(condition, wrapped)
}
}
typealias RenderedElement<Input> = (UIView, strongReferences: [Any], set: (Input) -> ())
struct ToRenderedElement<I, A> {
let render: (_ value: I, _ callback: @escaping (A) -> ()) -> RenderedElement<I>
}
extension ToRenderedElement: Element {
typealias Input = I
typealias Action = A
static func textField(value convert: @escaping (I) -> String, onChange: @escaping (String) -> A) -> ToRenderedElement<I, A> {
return ToRenderedElement { value, callback in
let result = UITextField()
result.borderStyle = .roundedRect
let ta = TargetAction { [unowned result] in
callback(onChange(result.text ?? ""))
}
result.addTarget(ta, action: #selector(TargetAction.action(_:)), for: .editingChanged)
return (result, [ta], {
result.text = convert($0)
})
}
}
static func button(title convert: @escaping (I) -> String, onTap action: A) -> ToRenderedElement<I, A> {
return ToRenderedElement { value, callback in
let result = UIButton(type: .roundedRect)
let ta = TargetAction {
callback(action)
}
result.addTarget(ta, action: #selector(TargetAction.action(_:)), for: .touchUpInside)
return (result, [ta], { result.setTitle(convert($0), for: .normal) })
}
}
static func label(_ convert: @escaping (I) -> String) -> ToRenderedElement<I, A> {
return ToRenderedElement { value, callback in
let result = UILabel()
result.numberOfLines = 0
return (result, [], { result.text = convert($0) })
}
}
static func hidden(_ condition: @escaping (I) -> Bool, _ wrapped: ToRenderedElement<I, A>) -> ToRenderedElement<I, A> {
return ToRenderedElement { value, callback in
let (v, refs, s) = wrapped.render(value, callback)
return (v, refs, { input in
s(input)
v.isHidden = condition(input)
})
}
}
}
var globalRefs: [Any] = [] // todo hack
func render<Input, Action>(initial: Input, _ form: ToRenderedElement<Input, Action>, reducer: @escaping (inout Input, Action) -> (), onChange: @escaping (Input) -> ()) -> UIView {
var observers: [(Input) -> ()] = [onChange]
var state = initial {
didSet {
for o in observers {
o(state)
}
}
}
let (view, refs, setter) = form.render(initial, { action in
reducer(&state, action)
})
setter(state)
globalRefs.append(refs)
observers.append(setter)
return view
}
struct SampleState {
var firstName: String
var lastName: String
var submitted: Bool
}
enum Action {
case setFirstName(String)
case setLastName(String)
case submit
}
extension SampleState {
mutating func apply(_ action: Action) {
switch action {
case let .setLastName(x):
lastName = x
case let .setFirstName(x):
firstName = x
case .submit:
submitted = true
}
}
}
extension SampleState {
var valid: Bool {
return !firstName.isEmpty && !lastName.isEmpty
}
var fullName: String {
return "\(firstName) \(lastName)"
}
}
func form2<E>() -> [E] where E: Element, E.Input == SampleState, E.Action == Action {
return [
.label { _ in "First Name" },
.textField(value: { $0.firstName }, onChange: Action.setFirstName),
.label { _ in "Last Name" },
.textField(value: { $0.lastName }, onChange: Action.setLastName),
.label { "Name length: \($0.fullName.count)" },
.hidden( { !$0.valid }, .button(title: { _ in "Submit" }, onTap: Action.submit))
]
}
// Now the cool part: we can extend `Element` by creating a new protocol:
protocol WithSwitch: Element { // the Element protocol inheritance isn't necessary in this case
static func `switch`(isOn: @escaping (Input) -> Bool, action: @escaping (Bool) -> Action) -> Self
}
// We can't conform `ElementEnum`, but we can conform `ToRenderedElement`:
extension ToRenderedElement: WithSwitch {
static func `switch`(isOn: @escaping (I) -> Bool, action: @escaping (Bool) -> A) -> ToRenderedElement<I, A> {
return ToRenderedElement { enabled, onChange in
let result = UISwitch()
let ta = TargetAction { [unowned result] in
onChange(action(result.isOn))
}
result.addTarget(ta, action: #selector(TargetAction.action(_:)), for: .valueChanged)
return (result, [ta], { newValue in
result.isOn = isOn(newValue)
})
}
}
}
protocol WithSections: Element {
static func _section(_ children: [Self]) -> Self
}
extension Sequence where Element: WithSections {
var formSection: Iterator.Element {
return ._section(Array(self))
}
}
extension ToRenderedElement: WithSections {
static func _section(_ children: [ToRenderedElement<I, A>]) -> ToRenderedElement<I,A> {
return ToRenderedElement { enabled, onChange in
let section = UIStackView()
section.axis = .vertical
section.spacing = 20
let datas = children.map { elem in
elem.render(enabled, onChange)
}
datas.map{ $0.0 }.forEach {
section.addArrangedSubview($0)
$0.translatesAutoresizingMaskIntoConstraints = false
}
section.translatesAutoresizingMaskIntoConstraints = false
let refs = datas.map { $0.1 }.flatMap { $0 }
let set = { input in
datas.map { $0.2}.forEach {
$0(input)
}
}
return (section, refs, set)
}
}
}
struct SampleState2 {
var wifiEnabled: Bool = false
var networkName: String = ""
enum Action {
case changeWifiEnabled(Bool)
case setNetworkName(String)
}
mutating func apply(_ action: Action) {
switch action {
case let .changeWifiEnabled(value):
self.wifiEnabled = value
case let .setNetworkName(newValue):
self.networkName = newValue
}
}
}
// We can make some convenience extensions as well
extension Element {
static func invalidLabel(text: @escaping (Input) -> String, valid: @escaping (Input) -> Bool) -> Self {
return .label { input in
let result = text(input)
return valid(input) ? result : result + " 🛑 (invalid)"
}
}
}
func form3<E>() -> E where E: WithSections & WithSwitch, E.Input == SampleState2, E.Action == SampleState2.Action {
return [
[
.label { _ in "Wifi Enabled" },
.`switch`(isOn: { $0.wifiEnabled }, action: SampleState2.Action.changeWifiEnabled),
].formSection,
.hidden({ !$0.wifiEnabled}, [
.invalidLabel(text: { _ in "Network Name" }, valid: { !$0.networkName.isEmpty }),
.textField(value: { $0.networkName }, onChange: SampleState2.Action.setNetworkName)
].formSection)
].formSection
}
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// let formView = render(initial: SampleState(firstName: "Chris", lastName: "Test", submitted: false), form2(), reducer: { $0.apply($1) }, onChange: {
// print($0)
// })
let formView = render(initial: SampleState2(), form3(), reducer: { $0.apply($1) }, onChange: {
print($0)
})
view.addSubview(formView)
formView.translatesAutoresizingMaskIntoConstraints = false
let guide = view.safeAreaLayoutGuide
view.addConstraints([
formView.topAnchor.constraintEqualToSystemSpacingBelow(guide.topAnchor, multiplier: 1),
formView.leftAnchor.constraintEqualToSystemSpacingAfter(guide.leftAnchor, multiplier: 1),
guide.rightAnchor.constraintEqualToSystemSpacingAfter(formView.rightAnchor, multiplier: 1),
])
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated.
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment