Skip to content

Instantly share code, notes, and snippets.

@chriseidhof
Last active December 6, 2019 22:52
  • Star 31 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
Star You must be signed in to star a gist
Save chriseidhof/40fde6c2be5519d5bb341fc65b3029ad to your computer and use it in GitHub Desktop.
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