-
-
Save alessandro-martin/78ba20f7e98d1f50c2e9dc07ca98244b to your computer and use it in GitHub Desktop.
Observable References
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 | |
// 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