Skip to content

Commit d92457e

Browse files
authored
Add ViewGraphGeometryObservers API (#695)
1 parent b744311 commit d92457e

File tree

3 files changed

+349
-10
lines changed

3 files changed

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

0 commit comments

Comments
 (0)