From 03513dd5b3a6251b8e547c48c6420b1808773ceb Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Mon, 30 May 2022 15:49:26 -0400 Subject: [PATCH] Custom Layout Engine for Fiber Reconciler (#472) * Initial Reconciler using visitor pattern * Preliminary static HTML renderer using the new reconciler * Add environment * Initial DOM renderer * Nearly-working and simplified reconciler * Working reconciler for HTML/DOM renderers * Rename files, and split code across files * Add some documentation and refinements * Remove GraphRendererTests * Initial layout engine (only implemented for the TestRenderer) * Layout engine for the DOM renderer * Refined layout pass * Revise positioning and restoration of position styles on .update * Re-add Optional.body for StackReconciler-based renderers * Add text measurement * Add spacing to StackLayout * Add benchmarks to compare the stack/fiber reconcilers * Fix some issues created for the StackReconciler, and add update benchmarks * Add BenchmarkState.measure to only calculate the time to update * Fix hang in update shallow benchmark * Fix build errors * Address build issues * Remove File.swift headers * Rename Element -> FiberElement and Element.Data -> FiberElement.Content * Add doc comment explaining unowned usage * Add doc comments explaining implicitly unwrapped optionals * Attempt to use Swift instead of JS for applying mutations * Fix issue with not applying updates to DOMFiberElement * Add comment explaining manual implementation of Hashable for PropertyInfo * Fix linter issues * Remove dynamicMember label from subscript * Re-enable carton test * Attempt GTK fix * Add option to disable layout in the FiberReconciler * Re-enable TokamakDemo with StackReconciler * Restore CI config * Restore CI config * Add file headers and cleanup structure * Add 'px' to font-size in test outputs * Remove extra newlines * Keep track of 'elementChildren' so children are positioned in the correct order * Use a ViewVisitor to pass the correct View type to the proposeSize function * Add support for view modifiers * Add frame modifier to demonstrate modifiers * Fix TestRenderer * Remove unused property * Fix doc comment * Fix linter issues and refactor slightly * Fix benchmark builds * Attempt to fix benchmarks * Fix sibling layout issues * Restore original demo * Address review comments * Remove maxAxis and fitAxis properties * Use switch instead of ternary operators * Add more documentation to layout steps * Resolve reconciler issue due to alternate child not being cleared/released * Apply suggestions from code review Co-authored-by: Max Desiatov * Reuse Text resolution code. * Add more documentation * Fix typo * Use structs for LayoutComputers * Update AlignmentID demo * Fix weird formatting Co-authored-by: Max Desiatov --- .../Environment/EnvironmentKey.swift | 19 +- .../Environment/EnvironmentValues.swift | 2 +- Sources/TokamakCore/Fiber/AlignmentID.swift | 151 +++++++ Sources/TokamakCore/Fiber/Fiber.swift | 73 +++- Sources/TokamakCore/Fiber/FiberElement.swift | 2 +- .../Fiber/FiberReconciler+TreeReducer.swift | 146 +++++++ .../TokamakCore/Fiber/FiberReconciler.swift | 406 +++++++++++------- Sources/TokamakCore/Fiber/FiberRenderer.swift | 21 + .../Fiber/Layout/FlexLayoutComputer.swift | 41 ++ .../Fiber/Layout/FrameLayoutComputer.swift | 74 ++++ .../Fiber/Layout/RootLayoutComputer.swift | 44 ++ .../Layout/ShrinkWrapLayoutComputer.swift | 46 ++ .../Fiber/Layout/StackLayoutComputer.swift | 154 +++++++ .../Fiber/Layout/TextLayoutComputer.swift | 60 +++ .../TokamakCore/Fiber/LayoutComputer.swift | 30 +- Sources/TokamakCore/Fiber/Mutation.swift | 9 +- Sources/TokamakCore/Fiber/ViewArguments.swift | 34 +- Sources/TokamakCore/Fiber/ViewGeometry.swift | 64 +++ Sources/TokamakCore/Fiber/walk.swift | 1 - .../Modifiers/ModifiedContent.swift | 4 +- .../TokamakCore/Modifiers/ViewModifier.swift | 17 + .../Shapes/ShapeStyles/BackgroundStyle.swift | 2 +- .../Shapes/ShapeStyles/ForegroundStyle.swift | 2 +- .../TokamakCore/Views/Containers/Group.swift | 6 +- Sources/TokamakCore/Views/Layout/HStack.swift | 7 - Sources/TokamakCore/Views/Layout/VStack.swift | 7 - Sources/TokamakCore/Views/Layout/ZStack.swift | 24 -- Sources/TokamakCore/Views/Text/Text.swift | 2 +- Sources/TokamakCoreBenchmark/main.swift | 36 +- Sources/TokamakDOM/DOMFiberRenderer.swift | 69 ++- Sources/TokamakGTK/Views/Stack.swift | 2 + .../Modifiers/LayoutModifiers.swift | 13 + .../Modifiers/ViewModifier.swift | 12 + .../StaticHTMLFiberRenderer.swift | 34 +- .../Views/Layout/HStack.swift | 2 + .../Views/Layout/VStack.swift | 2 + .../TokamakStaticHTML/Views/Text/Text.swift | 29 +- .../TestFiberRenderer.swift | 40 +- .../TokamakReconcilerTests/VisitorTests.swift | 9 +- .../HTMLTests/testDoubleTitle.1.html | 2 +- .../HTMLTests/testDoubleTitleModifier.1.html | 2 +- .../HTMLTests/testFontStacks.1.html | 2 +- .../HTMLTests/testFontStacks.2.html | 2 +- .../HTMLTests/testHTMLSanitizer.1.html | 2 +- .../HTMLTests/testHTMLSanitizer.2.html | 2 +- .../HTMLTests/testMetaAll.1.html | 2 +- .../HTMLTests/testMetaCharset.1.html | 2 +- .../HTMLTests/testMetaCharsetModifier.1.html | 2 +- .../__Snapshots__/HTMLTests/testTitle.1.html | 2 +- .../HTMLTests/testTitleModifier.1.html | 2 +- 50 files changed, 1416 insertions(+), 302 deletions(-) create mode 100644 Sources/TokamakCore/Fiber/AlignmentID.swift create mode 100644 Sources/TokamakCore/Fiber/FiberReconciler+TreeReducer.swift create mode 100644 Sources/TokamakCore/Fiber/Layout/FlexLayoutComputer.swift create mode 100644 Sources/TokamakCore/Fiber/Layout/FrameLayoutComputer.swift create mode 100644 Sources/TokamakCore/Fiber/Layout/RootLayoutComputer.swift create mode 100644 Sources/TokamakCore/Fiber/Layout/ShrinkWrapLayoutComputer.swift create mode 100644 Sources/TokamakCore/Fiber/Layout/StackLayoutComputer.swift create mode 100644 Sources/TokamakCore/Fiber/Layout/TextLayoutComputer.swift create mode 100644 Sources/TokamakCore/Fiber/ViewGeometry.swift diff --git a/Sources/TokamakCore/Environment/EnvironmentKey.swift b/Sources/TokamakCore/Environment/EnvironmentKey.swift index 6f482a892..8567336e5 100644 --- a/Sources/TokamakCore/Environment/EnvironmentKey.swift +++ b/Sources/TokamakCore/Environment/EnvironmentKey.swift @@ -17,11 +17,24 @@ public protocol EnvironmentKey { static var defaultValue: Value { get } } -protocol EnvironmentModifier { +/// This protocol defines a type which mutates the environment in some way. +/// Unlike `EnvironmentalModifier`, which reads the environment to +/// create a `ViewModifier`. +/// +/// It can be applied to a `View` or `ViewModifier`. +public protocol _EnvironmentModifier { func modifyEnvironment(_ values: inout EnvironmentValues) } -public struct _EnvironmentKeyWritingModifier: ViewModifier, EnvironmentModifier { +public extension ViewModifier where Self: _EnvironmentModifier { + static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { + var environment = inputs.environment.environment + inputs.content.modifyEnvironment(&environment) + return .init(inputs: inputs, environment: environment) + } +} + +public struct _EnvironmentKeyWritingModifier: ViewModifier, _EnvironmentModifier { public let keyPath: WritableKeyPath public let value: Value @@ -32,7 +45,7 @@ public struct _EnvironmentKeyWritingModifier: ViewModifier, EnvironmentMo public typealias Body = Never - func modifyEnvironment(_ values: inout EnvironmentValues) { + public func modifyEnvironment(_ values: inout EnvironmentValues) { values[keyPath: keyPath] = value } } diff --git a/Sources/TokamakCore/Environment/EnvironmentValues.swift b/Sources/TokamakCore/Environment/EnvironmentValues.swift index dc1ec51d7..6fbb9d669 100644 --- a/Sources/TokamakCore/Environment/EnvironmentValues.swift +++ b/Sources/TokamakCore/Environment/EnvironmentValues.swift @@ -76,7 +76,7 @@ public extension EnvironmentValues { } } -struct _EnvironmentValuesWritingModifier: ViewModifier, EnvironmentModifier { +struct _EnvironmentValuesWritingModifier: ViewModifier, _EnvironmentModifier { let environmentValues: EnvironmentValues func body(content: Content) -> some View { diff --git a/Sources/TokamakCore/Fiber/AlignmentID.swift b/Sources/TokamakCore/Fiber/AlignmentID.swift new file mode 100644 index 000000000..1f19467cd --- /dev/null +++ b/Sources/TokamakCore/Fiber/AlignmentID.swift @@ -0,0 +1,151 @@ +// Copyright 2022 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Carson Katri on 2/18/22. +// + +import Foundation + +/// Used to identify an alignment guide. +/// +/// Typically, you would define an alignment guide inside +/// an extension on `HorizontalAlignment` or `VerticalAlignment`: +/// +/// extension HorizontalAlignment { +/// private enum MyAlignmentGuide: AlignmentID { +/// static func defaultValue(in context: ViewDimensions) -> CGFloat { +/// return 0.0 +/// } +/// } +/// public static let myAlignmentGuide = Self(MyAlignmentGuide.self) +/// } +/// +/// Which you can then use with the `alignmentGuide` modifier: +/// +/// VStack(alignment: .myAlignmentGuide) { +/// Text("Align Leading") +/// .border(.red) +/// .alignmentGuide(.myAlignmentGuide) { $0[.leading] } +/// Text("Align Trailing") +/// .border(.blue) +/// .alignmentGuide(.myAlignmentGuide) { $0[.trailing] } +/// } +/// .border(.green) +public protocol AlignmentID { + /// The default value for this alignment guide + /// when not set via the `alignmentGuide` modifier. + static func defaultValue(in context: ViewDimensions) -> CGFloat +} + +/// An alignment position along the horizontal axis. +@frozen public struct HorizontalAlignment: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id + } + + let id: AlignmentID.Type + + public init(_ id: AlignmentID.Type) { + self.id = id + } +} + +extension HorizontalAlignment { + public static let leading = Self(Leading.self) + + private enum Leading: AlignmentID { + static func defaultValue(in context: ViewDimensions) -> CGFloat { + 0 + } + } + + public static let center = Self(Center.self) + + private enum Center: AlignmentID { + static func defaultValue(in context: ViewDimensions) -> CGFloat { + context.width / 2 + } + } + + public static let trailing = Self(Trailing.self) + + private enum Trailing: AlignmentID { + static func defaultValue(in context: ViewDimensions) -> CGFloat { + context.width + } + } +} + +@frozen public struct VerticalAlignment: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id + } + + let id: AlignmentID.Type + + public init(_ id: AlignmentID.Type) { + self.id = id + } +} + +extension VerticalAlignment { + public static let top = Self(Top.self) + private enum Top: AlignmentID { + static func defaultValue(in context: ViewDimensions) -> CGFloat { + 0 + } + } + + public static let center = Self(Center.self) + private enum Center: AlignmentID { + static func defaultValue(in context: ViewDimensions) -> CGFloat { + context.height / 2 + } + } + + public static let bottom = Self(Bottom.self) + private enum Bottom: AlignmentID { + static func defaultValue(in context: ViewDimensions) -> CGFloat { + context.height + } + } + + // TODO: Add baseline vertical alignment guides. + // public static let firstTextBaseline: VerticalAlignment + // public static let lastTextBaseline: VerticalAlignment +} + +/// An alignment in both axes. +public struct Alignment: Equatable { + public var horizontal: HorizontalAlignment + public var vertical: VerticalAlignment + + public init( + horizontal: HorizontalAlignment, + vertical: VerticalAlignment + ) { + self.horizontal = horizontal + self.vertical = vertical + } + + public static let topLeading = Self(horizontal: .leading, vertical: .top) + public static let top = Self(horizontal: .center, vertical: .top) + public static let topTrailing = Self(horizontal: .trailing, vertical: .top) + public static let leading = Self(horizontal: .leading, vertical: .center) + public static let center = Self(horizontal: .center, vertical: .center) + public static let trailing = Self(horizontal: .trailing, vertical: .center) + public static let bottomLeading = Self(horizontal: .leading, vertical: .bottom) + public static let bottom = Self(horizontal: .center, vertical: .bottom) + public static let bottomTrailing = Self(horizontal: .trailing, vertical: .bottom) +} diff --git a/Sources/TokamakCore/Fiber/Fiber.swift b/Sources/TokamakCore/Fiber/Fiber.swift index b592d61b9..2212ccc01 100644 --- a/Sources/TokamakCore/Fiber/Fiber.swift +++ b/Sources/TokamakCore/Fiber/Fiber.swift @@ -15,7 +15,10 @@ // Created by Carson Katri on 2/15/22. // -@_spi(TokamakCore) public extension FiberReconciler { +import Foundation + +@_spi(TokamakCore) +public extension FiberReconciler { /// A manager for a single `View`. /// /// There are always 2 `Fiber`s for every `View` in the tree, @@ -58,6 +61,8 @@ var id: Identity? /// The mounted element, if this is a Renderer primitive. var element: Renderer.ElementType? + /// The index of this element in its elementParent + var elementIndex: Int? /// The first child node. @_spi(TokamakCore) public var child: Fiber? /// This node's right sibling. @@ -75,6 +80,9 @@ /// Boxes that store `State` data. var state: [PropertyInfo: MutableStorage] = [:] + /// The computed dimensions and origin. + var geometry: ViewGeometry? + /// The WIP node if this is current, or the current node if this is WIP. weak var alternate: Fiber? @@ -107,7 +115,7 @@ element: Renderer.ElementType?, parent: Fiber?, elementParent: Fiber?, - childIndex: Int, + elementIndex: Int?, reconciler: FiberReconciler? ) { self.reconciler = reconciler @@ -117,14 +125,16 @@ self.elementParent = elementParent typeInfo = TokamakCore.typeInfo(of: V.self) - let viewInputs = ViewInputs( - view: view, - proposedSize: parent?.outputs.layoutComputer?.proposeSize(for: view, at: childIndex), - environment: parent?.outputs.environment ?? .init(.init()) - ) - state = bindProperties(to: &view, typeInfo, viewInputs) + let environment = parent?.outputs.environment ?? .init(.init()) + state = bindProperties(to: &view, typeInfo, environment.environment) self.view = view - outputs = V._makeView(viewInputs) + outputs = V._makeView( + .init( + content: view, + environment: environment + ) + ) + visitView = { [weak self] in guard let self = self else { return } // swiftlint:disable:next force_cast @@ -134,7 +144,14 @@ if let element = element { self.element = element } else if Renderer.isPrimitive(view) { - self.element = .init(from: .init(from: view)) + self.element = .init( + from: .init(from: view, shouldLayout: reconciler?.renderer.shouldLayout ?? false) + ) + } + + // Only specify an `elementIndex` if we have an element. + if self.element != nil { + self.elementIndex = elementIndex } let alternateView = view @@ -198,7 +215,7 @@ private func bindProperties( to view: inout V, _ typeInfo: TypeInfo?, - _ viewInputs: ViewInputs + _ environment: EnvironmentValues ) -> [PropertyInfo: MutableStorage] { guard let typeInfo = typeInfo else { return [:] } @@ -215,7 +232,7 @@ storage.setter = { box.setValue($0, with: $1) } value = storage } else if var environmentReader = value as? EnvironmentReader { - environmentReader.setContent(from: viewInputs.environment.environment) + environmentReader.setContent(from: environment) value = environmentReader } property.set(value: value, on: &view) @@ -225,18 +242,20 @@ func update( with view: inout V, - childIndex: Int + elementIndex: Int? ) -> Renderer.ElementType.Content? { typeInfo = TokamakCore.typeInfo(of: V.self) - let viewInputs = ViewInputs( - view: view, - proposedSize: parent?.outputs.layoutComputer?.proposeSize(for: view, at: childIndex), - environment: parent?.outputs.environment ?? .init(.init()) - ) - state = bindProperties(to: &view, typeInfo, viewInputs) + self.elementIndex = elementIndex + + let environment = parent?.outputs.environment ?? .init(.init()) + state = bindProperties(to: &view, typeInfo, environment.environment) self.view = view - outputs = V._makeView(viewInputs) + outputs = V._makeView(.init( + content: view, + environment: environment + )) + visitView = { [weak self] in guard let self = self else { return } // swiftlint:disable:next force_cast @@ -244,21 +263,29 @@ } if Renderer.isPrimitive(view) { - return .init(from: view) + return .init(from: view, shouldLayout: reconciler?.renderer.shouldLayout ?? false) } else { return nil } } public var debugDescription: String { - flush() + if let text = view as? Text { + return "Text(\"\(text.storage.rawText)\")" + } + return typeInfo?.name ?? "Unknown" } private func flush(level: Int = 0) -> String { let spaces = String(repeating: " ", count: level) + let geometry = geometry ?? .init( + origin: .init(origin: .zero), dimensions: .init(size: .zero, alignmentGuides: [:]) + ) return """ \(spaces)\(String(describing: typeInfo?.type ?? Any.self) - .split(separator: "<")[0])\(element != nil ? "(\(element!))" : "") { + .split(separator: "<")[0])\(element != nil ? "(\(element!))" : "") {\(element != nil ? + "\n\(spaces)geometry: \(geometry)" : + "") \(child?.flush(level: level + 2) ?? "") \(spaces)} \(sibling?.flush(level: level) ?? "") diff --git a/Sources/TokamakCore/Fiber/FiberElement.swift b/Sources/TokamakCore/Fiber/FiberElement.swift index 27e5ee4c5..cefe750ea 100644 --- a/Sources/TokamakCore/Fiber/FiberElement.swift +++ b/Sources/TokamakCore/Fiber/FiberElement.swift @@ -29,5 +29,5 @@ public protocol FiberElement: AnyObject { /// We re-use `FiberElement` instances in the `Fiber` tree, /// but can re-create and copy `FiberElementContent` as often as needed. public protocol FiberElementContent: Equatable { - init(from primitiveView: V) + init(from primitiveView: V, shouldLayout: Bool) } diff --git a/Sources/TokamakCore/Fiber/FiberReconciler+TreeReducer.swift b/Sources/TokamakCore/Fiber/FiberReconciler+TreeReducer.swift new file mode 100644 index 000000000..ed3d5da68 --- /dev/null +++ b/Sources/TokamakCore/Fiber/FiberReconciler+TreeReducer.swift @@ -0,0 +1,146 @@ +// Copyright 2022 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Carson Katri on 5/28/22. +// + +import Foundation + +extension FiberReconciler { + /// Convert the first level of children of a `View` into a linked list of `Fiber`s. + struct TreeReducer: ViewReducer { + final class Result { + // For references + let fiber: Fiber? + let visitChildren: (TreeReducer.Visitor) -> () + unowned var parent: Result? + var child: Result? + var sibling: Result? + var newContent: Renderer.ElementType.Content? + var elementIndices: [ObjectIdentifier: Int] + var layoutContexts: [ObjectIdentifier: LayoutContext] + + // For reducing + var lastSibling: Result? + var nextExisting: Fiber? + var nextExistingAlternate: Fiber? + + init( + fiber: Fiber?, + visitChildren: @escaping (TreeReducer.Visitor) -> (), + parent: Result?, + child: Fiber?, + alternateChild: Fiber?, + newContent: Renderer.ElementType.Content? = nil, + elementIndices: [ObjectIdentifier: Int], + layoutContexts: [ObjectIdentifier: LayoutContext] + ) { + self.fiber = fiber + self.visitChildren = visitChildren + self.parent = parent + nextExisting = child + nextExistingAlternate = alternateChild + self.newContent = newContent + self.elementIndices = elementIndices + self.layoutContexts = layoutContexts + } + } + + static func reduce(into partialResult: inout Result, nextView: V) where V: View { + // Create the node and its element. + var nextView = nextView + let resultChild: Result + if let existing = partialResult.nextExisting { + // If a fiber already exists, simply update it with the new view. + let key: ObjectIdentifier? + if let elementParent = existing.elementParent { + key = ObjectIdentifier(elementParent) + } else { + key = nil + } + let newContent = existing.update( + with: &nextView, + elementIndex: key.map { partialResult.elementIndices[$0, default: 0] } + ) + resultChild = Result( + fiber: existing, + visitChildren: nextView._visitChildren, + parent: partialResult, + child: existing.child, + alternateChild: existing.alternate?.child, + newContent: newContent, + elementIndices: partialResult.elementIndices, + layoutContexts: partialResult.layoutContexts + ) + partialResult.nextExisting = existing.sibling + + // If this fiber has an element, increment the elementIndex for its parent. + if let key = key, + existing.element != nil + { + partialResult.elementIndices[key] = partialResult.elementIndices[key, default: 0] + 1 + } + } else { + let elementParent = partialResult.fiber?.element != nil + ? partialResult.fiber + : partialResult.fiber?.elementParent + let key: ObjectIdentifier? + if let elementParent = elementParent { + key = ObjectIdentifier(elementParent) + } else { + key = nil + } + // Otherwise, create a new fiber for this child. + let fiber = Fiber( + &nextView, + element: partialResult.nextExistingAlternate?.element, + parent: partialResult.fiber, + elementParent: elementParent, + elementIndex: key.map { partialResult.elementIndices[$0, default: 0] }, + reconciler: partialResult.fiber?.reconciler + ) + // If a fiber already exists for an alternate, link them. + if let alternate = partialResult.nextExistingAlternate { + fiber.alternate = alternate + partialResult.nextExistingAlternate = alternate.sibling + } + // If this fiber has an element, increment the elementIndex for its parent. + if let key = key, + fiber.element != nil + { + partialResult.elementIndices[key] = partialResult.elementIndices[key, default: 0] + 1 + } + resultChild = Result( + fiber: fiber, + visitChildren: nextView._visitChildren, + parent: partialResult, + child: nil, + alternateChild: fiber.alternate?.child, + elementIndices: partialResult.elementIndices, + layoutContexts: partialResult.layoutContexts + ) + } + // Get the last child element we've processed, and add the new child as its sibling. + if let lastSibling = partialResult.lastSibling { + lastSibling.fiber?.sibling = resultChild.fiber + lastSibling.sibling = resultChild + } else { + // Otherwise setup the first child + partialResult.fiber?.child = resultChild.fiber + partialResult.child = resultChild + } + partialResult.lastSibling = resultChild + } + } +} diff --git a/Sources/TokamakCore/Fiber/FiberReconciler.swift b/Sources/TokamakCore/Fiber/FiberReconciler.swift index 0e214dbe3..904b4c20c 100644 --- a/Sources/TokamakCore/Fiber/FiberReconciler.swift +++ b/Sources/TokamakCore/Fiber/FiberReconciler.swift @@ -15,7 +15,10 @@ // Created by Carson Katri on 2/15/22. // -/// A reconciler modeled after React's [Fiber reconciler](https://reactjs.org/docs/faq-internals.html#what-is-react-fiber) +import Foundation + +/// A reconciler modeled after React's +/// [Fiber reconciler](https://reactjs.org/docs/faq-internals.html#what-is-react-fiber) public final class FiberReconciler { /// The root node in the `Fiber` tree that represents the `View`s currently rendered on screen. @_spi(TokamakCore) public var current: Fiber! @@ -27,15 +30,38 @@ public final class FiberReconciler { /// The `FiberRenderer` used to create and update the `Element`s on screen. public let renderer: Renderer + struct RootView: View { + let content: Content + let renderer: Renderer + + var environment: EnvironmentValues { + var environment = renderer.defaultEnvironment + environment.measureText = renderer.measureText + return environment + } + + var body: some View { + content + .environmentValues(environment) + } + + static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { + .init( + inputs: inputs, + layoutComputer: { _ in RootLayoutComputer(sceneSize: inputs.content.renderer.sceneSize) } + ) + } + } + public init(_ renderer: Renderer, _ view: V) { self.renderer = renderer - var view = view.environmentValues(renderer.defaultEnvironment) + var view = RootView(content: view, renderer: renderer) current = .init( &view, element: renderer.rootElement, parent: nil, elementParent: nil, - childIndex: 0, + elementIndex: 0, reconciler: self ) // Start by building the initial tree. @@ -43,99 +69,6 @@ public final class FiberReconciler { reconcile(from: current) } - /// Convert the first level of children of a `View` into a linked list of `Fiber`s. - struct TreeReducer: ViewReducer { - final class Result { - // For references - let fiber: Fiber? - let visitChildren: (TreeReducer.Visitor) -> () - unowned var parent: Result? - var child: Result? - var sibling: Result? - var newContent: Renderer.ElementType.Content? - - // For reducing - var childrenCount: Int = 0 - var lastSibling: Result? - var nextExisting: Fiber? - var nextExistingAlternate: Fiber? - - init( - fiber: Fiber?, - visitChildren: @escaping (TreeReducer.Visitor) -> (), - parent: Result?, - child: Fiber?, - alternateChild: Fiber?, - newContent: Renderer.ElementType.Content? = nil - ) { - self.fiber = fiber - self.visitChildren = visitChildren - self.parent = parent - nextExisting = child - nextExistingAlternate = alternateChild - self.newContent = newContent - } - } - - static func reduce(into partialResult: inout Result, nextView: V) where V: View { - // Create the node and its element. - var nextView = nextView - let resultChild: Result - if let existing = partialResult.nextExisting { - // If a fiber already exists, simply update it with the new view. - let newContent = existing.update( - with: &nextView, - childIndex: partialResult.childrenCount - ) - resultChild = Result( - fiber: existing, - visitChildren: nextView._visitChildren, - parent: partialResult, - child: existing.child, - alternateChild: existing.alternate?.child, - newContent: newContent - ) - partialResult.nextExisting = existing.sibling - } else { - // Otherwise, create a new fiber for this child. - let fiber = Fiber( - &nextView, - element: partialResult.nextExistingAlternate?.element, - parent: partialResult.fiber, - elementParent: partialResult.fiber?.element != nil - ? partialResult.fiber - : partialResult.fiber?.elementParent, - childIndex: partialResult.childrenCount, - reconciler: partialResult.fiber?.reconciler - ) - // If a fiber already exists for an alternate, link them. - if let alternate = partialResult.nextExistingAlternate { - fiber.alternate = alternate - partialResult.nextExistingAlternate = alternate.sibling - } - resultChild = Result( - fiber: fiber, - visitChildren: nextView._visitChildren, - parent: partialResult, - child: nil, - alternateChild: fiber.alternate?.child - ) - } - // Keep track of the index of the child so the LayoutComputer can propose sizes. - partialResult.childrenCount += 1 - // Get the last child element we've processed, and add the new child as its sibling. - if let lastSibling = partialResult.lastSibling { - lastSibling.fiber?.sibling = resultChild.fiber - lastSibling.sibling = resultChild - } else { - // Otherwise setup the first child - partialResult.fiber?.child = resultChild.fiber - partialResult.child = resultChild - } - partialResult.lastSibling = resultChild - } - } - final class ReconcilerVisitor: ViewVisitor { unowned let reconciler: FiberReconciler /// The current, mounted `Fiber`. @@ -147,6 +80,25 @@ public final class FiberReconciler { currentRoot = root } + /// A `ViewVisitor` that proposes a size for the `View` represented by the fiber `node`. + struct ProposeSizeVisitor: ViewVisitor { + let node: Fiber + let layoutContexts: [ObjectIdentifier: LayoutContext] + + func visit(_ view: V) where V: View { + // Ask the parent what space is available. + let proposedSize = node.elementParent?.outputs.layoutComputer.proposeSize( + for: view, + at: node.elementIndex ?? 0, + in: node.elementParent.flatMap { layoutContexts[ObjectIdentifier($0)] } + ?? .init(children: []) + ) ?? .zero + // Make our layout computer using that size. + node.outputs.layoutComputer = node.outputs.makeLayoutComputer(proposedSize) + node.alternate?.outputs.layoutComputer = node.outputs.layoutComputer + } + } + /// Walk the current tree, recomputing at each step to check for discrepancies. /// /// Parent-first depth-first traversal. @@ -201,7 +153,9 @@ public final class FiberReconciler { visitChildren: view._visitChildren, parent: nil, child: alternateRoot?.child, - alternateChild: currentRoot.child + alternateChild: currentRoot.child, + elementIndices: [:], + layoutContexts: [:] ) var node = rootResult @@ -209,14 +163,18 @@ public final class FiberReconciler { /// we are currently at. This ensures we place children in the correct order, even if they are /// at different levels in the `View` tree. var elementIndices = [ObjectIdentifier: Int]() + /// The `LayoutContext` for each parent view. + var layoutContexts = [ObjectIdentifier: LayoutContext]() + /// The (potentially nested) children of an `elementParent` with `element` values in order. + /// Used to position children in the correct order. + var elementChildren = [ObjectIdentifier: [Fiber]]() /// Compare `node` with its alternate, and add any mutations to the list. func reconcile(_ node: TreeReducer.Result) { if let element = node.fiber?.element, + let index = node.fiber?.elementIndex, let parent = node.fiber?.elementParent?.element { - let key = ObjectIdentifier(parent) - let index = elementIndices[key, default: 0] if node.fiber?.alternate == nil { // This didn't exist before (no alternate) mutations.append(.insert(element: element, parent: parent, index: index)) } else if node.fiber?.typeInfo?.type != node.fiber?.alternate?.typeInfo?.type, @@ -228,75 +186,221 @@ public final class FiberReconciler { newContent != element.content { // This is the same type of view, but its backing data has changed. - mutations.append(.update(previous: element, newContent: newContent)) + mutations.append(.update( + previous: element, + newContent: newContent, + geometry: node.fiber?.geometry ?? .init( + origin: .init(origin: .zero), + dimensions: .init(size: .zero, alignmentGuides: [:]) + ) + )) } - elementIndices[key] = index + 1 } } - // The main reconciler loop. - while true { - // Perform work on the node. - reconcile(node) + /// Ask the `LayoutComputer` for the fiber's `elementParent` to propose a size. + func proposeSize(for node: Fiber) { + guard node.element != nil else { return } + + // Use the visitor so we can pass the correct View type to the function. + node.visitView(ProposeSizeVisitor(node: node, layoutContexts: layoutContexts)) + } + + /// Request a size from the fiber's `elementParent`. + func size(_ node: Fiber) { + guard node.element != nil, + let elementParent = node.elementParent + else { return } + + let key = ObjectIdentifier(elementParent) + let elementIndex = node.elementIndex ?? 0 + var parentContext = layoutContexts[key, default: .init(children: [])] + + // Using our LayoutComputer, compute our required size. + // This does not have to respect the elementParent's proposed size. + let size = node.outputs.layoutComputer.requestSize( + in: layoutContexts[ObjectIdentifier(node), default: .init(children: [])] + ) + let dimensions = ViewDimensions(size: size, alignmentGuides: [:]) + let child = LayoutContext.Child(index: elementIndex, dimensions: dimensions) + + // Add ourself to the parent's LayoutContext. + parentContext.children.append(child) + layoutContexts[key] = parentContext + + // Update our geometry + node.geometry = .init( + origin: node.geometry?.origin ?? .init(origin: .zero), + dimensions: dimensions + ) + } + + /// Request a position from the parent on the way back up. + func position(_ node: Fiber) { + // FIXME: Add alignmentGuide modifier to override defaults and pass the correct guide data. + guard let element = node.element, + let elementParent = node.elementParent + else { return } + + let key = ObjectIdentifier(elementParent) + let elementIndex = node.elementIndex ?? 0 + let context = layoutContexts[key, default: .init(children: [])] - // Compute the children of the node. - let reducer = TreeReducer.Visitor(initialResult: node) - node.visitChildren(reducer) + // Find our child element in our parent's LayoutContext (as added by `size(_:)`). + guard let child = context.children.first(where: { $0.index == elementIndex }) + else { return } - // Setup the alternate if it doesn't exist yet. - if node.fiber?.alternate == nil { - _ = node.fiber?.createAndBindAlternate?() + // Ask our parent to position us in it's coordinate space (given our requested size). + let position = elementParent.outputs.layoutComputer.position(child, in: context) + let geometry = ViewGeometry( + origin: .init(origin: position), + dimensions: child.dimensions + ) + + // Push a layout mutation if needed. + if geometry != node.alternate?.geometry { + mutations.append(.layout(element: element, geometry: geometry)) } + // Update ours and our alternate's geometry + node.geometry = geometry + node.alternate?.geometry = geometry + } + + /// The main reconciler loop. + func mainLoop() { + while true { + // Perform work on the node. + reconcile(node) - // Walk all down all the way into the deepest child. - if let child = reducer.result.child { - node = child - continue - } else if let alternateChild = node.fiber?.alternate?.child { - walk(alternateChild) { node in - if let element = node.element, - let parent = node.elementParent?.element + node.elementIndices = elementIndices + + // Compute the children of the node. + let reducer = TreeReducer.Visitor(initialResult: node) + node.visitChildren(reducer) + elementIndices = node.elementIndices + + // As we walk down the tree, propose a size for each View. + if reconciler.renderer.shouldLayout, + let fiber = node.fiber + { + proposeSize(for: fiber) + if fiber.element != nil, + let key = fiber.elementParent.map(ObjectIdentifier.init) { - // The alternate has a child that no longer exists. - // Removals must happen in reverse order, so a child element - // is removed before its parent. - self.mutations.insert(.remove(element: element, parent: parent), at: 0) + elementChildren[key] = elementChildren[key, default: []] + [fiber] } - return true } - } - if reducer.result.child == nil { - node.fiber?.child = nil // Make sure we clear the child if there was none - } - // If we've made it back to the root, then exit. - if node === rootResult { - return - } - // Now walk back up the tree until we find a sibling. - while node.sibling == nil { - var alternateSibling = node.fiber?.alternate?.sibling - while alternateSibling != nil { // The alternate had siblings that no longer exist. - if let element = alternateSibling?.element, - let parent = alternateSibling?.elementParent?.element - { - // Removals happen in reverse order, so a child element is removed before - // its parent. - mutations.insert(.remove(element: element, parent: parent), at: 0) + // Setup the alternate if it doesn't exist yet. + if node.fiber?.alternate == nil { + _ = node.fiber?.createAndBindAlternate?() + } + + // Walk all down all the way into the deepest child. + if let child = reducer.result.child { + node = child + continue + } else if let alternateChild = node.fiber?.alternate?.child { + // The alternate has a child that no longer exists. + walk(alternateChild) { node in + if let element = node.element, + let parent = node.elementParent?.element + { + // Removals must happen in reverse order, so a child element + // is removed before its parent. + self.mutations.insert(.remove(element: element, parent: parent), at: 0) + } + return true } - alternateSibling = alternateSibling?.sibling } - // When we walk back to the root, exit - guard let parent = node.parent, - parent !== currentRoot.alternate - else { + if reducer.result.child == nil { + // Make sure we clear the child if there was none + node.fiber?.child = nil + node.fiber?.alternate?.child = nil + } + + // If we've made it back to the root, then exit. + if node === rootResult { return } - node = parent + + // Now walk back up the tree until we find a sibling. + while node.sibling == nil { + var alternateSibling = node.fiber?.alternate?.sibling + while alternateSibling != nil { // The alternate had siblings that no longer exist. + if let element = alternateSibling?.element, + let parent = alternateSibling?.elementParent?.element + { + // Removals happen in reverse order, so a child element is removed before + // its parent. + mutations.insert(.remove(element: element, parent: parent), at: 0) + } + alternateSibling = alternateSibling?.sibling + } + // We `size` and `position` when we are walking back up the tree. + if reconciler.renderer.shouldLayout, + let fiber = node.fiber + { + // The `elementParent` proposed a size for this fiber on the way down. + // At this point all of this fiber's children have requested sizes. + // On the way back up, we tell our elementParent what size we want, + // based on our own requirements and the sizes required by our children. + size(fiber) + // Loop through each (potentially nested) child fiber with an `element`, + // and position them in our coordinate space. This ensures children are + // positioned in order. + if let elementChildren = elementChildren[ObjectIdentifier(fiber)] { + for elementChild in elementChildren { + position(elementChild) + } + } + } + guard let parent = node.parent else { return } + // When we walk back to the root, exit + guard parent !== currentRoot.alternate else { return } + node = parent + } + + // We also request `size` and `position` when we reach the bottom-most view that has a sibling. + // Sizing and positioning also happen when we have no sibling, + // as seen in the above loop. + if reconciler.renderer.shouldLayout, + let fiber = node.fiber + { + // Request a size from our `elementParent`. + size(fiber) + // Position our children in order. + if let elementChildren = elementChildren[ObjectIdentifier(fiber)] { + for elementChild in elementChildren { + position(elementChild) + } + } + } + + // Walk across to the sibling, and repeat. + node = node.sibling! + } + } + mainLoop() + + if reconciler.renderer.shouldLayout { + // We continue to the very top to update all necessary positions. + var layoutNode = node.fiber?.child + while let current = layoutNode { + // We only need to re-position, because the size can't change if no state changed. + if let elementChildren = elementChildren[ObjectIdentifier(current)] { + for elementChild in elementChildren { + position(elementChild) + } + } + if current.sibling != nil { + // We also don't need to go deep into sibling children, + // because child positioning is relative to the parent. + layoutNode = current.sibling + } else { + layoutNode = current.parent + } } - // Walk across to the sibling, and repeat. - // swiftlint:disable:next force_unwrap - node = node.sibling! } } } diff --git a/Sources/TokamakCore/Fiber/FiberRenderer.swift b/Sources/TokamakCore/Fiber/FiberRenderer.swift index 5bdf4db88..7f05564d5 100644 --- a/Sources/TokamakCore/Fiber/FiberRenderer.swift +++ b/Sources/TokamakCore/Fiber/FiberRenderer.swift @@ -15,6 +15,8 @@ // Created by Carson Katri on 2/15/22. // +import Foundation + /// A renderer capable of performing mutations specified by a `FiberReconciler`. public protocol FiberRenderer { /// The element class this renderer uses. @@ -27,6 +29,12 @@ public protocol FiberRenderer { var rootElement: ElementType { get } /// The smallest set of initial `EnvironmentValues` needed for this renderer to function. var defaultEnvironment: EnvironmentValues { get } + /// The size of the window we are rendering in. + var sceneSize: CGSize { get } + /// Whether layout is enabled for this renderer. + var shouldLayout: Bool { get } + /// Calculate the size of `Text` in `environment` for layout. + func measureText(_ text: Text, proposedSize: CGSize, in environment: EnvironmentValues) -> CGSize } public extension FiberRenderer { @@ -37,3 +45,16 @@ public extension FiberRenderer { .init(self, view) } } + +extension EnvironmentValues { + private enum MeasureTextKey: EnvironmentKey { + static var defaultValue: (Text, CGSize, EnvironmentValues) -> CGSize { + { _, _, _ in .zero } + } + } + + var measureText: (Text, CGSize, EnvironmentValues) -> CGSize { + get { self[MeasureTextKey.self] } + set { self[MeasureTextKey.self] = newValue } + } +} diff --git a/Sources/TokamakCore/Fiber/Layout/FlexLayoutComputer.swift b/Sources/TokamakCore/Fiber/Layout/FlexLayoutComputer.swift new file mode 100644 index 000000000..fb8b93244 --- /dev/null +++ b/Sources/TokamakCore/Fiber/Layout/FlexLayoutComputer.swift @@ -0,0 +1,41 @@ +// Copyright 2022 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Carson Katri on 5/24/22. +// + +import Foundation + +/// A `LayoutComputer` that fills its parent. +struct FlexLayoutComputer: LayoutComputer { + let proposedSize: CGSize + + init(proposedSize: CGSize) { + self.proposedSize = proposedSize + } + + func proposeSize(for child: V, at index: Int, in context: LayoutContext) -> CGSize + where V: View + { + proposedSize + } + + func position(_ child: LayoutContext.Child, in context: LayoutContext) -> CGPoint { + .zero + } + + func requestSize(in context: LayoutContext) -> CGSize { + proposedSize + } +} diff --git a/Sources/TokamakCore/Fiber/Layout/FrameLayoutComputer.swift b/Sources/TokamakCore/Fiber/Layout/FrameLayoutComputer.swift new file mode 100644 index 000000000..535dcbf79 --- /dev/null +++ b/Sources/TokamakCore/Fiber/Layout/FrameLayoutComputer.swift @@ -0,0 +1,74 @@ +// Copyright 2022 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Carson Katri on 5/28/22. +// + +import Foundation + +/// A `LayoutComputer` that uses a specified size in one or more axes. +struct FrameLayoutComputer: LayoutComputer { + let proposedSize: CGSize + let width: CGFloat? + let height: CGFloat? + let alignment: Alignment + + init(proposedSize: CGSize, width: CGFloat?, height: CGFloat?, alignment: Alignment) { + self.proposedSize = proposedSize + self.width = width + self.height = height + self.alignment = alignment + } + + func proposeSize(for child: V, at index: Int, in context: LayoutContext) -> CGSize + where V: View + { + .init(width: width ?? proposedSize.width, height: height ?? proposedSize.height) + } + + func position(_ child: LayoutContext.Child, in context: LayoutContext) -> CGPoint { + let size = ViewDimensions( + size: .init( + width: width ?? child.dimensions.width, + height: height ?? child.dimensions.height + ), + alignmentGuides: [:] + ) + return .init( + x: size[alignment.horizontal] - child.dimensions[alignment.horizontal], + y: size[alignment.vertical] - child.dimensions[alignment.vertical] + ) + } + + func requestSize(in context: LayoutContext) -> CGSize { + let childSize = context.children.reduce(CGSize.zero) { + .init( + width: max($0.width, $1.dimensions.width), + height: max($0.height, $1.dimensions.height) + ) + } + return .init(width: width ?? childSize.width, height: height ?? childSize.height) + } +} + +public extension _FrameLayout { + static func _makeView(_ inputs: ViewInputs<_FrameLayout>) -> ViewOutputs { + .init(inputs: inputs, layoutComputer: { FrameLayoutComputer( + proposedSize: $0, + width: inputs.content.width, + height: inputs.content.height, + alignment: inputs.content.alignment + ) }) + } +} diff --git a/Sources/TokamakCore/Fiber/Layout/RootLayoutComputer.swift b/Sources/TokamakCore/Fiber/Layout/RootLayoutComputer.swift new file mode 100644 index 000000000..7e6c21499 --- /dev/null +++ b/Sources/TokamakCore/Fiber/Layout/RootLayoutComputer.swift @@ -0,0 +1,44 @@ +// Copyright 2022 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Carson Katri on 5/28/22. +// + +import Foundation + +/// A `LayoutComputer` for the root element of a `FiberRenderer`. +struct RootLayoutComputer: LayoutComputer { + let sceneSize: CGSize + + init(sceneSize: CGSize) { + self.sceneSize = sceneSize + } + + func proposeSize(for child: V, at index: Int, in context: LayoutContext) -> CGSize + where V: View + { + sceneSize + } + + func position(_ child: LayoutContext.Child, in context: LayoutContext) -> CGPoint { + .init( + x: sceneSize.width / 2 - child.dimensions[HorizontalAlignment.center], + y: sceneSize.height / 2 - child.dimensions[VerticalAlignment.center] + ) + } + + func requestSize(in context: LayoutContext) -> CGSize { + sceneSize + } +} diff --git a/Sources/TokamakCore/Fiber/Layout/ShrinkWrapLayoutComputer.swift b/Sources/TokamakCore/Fiber/Layout/ShrinkWrapLayoutComputer.swift new file mode 100644 index 000000000..7d312bf1c --- /dev/null +++ b/Sources/TokamakCore/Fiber/Layout/ShrinkWrapLayoutComputer.swift @@ -0,0 +1,46 @@ +// Copyright 2022 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Carson Katri on 5/24/22. +// + +import Foundation + +/// A `LayoutComputer` that shrinks to the size of its children. +struct ShrinkWrapLayoutComputer: LayoutComputer { + let proposedSize: CGSize + + init(proposedSize: CGSize) { + self.proposedSize = proposedSize + } + + func proposeSize(for child: V, at index: Int, in context: LayoutContext) -> CGSize + where V: View + { + proposedSize + } + + func position(_ child: LayoutContext.Child, in context: LayoutContext) -> CGPoint { + .zero + } + + func requestSize(in context: LayoutContext) -> CGSize { + context.children.reduce(CGSize.zero) { + .init( + width: max($0.width, $1.dimensions.width), + height: max($0.height, $1.dimensions.height) + ) + } + } +} diff --git a/Sources/TokamakCore/Fiber/Layout/StackLayoutComputer.swift b/Sources/TokamakCore/Fiber/Layout/StackLayoutComputer.swift new file mode 100644 index 000000000..4d56ef738 --- /dev/null +++ b/Sources/TokamakCore/Fiber/Layout/StackLayoutComputer.swift @@ -0,0 +1,154 @@ +// Copyright 2022 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Carson Katri on 5/24/22. +// + +import Foundation + +/// A `LayoutComputer` that aligns `Views` along a specified `Axis` +/// with a given spacing and alignment. +/// +/// The specified main `Axis` will fit to the combined width/height (depending on the axis) +/// of the children. +/// The cross axis will fit to the child with the largest height/width. +struct StackLayoutComputer: LayoutComputer { + let proposedSize: CGSize + let axis: Axis + let alignment: Alignment + let spacing: CGFloat + + init(proposedSize: CGSize, axis: Axis, alignment: Alignment, spacing: CGFloat) { + self.proposedSize = proposedSize + self.axis = axis + self.alignment = alignment + self.spacing = spacing + } + + func proposeSize(for child: V, at index: Int, in context: LayoutContext) -> CGSize + where V: View + { + let used = context.children.reduce(CGSize.zero) { + .init( + width: $0.width + $1.dimensions.width, + height: $0.height + $1.dimensions.height + ) + } + switch axis { + case .horizontal: + return .init( + width: proposedSize.width - used.width, + height: proposedSize.height + ) + case .vertical: + return .init( + width: proposedSize.width, + height: proposedSize.height - used.height + ) + } + } + + func position(_ child: LayoutContext.Child, in context: LayoutContext) -> CGPoint { + let (maxSize, fitSize) = context.children + .enumerated() + .reduce((CGSize.zero, CGSize.zero)) { res, next in + ( + .init( + width: max(res.0.width, next.element.dimensions.width), + height: max(res.0.height, next.element.dimensions.height) + ), + next.offset < child.index ? .init( + width: res.1.width + next.element.dimensions.width, + height: res.1.height + next.element.dimensions.height + ) : res.1 + ) + } + let maxDimensions = ViewDimensions(size: maxSize, alignmentGuides: [:]) + /// The gaps up to this point. + let fitSpacing = CGFloat(child.index) * spacing + switch axis { + case .horizontal: + return .init( + x: fitSize.width + fitSpacing, + y: maxDimensions[alignment.vertical] - child.dimensions[alignment.vertical] + ) + case .vertical: + return .init( + x: maxDimensions[alignment.horizontal] - child.dimensions[alignment.horizontal], + y: fitSize.height + fitSpacing + ) + } + } + + func requestSize(in context: LayoutContext) -> CGSize { + let maxDimensions = CGSize( + width: context.children + .max(by: { $0.dimensions.width < $1.dimensions.width })?.dimensions.width ?? .zero, + height: context.children + .max(by: { $0.dimensions.height < $1.dimensions.height })?.dimensions.height ?? .zero + ) + let fitDimensions = context.children + .reduce(CGSize.zero) { + .init(width: $0.width + $1.dimensions.width, height: $0.height + $1.dimensions.height) + } + + /// The combined gap size. + let fitSpacing = CGFloat(context.children.count - 1) * spacing + + switch axis { + case .horizontal: + return .init( + width: fitDimensions.width + fitSpacing, + height: maxDimensions.height + ) + case .vertical: + return .init( + width: maxDimensions.width, + height: fitDimensions.height + fitSpacing + ) + } + } +} + +public extension VStack { + static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { + .init( + inputs: inputs, + layoutComputer: { proposedSize in + StackLayoutComputer( + proposedSize: proposedSize, + axis: .vertical, + alignment: .init(horizontal: inputs.content.alignment, vertical: .center), + spacing: inputs.content.spacing + ) + } + ) + } +} + +public extension HStack { + static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { + .init( + inputs: inputs, + layoutComputer: { proposedSize in + StackLayoutComputer( + proposedSize: proposedSize, + axis: .horizontal, + alignment: .init(horizontal: .center, vertical: inputs.content.alignment), + spacing: inputs.content.spacing + ) + } + ) + } +} diff --git a/Sources/TokamakCore/Fiber/Layout/TextLayoutComputer.swift b/Sources/TokamakCore/Fiber/Layout/TextLayoutComputer.swift new file mode 100644 index 000000000..2b3644a83 --- /dev/null +++ b/Sources/TokamakCore/Fiber/Layout/TextLayoutComputer.swift @@ -0,0 +1,60 @@ +// Copyright 2022 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Carson Katri on 5/24/22. +// + +import Foundation + +/// Measures the bounds of the `Text` with modifiers and wraps it inside the `proposedSize`. +struct TextLayoutComputer: LayoutComputer { + let text: Text + let proposedSize: CGSize + let environment: EnvironmentValues + + init(text: Text, proposedSize: CGSize, environment: EnvironmentValues) { + self.text = text + self.proposedSize = proposedSize + self.environment = environment + } + + func proposeSize(for child: V, at index: Int, in context: LayoutContext) -> CGSize + where V: View + { + fatalError("Text views cannot have children.") + } + + func position(_ child: LayoutContext.Child, in context: LayoutContext) -> CGPoint { + fatalError("Text views cannot have children.") + } + + func requestSize(in context: LayoutContext) -> CGSize { + environment.measureText(text, proposedSize, environment) + } +} + +public extension Text { + static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { + .init( + inputs: inputs, + layoutComputer: { proposedSize in + TextLayoutComputer( + text: inputs.content, + proposedSize: proposedSize, + environment: inputs.environment.environment + ) + } + ) + } +} diff --git a/Sources/TokamakCore/Fiber/LayoutComputer.swift b/Sources/TokamakCore/Fiber/LayoutComputer.swift index 4d49d8366..d449d6a5a 100644 --- a/Sources/TokamakCore/Fiber/LayoutComputer.swift +++ b/Sources/TokamakCore/Fiber/LayoutComputer.swift @@ -17,9 +17,37 @@ import Foundation +/// The currently computed children. +struct LayoutContext { + var children: [Child] + + struct Child { + let index: Int + let dimensions: ViewDimensions + } +} + /// A type that is able to propose sizes for its children. +/// +/// The order of calls is guaranteed to be: +/// 1. `proposeSize(for: child1, at: 0, in: context)` +/// 2. `proposeSize(for: child2, at: 1, in: context)` +/// 3. `position(child1)` +/// 4. `position(child2)` +/// +/// The `context` will contain all of the previously computed children from `proposeSize` calls. +/// +/// The same `LayoutComputer` instance will be used for any given view during a single layout pass. +/// +/// Sizes from `proposeSize` will be clamped, so it is safe to return negative numbers. protocol LayoutComputer { /// Will be called every time a child is evaluated. /// The calls will always be in order, and no more than one call will be made per child. - func proposeSize(for child: V, at index: Int) -> CGSize + func proposeSize(for child: V, at index: Int, in context: LayoutContext) -> CGSize + + /// The child responds with their size and we place them relative to our origin. + func position(_ child: LayoutContext.Child, in context: LayoutContext) -> CGPoint + + /// Request a size for ourself from our parent. + func requestSize(in context: LayoutContext) -> CGSize } diff --git a/Sources/TokamakCore/Fiber/Mutation.swift b/Sources/TokamakCore/Fiber/Mutation.swift index f29d0cfaf..5c2e28945 100644 --- a/Sources/TokamakCore/Fiber/Mutation.swift +++ b/Sources/TokamakCore/Fiber/Mutation.swift @@ -15,6 +15,8 @@ // Created by Carson Katri on 2/15/22. // +import Foundation + public enum Mutation { case insert( element: Renderer.ElementType, @@ -27,5 +29,10 @@ public enum Mutation { previous: Renderer.ElementType, replacement: Renderer.ElementType ) - case update(previous: Renderer.ElementType, newContent: Renderer.ElementType.Content) + case update( + previous: Renderer.ElementType, + newContent: Renderer.ElementType.Content, + geometry: ViewGeometry + ) + case layout(element: Renderer.ElementType, geometry: ViewGeometry) } diff --git a/Sources/TokamakCore/Fiber/ViewArguments.swift b/Sources/TokamakCore/Fiber/ViewArguments.swift index ef35031fa..82a696b68 100644 --- a/Sources/TokamakCore/Fiber/ViewArguments.swift +++ b/Sources/TokamakCore/Fiber/ViewArguments.swift @@ -18,10 +18,8 @@ import Foundation /// Data passed to `_makeView` to create the `ViewOutputs` used in reconciling/rendering. -public struct ViewInputs { - let view: V - /// The size proposed by this view's parent. - let proposedSize: CGSize? +public struct ViewInputs { + let content: V let environment: EnvironmentBox } @@ -31,10 +29,9 @@ public struct ViewOutputs { /// This is stored as a reference to avoid copying the environment when unnecessary. let environment: EnvironmentBox let preferences: _PreferenceStore - /// The size requested by this view. - let size: CGSize + let makeLayoutComputer: (CGSize) -> LayoutComputer /// The `LayoutComputer` used to propose sizes for the children of this view. - let layoutComputer: LayoutComputer? + var layoutComputer: LayoutComputer! } final class EnvironmentBox { @@ -46,18 +43,19 @@ final class EnvironmentBox { } extension ViewOutputs { - init( + init( inputs: ViewInputs, environment: EnvironmentValues? = nil, preferences: _PreferenceStore? = nil, - size: CGSize? = nil, - layoutComputer: LayoutComputer? = nil + layoutComputer: ((CGSize) -> LayoutComputer)? = nil ) { - // Only replace the EnvironmentBox when we change the environment. Otherwise the same box can be reused. + // Only replace the `EnvironmentBox` when we change the environment. + // Otherwise the same box can be reused. self.environment = environment.map(EnvironmentBox.init) ?? inputs.environment self.preferences = preferences ?? .init() - self.size = size ?? inputs.proposedSize ?? .zero - self.layoutComputer = layoutComputer + makeLayoutComputer = layoutComputer ?? { proposedSize in + ShrinkWrapLayoutComputer(proposedSize: proposedSize) + } } } @@ -71,16 +69,10 @@ public extension View { public extension ModifiedContent where Content: View, Modifier: ViewModifier { static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { - // Update the environment if needed. - var environment = inputs.environment.environment - if let environmentWriter = inputs.view.modifier as? EnvironmentModifier { - environmentWriter.modifyEnvironment(&environment) - } - return .init(inputs: inputs, environment: environment) + Modifier._makeView(.init(content: inputs.content.modifier, environment: inputs.environment)) } func _visitChildren(_ visitor: V) where V: ViewVisitor { - // Visit the computed body of the modifier. - visitor.visit(modifier.body(content: .init(modifier: modifier, view: content))) + modifier._visitChildren(visitor, content: .init(modifier: modifier, view: content)) } } diff --git a/Sources/TokamakCore/Fiber/ViewGeometry.swift b/Sources/TokamakCore/Fiber/ViewGeometry.swift new file mode 100644 index 000000000..0a0adf8bb --- /dev/null +++ b/Sources/TokamakCore/Fiber/ViewGeometry.swift @@ -0,0 +1,64 @@ +// Copyright 2022 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Carson Katri on 2/17/22. +// + +import Foundation + +public struct ViewGeometry: Equatable { + @_spi(TokamakCore) + public let origin: ViewOrigin + + @_spi(TokamakCore) + public let dimensions: ViewDimensions +} + +/// The position of the `View` relative to its parent. +public struct ViewOrigin: Equatable { + @_spi(TokamakCore) + public let origin: CGPoint + + @_spi(TokamakCore) + public var x: CGFloat { origin.x } + @_spi(TokamakCore) + public var y: CGFloat { origin.y } +} + +public struct ViewDimensions: Equatable { + @_spi(TokamakCore) + public let size: CGSize + + @_spi(TokamakCore) + public let alignmentGuides: [ObjectIdentifier: CGFloat] + + public var width: CGFloat { size.width } + public var height: CGFloat { size.height } + + public subscript(guide: HorizontalAlignment) -> CGFloat { + self[explicit: guide] ?? guide.id.defaultValue(in: self) + } + + public subscript(guide: VerticalAlignment) -> CGFloat { + self[explicit: guide] ?? guide.id.defaultValue(in: self) + } + + public subscript(explicit guide: HorizontalAlignment) -> CGFloat? { + alignmentGuides[.init(guide.id)] + } + + public subscript(explicit guide: VerticalAlignment) -> CGFloat? { + alignmentGuides[.init(guide.id)] + } +} diff --git a/Sources/TokamakCore/Fiber/walk.swift b/Sources/TokamakCore/Fiber/walk.swift index 7e00c8ab4..0c7b49628 100644 --- a/Sources/TokamakCore/Fiber/walk.swift +++ b/Sources/TokamakCore/Fiber/walk.swift @@ -67,7 +67,6 @@ func walk( current = parent } // Walk the sibling - // swiftlint:disable:next force_unwrap current = current.sibling! } } diff --git a/Sources/TokamakCore/Modifiers/ModifiedContent.swift b/Sources/TokamakCore/Modifiers/ModifiedContent.swift index 9011ab1dc..dc592da57 100644 --- a/Sources/TokamakCore/Modifiers/ModifiedContent.swift +++ b/Sources/TokamakCore/Modifiers/ModifiedContent.swift @@ -13,7 +13,7 @@ // limitations under the License. protocol ModifierContainer { - var environmentModifier: EnvironmentModifier? { get } + var environmentModifier: _EnvironmentModifier? { get } } protocol ModifiedContentProtocol {} @@ -32,7 +32,7 @@ public struct ModifiedContent: ModifiedContentProtocol { } extension ModifiedContent: ModifierContainer { - var environmentModifier: EnvironmentModifier? { modifier as? EnvironmentModifier } + var environmentModifier: _EnvironmentModifier? { modifier as? _EnvironmentModifier } } extension ModifiedContent: EnvironmentReader where Modifier: EnvironmentReader { diff --git a/Sources/TokamakCore/Modifiers/ViewModifier.swift b/Sources/TokamakCore/Modifiers/ViewModifier.swift index 436e672f8..772676942 100644 --- a/Sources/TokamakCore/Modifiers/ViewModifier.swift +++ b/Sources/TokamakCore/Modifiers/ViewModifier.swift @@ -16,6 +16,23 @@ public protocol ViewModifier { typealias Content = _ViewModifier_Content associatedtype Body: View func body(content: Content) -> Self.Body + + static func _makeView(_ inputs: ViewInputs) -> ViewOutputs + func _visitChildren(_ visitor: V, content: Content) where V: ViewVisitor +} + +public extension ViewModifier { + static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { + .init(inputs: inputs) + } + + func _visitChildren(_ visitor: V, content: Content) where V: ViewVisitor { + if Body.self == Never.self { + content.visitChildren(visitor) + } else { + visitor.visit(body(content: content)) + } + } } public struct _ViewModifier_Content: View diff --git a/Sources/TokamakCore/Shapes/ShapeStyles/BackgroundStyle.swift b/Sources/TokamakCore/Shapes/ShapeStyles/BackgroundStyle.swift index f25587ab3..364225c64 100644 --- a/Sources/TokamakCore/Shapes/ShapeStyles/BackgroundStyle.swift +++ b/Sources/TokamakCore/Shapes/ShapeStyles/BackgroundStyle.swift @@ -56,7 +56,7 @@ public extension View { } } -@frozen public struct _BackgroundStyleModifier