1
0

ViewGraphNode.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. import Foundation
  2. /// A view graph node storing a view, its widget, and its children (likely a
  3. /// collection of more nodes).
  4. ///
  5. /// This is where updates are initiated when a view's state updates, and where state is persisted
  6. /// even when a view gets recomputed by its parent.
  7. @MainActor
  8. public class ViewGraphNode<NodeView: View, Backend: BaseAppBackend>: Sendable {
  9. /// The view's single widget for the entirety of its lifetime in the view graph.
  10. ///
  11. public var widget: Backend.Widget {
  12. _widget!
  13. }
  14. /// Only optional because of some initialisation order requirements. Private and wrapped to
  15. /// hide this inconvenient detail.
  16. private var _widget: Backend.Widget?
  17. /// The view's children (usually just contains more view graph nodes, but can handle extra logic
  18. /// such as figuring out how to update variable length array of children efficiently).
  19. ///
  20. /// It's type-erased because otherwise complex implementation details would
  21. /// be forced to the user or other compromises would have to be made. I
  22. /// believe that this is the best option with Swift's current generics landscape.
  23. public var children: any ViewGraphNodeChildren {
  24. get {
  25. _children!
  26. }
  27. set {
  28. _children = newValue
  29. }
  30. }
  31. /// Only optional because of some initialisation order requirements. Private and wrapped to
  32. /// hide this inconvenient detail.
  33. private var _children: (any ViewGraphNodeChildren)?
  34. /// A copy of the view itself (from the latest computed body of its parent).
  35. public var view: NodeView
  36. /// The backend used to create the view's widget.
  37. public var backend: Backend
  38. /// The view's most recently computed layout. Doesn't include cached layouts,
  39. /// as this is the layout that is currently 'ready to commit'.
  40. public var currentLayout: ViewLayoutResult?
  41. /// A cache of update results keyed by the proposed size they were for. Gets
  42. /// cleared before the results' sizes become invalid.
  43. var resultCache: [ProposedViewSize: ViewLayoutResult]
  44. /// The most recent size proposed by the parent view. Used when updating the wrapped
  45. /// view as a result of a state change rather than the parent view updating. Proposals
  46. /// that get cached responses don't update this size, as this size should stay in sync
  47. /// with currentLayout.
  48. private(set) var lastProposedSize: ProposedViewSize
  49. /// Whether the widget has had its first update yet.
  50. private var hasHadFirstUpdate = false
  51. /// A cancellable handle to the view's state property observations.
  52. private var cancellables: [Cancellable]
  53. /// The environment most recently provided by this node's parent.
  54. private var parentEnvironment: EnvironmentValues
  55. /// The dynamic property updater for this view.
  56. private var dynamicPropertyUpdater: DynamicPropertyUpdater<NodeView>
  57. /// Creates a node for a given view while also creating the nodes for its children, creating
  58. /// the view's widget, and starting to observe its state for changes.
  59. public init(
  60. for nodeView: NodeView,
  61. backend: Backend,
  62. snapshot: ViewGraphSnapshotter.NodeSnapshot? = nil,
  63. environment: EnvironmentValues
  64. ) {
  65. self.backend = backend
  66. // Restore node snapshot if present.
  67. self.view = nodeView
  68. snapshot?.restore(to: view)
  69. // First create the view's child nodes and widgets
  70. let childSnapshots = snapshot.map { snapshot in
  71. snapshot.isValid(for: NodeView.self) ? snapshot.children : [snapshot]
  72. }
  73. currentLayout = nil
  74. resultCache = [:]
  75. lastProposedSize = .zero
  76. parentEnvironment = environment
  77. cancellables = []
  78. dynamicPropertyUpdater = DynamicPropertyUpdater(for: nodeView)
  79. let viewEnvironment = updateEnvironment(environment)
  80. dynamicPropertyUpdater.update(view, with: viewEnvironment, previousValue: nil)
  81. let children = view.children(
  82. backend: backend,
  83. snapshots: childSnapshots,
  84. environment: viewEnvironment
  85. )
  86. self.children = children
  87. // Then create the widget for the view itself
  88. let widget = view.asWidget(
  89. children,
  90. backend: backend
  91. )
  92. _widget = widget
  93. let tag = String(String(describing: NodeView.self).split(separator: "<")[0])
  94. backend.tag(widget: widget, as: tag)
  95. // Update the view and its children when state changes (children are always updated first).
  96. forEachField(of: view) { name, _, fieldValue in
  97. #if DEBUG
  98. if name == "state", fieldValue is ObservableObject {
  99. logger.warning(
  100. """
  101. the View.state protocol requirement has been removed in favour of \
  102. SwiftUI-style @State annotations; decorate \(NodeView.self).state \
  103. with the @State property wrapper to restore previous behaviour
  104. """
  105. )
  106. }
  107. #endif
  108. guard let value = fieldValue as? any ObservableProperty else {
  109. return // i.e. continue
  110. }
  111. let cancellable = value.didChange.observeAsUIUpdater(backend: backend) { [weak self] in
  112. self?.bottomUpUpdate()
  113. }
  114. cancellables.append(cancellable)
  115. }
  116. }
  117. /// Triggers the view to be updated as part of a bottom-up chain of updates (where either the
  118. /// current view gets updated due to a state change and has potential to trigger its parent to
  119. /// update as well, or the current view's child has propagated such an update upwards).
  120. private func bottomUpUpdate() {
  121. // First we compute what size the view will be after the update. If it will change size,
  122. // propagate the update to this node's parent instead of updating straight away.
  123. let currentSize = currentLayout?.size
  124. let newLayout = self.computeLayout(
  125. proposedSize: lastProposedSize,
  126. environment: parentEnvironment
  127. )
  128. self.currentLayout = newLayout
  129. if newLayout.size != currentSize {
  130. resultCache[lastProposedSize] = newLayout
  131. parentEnvironment.onResize(newLayout.size)
  132. } else {
  133. _ = self.commit()
  134. }
  135. }
  136. private func updateEnvironment(_ environment: EnvironmentValues) -> EnvironmentValues {
  137. environment.with(\.onResize) { [weak self] _ in
  138. guard let self else { return }
  139. self.bottomUpUpdate()
  140. }
  141. }
  142. /// Recomputes the view's body and computes its layout and the layout of
  143. /// its children.
  144. ///
  145. /// The view may or may not propagate the update to its children depending
  146. /// on the nature of the update. If `newView` is provided (in the case that
  147. /// the parent's body got updated) then it simply replaces the old view
  148. /// while inheriting the old view's state.
  149. ///
  150. /// - Parameters:
  151. /// - newView: The recomputed view.
  152. /// - proposedSize: The view's proposed size.
  153. /// - environment: The current environment.
  154. /// - Returns: The result of laying out the view.
  155. public func computeLayout(
  156. with newView: NodeView? = nil,
  157. proposedSize: ProposedViewSize,
  158. environment: EnvironmentValues
  159. ) -> ViewLayoutResult {
  160. // Defensively ensure that all future scene implementations obey this
  161. // precondition. By putting the check here instead of only in views
  162. // that require `environment.window` (such as the alert modifier view),
  163. // we decrease the likelihood of a bug like this flying under the radar.
  164. precondition(
  165. environment.window != nil,
  166. "View graph updated without parent window present in environment"
  167. )
  168. if !hasHadFirstUpdate {
  169. // We show the widget here instead of in init, because in init the widget
  170. // hasn't been added to its parent widget yet.
  171. backend.show(widget: widget)
  172. hasHadFirstUpdate = true
  173. }
  174. if proposedSize == lastProposedSize && !resultCache.isEmpty
  175. && (!parentEnvironment.allowLayoutCaching || environment.allowLayoutCaching),
  176. let currentLayout
  177. {
  178. // If the previous proposal is the same as the current one, and our
  179. // cache hasn't been invalidated, then we can reuse the current layout.
  180. // But only if the previous layout was computed without caching, or the
  181. // current layout is being computed with caching, cause otherwise we could
  182. // end up using a layout computed with caching while computing a layout
  183. // without caching.
  184. return currentLayout
  185. } else if environment.allowLayoutCaching, let cachedResult = resultCache[proposedSize] {
  186. // If this layout pass is a probing pass (not a final pass), then we
  187. // can reuse any layouts that we've computed since the cache was last
  188. // cleared. The cache gets cleared on commit.
  189. return cachedResult
  190. }
  191. parentEnvironment = environment
  192. lastProposedSize = proposedSize
  193. let previousView: NodeView?
  194. if let newView {
  195. previousView = view
  196. view = newView
  197. } else {
  198. previousView = nil
  199. }
  200. let viewEnvironment = updateEnvironment(environment)
  201. dynamicPropertyUpdater.update(view, with: viewEnvironment, previousValue: previousView)
  202. let result = view.computeLayout(
  203. widget,
  204. children: children,
  205. proposedSize: proposedSize,
  206. environment: viewEnvironment,
  207. backend: backend
  208. )
  209. // We assume that the view's sizing behaviour won't change between consecutive
  210. // layout computations and the following commit, because groups of updates
  211. // following that pattern are assumed to be occurring within a single overarching
  212. // view update. Under that assumption, we can cache view layout results.
  213. resultCache[proposedSize] = result
  214. currentLayout = result
  215. return result
  216. }
  217. /// Commits the view's most recently computed layout and any view state changes
  218. /// that have occurred since the last update (e.g. text content changes or font
  219. /// size changes).
  220. ///
  221. /// - Returns: The most recently computed layout. Guaranteed to match the
  222. /// result of the last call to ``computeLayout(with:proposedSize:environment:)``.
  223. public func commit() -> ViewLayoutResult {
  224. guard let currentLayout else {
  225. logger.warning("layout committed before being computed, ignoring")
  226. return .leafView(size: .zero)
  227. }
  228. if parentEnvironment.allowLayoutCaching {
  229. logger.warning(
  230. "committing layout computed with caching enabled; results may be invalid",
  231. metadata: ["NodeView": "\(NodeView.self)"]
  232. )
  233. }
  234. if currentLayout.size.height == .infinity || currentLayout.size.width == .infinity {
  235. logger.warning(
  236. "infinite height or width on commit",
  237. metadata: [
  238. "NodeView": "\(NodeView.self)",
  239. "currentLayout.size": "\(currentLayout.size)",
  240. "lastProposedSize": "\(lastProposedSize)",
  241. ]
  242. )
  243. }
  244. view.commit(
  245. widget,
  246. children: children,
  247. layout: currentLayout,
  248. environment: parentEnvironment,
  249. backend: backend
  250. )
  251. resultCache = [:]
  252. backend.showUpdate(of: widget)
  253. return currentLayout
  254. }
  255. }