Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -28,6 +30,7 @@ public struct ResolveContext {
environment: EnvironmentStorage = EnvironmentStorage(),
titleSink: TitleSink? = nil,
refreshSink: RefreshSink? = nil,
preferences: PreferenceCollector? = nil,
path: String = "",
depth: Int = 0
) {
Expand All @@ -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
}
Expand Down
138 changes: 138 additions & 0 deletions AndroidSwiftUICore/Sources/AndroidSwiftUICore/Preferences.swift
Original file line number Diff line number Diff line change
@@ -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<K: PreferenceKey>(_ key: K.Type, _ value: K.Value) {
let id = ObjectIdentifier(key)
var current = (values[id] as? K.Value) ?? K.defaultValue
K.reduce(value: &current, nextValue: { value })
values[id] = current
let reduced = current
mergers[id] = { parent in parent.record(key, reduced) }
}

internal func value<K: PreferenceKey>(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<Value: Equatable> {
private var last: Value?
func shouldDeliver(_ value: Value) -> Bool {
guard last != value else { return false }
last = value
return true
}
}

// MARK: - preference

public struct _PreferenceView<Content: View, K: PreferenceKey>: 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<Content: View, K: PreferenceKey>: 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<K.Value>()
}
if memo.shouldDeliver(value) {
action(value)
}
return node
}
}

public extension View {

/// Publishes a value to ancestors observing `key`.
func preference<K: PreferenceKey>(key: K.Type, value: K.Value) -> _PreferenceView<Self, K> {
_PreferenceView(key: key, value: value, content: self)
}

/// Observes the reduced value of `key` across this view's subtree.
func onPreferenceChange<K: PreferenceKey>(
_ key: K.Type,
perform action: @escaping (K.Value) -> Void
) -> _OnPreferenceChangeView<Self, K> where K.Value: Equatable {
_OnPreferenceChangeView(key: key, action: action, content: self)
}
}
151 changes: 151 additions & 0 deletions AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
1 change: 1 addition & 0 deletions Demo/App.swiftpm/Sources/Catalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())),
Expand Down
53 changes: 53 additions & 0 deletions Demo/App.swiftpm/Sources/PreferencePlaygrounds.swift
Original file line number Diff line number Diff line change
@@ -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..<rows, id: \.self) { index in
Text("row \(index) publishes \((index + 1) * 30)")
.preference(key: WidestKey.self, value: Double((index + 1) * 30))
.preference(key: RowNamesKey.self, value: ["row \(index)"])
}
}
.onPreferenceChange(WidestKey.self) { widest = $0 }
.onPreferenceChange(RowNamesKey.self) { names = $0 }

Divider()
Text("Largest published: \(Int(widest))")
Text("Collected: \(names.joined(separator: ", "))")

Button("Add a row") { rows += 1 }
Button("Remove a row") { if rows > 1 { rows -= 1 } }
}
.padding()
.navigationTitle("Preferences")
}
}
Loading