diff --git a/Sources/OpenSwiftUICore/View/Graph/ViewGraph.swift b/Sources/OpenSwiftUICore/View/Graph/ViewGraph.swift index 15c5fd535..47259a7d6 100644 --- a/Sources/OpenSwiftUICore/View/Graph/ViewGraph.swift +++ b/Sources/OpenSwiftUICore/View/Graph/ViewGraph.swift @@ -81,7 +81,7 @@ package final class ViewGraph: GraphHost { @WeakAttribute var rootLayoutComputer: LayoutComputer? @WeakAttribute var rootDisplayList: (DisplayList, DisplayList.Version)? - // package var sizeThatFitsObservers: ViewGraphGeometryObservers = .init() + package var sizeThatFitsObservers: ViewGraphGeometryObservers = .init() package var accessibilityEnabled: Bool = false @@ -458,16 +458,41 @@ extension ViewGraph { } } -//package struct SizeThatFitsMeasurer: ViewGraphGeometryMeasurer { -// package static func measure(given proposal: _ProposedSize, in graph: ViewGraph) -> CGSize -// package static let invalidValue: CGSize -// package typealias Proposal = _ProposedSize -// package typealias Size = CGSize -//} +package struct SizeThatFitsMeasurer: ViewGraphGeometryMeasurer { + package typealias Proposal = _ProposedSize + + package typealias Size = CGSize + + package static func measure( + given proposal: _ProposedSize, + in graph: ViewGraph + ) -> CGSize { + ViewGraph.sizeThatFits( + proposal, + layoutComputer: graph.layoutComputer, + insets: graph.rootViewInsets + ) + } + + package static func measure( + proposal: _ProposedSize, + layoutComputer: LayoutComputer, + insets: EdgeInsets + ) -> CGSize { + ViewGraph.sizeThatFits( + proposal, + layoutComputer: layoutComputer, + insets: insets + ) + } + + package static let invalidValue: CGSize = CGSize.invalidValue +} + +package typealias SizeThatFitsObservers = ViewGraphGeometryObservers -//package typealias SizeThatFitsObservers = ViewGraphGeometryObservers extension ViewGraph { - private var layoutComputer: LayoutComputer? { + fileprivate var layoutComputer: LayoutComputer? { precondition( requestedOutputs.contains(.layout), "Cannot fetch layout computer without layout output" @@ -476,7 +501,7 @@ extension ViewGraph { return rootLayoutComputer } - private var rootViewInsets: EdgeInsets { + fileprivate var rootViewInsets: EdgeInsets { guard !safeAreaInsets.elements.isEmpty else { return .zero } diff --git a/Sources/OpenSwiftUICore/View/Graph/ViewGraphGeometryObservers.swift b/Sources/OpenSwiftUICore/View/Graph/ViewGraphGeometryObservers.swift new file mode 100644 index 000000000..c6f4e890e --- /dev/null +++ b/Sources/OpenSwiftUICore/View/Graph/ViewGraphGeometryObservers.swift @@ -0,0 +1,254 @@ +// +// ViewGraphGeometryObservers.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: Complete +// ID: 4717DAAA68693648A460F26E88C7D804 (SwiftUICore) + +// MARK: - ViewGraphGeometryObservers + +/// A container that manages geometry observers for a view graph. +/// +/// `ViewGraphGeometryObservers` tracks size changes for different layout proposals +/// and notifies registered callbacks when sizes change. It uses a measurer conforming +/// to ``ViewGraphGeometryMeasurer`` to compute sizes. +/// +/// The observer maintains a state machine for each proposal that tracks: +/// - The current stable size (`.value`) +/// - A pending size transition (`.pending`) +/// - Uninitialized state (`.none` or `.invalid`) +package struct ViewGraphGeometryObservers where Measurer: ViewGraphGeometryMeasurer { + /// The proposal type used for layout measurements. + package typealias Proposal = Measurer.Proposal + + /// The size type returned by measurements. + package typealias Size = Measurer.Size + + /// A callback invoked when a size change is detected. + /// + /// - Parameters: + /// - oldSize: The previous size value. + /// - newSize: The new size value. + package typealias Callback = (Size, Size) -> Void + + private var store: [Proposal: Observer] + + /// Creates an empty geometry observers container. + init() { + store = [:] + } + + /// Checks if any observer needs an update based on the current view graph state. + /// + /// This method measures sizes for all registered proposals and transitions + /// their storage states accordingly. + /// + /// - Parameter graph: The view graph to measure against. + /// - Returns: `true` if any observer detected a size change, `false` otherwise. + package mutating func needsUpdate(graph: ViewGraph) -> Bool { + guard !graph.data.isHiddenForReuse else { + return false + } + var result = false + let keys = store.keys + for proposal in keys { + let size = Measurer.measure(given: proposal, in: graph) + let changed = store[proposal]!.storage.transition(to: size) + result = result || changed + } + return result + } + + /// Collects and returns all pending size notifications. + /// + /// For each observer with a pending size change, this method transitions + /// the storage to the new value and collects the size to notify. + /// + /// - Returns: A dictionary mapping proposals to their new sizes that need notification. + package mutating func notifySizes() -> [Proposal: Size] { + var result: [Proposal: Size] = [:] + let keys = store.keys + for proposal in keys { + if let size = store[proposal]!.sizeToNotifyIfNeeded() { + result[proposal] = size + } + } + return result + } + + /// Adds an observer for a specific layout proposal. + /// + /// - Parameters: + /// - proposal: The layout proposal to observe. + /// - exclusive: If `true`, removes all existing observers before adding. + /// Defaults to `true`. + /// - callback: The callback to invoke when the size changes. + package mutating func addObserver( + for proposal: Proposal, + exclusive: Bool = true, + callback: @escaping Callback + ) { + if exclusive { + removeAll() + } + store[proposal] = Observer(callback: callback) + } + + /// Resets the observer for a specific proposal to its initial state. + /// + /// - Parameter proposal: The proposal whose observer should be reset. + /// - Returns: `true` if an observer existed and was reset, `false` otherwise. + @discardableResult + package mutating func resetObserver(for proposal: Proposal) -> Bool { + store[proposal]?.reset() ?? false + } + + /// Stops observing a specific proposal. + /// + /// - Parameter proposal: The proposal to stop observing. + package mutating func stopObserving(proposal: Proposal) { + store[proposal] = nil + } + + /// Removes all observers. + package mutating func removeAll() { + store.removeAll() + } + + // MARK: - Observer + + /// An individual geometry observer that tracks size changes for a proposal. + private struct Observer { + /// The current storage state tracking size transitions. + var storage: Storage + + /// The callback to invoke when a size change is detected. + let callback: Callback + + /// Creates an observer with the specified callback. + /// + /// The observer starts in the `.invalid` state. + /// + /// - Parameter callback: The callback to invoke on size changes. + init(callback: @escaping Callback) { + self.storage = .invalid + self.callback = callback + } + + /// Returns the size to notify if there is a pending transition. + /// + /// If the storage is in the `.pending` state with a size change, + /// transitions to `.value` and returns the new size. + /// + /// - Returns: The new size to notify, or `nil` if no notification is needed. + mutating func sizeToNotifyIfNeeded() -> Size? { + guard case let .pending(size, pending) = storage else { + return nil + } + storage = .value(pending) + guard pending != size else { + return nil + } + return pending + } + + /// Resets the observer to its initial `.invalid` state. + /// + /// - Returns: Always returns `true`. + mutating func reset() -> Bool { + storage = .invalid + return true + } + + // MARK: - Storage + + /// The state machine for tracking size transitions. + /// + /// The storage tracks the lifecycle of size measurements: + /// - `value`: A stable, committed size. + /// - `pending`: A size transition is in progress. + /// - `none`: Uninitialized state. + /// - `invalid`: Explicitly invalidated, needs fresh measurement. + enum Storage { + /// A stable size value. + case value(Size) + /// A pending transition from the first size to the second. + case pending(Size, pending: Size) + /// Uninitialized state. + case none + /// Invalidated state requiring fresh measurement. + case invalid + + /// Transitions the storage to reflect a new measured size. + /// + /// The state machine logic: + /// - `.value(x)` where `x == size`: No change, returns `false`. + /// - `.value(x)` where `x != size`: Transitions to `.pending(x, pending: size)`, returns `true`. + /// - `.pending(v, _)` where `v == size`: Settles to `.value(size)`, returns `false`. + /// - `.pending(v, _)` where `v != size`: Updates pending to new size, returns `true`. + /// - `.none`: Transitions to `.pending(invalidValue, pending: size)`, returns `true`. + /// - `.invalid`: Transitions to `.value(size)`, returns `false`. + /// + /// - Parameter size: The new measured size. + /// - Returns: `true` if a change was detected that requires notification. + mutating func transition(to size: Size) -> Bool { + switch self { + case let .value(currentSize): + guard currentSize != size else { + return false + } + self = .pending(currentSize, pending: size) + return true + case let .pending(value, _): + guard size != value else { + self = .value(size) + return false + } + self = .pending(value, pending: size) + return true + case .none: + self = .pending(Measurer.invalidValue, pending: size) + return true + case .invalid: + self = .value(size) + return false + } + } + } + } +} + +// MARK: - ViewGraphGeometryMeasurer + +/// A protocol that defines how to measure geometry in a view graph. +/// +/// Types conforming to `ViewGraphGeometryMeasurer` provide the measurement +/// logic used by ``ViewGraphGeometryObservers`` to track size changes. +package protocol ViewGraphGeometryMeasurer { + /// The type used to propose layout dimensions. + associatedtype Proposal: Hashable + + /// The type representing the measured size. + associatedtype Size: Equatable + + /// Measures the size for a given proposal in a view graph. + /// + /// - Parameters: + /// - proposal: The layout proposal to measure. + /// - graph: The view graph context for measurement. + /// - Returns: The measured size. + static func measure(given proposal: Proposal, in graph: ViewGraph) -> Size + + /// Measures the size using a layout computer and insets. + /// + /// - Parameters: + /// - proposal: The layout proposal to measure. + /// - layoutComputer: The layout computer to use for measurement. + /// - insets: The edge insets to apply. + /// - Returns: The measured size. + static func measure(proposal: Proposal, layoutComputer: LayoutComputer, insets: EdgeInsets) -> Size + + /// A sentinel value representing an invalid or uninitialized size. + static var invalidValue: Size { get } +} diff --git a/Tests/OpenSwiftUICoreTests/View/Graph/ViewGraphGeometryObserversTests.swift b/Tests/OpenSwiftUICoreTests/View/Graph/ViewGraphGeometryObserversTests.swift new file mode 100644 index 000000000..eff03ca45 --- /dev/null +++ b/Tests/OpenSwiftUICoreTests/View/Graph/ViewGraphGeometryObserversTests.swift @@ -0,0 +1,60 @@ +// +// ViewGraphGeometryObserversTests.swift +// OpenSwiftUICoreTests + +@testable import OpenSwiftUICore +import Foundation +import Testing + +private struct TestMeasurer: ViewGraphGeometryMeasurer { + typealias Proposal = CGSize + typealias Size = CGFloat + + static func measure(given proposal: CGSize, in graph: ViewGraph) -> CGFloat { + max(proposal.width, proposal.height) + } + + static func measure(proposal: CGSize, layoutComputer: LayoutComputer, insets: EdgeInsets) -> CGFloat { + max(proposal.width, proposal.height) + } + + static var invalidValue: CGFloat = .nan +} + +struct ViewGraphGeometryObserversTests { + fileprivate typealias Observers = ViewGraphGeometryObservers + + #if canImport(Darwin) + @MainActor + @Test + func observeCallback() async throws { + // TODO: when the callback got called. + await confirmation(expectedCount: 0) { confirm in + var observers = Observers() + observers.addObserver(for: CGSize(width: 10, height: 20)) { _, _ in + confirm() + } + let emptyViewGraph = ViewGraph(rootViewType: EmptyView.self) + _ = observers.needsUpdate(graph: emptyViewGraph) + } + } + #endif + + @Test + func addObserverExclusiveRemovesExisting() { + var observers = Observers() + observers.addObserver(for: CGSize(width: 10, height: 20)) { _, _ in } + observers.addObserver(for: CGSize(width: 30, height: 40), exclusive: true) { _, _ in } + #expect(observers.resetObserver(for: CGSize(width: 10, height: 20)) == false) + #expect(observers.resetObserver(for: CGSize(width: 30, height: 40)) == true) + } + + @Test + func addObserverNonExclusiveKeepsExisting() { + var observers = Observers() + observers.addObserver(for: CGSize(width: 10, height: 20)) { _, _ in } + observers.addObserver(for: CGSize(width: 30, height: 40), exclusive: false) { _, _ in } + #expect(observers.resetObserver(for: CGSize(width: 10, height: 20)) == true) + #expect(observers.resetObserver(for: CGSize(width: 30, height: 40)) == true) + } +}