Add onChange view modifiers

This commit is contained in:
Brendan Szymanski 2026-08-10 23:06:49 -04:00
parent dcaa660b78
commit 44f95fe0ad
2 changed files with 449 additions and 0 deletions

View file

@ -0,0 +1,228 @@
import Gtk
/// Installs the synchronous subscription path for a binding-backed value.
///
/// The initial snapshot and each callback use non-tracking reads. The previous
/// value is updated before invoking the action so write-back actions cannot
/// recurse on a stale snapshot.
@MainActor
private func installOnChange<V: Equatable>(
_ binding: Binding<V>,
initial: Bool,
registry: NodeRegistry,
_ action: @escaping @MainActor (V, V) -> Void
) {
var previous = binding.untrackedValue
if initial {
let seed = previous
DependencyTracker.untracked { action(seed, seed) }
}
registry.add(binding.subscribe { next in
guard next != previous else { return }
let old = previous
previous = next
DependencyTracker.untracked { action(old, next) }
})
}
/// Installs the dependency-tracked closure path for a value source.
///
/// The tracker keeps only sources that register a state or Observation
/// dependency. It compares successive values and suppresses tracking while
/// invoking the action.
@MainActor
private func installOnChange<V: Equatable>(
_ value: @escaping @MainActor () -> V,
initial: Bool,
registry: NodeRegistry,
_ action: @escaping @MainActor (V, V) -> Void
) {
var previous: V?
let tracker = DependencyTracker {
let next = value()
guard let old = previous else {
previous = next
if initial { DependencyTracker.untracked { action(next, next) } }
return
}
guard old != next else { return }
previous = next
DependencyTracker.untracked { action(old, next) }
}
tracker.run()
if tracker.hasDependencies || tracker.didArmObservation { registry.add(tracker) }
}
extension WidgetView {
/// Runs `action` when the bound value changes.
///
/// The value is compared with `==`, so writing an equal value does not fire
/// the action. The modifier is installed during mount and remains active
/// while the subtree is mounted, including while its widget is hidden. It is
/// released when the subtree tears down.
///
/// State-derived sources such as `$value` from `@State`,
/// `$model.property` from `@Bindable`, `Binding(model, \.property)`, and
/// `$value` from `@Environment(\.someKey)` notify this modifier. Observable
/// model bindings deliver on the coalesced idle flush described by
/// ``ObservationBridge``, rather than synchronously with the mutation.
///
/// A binding created with ``Binding/init(get:set:)`` never fires because its
/// ``Binding/subscribe(_:)`` is inert by design. Use the closure overload
/// when the getter reads a tracked source.
///
/// - Parameters:
/// - value: The binding whose changes trigger `action`.
/// - initial: Whether to run `action` once during mount. When `true`, the
/// mount-time value is passed as both `oldValue` and `newValue`.
/// - action: The side effect to run with the old and new values.
/// - Returns: A copy of this view with the change handler installed.
public func onChange<V: Equatable>(
of value: Binding<V>,
initial: Bool = false,
_ action: @escaping @MainActor (V, V) -> Void
) -> Self {
appending { _, ctx in
installOnChange(value, initial: initial, registry: ctx.registry, action)
}
}
/// Runs an action when the bound value changes, ignoring the old and new values.
///
/// This is the zero-argument counterpart of ``onChange(of:initial:_:)``.
///
/// - Parameters:
/// - value: The binding whose changes trigger `action`.
/// - initial: Whether to run `action` once during mount.
/// - action: The side effect to run after a distinct value change.
/// - Returns: A copy of this view with the change handler installed.
public func onChange<V: Equatable>(
of value: Binding<V>,
initial: Bool = false,
_ action: @escaping @MainActor () -> Void
) -> Self {
onChange(of: value, initial: initial) { (_: V, _: V) in action() }
}
/// Runs `action` when the value returned by the tracked closure changes.
///
/// The closure is evaluated inside a ``DependencyTracker``. Reads of
/// `@State`, `@Environment` values, and properties of `@Observable` models
/// become triggers. Plain values, ordinary classes and structs, global
/// variables, and GTK/GObject properties are not tracked. If the closure
/// reads nothing reactive, the tracker is dropped and this modifier is inert.
///
/// The returned value must be `Equatable`; project a non-Equatable value to
/// an Equatable property or identity, for example
/// `.onChange(of: { model.items.count })` or
/// `.onChange(of: { ObjectIdentifier(model.child) })`. The action runs with
/// tracking suppressed, so reads in the action do not become triggers.
///
/// - Parameters:
/// - value: A tracked closure that produces the value to compare.
/// - initial: Whether to run `action` once during mount. When `true`, the
/// mount-time value is passed as both `oldValue` and `newValue`.
/// - action: The side effect to run with the old and new values.
/// - Returns: A copy of this view with the change handler installed.
public func onChange<V: Equatable>(
of value: @escaping @MainActor () -> V,
initial: Bool = false,
_ action: @escaping @MainActor (V, V) -> Void
) -> Self {
appending { _, ctx in
installOnChange(value, initial: initial, registry: ctx.registry, action)
}
}
/// Runs an action when the tracked value changes, ignoring the old and new values.
///
/// This is the zero-argument counterpart of the tracked two-argument
/// ``onChange(of:initial:_:)`` overload.
///
/// - Parameters:
/// - value: A tracked closure that produces the value to compare.
/// - initial: Whether to run `action` once during mount.
/// - action: The side effect to run after a distinct value change.
/// - Returns: A copy of this view with the change handler installed.
public func onChange<V: Equatable>(
of value: @escaping @MainActor () -> V,
initial: Bool = false,
_ action: @escaping @MainActor () -> Void
) -> Self {
onChange(of: value, initial: initial) { (_: V, _: V) in action() }
}
}
extension View {
/// The `AnyView`-returning counterpart of the ``WidgetView`` binding
/// `onChange(of:initial:_:)` modifier for containers and other non-widget views.
///
/// - Parameters:
/// - value: The binding whose changes trigger `action`.
/// - initial: Whether to run `action` once during mount.
/// - action: The side effect to run with the old and new values.
/// - Returns: An ``AnyView`` wrapping this view with the change handler installed.
public func onChange<V: Equatable>(
of value: Binding<V>,
initial: Bool = false,
_ action: @escaping @MainActor (V, V) -> Void
) -> AnyView {
AnyView(makeWidget: { ctx in
let widget = AnyView(self).makeWidget(ctx)
installOnChange(value, initial: initial, registry: ctx.registry, action)
return widget
})
}
/// The zero-argument `AnyView`-returning counterpart of the binding change
/// modifier. It ignores the old and new values.
///
/// - Parameters:
/// - value: The binding whose changes trigger `action`.
/// - initial: Whether to run `action` once during mount.
/// - action: The side effect to run after a distinct value change.
/// - Returns: An ``AnyView`` wrapping this view with the change handler installed.
public func onChange<V: Equatable>(
of value: Binding<V>,
initial: Bool = false,
_ action: @escaping @MainActor () -> Void
) -> AnyView {
onChange(of: value, initial: initial) { (_: V, _: V) in action() }
}
/// The `AnyView`-returning counterpart of the tracked-closure
/// `onChange(of:initial:_:)` modifier for containers and other non-widget views.
///
/// - Parameters:
/// - value: A tracked closure that produces the value to compare.
/// - initial: Whether to run `action` once during mount.
/// - action: The side effect to run with the old and new values.
/// - Returns: An ``AnyView`` wrapping this view with the change handler installed.
public func onChange<V: Equatable>(
of value: @escaping @MainActor () -> V,
initial: Bool = false,
_ action: @escaping @MainActor (V, V) -> Void
) -> AnyView {
AnyView(makeWidget: { ctx in
let widget = AnyView(self).makeWidget(ctx)
installOnChange(value, initial: initial, registry: ctx.registry, action)
return widget
})
}
/// The zero-argument `AnyView`-returning counterpart of the tracked change
/// modifier. It ignores the old and new values.
///
/// - Parameters:
/// - value: A tracked closure that produces the value to compare.
/// - initial: Whether to run `action` once during mount.
/// - action: The side effect to run after a distinct value change.
/// - Returns: An ``AnyView`` wrapping this view with the change handler installed.
public func onChange<V: Equatable>(
of value: @escaping @MainActor () -> V,
initial: Bool = false,
_ action: @escaping @MainActor () -> Void
) -> AnyView {
onChange(of: value, initial: initial) { (_: V, _: V) in action() }
}
}

