Observable References
import Foundation | |
// A lens is a getter and a setter combined | |
struct Lens<Whole, Part> { | |
let get: (Whole) -> Part | |
let set: (inout Whole, Part) -> () | |
} | |
// We can create a lens from a key path | |
extension Lens { | |
init(_ keyPath: WritableKeyPath<Whole, Part>) { | |
get = { $0[keyPath: keyPath]} | |
set = { w, p in w[keyPath: keyPath] = p} | |
} | |
} | |
final class Disposable { | |
var dispose: () -> () | |
init(_ dispose: @escaping () -> ()) { | |
self.dispose = dispose | |
} | |
deinit { dispose() } | |
} | |
// A mutable, observable variable. The type `A` should have value semantics (e.g. a struct or enum) | |
final class Var<A> { | |
private var _get: () -> A | |
private var _set: (A) -> () | |
typealias AddObserver = (@escaping (A) -> ()) -> Disposable | |
let addObserver: AddObserver | |
var storage: A { | |
get { | |
return _get() | |
} | |
set { | |
_set(newValue) | |
} | |
} | |
init(_ value: A) { | |
var freshTokens = (0...).makeIterator() | |
var observers: [Int: (A) -> ()] = [:] | |
var x = value { | |
didSet { | |
observers.values.forEach { $0(x) } | |
} | |
} | |
addObserver = { | |
let token = freshTokens.next()! | |
observers[token] = $0 | |
return Disposable { | |
observers[token] = nil | |
} | |
} | |
_get = { x } | |
_set = { x = $0 } | |
} | |
private init(get: @escaping () -> A, set: @escaping (A) -> (), addObserver: @escaping AddObserver) { | |
self._get = get | |
self._set = set | |
self.addObserver = addObserver | |
} | |
// If we have a keypath, we can create a variable for that keypath. The variable will be observable and *mutable*. It is a reference, i.e. mutating it will mutate the "parent": | |
subscript<Part>(_ kp: WritableKeyPath<A, Part>) -> Var<Part> { | |
return self[Lens(kp)] | |
} | |
// Keypaths are just a special case of lenses | |
subscript<Part>(_ lens: Lens<A, Part>) -> Var<Part> { | |
return Var<Part>(get: { | |
lens.get(self.storage) | |
}, set: { | |
lens.set(&self.storage, $0) | |
}, addObserver: { o in | |
self.addObserver { newValue in | |
o(lens.get(newValue)) | |
} | |
}) | |
} | |
} | |
// If the variable is a mutable collection, we can even project out its elements as `Var`s: | |
extension Var where A: MutableCollection { | |
subscript(_ index: A.Index) -> Var<A.Element> { | |
let lens = Lens<A, A.Element>(get: { $0[index] }, set: { w, p in w[index] = p }) | |
return self[lens] | |
} | |
} | |
// We could create a Store that automatically serializes an A, given that A is Codable. By exposing its value as a `Var`, we can mutate the value, and it'll get persisted on disk: | |
final class Store<A: Codable> { | |
let value: Var<A> | |
let disposeBag: Any? | |
init(url: URL, defaultValue: A) { | |
if let data = try? Data(contentsOf: url), | |
let decoded = try? JSONDecoder().decode(A.self, from: data) { | |
self.value = Var(decoded) | |
} else { | |
self.value = Var(defaultValue) | |
} | |
let encoder = JSONEncoder() | |
disposeBag = self.value.addObserver { newValue in | |
let data = try! encoder.encode(newValue) | |
try! data.write(to: url) | |
} | |
} | |
} | |
// Given a `Var<[A]>`, we can create a generic table view controller which observes *and* mutates an A. | |
import UIKit | |
final class GenericTableViewController<A>: UITableViewController { | |
let items: Var<[A]> | |
let configure: (UITableViewCell, A) -> () | |
var didSelect: ((Var<A>) -> ())? | |
var disposeBag: Any? | |
init(items: Var<[A]>, configure: @escaping (UITableViewCell, A) -> ()) { | |
self.items = items | |
self.configure = configure | |
super.init(style: .plain) | |
disposeBag = items.addObserver { [weak self] _ in self?.tableView.reloadData() } | |
} | |
required init?(coder aDecoder: NSCoder) { | |
fatalError("Not implemented") | |
} | |
override func viewDidLoad() { | |
super.viewDidLoad() | |
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") | |
} | |
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { | |
return items.storage.count | |
} | |
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { | |
return true | |
} | |
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) { | |
guard editingStyle == .delete else { return } | |
items.storage.remove(at: indexPath.row) | |
} | |
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { | |
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell")! | |
configure(cell, items.storage[indexPath.row]) | |
return cell | |
} | |
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { | |
didSelect?(items[indexPath.row]) | |
} | |
} | |
// For example, an address book: | |
struct Address: Codable { | |
var street: String | |
} | |
struct Person: Codable { | |
var name: String | |
var address: [Address] | |
} | |
// Finally, let's put everything together. Here's a sample app which displays a list of people, can add new people. You can also tap on a person and see their addresses. Both table views are mutable, and because they work with `Var`s, they automatically communicate back! The entire state will be persisted automatically whenever you make a change. | |
import PlaygroundSupport | |
final class AddressViewController: UIViewController, UITextFieldDelegate { | |
let address: Var<Address> | |
let street = UITextField() | |
var disposeBag: Any? | |
init(address: Var<Address>) { | |
self.address = address | |
super.init(nibName: nil, bundle: nil) | |
disposeBag = address.addObserver { [weak self] newValue in | |
self?.configure(for: newValue) | |
} | |
} | |
required init?(coder aDecoder: NSCoder) { | |
fatalError() | |
} | |
override func viewDidLoad() { | |
super.viewDidLoad() | |
let stackView = UIStackView() | |
stackView.axis = .vertical | |
stackView.backgroundColor = .white | |
street.borderStyle = .roundedRect | |
street.placeholder = "Street" | |
street.delegate = self | |
stackView.addArrangedSubview(street) | |
view.addSubview(stackView) | |
self.configure(for: address.storage) | |
} | |
override func viewDidAppear(_ animated: Bool) { | |
view.subviews[0].frame = view.bounds // hack, too lazy for autolayout | |
} | |
func configure(for: Address) { | |
guard address.storage.street != street.text else { return } | |
street.text = address.storage.street | |
} | |
func textFieldDidEndEditing(_ textField: UITextField) { | |
guard let value = street.text else { return } | |
print("Text field did end editing") | |
address.storage.street = value | |
} | |
func textFieldShouldReturn(_ textField: UITextField) -> Bool { | |
return true | |
} | |
deinit { | |
print("Deiniting address vc: \(address.storage)") | |
} | |
} | |
final class Coordinator: NSObject { | |
var root: UINavigationController! | |
let store: Store<[Person]> | |
override init() { | |
let storeURL = playgroundSharedDataDirectory.appendingPathComponent("test.json") | |
store = Store<[Person]>(url: storeURL, defaultValue: []) | |
super.init() | |
let people = GenericTableViewController<Person>(items: store.value) { cell, person in | |
cell.textLabel?.text = person.name | |
cell.accessoryType = .disclosureIndicator | |
} | |
people.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addPerson(sender:))) | |
people.didSelect = { [weak self] (person: Var<Person>) in | |
self?.showPerson(person) | |
} | |
root = UINavigationController(rootViewController: people) | |
} | |
func showPerson(_ person: Var<Person>) { | |
let vc = GenericTableViewController<Address>(items: person[\.address]) { cell, address in | |
cell.textLabel?.text = address.street | |
} | |
vc.didSelect = { [weak self] address in | |
self?.showAddress(address) | |
} | |
root.pushViewController(vc, animated: true) | |
} | |
func showAddress(_ address: Var<Address>) { | |
let vc = AddressViewController(address: address) | |
root.pushViewController(vc, animated: true) | |
} | |
@objc func addPerson(sender: Any) { | |
store.value.storage.append(Person(name: "Sample Person", address: [ | |
Address(street: "Sample Street"), | |
Address(street: "Sample Street 2"), | |
Address(street: "Sample Street 3") | |
])) | |
} | |
} | |
print(playgroundSharedDataDirectory) | |
let c = Coordinator() | |
PlaygroundPage.current.liveView = c.root |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment