1
0

LayoutSystem.swift 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. public enum LayoutSystem {
  2. static func width(forHeight height: Double, aspectRatio: Double) -> Double {
  3. Double(height) * aspectRatio
  4. }
  5. static func height(forWidth width: Double, aspectRatio: Double) -> Double {
  6. Double(width) / aspectRatio
  7. }
  8. @_spi(Backends) public static func roundSize(_ size: Double) -> Int {
  9. if size.isInfinite {
  10. logger.warning("LayoutSystem.roundSize(_:) called with infinite size")
  11. }
  12. let size = size.rounded(.up)
  13. return if size >= Double(Int.max) {
  14. Int.max
  15. } else if size <= Double(Int.min) {
  16. Int.min
  17. } else {
  18. Int(size)
  19. }
  20. }
  21. static func clamp(_ value: Double, minimum: Double?, maximum: Double?) -> Double {
  22. var value = value
  23. if let minimum {
  24. value = max(minimum, value)
  25. }
  26. if let maximum {
  27. value = min(maximum, value)
  28. }
  29. return value
  30. }
  31. static func aspectRatio(of frame: ViewSize) -> Double {
  32. aspectRatio(of: SIMD2(frame.width, frame.height))
  33. }
  34. static func aspectRatio(of frame: SIMD2<Double>) -> Double {
  35. if frame.x == 0 || frame.y == 0 {
  36. // Even though we could technically compute an aspect ratio when the
  37. // ideal width is 0, it leads to a lot of annoying usecases and isn't
  38. // very meaningful, so we default to 1 in that case as well as the
  39. // division by zero case.
  40. return 1
  41. } else {
  42. return frame.x / frame.y
  43. }
  44. }
  45. public struct LayoutableChild {
  46. private var computeLayout:
  47. @MainActor (
  48. _ proposedSize: ProposedViewSize,
  49. _ environment: EnvironmentValues
  50. ) -> ViewLayoutResult
  51. private var _commit: @MainActor () -> ViewLayoutResult
  52. var tag: String?
  53. public init(
  54. computeLayout: @escaping @MainActor (ProposedViewSize, EnvironmentValues)
  55. -> ViewLayoutResult,
  56. commit: @escaping @MainActor () -> ViewLayoutResult,
  57. tag: String? = nil
  58. ) {
  59. self.computeLayout = computeLayout
  60. self._commit = commit
  61. self.tag = tag
  62. }
  63. init<Child: View>(
  64. _ node: AnyViewGraphNode<Child>,
  65. child: @escaping @Sendable @MainActor () -> Child?
  66. ) {
  67. self.init(
  68. computeLayout: { proposedSize, environment in
  69. node.computeLayout(
  70. with: child(),
  71. proposedSize: proposedSize,
  72. environment: environment
  73. )
  74. },
  75. commit: {
  76. node.commit()
  77. }
  78. )
  79. }
  80. @MainActor
  81. public func computeLayout(
  82. proposedSize: ProposedViewSize,
  83. environment: EnvironmentValues
  84. ) -> ViewLayoutResult {
  85. computeLayout(proposedSize, environment)
  86. }
  87. @MainActor
  88. public func commit() -> ViewLayoutResult {
  89. _commit()
  90. }
  91. }
  92. /// - Parameter inheritStackLayoutParticipation: If `true`, the stack layout
  93. /// will have ``ViewSize/participateInStackLayoutsWhenEmpty`` set to `true`
  94. /// if all of its children have it set to true. This allows views such as
  95. /// ``Group`` to avoid changing stack layout participation (since ``Group``
  96. /// is meant to appear completely invisible to the layout system).
  97. @MainActor
  98. static func computeStackLayout<Backend: BaseAppBackend>(
  99. container: Backend.Widget,
  100. children: [LayoutableChild],
  101. cache: inout StackLayoutCache,
  102. proposedSize: ProposedViewSize,
  103. environment: EnvironmentValues,
  104. backend: Backend,
  105. inheritStackLayoutParticipation: Bool = false
  106. ) -> ViewLayoutResult {
  107. let spacing = environment.layoutSpacing
  108. let orientation = environment.layoutOrientation
  109. let perpendicularOrientation = orientation.perpendicular
  110. let stackLength = proposedSize[component: orientation]
  111. if stackLength == 0 || stackLength == .infinity || stackLength == nil || children.count == 1
  112. {
  113. var resultLength: Double = 0
  114. var resultWidth: Double = 0
  115. var results: [ViewLayoutResult] = []
  116. for child in children {
  117. let result = child.computeLayout(
  118. proposedSize: proposedSize,
  119. environment: environment
  120. )
  121. resultLength += result.size[component: orientation]
  122. resultWidth = max(resultWidth, result.size[component: perpendicularOrientation])
  123. results.append(result)
  124. }
  125. let visibleChildrenCount = results.count { result in
  126. result.participatesInStackLayouts
  127. }
  128. let totalSpacing = Double(max(visibleChildrenCount - 1, 0) * spacing)
  129. var size = ViewSize.zero
  130. size[component: orientation] = resultLength + totalSpacing
  131. size[component: perpendicularOrientation] = resultWidth
  132. // In this case, flexibility and layout priority don't matter. We set
  133. // the grouping to the trivial grouping so that commitStackLayout
  134. // effectively ignores flexibility.
  135. let group = LayoutPriorityGroup(
  136. children: Array(children.indices)[...],
  137. priority: 0
  138. )
  139. cache = StackLayoutCache(
  140. priorityGroups: [group],
  141. isHidden: results.map(\.participatesInStackLayouts).map(!),
  142. // TODO(stackotter): How does SwiftUI handle space reservation during
  143. // relayouts? I feel like it probably doesn't use minimum lengths if
  144. // it didn't already have to during the initial layout pass because
  145. // the alternative would be expensive, but that approach would also
  146. // be a bit inconsistent
  147. totalSpacing: totalSpacing,
  148. totalReservedSpace: totalSpacing,
  149. minimumLengths: [Double](repeating: 0, count: children.count),
  150. redistributeSpaceOnCommit: shouldRedistributeSpaceOnCommit(
  151. proposedSize: proposedSize,
  152. orientation: orientation
  153. )
  154. )
  155. return ViewLayoutResult(
  156. size: size,
  157. childResults: results,
  158. participateInStackLayoutsWhenEmpty: results
  159. .contains(where: \.participateInStackLayoutsWhenEmpty),
  160. preferencesOverlay: nil
  161. )
  162. }
  163. guard let stackLength else {
  164. fatalError("unreachable")
  165. }
  166. cache = recomputeCache(
  167. children: children,
  168. proposedSize: proposedSize,
  169. environment: environment
  170. )
  171. let renderedChildren = computeLayouts(
  172. of: children,
  173. proposedLength: stackLength,
  174. proposedPerpendicular: proposedSize[component: perpendicularOrientation],
  175. cache: cache,
  176. environment: environment,
  177. ignoreHiddenChildrenEntirely: false
  178. )
  179. var size = ViewSize.zero
  180. size[component: orientation] =
  181. renderedChildren.map(\.size[component: orientation]).reduce(0, +) + cache.totalSpacing
  182. size[component: perpendicularOrientation] =
  183. renderedChildren.map(\.size[component: perpendicularOrientation]).max() ?? 0
  184. return ViewLayoutResult(
  185. size: size,
  186. childResults: renderedChildren,
  187. participateInStackLayoutsWhenEmpty: renderedChildren
  188. .contains(where: \.participateInStackLayoutsWhenEmpty)
  189. )
  190. }
  191. /// Computes whether or not we have to redistribute space on commit. Returns true
  192. /// if and only if the perpendicular component of the proposed size is nil.
  193. static func shouldRedistributeSpaceOnCommit(
  194. proposedSize: ProposedViewSize,
  195. orientation: Orientation
  196. ) -> Bool {
  197. // When the perpendicular axis is unspecified (nil), we need
  198. // to re-run the space distribution algorithm with our final size during
  199. // the commit phase. This opens the door to certain edge cases, but SwiftUI
  200. // has them too, and there's not a good general solution to these edge
  201. // cases, even if you assume that you have unlimited compute. The reason for
  202. // this distribution is so that flexible children get a chance to use up any
  203. // unused space within the final perpendicular size of the stack.
  204. proposedSize[component: orientation.perpendicular] == nil
  205. }
  206. /// Computes the cache from scratch for the slow path (this is our last
  207. /// resort if shortcuts can't be made), preparing it for subsequent layout
  208. /// operations.
  209. @MainActor
  210. static func recomputeCache(
  211. children: [LayoutableChild],
  212. proposedSize: ProposedViewSize,
  213. environment: EnvironmentValues
  214. ) -> StackLayoutCache {
  215. let orientation = environment.layoutOrientation
  216. let spacing = environment.layoutSpacing
  217. // My thanks go to this great article for investigating and explaining
  218. // how SwiftUI determines child view 'flexibility':
  219. // https://www.objc.io/blog/2020/11/10/hstacks-child-ordering/
  220. var minimumProposedSize = proposedSize
  221. minimumProposedSize[component: orientation] = 0
  222. var maximumProposedSize = proposedSize
  223. maximumProposedSize[component: orientation] = .infinity
  224. var isHidden = [Bool](repeating: false, count: children.count)
  225. var priorities = [Double](repeating: 0, count: children.count)
  226. var minimums = [Double](repeating: 0, count: children.count)
  227. var totalReservedSpace = 0.0
  228. let flexibilities = children.enumerated().map { i, child in
  229. let minimumResult = child.computeLayout(
  230. proposedSize: minimumProposedSize,
  231. environment: environment.with(\.allowLayoutCaching, true)
  232. )
  233. let maximumResult = child.computeLayout(
  234. proposedSize: maximumProposedSize,
  235. environment: environment.with(\.allowLayoutCaching, true)
  236. )
  237. isHidden[i] = !minimumResult.participatesInStackLayouts
  238. priorities[i] = minimumResult.preferences.layoutPriority
  239. let maximum = maximumResult.size[component: orientation]
  240. let minimum = minimumResult.size[component: orientation]
  241. totalReservedSpace += minimum
  242. minimums[i] = minimum
  243. return maximum - minimum
  244. }
  245. let visibleChildrenCount = isHidden.filter { hidden in
  246. !hidden
  247. }.count
  248. let totalSpacing = Double(max(visibleChildrenCount - 1, 0) * spacing)
  249. totalReservedSpace += totalSpacing
  250. let sortedChildren = zip(children.indices, zip(priorities.map(-), flexibilities))
  251. .sorted { first, second in
  252. // Sort by descending priority and then by ascending flexibility
  253. first.1 <= second.1
  254. }
  255. .map { index, _ in
  256. index
  257. }
  258. var priorityGroups: [LayoutPriorityGroup] = []
  259. var previousPriority: Double? = nil
  260. var startIndex: Int?
  261. for (sortedIndex, originalIndex) in sortedChildren.enumerated() {
  262. let priority = priorities[originalIndex]
  263. if priority != previousPriority {
  264. if let startIndex, let previousPriority {
  265. let group = LayoutPriorityGroup(
  266. children: sortedChildren[startIndex..<sortedIndex],
  267. priority: previousPriority
  268. )
  269. priorityGroups.append(group)
  270. }
  271. startIndex = sortedIndex
  272. previousPriority = priority
  273. }
  274. }
  275. if let startIndex, let previousPriority {
  276. let group = LayoutPriorityGroup(
  277. children: sortedChildren[startIndex..<sortedChildren.endIndex],
  278. priority: previousPriority
  279. )
  280. priorityGroups.append(group)
  281. }
  282. return StackLayoutCache(
  283. priorityGroups: priorityGroups,
  284. isHidden: isHidden,
  285. totalSpacing: totalSpacing,
  286. totalReservedSpace: totalReservedSpace,
  287. minimumLengths: minimums,
  288. redistributeSpaceOnCommit: shouldRedistributeSpaceOnCommit(
  289. proposedSize: proposedSize,
  290. orientation: orientation
  291. )
  292. )
  293. }
  294. @MainActor
  295. static func commitStackLayout<Backend: BaseAppBackend>(
  296. container: Backend.Widget,
  297. children: [LayoutableChild],
  298. cache: inout StackLayoutCache,
  299. layout: ViewLayoutResult,
  300. environment: EnvironmentValues,
  301. backend: Backend
  302. ) {
  303. let size = layout.size
  304. backend.setSize(of: container, to: size.vector)
  305. let alignment = environment.layoutAlignment
  306. let spacing = environment.layoutSpacing
  307. let orientation = environment.layoutOrientation
  308. let perpendicularOrientation = orientation.perpendicular
  309. if cache.redistributeSpaceOnCommit {
  310. _ = computeLayouts(
  311. of: children,
  312. proposedLength: layout.size[component: orientation],
  313. proposedPerpendicular: layout.size[component: perpendicularOrientation],
  314. cache: cache,
  315. environment: environment,
  316. ignoreHiddenChildrenEntirely: true
  317. )
  318. }
  319. let renderedChildren = children.map { $0.commit() }
  320. var position = Position.zero
  321. for (index, child) in renderedChildren.enumerated() {
  322. // Avoid the whole iteration if the child is hidden. If there
  323. // are weird positioning issues for views that do strange things
  324. // then this could be the cause.
  325. if !child.participatesInStackLayouts {
  326. continue
  327. }
  328. // Compute alignment
  329. switch alignment {
  330. case .leading:
  331. position[component: perpendicularOrientation] = 0
  332. case .center:
  333. let outer = size[component: perpendicularOrientation]
  334. let inner = child.size[component: perpendicularOrientation]
  335. position[component: perpendicularOrientation] = (outer - inner) / 2
  336. case .trailing:
  337. let outer = size[component: perpendicularOrientation]
  338. let inner = child.size[component: perpendicularOrientation]
  339. position[component: perpendicularOrientation] = outer - inner
  340. }
  341. backend.setPosition(ofChildAt: index, in: container, to: position.vector)
  342. position[component: orientation] += child.size[component: orientation] + Double(spacing)
  343. }
  344. }
  345. /// The main stack layout space allocation algorithm. Used during
  346. /// computeLayout, and sometimes during commit when we have to redistribute
  347. /// space (due to an unspecified perpendicular size proposal).
  348. @MainActor
  349. static func computeLayouts(
  350. of children: [LayoutableChild],
  351. proposedLength: Double,
  352. proposedPerpendicular: Double?,
  353. cache: StackLayoutCache,
  354. environment: EnvironmentValues,
  355. ignoreHiddenChildrenEntirely: Bool
  356. ) -> [ViewLayoutResult] {
  357. var renderedChildren = [ViewLayoutResult](
  358. repeating: .leafView(size: .zero),
  359. count: children.count
  360. )
  361. let orientation = environment.layoutOrientation
  362. let perpendicularOrientation = orientation.perpendicular
  363. var spaceUsedAlongStackAxis = 0.0
  364. var reservedSpace = cache.totalReservedSpace
  365. for group in cache.priorityGroups {
  366. var childrenRemaining = group.children.count { index in
  367. !cache.isHidden[index]
  368. }
  369. for index in group.children {
  370. let child = children[index]
  371. // No need to render visible children.
  372. if cache.isHidden[index] {
  373. if ignoreHiddenChildrenEntirely {
  374. continue
  375. }
  376. // Update child in case it has just changed from visible to hidden,
  377. // and to make sure that the view is still hidden (if it's not then
  378. // it's a bug with either the view or the layout system).
  379. let result = child.computeLayout(
  380. proposedSize: .zero,
  381. environment: environment
  382. )
  383. if result.participatesInStackLayouts {
  384. logger.warning(
  385. "hidden view became visible on second update; layout may break",
  386. metadata: [
  387. "view": "\(child.tag ?? "<unknown type>")"
  388. ]
  389. )
  390. }
  391. renderedChildren[index] = result
  392. renderedChildren[index].participateInStackLayoutsWhenEmpty = false
  393. renderedChildren[index].size = .zero
  394. continue
  395. }
  396. reservedSpace -= cache.minimumLengths[index]
  397. var proposedChildSize = ProposedViewSize.unspecified
  398. proposedChildSize[component: orientation] = max(
  399. proposedLength - spaceUsedAlongStackAxis - reservedSpace,
  400. 0
  401. ) / Double(childrenRemaining)
  402. proposedChildSize[component: perpendicularOrientation] = proposedPerpendicular
  403. let childResult = child.computeLayout(
  404. proposedSize: proposedChildSize,
  405. environment: environment
  406. )
  407. renderedChildren[index] = childResult
  408. childrenRemaining -= 1
  409. spaceUsedAlongStackAxis += childResult.size[component: orientation]
  410. }
  411. }
  412. return renderedChildren
  413. }
  414. }