portico/Sources/Portico/State/Binding.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) }
}
}