Last active
January 4, 2018 12:51
-
-
Save chriseidhof/c719fe57807429a33a21c17396c3b0be to your computer and use it in GitHub Desktop.
forms.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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