View file

@ -0,0 +1,221 @@
import Observation
import Testing
@_spi(Portico) import Portico
@_spi(SGTKInternal) import Gtk
@_silgen_name("g_main_context_iteration")
private nonisolated func onChange_g_main_context_iteration(
_ context: UnsafeMutableRawPointer?, _ mayBlock: Int32
) -> Int32
/// Drives the GLib default main context until `condition` holds or `turns` is exhausted.
/// Pumps GLib so coalesced Observation callbacks can reach the test action.
@MainActor private func pumpOnChange(until condition: () -> Bool, turns: Int = 200) {
for _ in 0..<turns {
if condition() { return }
_ = onChange_g_main_context_iteration(nil, 0)
}
}
/// Observable model used to verify closure-backed Observation updates.
@Observable
private final class OnChangeModel {
var number = 0
}
/// Equatable old/new pair used by the change assertions.
private struct OnChangePair: Equatable {
let old: Int
let new: Int
}
/// Regression tests for binding-backed and dependency-tracked onChange modifiers.
@MainActor @Suite(.serialized) struct OnChangeTests {
@Test func bindingFiresWithOldAndNewValues() {
guard Gtk.initCheck() else { return }
let box = StateBox(0)
let binding = Binding(box)
var pairs: [OnChangePair] = []
let ctx = MountContext()
_ = AnyView(Label(str: "x").onChange(of: binding) { old, new in
pairs.append(OnChangePair(old: old, new: new))
}).makeWidget(ctx)
defer { ctx.registry.teardown() }
box.set(1)
box.set(5)
#expect(pairs == [OnChangePair(old: 0, new: 1), OnChangePair(old: 1, new: 5)])
}
@Test func bindingEqualWriteDoesNotFire() {
guard Gtk.initCheck() else { return }
let box = StateBox(0)
var fires = 0
let ctx = MountContext()
_ = AnyView(Label(str: "x").onChange(of: Binding(box)) { fires += 1 })
.makeWidget(ctx)
defer { ctx.registry.teardown() }
box.set(1)
box.set(1)
#expect(fires == 1)
}
@Test func initialTrueFiresAtMountWithIdenticalValues() {
guard Gtk.initCheck() else { return }
let box = StateBox(7)
var pairs: [OnChangePair] = []
let ctx = MountContext()
_ = AnyView(Label(str: "x").onChange(of: Binding(box), initial: true) { old, new in
pairs.append(OnChangePair(old: old, new: new))
}).makeWidget(ctx)
defer { ctx.registry.teardown() }
#expect(pairs == [OnChangePair(old: 7, new: 7)])
}
@Test func initialFalseDoesNotFireAtMount() {
guard Gtk.initCheck() else { return }
let box = StateBox(7)
var fires = 0
let ctx = MountContext()
_ = AnyView(Label(str: "x").onChange(of: Binding(box)) { fires += 1 })
.makeWidget(ctx)
defer { ctx.registry.teardown() }
#expect(fires == 0)
}
@Test func zeroArityOverloadFires() {
guard Gtk.initCheck() else { return }
let box = StateBox(0)
var fires = 0
let ctx = MountContext()
_ = AnyView(Label(str: "x").onChange(of: Binding(box)) { fires += 1 })
.makeWidget(ctx)
defer { ctx.registry.teardown() }
box.set(1)
#expect(fires == 1)
}
@Test func trackedClosureFiresForState() {
guard Gtk.initCheck() else { return }
let box = StateBox(1)
var pairs: [OnChangePair] = []
let ctx = MountContext()
_ = AnyView(Label(str: "x").onChange(of: { box.get() }) { old, new in
pairs.append(OnChangePair(old: old, new: new))
}).makeWidget(ctx)
defer { ctx.registry.teardown() }
box.set(2)
#expect(pairs == [OnChangePair(old: 1, new: 2)])
}
@Test func trackedClosureFiresForObservable() {
guard Gtk.initCheck() else { return }
let model = OnChangeModel()
var pairs: [OnChangePair] = []
let ctx = MountContext()
_ = AnyView(Label(str: "x").onChange(of: { model.number }) { old, new in
pairs.append(OnChangePair(old: old, new: new))
}).makeWidget(ctx)
defer { ctx.registry.teardown() }
model.number = 1
pumpOnChange(until: { pairs == [OnChangePair(old: 0, new: 1)] })
#expect(pairs == [OnChangePair(old: 0, new: 1)])
}
@Test func teardownStopsOnChange() {
guard Gtk.initCheck() else { return }
let box = StateBox(0)
var fires = 0
let ctx = MountContext()
_ = AnyView(Label(str: "x").onChange(of: Binding(box)) { fires += 1 })
.makeWidget(ctx)
ctx.registry.teardown()
box.set(9)
pumpOnChange(until: { false }, turns: 20)
#expect(fires == 0)
}
@Test func customBindingNeverFires() {
guard Gtk.initCheck() else { return }
let box = StateBox(0)
let custom = Binding<Int>(get: { box.peek() }, set: { box.set($0) })
var fires = 0
let ctx = MountContext()
_ = AnyView(Label(str: "x").onChange(of: custom) { fires += 1 })
.makeWidget(ctx)
defer { ctx.registry.teardown() }
custom.wrappedValue = 1
#expect(fires == 0)
}
@Test func nonReactiveClosureLeavesNoResidue() {
guard Gtk.initCheck() else { return }
let ctx = MountContext()
_ = AnyView(Label(str: "x").onChange(of: { 1 }) { _, _ in })
.makeWidget(ctx)
defer { ctx.registry.teardown() }
#expect(ctx.registry.isEmpty)
}
@Test func actionReadsDoNotBecomeTriggers() {
guard Gtk.initCheck() else { return }
let source = StateBox(0)
let actionSource = StateBox(100)
var evaluations = 0
var fires = 0
let ctx = MountContext()
_ = AnyView(Label(str: "x").onChange(of: {
evaluations += 1
return source.get()
}) {
_ = actionSource.get()
fires += 1
}).makeWidget(ctx)
defer { ctx.registry.teardown() }
#expect(evaluations == 1)
source.set(1)
#expect(evaluations == 2)
#expect(fires == 1)
actionSource.set(101)
#expect(evaluations == 2)
#expect(fires == 1)
}
@Test func widgetViewOverloadPreservesSelf() {
guard Gtk.initCheck() else { return }
let box = StateBox(0)
let view: Portico.Label = Portico.Label(str: "x")
.onChange(of: Binding(box)) { _, _ in }
.label("done")
let ctx = MountContext()
let widget = AnyView(view).makeWidget(ctx) as! Gtk.Label
defer { ctx.registry.teardown() }
#expect(widget.getText() == "done")
}
@Test func viewOverloadWorksOnContainer() {
guard Gtk.initCheck() else { return }
let box = StateBox(0)
var fires = 0
let view = VStack { Label(str: "x") }
.onChange(of: Binding(box)) { fires += 1 }
let ctx = MountContext()
_ = AnyView(view).makeWidget(ctx)
defer { ctx.registry.teardown() }
box.set(1)
#expect(fires == 1)
}
}