| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291 |
- import Foundation
- /// A view graph node storing a view, its widget, and its children (likely a
- /// collection of more nodes).
- ///
- /// This is where updates are initiated when a view's state updates, and where state is persisted
- /// even when a view gets recomputed by its parent.
- @MainActor
- public class ViewGraphNode<NodeView: View, Backend: BaseAppBackend>: Sendable {
- /// The view's single widget for the entirety of its lifetime in the view graph.
- ///
- public var widget: Backend.Widget {
- _widget!
- }
- /// Only optional because of some initialisation order requirements. Private and wrapped to
- /// hide this inconvenient detail.
- private var _widget: Backend.Widget?
- /// The view's children (usually just contains more view graph nodes, but can handle extra logic
- /// such as figuring out how to update variable length array of children efficiently).
- ///
- /// It's type-erased because otherwise complex implementation details would
- /// be forced to the user or other compromises would have to be made. I
- /// believe that this is the best option with Swift's current generics landscape.
- public var children: any ViewGraphNodeChildren {
- get {
- _children!
- }
- set {
- _children = newValue
- }
- }
- /// Only optional because of some initialisation order requirements. Private and wrapped to
- /// hide this inconvenient detail.
- private var _children: (any ViewGraphNodeChildren)?
- /// A copy of the view itself (from the latest computed body of its parent).
- public var view: NodeView
- /// The backend used to create the view's widget.
- public var backend: Backend
- /// The view's most recently computed layout. Doesn't include cached layouts,
- /// as this is the layout that is currently 'ready to commit'.
- public var currentLayout: ViewLayoutResult?
- /// A cache of update results keyed by the proposed size they were for. Gets
- /// cleared before the results' sizes become invalid.
- var resultCache: [ProposedViewSize: ViewLayoutResult]
- /// The most recent size proposed by the parent view. Used when updating the wrapped
- /// view as a result of a state change rather than the parent view updating. Proposals
- /// that get cached responses don't update this size, as this size should stay in sync
- /// with currentLayout.
- private(set) var lastProposedSize: ProposedViewSize
- /// Whether the widget has had its first update yet.
- private var hasHadFirstUpdate = false
- /// A cancellable handle to the view's state property observations.
- private var cancellables: [Cancellable]
- /// The environment most recently provided by this node's parent.
- private var parentEnvironment: EnvironmentValues
- /// The dynamic property updater for this view.
- private var dynamicPropertyUpdater: DynamicPropertyUpdater<NodeView>
- /// Creates a node for a given view while also creating the nodes for its children, creating
- /// the view's widget, and starting to observe its state for changes.
- public init(
- for nodeView: NodeView,
- backend: Backend,
- snapshot: ViewGraphSnapshotter.NodeSnapshot? = nil,
- environment: EnvironmentValues
- ) {
- self.backend = backend
- // Restore node snapshot if present.
- self.view = nodeView
- snapshot?.restore(to: view)
- // First create the view's child nodes and widgets
- let childSnapshots = snapshot.map { snapshot in
- snapshot.isValid(for: NodeView.self) ? snapshot.children : [snapshot]
- }
- currentLayout = nil
- resultCache = [:]
- lastProposedSize = .zero
- parentEnvironment = environment
- cancellables = []
- dynamicPropertyUpdater = DynamicPropertyUpdater(for: nodeView)
- let viewEnvironment = updateEnvironment(environment)
- dynamicPropertyUpdater.update(view, with: viewEnvironment, previousValue: nil)
- let children = view.children(
- backend: backend,
- snapshots: childSnapshots,
- environment: viewEnvironment
- )
- self.children = children
- // Then create the widget for the view itself
- let widget = view.asWidget(
- children,
- backend: backend
- )
- _widget = widget
- let tag = String(String(describing: NodeView.self).split(separator: "<")[0])
- backend.tag(widget: widget, as: tag)
- // Update the view and its children when state changes (children are always updated first).
- forEachField(of: view) { name, _, fieldValue in
- #if DEBUG
- if name == "state", fieldValue is ObservableObject {
- logger.warning(
- """
- the View.state protocol requirement has been removed in favour of \
- SwiftUI-style @State annotations; decorate \(NodeView.self).state \
- with the @State property wrapper to restore previous behaviour
- """
- )
- }
- #endif
- guard let value = fieldValue as? any ObservableProperty else {
- return // i.e. continue
- }
- let cancellable = value.didChange.observeAsUIUpdater(backend: backend) { [weak self] in
- self?.bottomUpUpdate()
- }
- cancellables.append(cancellable)
- }
- }
- /// Triggers the view to be updated as part of a bottom-up chain of updates (where either the
- /// current view gets updated due to a state change and has potential to trigger its parent to
- /// update as well, or the current view's child has propagated such an update upwards).
- private func bottomUpUpdate() {
- // First we compute what size the view will be after the update. If it will change size,
- // propagate the update to this node's parent instead of updating straight away.
- let currentSize = currentLayout?.size
- let newLayout = self.computeLayout(
- proposedSize: lastProposedSize,
- environment: parentEnvironment
- )
- self.currentLayout = newLayout
- if newLayout.size != currentSize {
- resultCache[lastProposedSize] = newLayout
- parentEnvironment.onResize(newLayout.size)
- } else {
- _ = self.commit()
- }
- }
- private func updateEnvironment(_ environment: EnvironmentValues) -> EnvironmentValues {
- environment.with(\.onResize) { [weak self] _ in
- guard let self else { return }
- self.bottomUpUpdate()
- }
- }
- /// Recomputes the view's body and computes its layout and the layout of
- /// its children.
- ///
- /// The view may or may not propagate the update to its children depending
- /// on the nature of the update. If `newView` is provided (in the case that
- /// the parent's body got updated) then it simply replaces the old view
- /// while inheriting the old view's state.
- ///
- /// - Parameters:
- /// - newView: The recomputed view.
- /// - proposedSize: The view's proposed size.
- /// - environment: The current environment.
- /// - Returns: The result of laying out the view.
- public func computeLayout(
- with newView: NodeView? = nil,
- proposedSize: ProposedViewSize,
- environment: EnvironmentValues
- ) -> ViewLayoutResult {
- // Defensively ensure that all future scene implementations obey this
- // precondition. By putting the check here instead of only in views
- // that require `environment.window` (such as the alert modifier view),
- // we decrease the likelihood of a bug like this flying under the radar.
- precondition(
- environment.window != nil,
- "View graph updated without parent window present in environment"
- )
- if !hasHadFirstUpdate {
- // We show the widget here instead of in init, because in init the widget
- // hasn't been added to its parent widget yet.
- backend.show(widget: widget)
- hasHadFirstUpdate = true
- }
- if proposedSize == lastProposedSize && !resultCache.isEmpty
- && (!parentEnvironment.allowLayoutCaching || environment.allowLayoutCaching),
- let currentLayout
- {
- // If the previous proposal is the same as the current one, and our
- // cache hasn't been invalidated, then we can reuse the current layout.
- // But only if the previous layout was computed without caching, or the
- // current layout is being computed with caching, cause otherwise we could
- // end up using a layout computed with caching while computing a layout
- // without caching.
- return currentLayout
- } else if environment.allowLayoutCaching, let cachedResult = resultCache[proposedSize] {
- // If this layout pass is a probing pass (not a final pass), then we
- // can reuse any layouts that we've computed since the cache was last
- // cleared. The cache gets cleared on commit.
- return cachedResult
- }
- parentEnvironment = environment
- lastProposedSize = proposedSize
- let previousView: NodeView?
- if let newView {
- previousView = view
- view = newView
- } else {
- previousView = nil
- }
- let viewEnvironment = updateEnvironment(environment)
- dynamicPropertyUpdater.update(view, with: viewEnvironment, previousValue: previousView)
- let result = view.computeLayout(
- widget,
- children: children,
- proposedSize: proposedSize,
- environment: viewEnvironment,
- backend: backend
- )
- // We assume that the view's sizing behaviour won't change between consecutive
- // layout computations and the following commit, because groups of updates
- // following that pattern are assumed to be occurring within a single overarching
- // view update. Under that assumption, we can cache view layout results.
- resultCache[proposedSize] = result
- currentLayout = result
- return result
- }
- /// Commits the view's most recently computed layout and any view state changes
- /// that have occurred since the last update (e.g. text content changes or font
- /// size changes).
- ///
- /// - Returns: The most recently computed layout. Guaranteed to match the
- /// result of the last call to ``computeLayout(with:proposedSize:environment:)``.
- public func commit() -> ViewLayoutResult {
- guard let currentLayout else {
- logger.warning("layout committed before being computed, ignoring")
- return .leafView(size: .zero)
- }
- if parentEnvironment.allowLayoutCaching {
- logger.warning(
- "committing layout computed with caching enabled; results may be invalid",
- metadata: ["NodeView": "\(NodeView.self)"]
- )
- }
- if currentLayout.size.height == .infinity || currentLayout.size.width == .infinity {
- logger.warning(
- "infinite height or width on commit",
- metadata: [
- "NodeView": "\(NodeView.self)",
- "currentLayout.size": "\(currentLayout.size)",
- "lastProposedSize": "\(lastProposedSize)",
- ]
- )
- }
- view.commit(
- widget,
- children: children,
- layout: currentLayout,
- environment: parentEnvironment,
- backend: backend
- )
- resultCache = [:]
- backend.showUpdate(of: widget)
- return currentLayout
- }
- }
|