Add onChange view modifiers
This commit is contained in:
parent
dcaa660b78
commit
44f95fe0ad
2 changed files with 449 additions and 0 deletions
228
Sources/Portico/Core/View+OnChange.swift
Normal file
228
Sources/Portico/Core/View+OnChange.swift
Normal 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() }
|
||||
}
|
||||
}
|
||||
221
Tests/PorticoTests/OnChangeTests.swift
Normal file
221
Tests/PorticoTests/OnChangeTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue