Adds SwiftUI-style @Environment property wrapper with key-path and object-injection flavors, an @Observable bridge that flushes on a GLib idle source at highIdle priority (ahead of GTK redraw), targeted widget updates driven by Observation tracking, and two-way observable bindings that ride the existing bindProperty path. New library files (4): - State/ObservationBridge.swift -- coalesced flush bridge - State/EnvironmentValues.swift -- scope-local key-to-box map - State/Environment.swift -- @Environment wrapper, DynamicProperty - Core/View+Environment.swift -- .environment(...) modifiers Modified library files (8): - DependencyTracker: import Observation, observing: param, didArmObservation - EitherView/ForEach: opt container trackers out of Observation - NodeRegistry: isEmpty for retention check - PropertyBinding: hasDependencies || didArmObservation gate - Binding: readOnly init, observable key-path init - MountContext: environment field, withEnvironment, inheritance - AnyView: _resolveDynamicProperties hook before user body eval New tests (21 across 3 files): - EnvironmentTests (7): scoping, nesting, projections, ForEach, lazy branches, reuse - ObservableTests (11): acceptance case, write-back, coalescing, teardown, binding echo, re-arm de-dupe, retention, flush ordering - StateTests (+3): Observation arming unit tests Example: EnvironmentDemoPage demonstrates the feature end to end.
164 lines
6.8 KiB
Swift
164 lines
6.8 KiB
Swift
/// SwiftUI-style `@Environment`: the property wrapper, its shared resolved
|
|
/// storage, and the mount-time walk that binds every ``DynamicProperty``
|
|
/// declared by a view.
|
|
///
|
|
/// A stored property of a ``View`` that must be bound to the mounting scope
|
|
/// before `body` runs.
|
|
///
|
|
/// ``AnyView`` walks a user view's stored properties once, at mount, and hands
|
|
/// each conformer the scope's ``EnvironmentValues``. Mount happens exactly once
|
|
/// per view instance, so the walk is not on any update path.
|
|
@MainActor public protocol DynamicProperty {
|
|
/// Binds this property to `environment`. Called once, before `body`.
|
|
func _resolve(in environment: EnvironmentValues)
|
|
}
|
|
|
|
/// Shared, resolved storage for one `@Environment` declaration.
|
|
///
|
|
/// A reference type so that struct copies of the enclosing view - and of the
|
|
/// wrapper itself, which `Mirror` hands out by value - all observe the same
|
|
/// resolution.
|
|
@MainActor final class EnvironmentSlot<Value> {
|
|
/// What this declaration resolved to, plus the reference identifying it.
|
|
///
|
|
/// The identity is carried explicitly rather than derived from `value`,
|
|
/// because `Value` may be an `Optional` whose `nil` has no stable object
|
|
/// to compare.
|
|
struct Resolution {
|
|
var box: StateBox<Value>?
|
|
var value: Value?
|
|
var identity: AnyObject?
|
|
|
|
static func box(_ box: StateBox<Value>) -> Resolution {
|
|
Resolution(box: box, value: nil, identity: box)
|
|
}
|
|
|
|
static func object(_ value: Value, identity: AnyObject?) -> Resolution {
|
|
Resolution(box: nil, value: value, identity: identity)
|
|
}
|
|
}
|
|
|
|
var resolution: Resolution?
|
|
}
|
|
|
|
/// Reads a value injected by an ancestor's `.environment(...)` modifier.
|
|
///
|
|
/// Two flavors, mirroring SwiftUI:
|
|
///
|
|
/// - `@Environment(\.isFullscreen) var isFullscreen` - a key-path slot. Reads
|
|
/// are *tracking*: inside a reactive closure the read registers a dependency,
|
|
/// so a later write updates only the widgets that consumed it. Writes are
|
|
/// allowed and land in the box shared by the whole scope, so every other
|
|
/// declaration of the same slot in that scope sees them.
|
|
/// - `@Environment(Model.self) var model` - an injected reference type,
|
|
/// typically `@Observable`. Read-only; change notification comes from
|
|
/// Observation, not from a box.
|
|
///
|
|
/// A declaration that is never resolved (read before mount, or on a view that
|
|
/// was never mounted) falls back to the key's process-wide default box, so it
|
|
/// still shares state with every other unresolved declaration of that slot.
|
|
@propertyWrapper @MainActor public struct Environment<Value>: DynamicProperty {
|
|
private let bind: (EnvironmentValues) -> EnvironmentSlot<Value>.Resolution?
|
|
private let slot = EnvironmentSlot<Value>()
|
|
|
|
/// Binds this declaration to the environment slot named by `keyPath`.
|
|
public init(_ keyPath: KeyPath<EnvironmentValues, Value>) {
|
|
bind = { env in env._box(for: keyPath).map { .box($0) } }
|
|
}
|
|
|
|
/// Binds this declaration to the injected instance of `type`.
|
|
public init(_ type: Value.Type) where Value: AnyObject {
|
|
bind = { env in
|
|
env.object(Value.self).map { .object($0, identity: $0) }
|
|
}
|
|
}
|
|
|
|
/// Binds this declaration to the injected instance of `type`, or `nil`.
|
|
public init<T: AnyObject>(_ type: T.Type) where Value == T? {
|
|
bind = { env in
|
|
let found = env.object(T.self)
|
|
return .object(found, identity: found)
|
|
}
|
|
}
|
|
|
|
/// The current value. Read-only, matching SwiftUI.
|
|
///
|
|
/// Key-path slots read through the scope's shared ``StateBox``, so the read
|
|
/// *tracks*: a reactive closure that reads it re-runs when the scope's
|
|
/// value is replaced. Mutable shared state belongs in an `@Observable`
|
|
/// object injected with ``View/environment(_:)-(T)``, whose properties are
|
|
/// writable and propagate on their own.
|
|
public var wrappedValue: Value {
|
|
let r = resolution
|
|
if let box = r.box { return box.get() }
|
|
return r.value!
|
|
}
|
|
|
|
/// A read-only ``Binding`` to the scope's slot, for feeding the existing
|
|
/// binding-taking inits and modifiers.
|
|
///
|
|
/// Writes through this binding are ignored: the slot's value is owned by
|
|
/// whichever ancestor injected it. Only meaningful for key-path slots;
|
|
/// traps on an object slot.
|
|
public var projectedValue: Binding<Value> {
|
|
guard let box = resolution.box else {
|
|
fatalError("@Environment object slots have no binding")
|
|
}
|
|
return Binding(readOnly: box)
|
|
}
|
|
|
|
/// Binds this declaration to `environment`.
|
|
///
|
|
/// Traps when the same declaration is bound twice to *different* values.
|
|
/// One resolution is shared by every copy of the enclosing view struct, so
|
|
/// mounting one view instance into two different environment scopes would
|
|
/// otherwise leave the first subtree silently reading the second scope's
|
|
/// cell. Mounting the same instance twice within one scope resolves
|
|
/// identically and is allowed.
|
|
@_spi(Portico) public func _resolve(in environment: EnvironmentValues) {
|
|
let next = bind(environment)
|
|
if let previous = slot.resolution, previous.identity !== next?.identity {
|
|
fatalError(
|
|
"""
|
|
@Environment: view instance mounted into two different \
|
|
environment scopes. A view instance resolves its environment \
|
|
once; build a fresh instance per mount instead of reusing one \
|
|
across scopes.
|
|
"""
|
|
)
|
|
}
|
|
slot.resolution = next
|
|
}
|
|
|
|
/// The resolution, binding lazily against an empty scope if the mount-time
|
|
/// walk never reached this declaration.
|
|
private var resolution: EnvironmentSlot<Value>.Resolution {
|
|
if let r = slot.resolution { return r }
|
|
guard let r = bind(EnvironmentValues()) else {
|
|
fatalError("@Environment: no value injected for this slot")
|
|
}
|
|
slot.resolution = r
|
|
return r
|
|
}
|
|
}
|
|
|
|
/// Binds every ``DynamicProperty`` stored in `view` to `environment`.
|
|
///
|
|
/// Types with no dynamic properties are recorded on first sight and skipped
|
|
/// thereafter, so the reflection cost is one `Mirror` per view *type*, not per
|
|
/// instance.
|
|
@_spi(Portico) @MainActor
|
|
public func _resolveDynamicProperties<V>(_ view: V, in environment: EnvironmentValues) {
|
|
let id = ObjectIdentifier(V.self)
|
|
if _inertViewTypes.contains(id) { return }
|
|
var found = false
|
|
for child in Mirror(reflecting: view).children {
|
|
guard let property = child.value as? DynamicProperty else { continue }
|
|
found = true
|
|
property._resolve(in: environment)
|
|
}
|
|
if !found { _inertViewTypes.insert(id) }
|
|
}
|
|
|
|
/// View types already known to store no ``DynamicProperty``.
|
|
@MainActor private var _inertViewTypes: Set<ObjectIdentifier> = []
|