From 15add96fdc87a34c41fb7d5c93444a65346bd9ae Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 22:03:43 -0400 Subject: [PATCH 1/5] Thread a preference collector through the resolve context --- AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift index 82d895b..358fef1 100644 --- a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift @@ -19,6 +19,8 @@ public struct ResolveContext { public var titleSink: TitleSink? /// Carries a `refreshable` action to the List it wraps. public var refreshSink: RefreshSink? + /// Collects preferences published by the subtree currently being resolved. + public var preferences: PreferenceCollector? public var path: String public var depth: Int @@ -28,6 +30,7 @@ public struct ResolveContext { environment: EnvironmentStorage = EnvironmentStorage(), titleSink: TitleSink? = nil, refreshSink: RefreshSink? = nil, + preferences: PreferenceCollector? = nil, path: String = "", depth: Int = 0 ) { @@ -36,6 +39,7 @@ public struct ResolveContext { self.environment = environment self.titleSink = titleSink self.refreshSink = refreshSink + self.preferences = preferences self.path = path self.depth = depth } From 9660c9f112ecdaa5b80ef7fb28add57a9045ad39 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 22:03:43 -0400 Subject: [PATCH 2/5] Add PreferenceKey with preference and onPreferenceChange --- .../AndroidSwiftUICore/Preferences.swift | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 AndroidSwiftUICore/Sources/AndroidSwiftUICore/Preferences.swift diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Preferences.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Preferences.swift new file mode 100644 index 0000000..483f68b --- /dev/null +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Preferences.swift @@ -0,0 +1,138 @@ +// +// Preferences.swift +// AndroidSwiftUICore +// +// Child-to-parent data flow. Unlike `GeometryReader`, none of this crosses the +// bridge: a preference is set and reduced entirely within one Swift resolve +// pass, so the interpreter never learns preferences exist. `onPreferenceChange` +// installs a collector for its subtree, resolves it, and reads the reduction. +// + +/// A value a view publishes to its ancestors, combined across the subtree by +/// `reduce`. +public protocol PreferenceKey { + associatedtype Value + static var defaultValue: Value { get } + static func reduce(value: inout Value, nextValue: () -> Value) +} + +/// Accumulates the preferences declared in one subtree, keyed by type. +public final class PreferenceCollector { + + private var values: [ObjectIdentifier: Any] = [:] + /// One closure per key that folds this collector's value into another. + /// Values are type-erased, so re-reducing them elsewhere needs the concrete + /// key type captured here at record time. + private var mergers: [ObjectIdentifier: (PreferenceCollector) -> Void] = [:] + + public init() {} + + /// Folds one published value in, starting from the key's default so the + /// reduction is the same whether or not anything published before. + internal func record(_ key: K.Type, _ value: K.Value) { + let id = ObjectIdentifier(key) + var current = (values[id] as? K.Value) ?? K.defaultValue + K.reduce(value: ¤t, nextValue: { value }) + values[id] = current + let reduced = current + mergers[id] = { parent in parent.record(key, reduced) } + } + + internal func value(for key: K.Type) -> K.Value { + (values[ObjectIdentifier(key)] as? K.Value) ?? K.defaultValue + } + + /// Folds everything collected here into `other`. + /// + /// An observer scopes a collector to its subtree, but it must not swallow + /// the keys it isn't watching — an ancestor observing a *different* key + /// still needs to see what that subtree published. + internal func propagate(into other: PreferenceCollector) { + for merge in mergers.values { merge(other) } + } +} + +/// Remembers the last value delivered for one key so an unchanged reduction +/// doesn't fire the callback again — without this the write the callback +/// usually performs would re-evaluate forever. +internal final class PreferenceMemo { + private var last: Value? + func shouldDeliver(_ value: Value) -> Bool { + guard last != value else { return false } + last = value + return true + } +} + +// MARK: - preference + +public struct _PreferenceView: View { + internal let key: K.Type + internal let value: K.Value + internal let content: Content + public typealias Body = Never +} + +extension _PreferenceView: _ResolutionEffectView { + public func _applyEffect(_ context: inout ResolveContext) -> any View { + context.preferences?.record(key, value) + return content + } +} + +// MARK: - onPreferenceChange + +public struct _OnPreferenceChangeView: View where K.Value: Equatable { + internal let key: K.Type + internal let action: (K.Value) -> Void + internal let content: Content + public typealias Body = Never +} + +extension _OnPreferenceChangeView: PrimitiveView { + + public func _render(in context: ResolveContext) -> RenderNode { + // A fresh collector scopes the reduction to this subtree; resolving at + // the same path keeps the wrapper identity-transparent. + let collector = PreferenceCollector() + var childContext = context + childContext.preferences = collector + let node = Evaluator.resolve(content, childContext) + + let value = collector.value(for: key) + // Everything published here keeps flowing upward, not just the observed + // key: chained observers each scope a collector, and dropping the rest + // would strand an ancestor watching a different key. + if let parent = context.preferences { + collector.propagate(into: parent) + } + + // Keyed by the preference type, not just the path: chained observers sit + // at the SAME identity path, so a shared key would let each overwrite the + // other's memo, make every delivery look new, and re-evaluate forever. + let memoPath = context.path + ".preference." + String(describing: K.self) + let memo = context.storage.persistentObject(at: memoPath) { + PreferenceMemo() + } + if memo.shouldDeliver(value) { + action(value) + } + return node + } +} + +public extension View { + + /// Publishes a value to ancestors observing `key`. + func preference(key: K.Type, value: K.Value) -> _PreferenceView { + _PreferenceView(key: key, value: value, content: self) + } + + /// Observes the reduced value of `key` across this view's subtree. + func onPreferenceChange( + _ key: K.Type, + perform action: @escaping (K.Value) -> Void + ) -> _OnPreferenceChangeView where K.Value: Equatable { + _OnPreferenceChangeView(key: key, action: action, content: self) + } +} From 59eb9570f86a0f67232f6dc048af730265395b60 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 22:03:43 -0400 Subject: [PATCH 3/5] Test preference reduction, propagation, and settling --- .../EvaluatorTests.swift | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift index de9a585..0562225 100644 --- a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift +++ b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift @@ -586,3 +586,154 @@ struct GeometryTests { #expect(store.size.width == 180) } } + +private struct MaxWidthKey: PreferenceKey { + static var defaultValue: Double { 0 } + static func reduce(value: inout Double, nextValue: () -> Double) { + value = max(value, nextValue()) + } +} + +private struct NamesKey: PreferenceKey { + static var defaultValue: [String] { [] } + static func reduce(value: inout [String], nextValue: () -> [String]) { + value += nextValue() + } +} + +@Suite("Preferences") +struct PreferenceTests { + + @Test("An ancestor sees its subtree's preferences reduced") + func reducesAcrossSubtree() { + var seen: Double? + let host = ViewHost( + VStack { + Text("a").preference(key: MaxWidthKey.self, value: 40) + Text("b").preference(key: MaxWidthKey.self, value: 120) + Text("c").preference(key: MaxWidthKey.self, value: 80) + } + .onPreferenceChange(MaxWidthKey.self) { seen = $0 } + ) + _ = host.evaluate() + #expect(seen == 120) // reduce kept the largest + } + + @Test("Reduce runs in tree order and starts from the default") + func reducesInOrder() { + var seen: [String]? + let host = ViewHost( + VStack { + Text("x").preference(key: NamesKey.self, value: ["first"]) + Text("y").preference(key: NamesKey.self, value: ["second"]) + } + .onPreferenceChange(NamesKey.self) { seen = $0 } + ) + _ = host.evaluate() + #expect(seen == ["first", "second"]) + } + + @Test("A subtree that publishes nothing delivers the default") + func deliversDefault() { + var seen: Double = -1 + let host = ViewHost( + VStack { Text("nothing here") } + .onPreferenceChange(MaxWidthKey.self) { seen = $0 } + ) + _ = host.evaluate() + #expect(seen == 0) + } + + @Test("An unchanged reduction doesn't fire the callback again") + func settlesWhenUnchanged() { + // The callback normally writes state, so re-delivering an unchanged + // value would re-evaluate forever. + final class Counter: @unchecked Sendable { var count = 0 } + let counter = Counter() + struct Screen: View { + let counter: Counter + @State var bump = 0 + var body: some View { + VStack { + Text("\(bump)").preference(key: MaxWidthKey.self, value: 50) + } + .onPreferenceChange(MaxWidthKey.self) { _ in counter.count += 1 } + } + } + let host = ViewHost(Screen(counter: counter)) + _ = host.evaluate() + #expect(counter.count == 1) + _ = host.evaluate() + _ = host.evaluate() + #expect(counter.count == 1) // same value across passes — delivered once + } + + @Test("Chained observers watching different keys each get their own") + func chainedObserversDifferentKeys() { + // An observer scopes a collector to its subtree. If it only forwarded + // the key it watches, the outer observer here would see nothing — + // which is exactly what happened before propagate(into:). + var widest: Double? + var collected: [String]? + let host = ViewHost( + VStack { + Text("a").preference(key: MaxWidthKey.self, value: 30) + .preference(key: NamesKey.self, value: ["a"]) + Text("b").preference(key: MaxWidthKey.self, value: 90) + .preference(key: NamesKey.self, value: ["b"]) + } + .onPreferenceChange(MaxWidthKey.self) { widest = $0 } + .onPreferenceChange(NamesKey.self) { collected = $0 } + ) + _ = host.evaluate() + #expect(widest == 90) + #expect(collected == ["a", "b"]) + } + + @Test("Chained observers settle instead of re-evaluating forever") + func chainedObserversSettle() { + // Chained observers share an identity path. If their change-memos also + // shared a storage key they would overwrite each other every pass, so + // every delivery would look new and the callbacks — which write state — + // would re-evaluate without end. This hung the app on device. + final class Counter: @unchecked Sendable { + var widest = 0 + var names = 0 + } + let counter = Counter() + struct Screen: View { + let counter: Counter + var body: some View { + VStack { + Text("a").preference(key: MaxWidthKey.self, value: 30) + .preference(key: NamesKey.self, value: ["a"]) + } + .onPreferenceChange(MaxWidthKey.self) { _ in counter.widest += 1 } + .onPreferenceChange(NamesKey.self) { _ in counter.names += 1 } + } + } + let host = ViewHost(Screen(counter: counter)) + for _ in 0 ..< 5 { _ = host.evaluate() } + #expect(counter.widest == 1) // delivered once, then quiet + #expect(counter.names == 1) + } + + @Test("A nested observer consumes its subtree yet still publishes upward") + func nestedObserversCompose() { + var inner: Double? + var outer: Double? + let host = ViewHost( + VStack { + VStack { + Text("deep").preference(key: MaxWidthKey.self, value: 70) + } + .onPreferenceChange(MaxWidthKey.self) { inner = $0 } + Text("shallow").preference(key: MaxWidthKey.self, value: 30) + } + .onPreferenceChange(MaxWidthKey.self) { outer = $0 } + ) + _ = host.evaluate() + #expect(inner == 70) + #expect(outer == 70) // the inner reduction reached the outer one + } +} From 77034fee94dfc0c6ddb4209748b18e35979d3917 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 22:03:43 -0400 Subject: [PATCH 4/5] Add a preference playground --- .../Sources/PreferencePlaygrounds.swift | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 Demo/App.swiftpm/Sources/PreferencePlaygrounds.swift diff --git a/Demo/App.swiftpm/Sources/PreferencePlaygrounds.swift b/Demo/App.swiftpm/Sources/PreferencePlaygrounds.swift new file mode 100644 index 0000000..8be38f7 --- /dev/null +++ b/Demo/App.swiftpm/Sources/PreferencePlaygrounds.swift @@ -0,0 +1,53 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif + +/// Keeps the largest value any descendant published. +struct WidestKey: PreferenceKey { + static var defaultValue: Double { 0 } + static func reduce(value: inout Double, nextValue: () -> Double) { + value = max(value, nextValue()) + } +} + +/// Gathers every descendant's label, in tree order. +struct RowNamesKey: PreferenceKey { + static var defaultValue: [String] { [] } + static func reduce(value: inout [String], nextValue: () -> [String]) { + value += nextValue() + } +} + +struct PreferencePlayground: View { + + @State private var rows = 3 + @State private var widest = 0.0 + @State private var names: [String] = [] + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Children publish values; an ancestor reads them reduced.") + + VStack(alignment: .leading, spacing: 6) { + ForEach(0.. 1 { rows -= 1 } } + } + .padding() + .navigationTitle("Preferences") + } +} From 6ecbd668a6ab71eb39b02a59e4fdf22a93673e0f Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 22:03:43 -0400 Subject: [PATCH 5/5] List the preference playground in the catalog --- Demo/App.swiftpm/Sources/Catalog.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Demo/App.swiftpm/Sources/Catalog.swift b/Demo/App.swiftpm/Sources/Catalog.swift index 3ab3ccc..1c194f5 100644 --- a/Demo/App.swiftpm/Sources/Catalog.swift +++ b/Demo/App.swiftpm/Sources/Catalog.swift @@ -62,6 +62,7 @@ struct CatalogEntry: Identifiable { CatalogEntry(id: "sheet", title: "Sheet", screen: AnyCatalogScreen(SheetPlayground())), CatalogEntry(id: "alert", title: "Alert", screen: AnyCatalogScreen(AlertPlayground())), CatalogEntry(id: "state", title: "State", screen: AnyCatalogScreen(StatePlayground())), + CatalogEntry(id: "preference", title: "Preferences", screen: AnyCatalogScreen(PreferencePlayground())), CatalogEntry(id: "environment", title: "Environment", screen: AnyCatalogScreen(EnvironmentPlayground())), CatalogEntry(id: "observable", title: "Observable", screen: AnyCatalogScreen(ObservablePlayground())), CatalogEntry(id: "bindable", title: "Bindable", screen: AnyCatalogScreen(BindablePlayground())),