Skip to content

Commit 65bc4c8

Browse files
authored
Merge pull request #57 from PureSwift/feature/preferences
Add PreferenceKey, preference, and onPreferenceChange
2 parents 8a2596e + 6ecbd66 commit 65bc4c8

5 files changed

Lines changed: 347 additions & 0 deletions

File tree

AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ public struct ResolveContext {
1919
public var titleSink: TitleSink?
2020
/// Carries a `refreshable` action to the List it wraps.
2121
public var refreshSink: RefreshSink?
22+
/// Collects preferences published by the subtree currently being resolved.
23+
public var preferences: PreferenceCollector?
2224
public var path: String
2325
public var depth: Int
2426

@@ -28,6 +30,7 @@ public struct ResolveContext {
2830
environment: EnvironmentStorage = EnvironmentStorage(),
2931
titleSink: TitleSink? = nil,
3032
refreshSink: RefreshSink? = nil,
33+
preferences: PreferenceCollector? = nil,
3134
path: String = "",
3235
depth: Int = 0
3336
) {
@@ -36,6 +39,7 @@ public struct ResolveContext {
3639
self.environment = environment
3740
self.titleSink = titleSink
3841
self.refreshSink = refreshSink
42+
self.preferences = preferences
3943
self.path = path
4044
self.depth = depth
4145
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
//
2+
// Preferences.swift
3+
// AndroidSwiftUICore
4+
//
5+
// Child-to-parent data flow. Unlike `GeometryReader`, none of this crosses the
6+
// bridge: a preference is set and reduced entirely within one Swift resolve
7+
// pass, so the interpreter never learns preferences exist. `onPreferenceChange`
8+
// installs a collector for its subtree, resolves it, and reads the reduction.
9+
//
10+
11+
/// A value a view publishes to its ancestors, combined across the subtree by
12+
/// `reduce`.
13+
public protocol PreferenceKey {
14+
associatedtype Value
15+
static var defaultValue: Value { get }
16+
static func reduce(value: inout Value, nextValue: () -> Value)
17+
}
18+
19+
/// Accumulates the preferences declared in one subtree, keyed by type.
20+
public final class PreferenceCollector {
21+
22+
private var values: [ObjectIdentifier: Any] = [:]
23+
/// One closure per key that folds this collector's value into another.
24+
/// Values are type-erased, so re-reducing them elsewhere needs the concrete
25+
/// key type captured here at record time.
26+
private var mergers: [ObjectIdentifier: (PreferenceCollector) -> Void] = [:]
27+
28+
public init() {}
29+
30+
/// Folds one published value in, starting from the key's default so the
31+
/// reduction is the same whether or not anything published before.
32+
internal func record<K: PreferenceKey>(_ key: K.Type, _ value: K.Value) {
33+
let id = ObjectIdentifier(key)
34+
var current = (values[id] as? K.Value) ?? K.defaultValue
35+
K.reduce(value: &current, nextValue: { value })
36+
values[id] = current
37+
let reduced = current
38+
mergers[id] = { parent in parent.record(key, reduced) }
39+
}
40+
41+
internal func value<K: PreferenceKey>(for key: K.Type) -> K.Value {
42+
(values[ObjectIdentifier(key)] as? K.Value) ?? K.defaultValue
43+
}
44+
45+
/// Folds everything collected here into `other`.
46+
///
47+
/// An observer scopes a collector to its subtree, but it must not swallow
48+
/// the keys it isn't watching — an ancestor observing a *different* key
49+
/// still needs to see what that subtree published.
50+
internal func propagate(into other: PreferenceCollector) {
51+
for merge in mergers.values { merge(other) }
52+
}
53+
}
54+
55+
/// Remembers the last value delivered for one key so an unchanged reduction
56+
/// doesn't fire the callback again — without this the write the callback
57+
/// usually performs would re-evaluate forever.
58+
internal final class PreferenceMemo<Value: Equatable> {
59+
private var last: Value?
60+
func shouldDeliver(_ value: Value) -> Bool {
61+
guard last != value else { return false }
62+
last = value
63+
return true
64+
}
65+
}
66+
67+
// MARK: - preference
68+
69+
public struct _PreferenceView<Content: View, K: PreferenceKey>: View {
70+
internal let key: K.Type
71+
internal let value: K.Value
72+
internal let content: Content
73+
public typealias Body = Never
74+
}
75+
76+
extension _PreferenceView: _ResolutionEffectView {
77+
public func _applyEffect(_ context: inout ResolveContext) -> any View {
78+
context.preferences?.record(key, value)
79+
return content
80+
}
81+
}
82+
83+
// MARK: - onPreferenceChange
84+
85+
public struct _OnPreferenceChangeView<Content: View, K: PreferenceKey>: View where K.Value: Equatable {
86+
internal let key: K.Type
87+
internal let action: (K.Value) -> Void
88+
internal let content: Content
89+
public typealias Body = Never
90+
}
91+
92+
extension _OnPreferenceChangeView: PrimitiveView {
93+
94+
public func _render(in context: ResolveContext) -> RenderNode {
95+
// A fresh collector scopes the reduction to this subtree; resolving at
96+
// the same path keeps the wrapper identity-transparent.
97+
let collector = PreferenceCollector()
98+
var childContext = context
99+
childContext.preferences = collector
100+
let node = Evaluator.resolve(content, childContext)
101+
102+
let value = collector.value(for: key)
103+
// Everything published here keeps flowing upward, not just the observed
104+
// key: chained observers each scope a collector, and dropping the rest
105+
// would strand an ancestor watching a different key.
106+
if let parent = context.preferences {
107+
collector.propagate(into: parent)
108+
}
109+
110+
// Keyed by the preference type, not just the path: chained observers sit
111+
// at the SAME identity path, so a shared key would let each overwrite the
112+
// other's memo, make every delivery look new, and re-evaluate forever.
113+
let memoPath = context.path + ".preference." + String(describing: K.self)
114+
let memo = context.storage.persistentObject(at: memoPath) {
115+
PreferenceMemo<K.Value>()
116+
}
117+
if memo.shouldDeliver(value) {
118+
action(value)
119+
}
120+
return node
121+
}
122+
}
123+
124+
public extension View {
125+
126+
/// Publishes a value to ancestors observing `key`.
127+
func preference<K: PreferenceKey>(key: K.Type, value: K.Value) -> _PreferenceView<Self, K> {
128+
_PreferenceView(key: key, value: value, content: self)
129+
}
130+
131+
/// Observes the reduced value of `key` across this view's subtree.
132+
func onPreferenceChange<K: PreferenceKey>(
133+
_ key: K.Type,
134+
perform action: @escaping (K.Value) -> Void
135+
) -> _OnPreferenceChangeView<Self, K> where K.Value: Equatable {
136+
_OnPreferenceChangeView(key: key, action: action, content: self)
137+
}
138+
}

AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,3 +586,154 @@ struct GeometryTests {
586586
#expect(store.size.width == 180)
587587
}
588588
}
589+
590+
private struct MaxWidthKey: PreferenceKey {
591+
static var defaultValue: Double { 0 }
592+
static func reduce(value: inout Double, nextValue: () -> Double) {
593+
value = max(value, nextValue())
594+
}
595+
}
596+
597+
private struct NamesKey: PreferenceKey {
598+
static var defaultValue: [String] { [] }
599+
static func reduce(value: inout [String], nextValue: () -> [String]) {
600+
value += nextValue()
601+
}
602+
}
603+
604+
@Suite("Preferences")
605+
struct PreferenceTests {
606+
607+
@Test("An ancestor sees its subtree's preferences reduced")
608+
func reducesAcrossSubtree() {
609+
var seen: Double?
610+
let host = ViewHost(
611+
VStack {
612+
Text("a").preference(key: MaxWidthKey.self, value: 40)
613+
Text("b").preference(key: MaxWidthKey.self, value: 120)
614+
Text("c").preference(key: MaxWidthKey.self, value: 80)
615+
}
616+
.onPreferenceChange(MaxWidthKey.self) { seen = $0 }
617+
)
618+
_ = host.evaluate()
619+
#expect(seen == 120) // reduce kept the largest
620+
}
621+
622+
@Test("Reduce runs in tree order and starts from the default")
623+
func reducesInOrder() {
624+
var seen: [String]?
625+
let host = ViewHost(
626+
VStack {
627+
Text("x").preference(key: NamesKey.self, value: ["first"])
628+
Text("y").preference(key: NamesKey.self, value: ["second"])
629+
}
630+
.onPreferenceChange(NamesKey.self) { seen = $0 }
631+
)
632+
_ = host.evaluate()
633+
#expect(seen == ["first", "second"])
634+
}
635+
636+
@Test("A subtree that publishes nothing delivers the default")
637+
func deliversDefault() {
638+
var seen: Double = -1
639+
let host = ViewHost(
640+
VStack { Text("nothing here") }
641+
.onPreferenceChange(MaxWidthKey.self) { seen = $0 }
642+
)
643+
_ = host.evaluate()
644+
#expect(seen == 0)
645+
}
646+
647+
@Test("An unchanged reduction doesn't fire the callback again")
648+
func settlesWhenUnchanged() {
649+
// The callback normally writes state, so re-delivering an unchanged
650+
// value would re-evaluate forever.
651+
final class Counter: @unchecked Sendable { var count = 0 }
652+
let counter = Counter()
653+
struct Screen: View {
654+
let counter: Counter
655+
@State var bump = 0
656+
var body: some View {
657+
VStack {
658+
Text("\(bump)").preference(key: MaxWidthKey.self, value: 50)
659+
}
660+
.onPreferenceChange(MaxWidthKey.self) { _ in counter.count += 1 }
661+
}
662+
}
663+
let host = ViewHost(Screen(counter: counter))
664+
_ = host.evaluate()
665+
#expect(counter.count == 1)
666+
_ = host.evaluate()
667+
_ = host.evaluate()
668+
#expect(counter.count == 1) // same value across passes — delivered once
669+
}
670+
671+
@Test("Chained observers watching different keys each get their own")
672+
func chainedObserversDifferentKeys() {
673+
// An observer scopes a collector to its subtree. If it only forwarded
674+
// the key it watches, the outer observer here would see nothing —
675+
// which is exactly what happened before propagate(into:).
676+
var widest: Double?
677+
var collected: [String]?
678+
let host = ViewHost(
679+
VStack {
680+
Text("a").preference(key: MaxWidthKey.self, value: 30)
681+
.preference(key: NamesKey.self, value: ["a"])
682+
Text("b").preference(key: MaxWidthKey.self, value: 90)
683+
.preference(key: NamesKey.self, value: ["b"])
684+
}
685+
.onPreferenceChange(MaxWidthKey.self) { widest = $0 }
686+
.onPreferenceChange(NamesKey.self) { collected = $0 }
687+
)
688+
_ = host.evaluate()
689+
#expect(widest == 90)
690+
#expect(collected == ["a", "b"])
691+
}
692+
693+
@Test("Chained observers settle instead of re-evaluating forever")
694+
func chainedObserversSettle() {
695+
// Chained observers share an identity path. If their change-memos also
696+
// shared a storage key they would overwrite each other every pass, so
697+
// every delivery would look new and the callbacks — which write state —
698+
// would re-evaluate without end. This hung the app on device.
699+
final class Counter: @unchecked Sendable {
700+
var widest = 0
701+
var names = 0
702+
}
703+
let counter = Counter()
704+
struct Screen: View {
705+
let counter: Counter
706+
var body: some View {
707+
VStack {
708+
Text("a").preference(key: MaxWidthKey.self, value: 30)
709+
.preference(key: NamesKey.self, value: ["a"])
710+
}
711+
.onPreferenceChange(MaxWidthKey.self) { _ in counter.widest += 1 }
712+
.onPreferenceChange(NamesKey.self) { _ in counter.names += 1 }
713+
}
714+
}
715+
let host = ViewHost(Screen(counter: counter))
716+
for _ in 0 ..< 5 { _ = host.evaluate() }
717+
#expect(counter.widest == 1) // delivered once, then quiet
718+
#expect(counter.names == 1)
719+
}
720+
721+
@Test("A nested observer consumes its subtree yet still publishes upward")
722+
func nestedObserversCompose() {
723+
var inner: Double?
724+
var outer: Double?
725+
let host = ViewHost(
726+
VStack {
727+
VStack {
728+
Text("deep").preference(key: MaxWidthKey.self, value: 70)
729+
}
730+
.onPreferenceChange(MaxWidthKey.self) { inner = $0 }
731+
Text("shallow").preference(key: MaxWidthKey.self, value: 30)
732+
}
733+
.onPreferenceChange(MaxWidthKey.self) { outer = $0 }
734+
)
735+
_ = host.evaluate()
736+
#expect(inner == 70)
737+
#expect(outer == 70) // the inner reduction reached the outer one
738+
}
739+
}

Demo/App.swiftpm/Sources/Catalog.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ struct CatalogEntry: Identifiable {
6262
CatalogEntry(id: "sheet", title: "Sheet", screen: AnyCatalogScreen(SheetPlayground())),
6363
CatalogEntry(id: "alert", title: "Alert", screen: AnyCatalogScreen(AlertPlayground())),
6464
CatalogEntry(id: "state", title: "State", screen: AnyCatalogScreen(StatePlayground())),
65+
CatalogEntry(id: "preference", title: "Preferences", screen: AnyCatalogScreen(PreferencePlayground())),
6566
CatalogEntry(id: "environment", title: "Environment", screen: AnyCatalogScreen(EnvironmentPlayground())),
6667
CatalogEntry(id: "observable", title: "Observable", screen: AnyCatalogScreen(ObservablePlayground())),
6768
CatalogEntry(id: "bindable", title: "Bindable", screen: AnyCatalogScreen(BindablePlayground())),
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
#if canImport(AndroidSwiftUI)
2+
import AndroidSwiftUI
3+
#else
4+
import SwiftUI
5+
#endif
6+
7+
/// Keeps the largest value any descendant published.
8+
struct WidestKey: PreferenceKey {
9+
static var defaultValue: Double { 0 }
10+
static func reduce(value: inout Double, nextValue: () -> Double) {
11+
value = max(value, nextValue())
12+
}
13+
}
14+
15+
/// Gathers every descendant's label, in tree order.
16+
struct RowNamesKey: PreferenceKey {
17+
static var defaultValue: [String] { [] }
18+
static func reduce(value: inout [String], nextValue: () -> [String]) {
19+
value += nextValue()
20+
}
21+
}
22+
23+
struct PreferencePlayground: View {
24+
25+
@State private var rows = 3
26+
@State private var widest = 0.0
27+
@State private var names: [String] = []
28+
29+
var body: some View {
30+
VStack(alignment: .leading, spacing: 14) {
31+
Text("Children publish values; an ancestor reads them reduced.")
32+
33+
VStack(alignment: .leading, spacing: 6) {
34+
ForEach(0..<rows, id: \.self) { index in
35+
Text("row \(index) publishes \((index + 1) * 30)")
36+
.preference(key: WidestKey.self, value: Double((index + 1) * 30))
37+
.preference(key: RowNamesKey.self, value: ["row \(index)"])
38+
}
39+
}
40+
.onPreferenceChange(WidestKey.self) { widest = $0 }
41+
.onPreferenceChange(RowNamesKey.self) { names = $0 }
42+
43+
Divider()
44+
Text("Largest published: \(Int(widest))")
45+
Text("Collected: \(names.joined(separator: ", "))")
46+
47+
Button("Add a row") { rows += 1 }
48+
Button("Remove a row") { if rows > 1 { rows -= 1 } }
49+
}
50+
.padding()
51+
.navigationTitle("Preferences")
52+
}
53+
}

0 commit comments

Comments
 (0)