Skip to content

Instantly share code, notes, and snippets.

@andersio
Last active October 16, 2016 14:32
Show Gist options
  • Select an option

  • Save andersio/4988f9ee44d65f27cdbd0076a89d6a21 to your computer and use it in GitHub Desktop.

Select an option

Save andersio/4988f9ee44d65f27cdbd0076a89d6a21 to your computer and use it in GitHub Desktop.
import ReactiveSwift
import enum Result.NoError
/// Bidirectional Binding Operator: `<~>`
precedencegroup BidirectionalBinding {
higherThan: AssignmentPrecedence
}
infix operator <~>: BidirectionalBinding
/// Describes a transform for an Editor to bridge presented values in UI
/// with persisted value in the model layer.
public protocol EditorTransform {
associatedtype Presented
associatedtype Persisted: Equatable
static func convert(persisted value: Persisted) -> Presented
static func convert(presented value: Presented) -> Persisted
}
/// Defines how an Editor should merge values shall any conflict arises.
public protocol EditorMergePolicy {
associatedtype Persisted
static var stopsPersistedAfterFirstEdit: Bool { get }
static func merge(cached value: Persisted, with proposedValue: Persisted) -> Persisted
}
/// A merge policy that always prefers user edits.
public enum PreferUserEditMergePolicy<Value>: EditorMergePolicy {
public static var stopsPersistedAfterFirstEdit: Bool {
return true
}
public static func merge(cached value: Value, with proposedValue: Value) -> Value {
return value
}
}
/// Represents a subject of a control that can be bond with an Editor.
public struct ControlSubject<Value> {
public let signal: Signal<Value, NoError>
public let value: BindingTarget<Value>
public let isEnabled: BindingTarget<Bool>
public let duration: Signal<(), NoError>
public init(signal: Signal<Value, NoError>, value: BindingTarget<Value>, isEnabled: BindingTarget<Bool>, duration: Signal<(), NoError>) {
self.signal = signal
self.value = value
self.isEnabled = isEnabled
self.duration = duration
}
}
/// Manages a two-way binding between a mutable entity and a ControlSubject.
public class Editor<Transform: EditorTransform> {
/// The latest cached value of the Editor.
///
/// - important: This is eventually consistent with the persisted source, and
/// strongly consistent with the bound control.
public var value: Transform.Persisted {
return _value
}
/// The underlying storage of `value`.
private var _value: Transform.Persisted!
/// Whether a user initiated edit has ever been caught.
public private(set) var hasBeenEdited: Bool = false
/// Whether the Editor should drop non distinct user changes.
private var dropsNonDistinctUserChanges = true
/// Whether the changes from the persisted source should be ignored.
private var ignoresPersistedValue = false
/// Whether the changes from the persisted change should be ignored after the
/// user has made a very first edit.
private let stopsPersistedAfterFirstEdit: Bool
/// An action to merge the cached value and the persisted change.
private let merge: (Transform.Persisted, Transform.Persisted) -> Transform.Persisted
/// An action to write the value back to the persisted source.
private let _writeback: (Transform.Persisted) -> Void
/// A scheduler to serialize changes from the persisted source and the
/// writebacks from the editor.
private let scheduler: UIScheduler
/// A producer that controls whether the control should be enabled.
private let enabledProducer: SignalProducer<Bool, NoError>
/// The ControlSubject being bound to `self`.
private var subject: ControlSubject<Transform.Presented>? {
didSet {
guard let subject = subject else { return }
let controlDisposable = CompositeDisposable()
subject.duration.take(first: 1).observe { _ in controlDisposable.dispose() }
controlDisposable += { [weak self] in self?.subject = nil }
controlDisposable += subject.isEnabled <~ enabledProducer
controlDisposable += subject.signal.observeValues { userValue in
let newValue = Transform.convert(presented: userValue)
if self.dropsNonDistinctUserChanges && newValue == self._value {
return
}
if !self.hasBeenEdited {
self.hasBeenEdited = true
}
self._value = newValue
self.writeback(async: false)
}
subject.value.consume(Transform.convert(persisted: _value))
self.controlDisposable = controlDisposable
}
}
/// The disposable of the current ControlSubject.
private var controlDisposable: CompositeDisposable?
/// The disposable of the persisted source.
private var persistedDisposable: CompositeDisposable
/// Initialize an Editor that manages two-way bindings with the supplied
/// persisted source (`persistedValues` and `writeback`).
///
/// - important: You may supply a `writeback` action that would cause values
/// being sent upon `persistedValues`. The editor would track
/// and ignore these values at best effort to avoid deadlocks and
/// feedback loops.
///
/// - parameters:
/// - persistedValues: The initial value and subsequent changes over time.
/// - writeback: An action to writeback changes.
/// - mergePolicy: The desired merge policy for the Editor.
/// - enabledProducer: A producer which specifies whether the Editor is
/// enabled.
public init<MergePolicy: EditorMergePolicy>(
persistedValues: SignalProducer<Transform.Persisted, NoError>,
writeback setter: @escaping (Transform.Persisted) -> Void,
mergePolicy: MergePolicy.Type,
enabledIf enabledProducer: SignalProducer<Bool, NoError>,
dropsNonDistinctUserChanges: Bool = true
) where MergePolicy.Persisted == Transform.Persisted {
self.dropsNonDistinctUserChanges = dropsNonDistinctUserChanges
stopsPersistedAfterFirstEdit = mergePolicy.stopsPersistedAfterFirstEdit
merge = mergePolicy.merge(cached:with:)
_writeback = setter
self.enabledProducer = enabledProducer
persistedDisposable = CompositeDisposable()
scheduler = UIScheduler()
persistedDisposable += persistedValues
.observe(on: scheduler)
.startWithValues { value in
if self.ignoresPersistedValue {
return
}
if self.hasBeenEdited {
if self.stopsPersistedAfterFirstEdit {
let mergedValue = self.merge(self._value, value)
self._value = mergedValue
}
// The writeback needs to be asynchronous, since the `sendLock` of
// `persistedValues` is held by this scope.
self.writeback(async: true)
} else {
self._value = value
}
self.subject?.value.consume(Transform.convert(persisted: value))
}
assert(_value != nil)
}
/// Write back the cached value to the persisted source.
///
/// - parameters:
/// - async: Whether the writeback should be performed asynchronously.
private func writeback(async: Bool = false) {
ignoresPersistedValue = true
if async {
scheduler.schedule {
self._writeback(self._value)
// Since the writeback is delivered to `scheduler` asynchronously, we
// enqueue a block to flip the ignoring flag after the writeback.
self.scheduler.schedule {
self.ignoresPersistedValue = false
}
}
} else {
self._writeback(self._value)
self.ignoresPersistedValue = false
}
}
/// Bind a ControlSubject with an Editor.
public static func <~> (left: Editor<Transform>, right: ControlSubject<Transform.Presented>) {
left.subject = right
}
}
import AppKit
class TweetCell: NSTableCellView {
func setViewModel(_ viewModel: TweetViewModel) {
let subject = ControlSubject(signal: textField!.reactive.continuousStringValues,
value: textField!.reactive.stringValue,
isEnabled: textField!.reactive.isEnabled,
duration: reactive.trigger(for: #selector(self.prepareForReuse)))
viewModel.content <~> subject!
}
}
import MantleData
import ReactiveSwift
public enum NopTransform<Value>: EditorTransform {
public static func convert(persisted value: Value) -> Value {
return value
}
public static func convert(presented value: Value) -> Value {
return value
}
}
public struct TweetViewModel {
let content: Editor<NopTransform<String>>
internal init(_ tweet: Tweet) {
content = Editor(persistedValues: content.producer(forKeyPath: #keyPath(Entry.flow)),
writeback: { entry.flow = $0 },
mergePolicy: PreferUserEditMergePolicy.self,
enabledIf: SignalProducer(value: true))
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment