Skip to content

Commit fc3c0a6

Browse files
committed
Add ViewGraphGeometryObservers API
1 parent b744311 commit fc3c0a6

File tree

3 files changed

+345
-10
lines changed

3 files changed

+345
-10
lines changed

Sources/OpenSwiftUICore/View/Graph/ViewGraph.swift

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ package final class ViewGraph: GraphHost {
8181
@WeakAttribute var rootLayoutComputer: LayoutComputer?
8282
@WeakAttribute var rootDisplayList: (DisplayList, DisplayList.Version)?
8383

84-
// package var sizeThatFitsObservers: ViewGraphGeometryObservers<SizeThatFitsMeasurer> = .init()
84+
package var sizeThatFitsObservers: ViewGraphGeometryObservers<SizeThatFitsMeasurer> = .init()
8585

8686
package var accessibilityEnabled: Bool = false
8787

@@ -458,16 +458,41 @@ extension ViewGraph {
458458
}
459459
}
460460

461-
//package struct SizeThatFitsMeasurer: ViewGraphGeometryMeasurer {
462-
// package static func measure(given proposal: _ProposedSize, in graph: ViewGraph) -> CGSize
463-
// package static let invalidValue: CGSize
464-
// package typealias Proposal = _ProposedSize
465-
// package typealias Size = CGSize
466-
//}
461+
package struct SizeThatFitsMeasurer: ViewGraphGeometryMeasurer {
462+
package typealias Proposal = _ProposedSize
463+
464+
package typealias Size = CGSize
465+
466+
package static func measure(
467+
given proposal: _ProposedSize,
468+
in graph: ViewGraph
469+
) -> CGSize {
470+
ViewGraph.sizeThatFits(
471+
proposal,
472+
layoutComputer: graph.layoutComputer,
473+
insets: graph.rootViewInsets
474+
)
475+
}
476+
477+
package static func measure(
478+
proposal: _ProposedSize,
479+
layoutComputer: LayoutComputer,
480+
insets: EdgeInsets
481+
) -> CGSize {
482+
ViewGraph.sizeThatFits(
483+
proposal,
484+
layoutComputer: layoutComputer,
485+
insets: insets
486+
)
487+
}
488+
489+
package static let invalidValue: CGSize = CGSize.invalidValue
490+
}
491+
492+
package typealias SizeThatFitsObservers = ViewGraphGeometryObservers<SizeThatFitsMeasurer>
467493

468-
//package typealias SizeThatFitsObservers = ViewGraphGeometryObservers<SizeThatFitsMeasurer>
469494
extension ViewGraph {
470-
private var layoutComputer: LayoutComputer? {
495+
fileprivate var layoutComputer: LayoutComputer? {
471496
precondition(
472497
requestedOutputs.contains(.layout),
473498
"Cannot fetch layout computer without layout output"
@@ -476,7 +501,7 @@ extension ViewGraph {
476501
return rootLayoutComputer
477502
}
478503

479-
private var rootViewInsets: EdgeInsets {
504+
fileprivate var rootViewInsets: EdgeInsets {
480505
guard !safeAreaInsets.elements.isEmpty else {
481506
return .zero
482507
}
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
//
2+
// ViewGraphGeometryObservers.swift
3+
// OpenSwiftUICore
4+
//
5+
// Audited for 6.5.4
6+
// Status: Complete
7+
// ID: 4717DAAA68693648A460F26E88C7D804 (SwiftUICore)
8+
9+
// MARK: - ViewGraphGeometryObservers
10+
11+
/// A container that manages geometry observers for a view graph.
12+
///
13+
/// `ViewGraphGeometryObservers` tracks size changes for different layout proposals
14+
/// and notifies registered callbacks when sizes change. It uses a measurer conforming
15+
/// to ``ViewGraphGeometryMeasurer`` to compute sizes.
16+
///
17+
/// The observer maintains a state machine for each proposal that tracks:
18+
/// - The current stable size (`.value`)
19+
/// - A pending size transition (`.pending`)
20+
/// - Uninitialized state (`.none` or `.invalid`)
21+
package struct ViewGraphGeometryObservers<Measurer> where Measurer: ViewGraphGeometryMeasurer {
22+
/// The proposal type used for layout measurements.
23+
package typealias Proposal = Measurer.Proposal
24+
25+
/// The size type returned by measurements.
26+
package typealias Size = Measurer.Size
27+
28+
/// A callback invoked when a size change is detected.
29+
///
30+
/// - Parameters:
31+
/// - oldSize: The previous size value.
32+
/// - newSize: The new size value.
33+
package typealias Callback = (Size, Size) -> Void
34+
35+
private var store: [Proposal: Observer]
36+
37+
/// Creates an empty geometry observers container.
38+
init() {
39+
store = [:]
40+
}
41+
42+
/// Checks if any observer needs an update based on the current view graph state.
43+
///
44+
/// This method measures sizes for all registered proposals and transitions
45+
/// their storage states accordingly.
46+
///
47+
/// - Parameter graph: The view graph to measure against.
48+
/// - Returns: `true` if any observer detected a size change, `false` otherwise.
49+
package mutating func needsUpdate(graph: ViewGraph) -> Bool {
50+
guard !graph.data.isHiddenForReuse else {
51+
return false
52+
}
53+
var result = false
54+
for proposal in store.keys {
55+
let size = Measurer.measure(given: proposal, in: graph)
56+
let changed = store[proposal]!.storage.transition(to: size)
57+
result = result || changed
58+
}
59+
return result
60+
}
61+
62+
/// Collects and returns all pending size notifications.
63+
///
64+
/// For each observer with a pending size change, this method transitions
65+
/// the storage to the new value and collects the size to notify.
66+
///
67+
/// - Returns: A dictionary mapping proposals to their new sizes that need notification.
68+
package mutating func notifySizes() -> [Proposal: Size] {
69+
var result: [Proposal: Size] = [:]
70+
for proposal in store.keys {
71+
if let size = store[proposal]!.sizeToNotifyIfNeeded() {
72+
result[proposal] = size
73+
}
74+
}
75+
return result
76+
}
77+
78+
/// Adds an observer for a specific layout proposal.
79+
///
80+
/// - Parameters:
81+
/// - proposal: The layout proposal to observe.
82+
/// - exclusive: If `true`, removes all existing observers before adding.
83+
/// Defaults to `true`.
84+
/// - callback: The callback to invoke when the size changes.
85+
package mutating func addObserver(
86+
for proposal: Proposal,
87+
exclusive: Bool = true,
88+
callback: @escaping Callback
89+
) {
90+
if exclusive {
91+
removeAll()
92+
}
93+
store[proposal] = Observer(callback: callback)
94+
}
95+
96+
/// Resets the observer for a specific proposal to its initial state.
97+
///
98+
/// - Parameter proposal: The proposal whose observer should be reset.
99+
/// - Returns: `true` if an observer existed and was reset, `false` otherwise.
100+
@discardableResult
101+
package mutating func resetObserver(for proposal: Proposal) -> Bool {
102+
store[proposal]?.reset() ?? false
103+
}
104+
105+
/// Stops observing a specific proposal.
106+
///
107+
/// - Parameter proposal: The proposal to stop observing.
108+
package mutating func stopObserving(proposal: Proposal) {
109+
store[proposal] = nil
110+
}
111+
112+
/// Removes all observers.
113+
package mutating func removeAll() {
114+
store.removeAll()
115+
}
116+
117+
// MARK: - Observer
118+
119+
/// An individual geometry observer that tracks size changes for a proposal.
120+
private struct Observer {
121+
/// The current storage state tracking size transitions.
122+
var storage: Storage
123+
124+
/// The callback to invoke when a size change is detected.
125+
let callback: Callback
126+
127+
/// Creates an observer with the specified callback.
128+
///
129+
/// The observer starts in the `.invalid` state.
130+
///
131+
/// - Parameter callback: The callback to invoke on size changes.
132+
init(callback: @escaping Callback) {
133+
self.storage = .invalid
134+
self.callback = callback
135+
}
136+
137+
/// Returns the size to notify if there is a pending transition.
138+
///
139+
/// If the storage is in the `.pending` state with a size change,
140+
/// transitions to `.value` and returns the new size.
141+
///
142+
/// - Returns: The new size to notify, or `nil` if no notification is needed.
143+
mutating func sizeToNotifyIfNeeded() -> Size? {
144+
guard case let .pending(size, pending) = storage else {
145+
return nil
146+
}
147+
storage = .value(pending)
148+
guard pending != size else {
149+
return nil
150+
}
151+
return pending
152+
}
153+
154+
/// Resets the observer to its initial `.invalid` state.
155+
///
156+
/// - Returns: Always returns `true`.
157+
mutating func reset() -> Bool {
158+
storage = .invalid
159+
return true
160+
}
161+
162+
// MARK: - Storage
163+
164+
/// The state machine for tracking size transitions.
165+
///
166+
/// The storage tracks the lifecycle of size measurements:
167+
/// - `value`: A stable, committed size.
168+
/// - `pending`: A size transition is in progress.
169+
/// - `none`: Uninitialized state.
170+
/// - `invalid`: Explicitly invalidated, needs fresh measurement.
171+
enum Storage {
172+
/// A stable size value.
173+
case value(Size)
174+
/// A pending transition from the first size to the second.
175+
case pending(Size, pending: Size)
176+
/// Uninitialized state.
177+
case none
178+
/// Invalidated state requiring fresh measurement.
179+
case invalid
180+
181+
/// Transitions the storage to reflect a new measured size.
182+
///
183+
/// The state machine logic:
184+
/// - `.value(x)` where `x == size`: No change, returns `false`.
185+
/// - `.value(x)` where `x != size`: Transitions to `.pending(x, pending: size)`, returns `true`.
186+
/// - `.pending(v, _)` where `v == size`: Settles to `.value(size)`, returns `false`.
187+
/// - `.pending(v, _)` where `v != size`: Updates pending to new size, returns `true`.
188+
/// - `.none`: Transitions to `.pending(invalidValue, pending: size)`, returns `true`.
189+
/// - `.invalid`: Transitions to `.value(size)`, returns `false`.
190+
///
191+
/// - Parameter size: The new measured size.
192+
/// - Returns: `true` if a change was detected that requires notification.
193+
mutating func transition(to size: Size) -> Bool {
194+
switch self {
195+
case let .value(currentSize):
196+
guard currentSize != size else {
197+
return false
198+
}
199+
self = .pending(currentSize, pending: size)
200+
return true
201+
case let .pending(value, _):
202+
guard size != value else {
203+
self = .value(size)
204+
return false
205+
}
206+
self = .pending(value, pending: size)
207+
return true
208+
case .none:
209+
self = .pending(Measurer.invalidValue, pending: size)
210+
return true
211+
case .invalid:
212+
self = .value(size)
213+
return false
214+
}
215+
}
216+
}
217+
}
218+
}
219+
220+
// MARK: - ViewGraphGeometryMeasurer
221+
222+
/// A protocol that defines how to measure geometry in a view graph.
223+
///
224+
/// Types conforming to `ViewGraphGeometryMeasurer` provide the measurement
225+
/// logic used by ``ViewGraphGeometryObservers`` to track size changes.
226+
package protocol ViewGraphGeometryMeasurer {
227+
/// The type used to propose layout dimensions.
228+
associatedtype Proposal: Hashable
229+
230+
/// The type representing the measured size.
231+
associatedtype Size: Equatable
232+
233+
/// Measures the size for a given proposal in a view graph.
234+
///
235+
/// - Parameters:
236+
/// - proposal: The layout proposal to measure.
237+
/// - graph: The view graph context for measurement.
238+
/// - Returns: The measured size.
239+
static func measure(given proposal: Proposal, in graph: ViewGraph) -> Size
240+
241+
/// Measures the size using a layout computer and insets.
242+
///
243+
/// - Parameters:
244+
/// - proposal: The layout proposal to measure.
245+
/// - layoutComputer: The layout computer to use for measurement.
246+
/// - insets: The edge insets to apply.
247+
/// - Returns: The measured size.
248+
static func measure(proposal: Proposal, layoutComputer: LayoutComputer, insets: EdgeInsets) -> Size
249+
250+
/// A sentinel value representing an invalid or uninitialized size.
251+
static var invalidValue: Size { get }
252+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
//
2+
// ViewGraphGeometryObserversTests.swift
3+
// OpenSwiftUICoreTests
4+
5+
@testable import OpenSwiftUICore
6+
import Foundation
7+
import Testing
8+
9+
private struct TestMeasurer: ViewGraphGeometryMeasurer {
10+
typealias Proposal = CGSize
11+
typealias Size = CGFloat
12+
13+
static func measure(given proposal: CGSize, in graph: ViewGraph) -> CGFloat {
14+
max(proposal.width, proposal.height)
15+
}
16+
17+
static func measure(proposal: CGSize, layoutComputer: LayoutComputer, insets: EdgeInsets) -> CGFloat {
18+
max(proposal.width, proposal.height)
19+
}
20+
21+
static var invalidValue: CGFloat = .nan
22+
}
23+
24+
struct ViewGraphGeometryObserversTests {
25+
fileprivate typealias Observers = ViewGraphGeometryObservers<TestMeasurer>
26+
27+
@MainActor
28+
@Test
29+
func observeCallback() async throws {
30+
// TODO: when the callback got called.
31+
await confirmation(expectedCount: 0) { confirm in
32+
var observers = Observers()
33+
observers.addObserver(for: CGSize(width: 10, height: 20)) { _, _ in
34+
confirm()
35+
}
36+
let emptyViewGraph = ViewGraph(rootViewType: EmptyView.self)
37+
_ = observers.needsUpdate(graph: emptyViewGraph)
38+
}
39+
}
40+
41+
@Test
42+
func addObserverExclusiveRemovesExisting() {
43+
var observers = Observers()
44+
observers.addObserver(for: CGSize(width: 10, height: 20)) { _, _ in }
45+
observers.addObserver(for: CGSize(width: 30, height: 40), exclusive: true) { _, _ in }
46+
#expect(observers.resetObserver(for: CGSize(width: 10, height: 20)) == false)
47+
#expect(observers.resetObserver(for: CGSize(width: 30, height: 40)) == true)
48+
}
49+
50+
@Test
51+
func addObserverNonExclusiveKeepsExisting() {
52+
var observers = Observers()
53+
observers.addObserver(for: CGSize(width: 10, height: 20)) { _, _ in }
54+
observers.addObserver(for: CGSize(width: 30, height: 40), exclusive: false) { _, _ in }
55+
#expect(observers.resetObserver(for: CGSize(width: 10, height: 20)) == true)
56+
#expect(observers.resetObserver(for: CGSize(width: 30, height: 40)) == true)
57+
}
58+
}

0 commit comments

Comments
 (0)