Last active
March 20, 2019 23:16
-
-
Save chriseidhof/0082d246a1bc983a50009a835c4022e2 to your computer and use it in GitHub Desktop.
Extensible Commands in TEA
This file contains hidden or 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
import Foundation | |
// Let's use a Command protocol... | |
protocol Command { | |
associatedtype Action | |
static func modalAlert(title: String, accept: String) -> Self | |
static func request(_ request: URLRequest, available: @escaping (Data?) -> Action) -> Self | |
} | |
// And let's say we built a sample Elm app which has MyAction as the Action type... | |
enum MyAction { | |
case present(text: String) | |
} | |
// In the sample app, we would return something like this: | |
func sample<C>() -> C where C: Command, C.Action == MyAction { | |
return C.modalAlert(title: "Hello", accept: "Test") | |
} | |
func sample2<C>() -> C where C: Command, C.Action == MyAction { | |
let r = URLRequest(url: URL(string: "http://www.objc.io")!) | |
return C.request(r, available: { data in | |
return .present(text: "got data: \(data?.count ?? 0)") | |
}) | |
} | |
// To run an action, we can define an "interpretation context" that looks something like this: | |
struct Run<A> { | |
let run: (_ callback: @escaping (A) -> ()) -> () | |
} | |
extension Run: Command { | |
typealias Action = A | |
static func modalAlert(title: String, accept: String) -> Run<A> { | |
return Run { callback in | |
print("Modal alert: \(title), accept: \(accept).") | |
} | |
} | |
static func request(_ request: URLRequest, available: @escaping (Data?) -> A) -> Run<A> { | |
return Run { callback in | |
URLSession.shared.dataTask(with: request) { data, _, _ in | |
callback(available(data)) | |
}.resume() | |
} | |
} | |
} | |
// Here's an example: | |
(sample() as Run).run { _ in () } // this prints | |
// If we wanted to test, however, we can define an enum: | |
enum Test<A> { | |
case _modalAlert(title: String, accept: String) | |
case _request(URLRequest, available: (Data?) -> A) | |
} | |
extension Test: Command { | |
typealias Action = A | |
static func modalAlert(title: String, accept: String) -> Test<A> { | |
return ._modalAlert(title: title, accept: accept) | |
} | |
static func request(_ request: URLRequest, available: @escaping (Data?) -> A) -> Test<A> { | |
return ._request(request, available: available) | |
} | |
} | |
// Let's run a networking test: | |
let test: Test = sample2() as Test // gives us the correct enum | |
// We could also implement map on the Command: | |
struct Map<A, B, C> where C: Command, C.Action == B { | |
let map: (_ : @escaping (A) -> B) -> C | |
} | |
extension Map: Command { | |
typealias Action = A | |
static func modalAlert(title: String, accept: String) -> Map { | |
return Map { transform in | |
C.modalAlert(title: title, accept: accept) | |
} | |
} | |
static func request(_ request: URLRequest, available: @escaping (Data?) -> A) -> Map { | |
return Map { transform in | |
C.request(request, available: { data in | |
transform(available(data)) | |
}) | |
} | |
} | |
} | |
// We'll make views abstract, as well | |
protocol ViewP { | |
associatedtype Action | |
static func button(title: String, onTap: Action) -> Self | |
static func label(_ text: String) -> Self | |
static func stack(_ children: [Self]) -> Self | |
} | |
enum VirtualView<A> { | |
case _button(title: String, onTap: A) | |
case _label(String) | |
case _stack([VirtualView<A>]) | |
} | |
extension VirtualView: ViewP { | |
static func button(title: String, onTap: A) -> VirtualView<A> { | |
return ._button(title: title, onTap: onTap) | |
} | |
static func label(_ text: String) -> VirtualView<A> { | |
return ._label(text) | |
} | |
static func stack(_ children: [VirtualView<A>]) -> VirtualView<A> { | |
return ._stack(children) | |
} | |
typealias Action = A | |
} | |
// We can build a driver: | |
final class Driver<State, Action> { | |
var state: State | |
let view: (State) -> VirtualView<Action> | |
let update: (inout State, Action) -> Run<Action>? | |
init(_ state: State, _ view: @escaping (State) -> VirtualView<Action>, update: @escaping (inout State, Action) -> Run<Action>?) { | |
self.state = state | |
self.view = view | |
self.update = update | |
} | |
} | |
enum SampleAction { | |
case increase | |
case decrease | |
case received(newState: Int) | |
case reload | |
} | |
struct SampleState { | |
var counter: Int | |
mutating func update<C: Command>(_ action: SampleAction) -> C? where C.Action == SampleAction { | |
switch action { | |
case .decrease: counter -= 1 | |
case .increase: counter += 1 | |
case .received(let s): counter = s | |
case .reload: return C.request(URLRequest(url: URL(string: "mycounter.com")!), available: { data in | |
return .received(newState: data?.count ?? 0) | |
}) | |
} | |
return nil | |
} | |
func view<V>() -> V where V: ViewP, V.Action == SampleAction { | |
return V.stack([ | |
.label("counter: \(counter)"), | |
.button(title: "+", onTap: .increase), | |
.button(title: "-", onTap: .decrease), | |
.button(title: "reload", onTap: .reload) | |
]) | |
} | |
} | |
let d = Driver<SampleState, SampleAction>(SampleState(counter: 0), { $0.view() }, update: { state, action in | |
state.update(action) | |
}) | |
// We can also test without mocking/stubbing: | |
var z = SampleState(counter: 5) | |
if case let ._request(url, _)? = z.update(.reload) as Test? { | |
print("test succeeded") | |
} | |
// And the nice thing, we can extend the command retroactively, without having to modify the driver. | |
protocol WriteFile: Command { | |
static func write(_ data: Data, to url: URL) -> Self | |
} | |
extension Run: WriteFile { | |
static func write(_ data: Data, to url: URL) -> Run<A> { | |
return Run { _ in | |
try! data.write(to: url) | |
} | |
} | |
} | |
extension SampleState { | |
mutating func updateAlt<C>(action: SampleAction) -> C? where C: Command & WriteFile, C.Action == SampleAction { | |
return C.write(Data(), to: URL(string: "")!) | |
} | |
} | |
let d2 = Driver<SampleState, SampleAction>(SampleState(counter: 0), { $0.view() }, update: { state, a in | |
state.updateAlt(action: a) | |
}) | |
// todo we can have a diffable/updatable virtual view hierarchy as well... | |
import UIKit | |
struct Virtual<Action> { | |
let construct: (_ callback: @escaping (Action) -> ()) -> UIView | |
let update: (_ existing: UIView) -> Bool // success | |
} | |
extension Virtual: ViewP { | |
static func button(title: String, onTap: Action) -> Virtual<Action> { | |
return Virtual(construct: { callback in | |
let b = UIButton(type: .custom) | |
b.setTitle(title, for: .normal) | |
// todo set action | |
return b | |
}, update: { existing in | |
guard let b = existing as? UIButton else { return false } | |
// ... | |
return true | |
}) | |
} | |
static func label(_ text: String) -> Virtual<Action> { | |
fatalError("TODO") | |
} | |
static func stack(_ children: [Virtual<Action>]) -> Virtual<Action> { | |
fatalError("TODO") | |
} | |
} | |
final class Driver2<State, Action> { | |
var state: State | |
let view: (State) -> Virtual<Action> | |
let update: (inout State, Action) -> Run<Action>? | |
init(_ state: State, _ view: @escaping (State) -> Virtual<Action>, update: @escaping (inout State, Action) -> Run<Action>?) { | |
self.state = state | |
self.view = view | |
self.update = update | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment