120 lines
4.7 KiB
Swift
120 lines
4.7 KiB
Swift
import Observation
|
|
|
|
/// A two-way connection to a mutable value source.
|
|
///
|
|
/// State-derived bindings (created via ``State/projectedValue``)
|
|
/// use tracking reads and notifying writes. Custom bindings created
|
|
/// with ``init(get:set:)`` provide an escape hatch for constant or
|
|
/// bridging bindings where ``subscribe(_:)`` is inert.
|
|
@MainActor public struct Binding<Value> {
|
|
private let getter: () -> Value
|
|
private let setter: (Value) -> Void
|
|
private let subscriber: (@escaping (Value) -> Void) -> SubscriptionToken
|
|
private let peeker: () -> Value
|
|
|
|
/// The current value. Get tracks (registers a dependency within a
|
|
/// ``DependencyTracker``); set notifies.
|
|
public var wrappedValue: Value {
|
|
get { getter() }
|
|
nonmutating set { setter(newValue) }
|
|
}
|
|
|
|
/// Non-tracking read. Never registers a ``DependencyTracker`` dependency,
|
|
/// unlike ``wrappedValue``.
|
|
@_spi(Portico) public var untrackedValue: Value { peeker() }
|
|
|
|
/// Registers a callback that fires on every change until the returned
|
|
/// token is cancelled.
|
|
public func subscribe(
|
|
_ onChange: @escaping (Value) -> Void
|
|
) -> SubscriptionToken {
|
|
subscriber(onChange)
|
|
}
|
|
|
|
/// Escape hatch for custom or constant bindings.
|
|
///
|
|
/// The ``subscribe(_:)`` method on this binding is inert (returns
|
|
/// a no-op token that never fires).
|
|
public init(
|
|
get: @escaping () -> Value,
|
|
set: @escaping (Value) -> Void
|
|
) {
|
|
self.getter = get
|
|
self.setter = set
|
|
self.subscriber = { _ in SubscriptionToken(onCancel: {}) }
|
|
self.peeker = get
|
|
}
|
|
|
|
/// Creates a binding backed by a state box: tracking get, notifying
|
|
/// set, and live subscription.
|
|
@_spi(Portico) public init(_ box: StateBox<Value>) {
|
|
self.getter = { box.get() }
|
|
self.setter = { box.set($0) }
|
|
self.subscriber = { box.subscribe($0) }
|
|
self.peeker = { box.peek() }
|
|
}
|
|
|
|
/// Creates a binding backed by a state box that ignores writes.
|
|
///
|
|
/// Backs ``Environment/projectedValue``: an environment slot's value is
|
|
/// owned by the ancestor that injected it, so a consumer may observe it
|
|
/// but not replace it. Reads still track and subscriptions still fire.
|
|
@_spi(Portico) public init(readOnly box: StateBox<Value>) {
|
|
self.getter = { box.get() }
|
|
self.setter = { _ in }
|
|
self.subscriber = { box.subscribe($0) }
|
|
self.peeker = { box.peek() }
|
|
}
|
|
|
|
/// Creates a two-way binding to a property of an `@Observable` object.
|
|
///
|
|
/// Completes the loop for injected models: the object's properties are the
|
|
/// writable half of a read-only `@Environment`, and this hands them to the
|
|
/// widget property binders that expect a ``Binding``. Widget edits write
|
|
/// straight through to the object, and Observation carries the change to
|
|
/// every other widget that read the same property.
|
|
///
|
|
/// ``subscribe(_:)`` is backed by a ``DependencyTracker``, so the callback
|
|
/// fires on the coalesced idle flush rather than inside the setter - see
|
|
/// ``ObservationBridge``. The returned token owns the tracker; cancelling
|
|
/// it (or tearing down the registry holding it) stops the updates.
|
|
///
|
|
/// - Parameters:
|
|
/// - object: The observable model. Held strongly by the binding.
|
|
/// - keyPath: The property to read and write.
|
|
public init<T: AnyObject & Observation.Observable>(
|
|
_ object: T,
|
|
_ keyPath: ReferenceWritableKeyPath<T, Value>
|
|
) {
|
|
self.getter = { object[keyPath: keyPath] }
|
|
self.setter = { object[keyPath: keyPath] = $0 }
|
|
self.peeker = { object[keyPath: keyPath] }
|
|
self.subscriber = { onChange in
|
|
var isInitialRun = true
|
|
let tracker = DependencyTracker {
|
|
let value = object[keyPath: keyPath]
|
|
if isInitialRun {
|
|
isInitialRun = false
|
|
return
|
|
}
|
|
onChange(value)
|
|
}
|
|
tracker.run()
|
|
return SubscriptionToken { tracker.teardown() }
|
|
}
|
|
}
|
|
}
|
|
|
|
extension Binding where Value: Equatable {
|
|
/// Writes `newValue` only when it differs from the current value.
|
|
///
|
|
/// The regular ``wrappedValue`` setter still writes on every assignment;
|
|
/// this method gates the write itself, skipping subscribers, dependency
|
|
/// tracker re-evaluations, and widget writes for an equal value. Its
|
|
/// non-tracking read does not register a dependency.
|
|
///
|
|
/// - Parameter newValue: The value to write when it differs from the current one.
|
|
public func setIfChanged(_ newValue: Value) {
|
|
if peeker() != newValue { setter(newValue) }
|
|
}
|
|
}
|