From 2e9e2abb4aed44faa6e9bb2ec3a47bf41dd366db Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Wed, 15 Jun 2022 20:40:55 -0400 Subject: [PATCH 01/38] Initial pass at Layout protocol implementation --- Sources/TokamakCore/Fiber/Fiber+Layout.swift | 133 +++++ Sources/TokamakCore/Fiber/Fiber.swift | 18 +- .../Fiber/FiberReconciler+TreeReducer.swift | 11 +- .../TokamakCore/Fiber/FiberReconciler.swift | 273 ++++++----- Sources/TokamakCore/Fiber/FiberRenderer.swift | 10 +- .../Layout/BackgroundLayoutComputer.swift | 142 +++--- .../Fiber/Layout/FlexLayoutComputer.swift | 42 -- .../Fiber/Layout/FrameLayoutComputer.swift | 57 +-- .../Fiber/Layout/PaddingLayoutComputer.swift | 74 ++- .../Fiber/Layout/RootLayoutComputer.swift | 44 -- .../Layout/ShrinkWrapLayoutComputer.swift | 47 -- .../Fiber/Layout/StackLayoutComputer.swift | 251 +++++----- .../Fiber/Layout/TextLayoutComputer.swift | 60 --- .../TokamakCore/Fiber/LayoutComputer.swift | 462 ++++++++++++++++-- .../Fiber/Scene/SceneArguments.swift | 5 +- Sources/TokamakCore/Fiber/ViewArguments.swift | 9 +- Sources/TokamakCore/Shapes/Shape.swift | 26 +- Sources/TokamakCore/Views/Layout/HStack.swift | 6 +- Sources/TokamakCore/Views/Layout/VStack.swift | 6 +- Sources/TokamakDOM/DOMFiberRenderer.swift | 10 +- Sources/TokamakStaticHTML/Shapes/Path.swift | 26 +- .../StaticHTMLFiberRenderer.swift | 10 +- Sources/TokamakStaticHTML/Views/HTML.swift | 63 ++- .../TestFiberRenderer.swift | 4 +- 24 files changed, 1073 insertions(+), 716 deletions(-) create mode 100644 Sources/TokamakCore/Fiber/Fiber+Layout.swift delete mode 100644 Sources/TokamakCore/Fiber/Layout/FlexLayoutComputer.swift delete mode 100644 Sources/TokamakCore/Fiber/Layout/RootLayoutComputer.swift delete mode 100644 Sources/TokamakCore/Fiber/Layout/ShrinkWrapLayoutComputer.swift delete mode 100644 Sources/TokamakCore/Fiber/Layout/TextLayoutComputer.swift diff --git a/Sources/TokamakCore/Fiber/Fiber+Layout.swift b/Sources/TokamakCore/Fiber/Fiber+Layout.swift new file mode 100644 index 000000000..d73ead112 --- /dev/null +++ b/Sources/TokamakCore/Fiber/Fiber+Layout.swift @@ -0,0 +1,133 @@ +// 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 6/15/22. +// + +import Foundation + +extension FiberReconciler.Fiber: Layout { + public func makeCache(subviews: Subviews) -> Any { + layout.makeCache(subviews) + } + + public func updateCache(_ cache: inout Any, subviews: Subviews) { + layout.updateCache(&cache, subviews) + } + + public func spacing(subviews: Subviews, cache: inout Any) -> ViewSpacing { + layout.spacing(subviews, &cache) + } + + public func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Any + ) -> CGSize { + if case let .view(view, _) = content, + let text = view as? Text + { + return self.reconciler?.renderer.measureText( + text, proposal: proposal, in: self.outputs.environment.environment + ) ?? .zero + } else { + return layout.sizeThatFits(proposal, subviews, &cache) + } + } + + public func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Any + ) { + layout.placeSubviews(bounds, proposal, subviews, &cache) + } + + public func explicitAlignment( + of guide: HorizontalAlignment, + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Any + ) -> CGFloat? { + layout.explicitHorizontalAlignment(guide, bounds, proposal, subviews, &cache) + } + + public func explicitAlignment( + of guide: VerticalAlignment, + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Any + ) -> CGFloat? { + layout.explicitVerticalAlignment(guide, bounds, proposal, subviews, &cache) + } +} + +public struct LayoutActions { + let makeCache: (LayoutSubviews) -> Any + let updateCache: (inout Any, LayoutSubviews) -> () + let spacing: (LayoutSubviews, inout Any) -> ViewSpacing + let sizeThatFits: (ProposedViewSize, LayoutSubviews, inout Any) -> CGSize + let placeSubviews: (CGRect, ProposedViewSize, LayoutSubviews, inout Any) -> () + let explicitHorizontalAlignment: ( + HorizontalAlignment, CGRect, ProposedViewSize, LayoutSubviews, inout Any + ) -> CGFloat? + let explicitVerticalAlignment: ( + VerticalAlignment, CGRect, ProposedViewSize, LayoutSubviews, inout Any + ) -> CGFloat? + + private static func useCache(_ cache: inout Any, _ action: (inout C) -> R) -> R { + guard var typedCache = cache as? C else { fatalError("Cache mismatch") } + let result = action(&typedCache) + cache = typedCache + return result + } + + init(_ layout: L) { + makeCache = { layout.makeCache(subviews: $0) } + updateCache = { cache, subviews in + Self.useCache(&cache) { layout.updateCache(&$0, subviews: subviews) } + } + spacing = { subviews, cache in + Self.useCache(&cache) { layout.spacing(subviews: subviews, cache: &$0) } + } + sizeThatFits = { proposal, subviews, cache in + Self + .useCache(&cache) { + layout.sizeThatFits(proposal: proposal, subviews: subviews, cache: &$0) + } + } + placeSubviews = { bounds, proposal, subviews, cache in + Self.useCache(&cache) { + layout.placeSubviews(in: bounds, proposal: proposal, subviews: subviews, cache: &$0) + } + } + explicitHorizontalAlignment = { alignment, bounds, proposal, subviews, cache in + Self.useCache(&cache) { + layout.explicitAlignment( + of: alignment, in: bounds, proposal: proposal, subviews: subviews, cache: &$0 + ) + } + } + explicitVerticalAlignment = { alignment, bounds, proposal, subviews, cache in + Self.useCache(&cache) { + layout.explicitAlignment( + of: alignment, in: bounds, proposal: proposal, subviews: subviews, cache: &$0 + ) + } + } + } +} diff --git a/Sources/TokamakCore/Fiber/Fiber.swift b/Sources/TokamakCore/Fiber/Fiber.swift index d19a0e37d..86981e5d9 100644 --- a/Sources/TokamakCore/Fiber/Fiber.swift +++ b/Sources/TokamakCore/Fiber/Fiber.swift @@ -54,6 +54,7 @@ public extension FiberReconciler { /// all stored properties be set before using. /// `outputs` is guaranteed to be set in the initializer. var outputs: ViewOutputs! + var layout: LayoutActions /// The identity of this `View` var id: Identity? /// The mounted element, if this is a Renderer primitive. @@ -111,6 +112,7 @@ public extension FiberReconciler { init( _ view: inout V, + layoutActions: LayoutActions? = nil, element: Renderer.ElementType?, parent: Fiber?, elementParent: Fiber?, @@ -122,6 +124,7 @@ public extension FiberReconciler { sibling = nil self.parent = parent self.elementParent = elementParent + layout = (view as? _AnyLayout)?._makeActions() ?? .init(DefaultLayout()) typeInfo = TokamakCore.typeInfo(of: V.self) let environment = parent?.outputs.environment ?? .init(.init()) @@ -154,6 +157,7 @@ public extension FiberReconciler { // Create the alternate lazily let alternate = Fiber( bound: alternateView, + layoutActions: self.layout, alternate: self, outputs: self.outputs, typeInfo: self.typeInfo, @@ -182,6 +186,7 @@ public extension FiberReconciler { init( bound view: V, + layoutActions: LayoutActions, alternate: Fiber, outputs: ViewOutputs, typeInfo: TypeInfo?, @@ -199,6 +204,7 @@ public extension FiberReconciler { self.elementParent = elementParent self.typeInfo = typeInfo self.outputs = outputs + layout = layoutActions content = content(for: view) } @@ -272,11 +278,10 @@ public extension FiberReconciler { elementParent = nil element = rootElement typeInfo = TokamakCore.typeInfo(of: A.self) - + layout = .init(RootLayout(renderer: reconciler.renderer)) state = bindProperties(to: &app, typeInfo, rootEnvironment) outputs = .init( - inputs: .init(content: app, environment: .init(rootEnvironment)), - layoutComputer: RootLayoutComputer.init + inputs: .init(content: app, environment: .init(rootEnvironment)) ) content = content(for: app) @@ -287,6 +292,7 @@ public extension FiberReconciler { // Create the alternate lazily let alternate = Fiber( bound: alternateApp, + layout: self.layout, alternate: self, outputs: self.outputs, typeInfo: self.typeInfo, @@ -300,6 +306,7 @@ public extension FiberReconciler { init( bound app: A, + layout: LayoutActions, alternate: Fiber, outputs: SceneOutputs, typeInfo: TypeInfo?, @@ -315,6 +322,7 @@ public extension FiberReconciler { elementParent = nil self.typeInfo = typeInfo self.outputs = outputs + self.layout = layout content = content(for: app) } @@ -332,6 +340,7 @@ public extension FiberReconciler { self.parent = parent self.elementParent = elementParent self.element = element + layout = (scene as? _AnyLayout)?._makeActions() ?? .init(DefaultLayout()) typeInfo = TokamakCore.typeInfo(of: S.self) let environment = environment ?? parent?.outputs.environment ?? .init(.init()) @@ -351,6 +360,7 @@ public extension FiberReconciler { // Create the alternate lazily let alternate = Fiber( bound: alternateScene, + layout: self.layout, alternate: self, outputs: self.outputs, typeInfo: self.typeInfo, @@ -379,6 +389,7 @@ public extension FiberReconciler { init( bound scene: S, + layout: LayoutActions, alternate: Fiber, outputs: SceneOutputs, typeInfo: TypeInfo?, @@ -396,6 +407,7 @@ public extension FiberReconciler { self.elementParent = elementParent self.typeInfo = typeInfo self.outputs = outputs + self.layout = layout content = content(for: scene) } diff --git a/Sources/TokamakCore/Fiber/FiberReconciler+TreeReducer.swift b/Sources/TokamakCore/Fiber/FiberReconciler+TreeReducer.swift index 8ff5e42df..4c907f97c 100644 --- a/Sources/TokamakCore/Fiber/FiberReconciler+TreeReducer.swift +++ b/Sources/TokamakCore/Fiber/FiberReconciler+TreeReducer.swift @@ -29,7 +29,6 @@ extension FiberReconciler { var sibling: Result? var newContent: Renderer.ElementType.Content? var elementIndices: [ObjectIdentifier: Int] - var layoutContexts: [ObjectIdentifier: LayoutContext] // For reducing var lastSibling: Result? @@ -43,8 +42,7 @@ extension FiberReconciler { child: Fiber?, alternateChild: Fiber?, newContent: Renderer.ElementType.Content? = nil, - elementIndices: [ObjectIdentifier: Int], - layoutContexts: [ObjectIdentifier: LayoutContext] + elementIndices: [ObjectIdentifier: Int] ) { self.fiber = fiber self.visitChildren = visitChildren @@ -53,7 +51,6 @@ extension FiberReconciler { nextExistingAlternate = alternateChild self.newContent = newContent self.elementIndices = elementIndices - self.layoutContexts = layoutContexts } } @@ -132,8 +129,7 @@ extension FiberReconciler { child: existing.child, alternateChild: existing.alternate?.child, newContent: newContent, - elementIndices: partialResult.elementIndices, - layoutContexts: partialResult.layoutContexts + elementIndices: partialResult.elementIndices ) partialResult.nextExisting = existing.sibling } else { @@ -166,8 +162,7 @@ extension FiberReconciler { parent: partialResult, child: nil, alternateChild: fiber.alternate?.child, - elementIndices: partialResult.elementIndices, - layoutContexts: partialResult.layoutContexts + elementIndices: partialResult.elementIndices ) } // Get the last child element we've processed, and add the new child as its sibling. diff --git a/Sources/TokamakCore/Fiber/FiberReconciler.swift b/Sources/TokamakCore/Fiber/FiberReconciler.swift index a85c593c9..d4ef31b8e 100644 --- a/Sources/TokamakCore/Fiber/FiberReconciler.swift +++ b/Sources/TokamakCore/Fiber/FiberReconciler.swift @@ -45,12 +45,32 @@ public final class FiberReconciler { content .environmentValues(environment) } + } - static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { - .init( - inputs: inputs, - layoutComputer: { _ in RootLayoutComputer(sceneSize: inputs.content.renderer.sceneSize) } - ) + struct RootLayout: Layout { + let renderer: Renderer + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) -> CGSize { + renderer.sceneSize + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + for subview in subviews { + subview.place( + at: .init(x: bounds.midX, y: bounds.midY), + anchor: .center, + proposal: .init(width: bounds.width, height: bounds.height) + ) + } } } @@ -97,36 +117,6 @@ public final class FiberReconciler { currentRoot = root } - /// A `ViewVisitor` that proposes a size for the `View` represented by the fiber `node`. - struct ProposeSizeVisitor: AppVisitor, SceneVisitor, ViewVisitor { - let node: Fiber - let renderer: Renderer - 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 - } - - func visit(_ scene: S) where S: Scene { - node.outputs.layoutComputer = node.outputs.makeLayoutComputer(renderer.sceneSize) - node.alternate?.outputs.layoutComputer = node.outputs.layoutComputer - } - - func visit(_ app: A) where A: App { - node.outputs.layoutComputer = node.outputs.makeLayoutComputer(renderer.sceneSize) - node.alternate?.outputs.layoutComputer = node.outputs.layoutComputer - } - } - func visit(_ view: V) where V: View { visitAny(view, visitChildren: reconciler.renderer.viewVisitor(for: view)) } @@ -197,8 +187,7 @@ public final class FiberReconciler { parent: nil, child: alternateRoot?.child, alternateChild: currentRoot.child, - elementIndices: [:], - layoutContexts: [:] + elementIndices: [:] ) var node = rootResult @@ -206,8 +195,10 @@ 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 `Cache` for a fiber's layout. + var caches = [ObjectIdentifier: Any]() + /// The `LayoutSubviews` for each fiber. + var layoutSubviews = [ObjectIdentifier: LayoutSubviews]() /// The (potentially nested) children of an `elementParent` with `element` values in order. /// Used to position children in the correct order. var elementChildren = [ObjectIdentifier: [Fiber]]() @@ -241,49 +232,24 @@ public final class FiberReconciler { } } - /// Ask the `LayoutComputer` for the fiber's `elementParent` to propose a size. - func proposeSize(for node: Fiber) { - guard node.element != nil else { return } - - // Use a visitor so we can pass the correct `View`/`Scene` type to the function. - let visitor = ProposeSizeVisitor( - node: node, - renderer: reconciler.renderer, - layoutContexts: layoutContexts - ) - switch node.content { - case let .view(_, visit): - visit(visitor) - case let .scene(_, visit): - visit(visitor) - case let .app(_, visit): - visit(visitor) - case .none: - break - } - } - /// Request a size from the fiber's `elementParent`. - func size(_ node: Fiber) { - guard node.element != nil, - let elementParent = node.elementParent + func sizeThatFits(_ node: Fiber, proposal: ProposedViewSize) { + guard node.element != nil else { return } - let key = ObjectIdentifier(elementParent) - let elementIndex = node.elementIndex ?? 0 - var parentContext = layoutContexts[key, default: .init(children: [])] + let key = ObjectIdentifier(node) - // Using our LayoutComputer, compute our required size. + // 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 subviews = layoutSubviews[key, default: .init(node)] + var cache = caches[key, default: node.makeCache(subviews: subviews)] + let size = node.sizeThatFits( + proposal: proposal, + subviews: subviews, + cache: &cache ) + caches[key] = cache 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( @@ -292,37 +258,6 @@ public final class FiberReconciler { ) } - /// 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: [])] - - // 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 } - - // 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 { @@ -346,15 +281,76 @@ public final class FiberReconciler { let reducer = TreeReducer.SceneVisitor(initialResult: node) node.visitChildren(reducer) - // As we walk down the tree, propose a size for each View. if reconciler.renderer.useDynamicLayout, let fiber = node.fiber { - proposeSize(for: fiber) - if fiber.element != nil, - let key = fiber.elementParent.map(ObjectIdentifier.init) + if let element = fiber.element, + let elementParent = fiber.elementParent { - elementChildren[key] = elementChildren[key, default: []] + [fiber] + let parentKey = ObjectIdentifier(elementParent) + elementChildren[parentKey] = elementChildren[parentKey, default: []] + [fiber] + var subviews = layoutSubviews[parentKey, default: .init(elementParent)] + let key = ObjectIdentifier(fiber) + subviews.storage.append(LayoutSubview( + id: ObjectIdentifier(node), + sizeThatFits: { [weak fiber] in + guard let fiber = fiber else { return .zero } + let subviews = layoutSubviews[key, default: .init(fiber)] + var cache = caches[key, default: fiber.makeCache(subviews: subviews)] + print("Size \(fiber)") + let size = fiber.sizeThatFits( + proposal: $0, + subviews: subviews, + cache: &cache + ) + caches[key] = cache + return size + }, + dimensions: { [weak fiber] in + guard let fiber = fiber else { return .init(size: .zero, alignmentGuides: [:]) } + let subviews = layoutSubviews[key, default: .init(fiber)] + var cache = caches[key, default: fiber.makeCache(subviews: subviews)] + let size = fiber.sizeThatFits( + proposal: $0, + subviews: subviews, + cache: &cache + ) + caches[key] = cache + // TODO: Add `alignmentGuide` modifier and pass into `ViewDimensions` + return ViewDimensions(size: size, alignmentGuides: [:]) + }, + place: { [weak self, weak fiber, weak element] position, anchor, proposal in + guard let self = self, let fiber = fiber, let element = element else { return } + let parentOrigin = fiber.elementParent?.geometry?.origin.origin ?? .zero + var cache = caches[key, default: fiber.makeCache(subviews: subviews)] + print("Place \(fiber)") + let dimensions = ViewDimensions( + size: fiber.sizeThatFits( + proposal: proposal, + subviews: layoutSubviews[key, default: .init(fiber)], + cache: &cache + ), + alignmentGuides: [:] + ) + caches[key] = cache + let geometry = ViewGeometry( + // Shift to the anchor point in the parent's coordinate space. + origin: .init(origin: .init( + x: position.x - (dimensions.width * anchor.x) - parentOrigin.x, + y: position.y - (dimensions.height * anchor.y) - parentOrigin.y + )), + dimensions: dimensions + ) + // Push a layout mutation if needed. + if geometry != fiber.alternate?.geometry { + self.mutations.append(.layout(element: element, geometry: geometry)) + } + // Update ours and our alternate's geometry + fiber.geometry = geometry + fiber.alternate?.geometry = geometry + } + )) + layoutSubviews[parentKey] = subviews } } @@ -410,18 +406,23 @@ public final class FiberReconciler { if reconciler.renderer.useDynamicLayout, 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) + // Compute our size, proposing the entire scene. + // This is done to get an initial value for the size of this fiber before + // being recomputed by our elementParent when it calls `placeSubviews`. +// sizeThatFits(fiber, proposal: .init(reconciler.renderer.sceneSize)) // 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) - } + let key = ObjectIdentifier(fiber) + if let subviews = layoutSubviews[key] { + var cache = caches[key, default: fiber.makeCache(subviews: subviews)] + fiber.placeSubviews( + in: .init(origin: .zero, size: reconciler.renderer.sceneSize), + proposal: .unspecified, + subviews: subviews, + cache: &cache + ) + caches[key] = cache } } guard let parent = node.parent else { return } @@ -437,13 +438,19 @@ public final class FiberReconciler { if reconciler.renderer.useDynamicLayout, let fiber = node.fiber { - // Request a size from our `elementParent`. - size(fiber) + // Size this fiber proposing the scene size. +// sizeThatFits(fiber, proposal: .init(reconciler.renderer.sceneSize)) // Position our children in order. - if let elementChildren = elementChildren[ObjectIdentifier(fiber)] { - for elementChild in elementChildren { - position(elementChild) - } + if let subviews = layoutSubviews[ObjectIdentifier(fiber)] { + let key = ObjectIdentifier(fiber) + var cache = caches[key, default: fiber.makeCache(subviews: subviews)] + fiber.placeSubviews( + in: .init(origin: .zero, size: reconciler.renderer.sceneSize), + proposal: .unspecified, + subviews: subviews, + cache: &cache + ) + caches[key] = cache } } @@ -458,9 +465,17 @@ public final class FiberReconciler { 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 let subviews = layoutSubviews[ObjectIdentifier(current)] { + if let geometry = current.geometry { + let key = ObjectIdentifier(current) + var cache = caches[key, default: current.makeCache(subviews: subviews)] + current.placeSubviews( + in: .init(origin: geometry.origin.origin, size: geometry.dimensions.size), + proposal: .unspecified, + subviews: subviews, + cache: &cache + ) + caches[key] = cache } } if current.sibling != nil { diff --git a/Sources/TokamakCore/Fiber/FiberRenderer.swift b/Sources/TokamakCore/Fiber/FiberRenderer.swift index 45d88f364..f6c569843 100644 --- a/Sources/TokamakCore/Fiber/FiberRenderer.swift +++ b/Sources/TokamakCore/Fiber/FiberRenderer.swift @@ -37,7 +37,11 @@ public protocol FiberRenderer { /// Whether layout is enabled for this renderer. var useDynamicLayout: Bool { get } /// Calculate the size of `Text` in `environment` for layout. - func measureText(_ text: Text, proposedSize: CGSize, in environment: EnvironmentValues) -> CGSize + func measureText( + _ text: Text, + proposal: ProposedViewSize, + in environment: EnvironmentValues + ) -> CGSize } public extension FiberRenderer { @@ -70,12 +74,12 @@ public extension FiberRenderer { extension EnvironmentValues { private enum MeasureTextKey: EnvironmentKey { - static var defaultValue: (Text, CGSize, EnvironmentValues) -> CGSize { + static var defaultValue: (Text, ProposedViewSize, EnvironmentValues) -> CGSize { { _, _, _ in .zero } } } - var measureText: (Text, CGSize, EnvironmentValues) -> CGSize { + var measureText: (Text, ProposedViewSize, EnvironmentValues) -> CGSize { get { self[MeasureTextKey.self] } set { self[MeasureTextKey.self] = newValue } } diff --git a/Sources/TokamakCore/Fiber/Layout/BackgroundLayoutComputer.swift b/Sources/TokamakCore/Fiber/Layout/BackgroundLayoutComputer.swift index b4552bd98..341ecacc3 100644 --- a/Sources/TokamakCore/Fiber/Layout/BackgroundLayoutComputer.swift +++ b/Sources/TokamakCore/Fiber/Layout/BackgroundLayoutComputer.swift @@ -17,69 +17,93 @@ import Foundation -/// A `LayoutComputer` that constrains a background to a foreground. -final class BackgroundLayoutComputer: LayoutComputer { - let proposedSize: CGSize - let alignment: Alignment - - init(proposedSize: CGSize, alignment: Alignment) { - self.proposedSize = proposedSize - self.alignment = alignment - } - - func proposeSize(for child: V, at index: Int, in context: LayoutContext) -> CGSize - where V: View - { - if index == 0 { - // The foreground can pick their size. - return proposedSize - } else { - // The background is constrained to the foreground. - return context.children.first?.dimensions.size ?? .zero - } - } +///// A `LayoutComputer` that constrains a background to a foreground. +// final class BackgroundLayoutComputer: LayoutComputer { +// let proposedSize: CGSize +// let alignment: Alignment +// +// init(proposedSize: CGSize, alignment: Alignment) { +// self.proposedSize = proposedSize +// self.alignment = alignment +// } +// +// func proposeSize(for child: V, at index: Int, in context: LayoutContext) -> CGSize +// where V: View +// { +// if index == 0 { +// // The foreground can pick their size. +// return proposedSize +// } else { +// // The background is constrained to the foreground. +// return context.children.first?.dimensions.size ?? .zero +// } +// } +// +// func position(_ child: LayoutContext.Child, in context: LayoutContext) -> CGPoint { +// let foregroundSize = ViewDimensions( +// size: .init( +// width: context.children.first?.dimensions.width ?? 0, +// height: context.children.first?.dimensions.height ?? 0 +// ), +// alignmentGuides: [:] +// ) +// return .init( +// x: foregroundSize[alignment.horizontal] - child.dimensions[alignment.horizontal], +// y: foregroundSize[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: childSize.width, height: childSize.height) +// } +// } +// +// public extension _BackgroundLayout { +// static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { +// .init( +// inputs: inputs, +// layoutComputer: { +// BackgroundLayoutComputer(proposedSize: $0, alignment: inputs.content.alignment) +// } +// ) +// } +// } +// +// public extension _BackgroundStyleModifier { +// static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { +// .init( +// inputs: inputs, +// layoutComputer: { BackgroundLayoutComputer(proposedSize: $0, alignment: .center) } +// ) +// } +// } - func position(_ child: LayoutContext.Child, in context: LayoutContext) -> CGPoint { - let foregroundSize = ViewDimensions( - size: .init( - width: context.children.first?.dimensions.width ?? 0, - height: context.children.first?.dimensions.height ?? 0 - ), - alignmentGuides: [:] - ) - return .init( - x: foregroundSize[alignment.horizontal] - child.dimensions[alignment.horizontal], - y: foregroundSize[alignment.vertical] - child.dimensions[alignment.vertical] - ) +extension _BackgroundLayout: Layout { + public func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) -> CGSize { + subviews.first?.sizeThatFits(proposal) ?? .zero } - 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) + public func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + for subview in subviews { + subview.place( + at: bounds.origin, + proposal: .init(width: bounds.width, height: bounds.height) ) } - return .init(width: childSize.width, height: childSize.height) - } -} - -public extension _BackgroundLayout { - static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { - .init( - inputs: inputs, - layoutComputer: { - BackgroundLayoutComputer(proposedSize: $0, alignment: inputs.content.alignment) - } - ) - } -} - -public extension _BackgroundStyleModifier { - static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { - .init( - inputs: inputs, - layoutComputer: { BackgroundLayoutComputer(proposedSize: $0, alignment: .center) } - ) } } diff --git a/Sources/TokamakCore/Fiber/Layout/FlexLayoutComputer.swift b/Sources/TokamakCore/Fiber/Layout/FlexLayoutComputer.swift deleted file mode 100644 index 4dd3b63c2..000000000 --- a/Sources/TokamakCore/Fiber/Layout/FlexLayoutComputer.swift +++ /dev/null @@ -1,42 +0,0 @@ -// 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. -@_spi(TokamakCore) -public struct FlexLayoutComputer: LayoutComputer { - let proposedSize: CGSize - - public init(proposedSize: CGSize) { - self.proposedSize = proposedSize - } - - public func proposeSize(for child: V, at index: Int, in context: LayoutContext) -> CGSize - where V: View - { - proposedSize - } - - public func position(_ child: LayoutContext.Child, in context: LayoutContext) -> CGPoint { - .zero - } - - public func requestSize(in context: LayoutContext) -> CGSize { - proposedSize - } -} diff --git a/Sources/TokamakCore/Fiber/Layout/FrameLayoutComputer.swift b/Sources/TokamakCore/Fiber/Layout/FrameLayoutComputer.swift index 2f67f0448..b349dbf3d 100644 --- a/Sources/TokamakCore/Fiber/Layout/FrameLayoutComputer.swift +++ b/Sources/TokamakCore/Fiber/Layout/FrameLayoutComputer.swift @@ -17,59 +17,4 @@ import Foundation -/// A `LayoutComputer` that uses a specified size in one or more axes. -@_spi(TokamakCore) -public struct FrameLayoutComputer: LayoutComputer { - let proposedSize: CGSize - let width: CGFloat? - let height: CGFloat? - let alignment: Alignment - - public init(proposedSize: CGSize, width: CGFloat?, height: CGFloat?, alignment: Alignment) { - self.proposedSize = proposedSize - self.width = width - self.height = height - self.alignment = alignment - } - - public func proposeSize(for child: V, at index: Int, in context: LayoutContext) -> CGSize - where V: View - { - .init(width: width ?? proposedSize.width, height: height ?? proposedSize.height) - } - - public 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] - ) - } - - public 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 - ) }) - } -} +extension _FrameLayout {} diff --git a/Sources/TokamakCore/Fiber/Layout/PaddingLayoutComputer.swift b/Sources/TokamakCore/Fiber/Layout/PaddingLayoutComputer.swift index 8904e6fb5..8ddb95fad 100644 --- a/Sources/TokamakCore/Fiber/Layout/PaddingLayoutComputer.swift +++ b/Sources/TokamakCore/Fiber/Layout/PaddingLayoutComputer.swift @@ -28,57 +28,47 @@ private extension EdgeInsets { } } -/// A `LayoutComputer` that fits to its children then adds padding. -struct PaddingLayoutComputer: LayoutComputer { - let proposedSize: CGSize - let insets: EdgeInsets +private struct PaddingLayout: Layout { + let edges: Edge.Set + let insets: EdgeInsets? - init(proposedSize: CGSize, edges: Edge.Set, insets: EdgeInsets?) { - self.proposedSize = proposedSize - self.insets = .init(applying: edges, to: insets ?? EdgeInsets(_all: 10)) - } - - func proposeSize(for child: V, at index: Int, in context: LayoutContext) -> CGSize - where V: View - { - .init( - width: proposedSize.width - insets.leading - insets.trailing, - height: proposedSize.height - insets.top - insets.bottom - ) - } - - func position(_ child: LayoutContext.Child, in context: LayoutContext) -> CGPoint { - .init( - x: insets.leading, - y: insets.top + public func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) -> CGSize { + let subviewSize = (subviews.first?.sizeThatFits(proposal) ?? .zero) + let insets = EdgeInsets(applying: edges, to: insets ?? .init(_all: 10)) + return .init( + width: subviewSize.width + insets.leading + insets.trailing, + height: subviewSize.height + insets.top + insets.bottom ) } - 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) + public func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + let insets = EdgeInsets(applying: edges, to: insets ?? .init(_all: 10)) + let proposal = proposal.replacingUnspecifiedDimensions() + for subview in subviews { + subview.place( + at: .init(x: bounds.minX + insets.leading, y: bounds.minY + insets.top), + proposal: .init( + width: proposal.width - insets.leading - insets.trailing, + height: proposal.height - insets.top - insets.bottom + ) ) } - return .init( - width: childSize.width + insets.leading + insets.trailing, - height: childSize.height + insets.top + insets.bottom - ) } } public extension _PaddingLayout { - static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { - .init( - inputs: inputs, - layoutComputer: { - PaddingLayoutComputer( - proposedSize: $0, - edges: inputs.content.edges, - insets: inputs.content.insets - ) - } - ) + func _visitChildren(_ visitor: V, content: Content) where V: ViewVisitor { + visitor.visit(PaddingLayout(edges: edges, insets: insets).callAsFunction { + content + }) } } diff --git a/Sources/TokamakCore/Fiber/Layout/RootLayoutComputer.swift b/Sources/TokamakCore/Fiber/Layout/RootLayoutComputer.swift deleted file mode 100644 index 7e6c21499..000000000 --- a/Sources/TokamakCore/Fiber/Layout/RootLayoutComputer.swift +++ /dev/null @@ -1,44 +0,0 @@ -// 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 deleted file mode 100644 index 92df4fa1d..000000000 --- a/Sources/TokamakCore/Fiber/Layout/ShrinkWrapLayoutComputer.swift +++ /dev/null @@ -1,47 +0,0 @@ -// 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. -@_spi(TokamakCore) -public struct ShrinkWrapLayoutComputer: LayoutComputer { - let proposedSize: CGSize - - public init(proposedSize: CGSize) { - self.proposedSize = proposedSize - } - - public func proposeSize(for child: V, at index: Int, in context: LayoutContext) -> CGSize - where V: View - { - proposedSize - } - - public func position(_ child: LayoutContext.Child, in context: LayoutContext) -> CGPoint { - .zero - } - - public 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 index 4d56ef738..3fe6fb972 100644 --- a/Sources/TokamakCore/Fiber/Layout/StackLayoutComputer.swift +++ b/Sources/TokamakCore/Fiber/Layout/StackLayoutComputer.swift @@ -17,138 +17,165 @@ 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 +struct StackLayoutCache { + var largestSubview: LayoutSubview? + var minSize: CGFloat + var flexibleSubviews: Int +} + +protocol StackLayout: Layout where Cache == StackLayoutCache { + static var orientation: Axis { get } + var stackAlignment: Alignment { get } + var spacing: CGFloat? { get } +} + +private extension ViewDimensions { + subscript(alignment alignment: Alignment, in axis: Axis) -> CGFloat { + switch axis { + case .horizontal: return self[alignment.vertical] + case .vertical: return self[alignment.horizontal] + } + } +} - init(proposedSize: CGSize, axis: Axis, alignment: Alignment, spacing: CGFloat) { - self.proposedSize = proposedSize - self.axis = axis - self.alignment = alignment - self.spacing = spacing +extension StackLayout { + public static var layoutProperties: LayoutProperties { + var properties = LayoutProperties() + properties.stackOrientation = orientation + return properties } - 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 - ) + public func makeCache(subviews: Subviews) -> Cache { + .init( + largestSubview: nil, + minSize: .zero, + flexibleSubviews: 0 + ) + } + + static var mainAxis: WritableKeyPath { + switch orientation { + case .vertical: return \.height + case .horizontal: return \.width } - 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 - ) + } + + static var crossAxis: WritableKeyPath { + switch orientation { + case .vertical: return \.width + case .horizontal: return \.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 - ) + public func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) -> CGSize { + cache.largestSubview = subviews + .map { ($0, $0.sizeThatFits(proposal)) } + .max { a, b in + a.1[keyPath: Self.crossAxis] < b.1[keyPath: Self.crossAxis] + }?.0 + let largestSize = cache.largestSubview?.sizeThatFits(proposal) ?? .zero + + var last: Subviews.Element? + cache.minSize = .zero + cache.flexibleSubviews = 0 + for subview in subviews { + let sizeThatFits = subview.sizeThatFits(.infinity) + if sizeThatFits[keyPath: Self.mainAxis] == .infinity { + cache.flexibleSubviews += 1 + } else { + cache.minSize += sizeThatFits[keyPath: Self.mainAxis] } - 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 + if let last = last { + if let spacing = spacing { + cache.minSize += spacing + } else { + cache.minSize += last.spacing.distance( + to: subview.spacing, + along: Self.orientation + ) + } + } + last = subview + } + var size = CGSize.zero + if cache.flexibleSubviews > 0 { + size[keyPath: Self.mainAxis] = max( + cache.minSize, + proposal.replacingUnspecifiedDimensions()[keyPath: Self.mainAxis] ) + size[keyPath: Self.crossAxis] = largestSize[keyPath: Self.crossAxis] + } else { + size[keyPath: Self.mainAxis] = cache.minSize + size[keyPath: Self.crossAxis] = largestSize[keyPath: Self.crossAxis] == .infinity + ? proposal.replacingUnspecifiedDimensions()[keyPath: Self.crossAxis] + : largestSize[keyPath: Self.crossAxis] } + return size } - 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) + public func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) { + var last: Subviews.Element? + var offset = CGFloat.zero + let alignmentOffset = cache.largestSubview? + .dimensions(in: proposal)[alignment: stackAlignment, in: Self.orientation] ?? .zero + let flexibleSize = (bounds.size[keyPath: Self.mainAxis] - cache.minSize) / + CGFloat(cache.flexibleSubviews) + for subview in subviews { + if let last = last { + if let spacing = spacing { + offset += spacing + } else { + offset += last.spacing.distance(to: subview.spacing, along: Self.orientation) + } } - /// 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 + let dimensions = subview.dimensions( + in: .init( + width: Self.orientation == .horizontal ? .infinity : bounds.width, + height: Self.orientation == .vertical ? .infinity : bounds.height + ) ) - case .vertical: - return .init( - width: maxDimensions.width, - height: fitDimensions.height + fitSpacing + var position = CGSize(width: bounds.minX, height: bounds.minY) + position[keyPath: Self.mainAxis] += offset + position[keyPath: Self.crossAxis] += alignmentOffset - dimensions[ + alignment: stackAlignment, + in: Self.orientation + ] + var size = CGSize.zero + size[keyPath: Self.mainAxis] = dimensions.size[keyPath: Self.mainAxis] == .infinity + ? flexibleSize + : bounds.size[keyPath: Self.mainAxis] + size[keyPath: Self.crossAxis] = bounds.size[keyPath: Self.crossAxis] + subview.place( + at: .init(x: position.width, y: position.height), + proposal: .init(width: size.width, height: size.height) ) + + if dimensions.size[keyPath: Self.mainAxis] == .infinity { + offset += flexibleSize + } else { + offset += dimensions.size[keyPath: Self.mainAxis] + } + last = subview } } } -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 - ) - } - ) - } +extension VStack: StackLayout { + public static var orientation: Axis { .vertical } + public var stackAlignment: Alignment { .init(horizontal: alignment, vertical: .center) } } -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 - ) - } - ) - } +extension HStack: StackLayout { + public static var orientation: Axis { .horizontal } + public var stackAlignment: Alignment { .init(horizontal: .center, vertical: alignment) } } diff --git a/Sources/TokamakCore/Fiber/Layout/TextLayoutComputer.swift b/Sources/TokamakCore/Fiber/Layout/TextLayoutComputer.swift deleted file mode 100644 index 2b3644a83..000000000 --- a/Sources/TokamakCore/Fiber/Layout/TextLayoutComputer.swift +++ /dev/null @@ -1,60 +0,0 @@ -// 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 0eee3f398..fa5fe1837 100644 --- a/Sources/TokamakCore/Fiber/LayoutComputer.swift +++ b/Sources/TokamakCore/Fiber/LayoutComputer.swift @@ -17,37 +17,433 @@ import Foundation -/// The currently computed children. -public struct LayoutContext { - public var children: [Child] - - public struct Child { - public let index: Int - public 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. -public 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, 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 +public protocol _AnyLayout { + func _makeActions() -> LayoutActions } + +public protocol Layout: Animatable, _AnyLayout { + static var layoutProperties: LayoutProperties { get } + + associatedtype Cache = () + + typealias Subviews = LayoutSubviews + + func makeCache(subviews: Self.Subviews) -> Self.Cache + + func updateCache(_ cache: inout Self.Cache, subviews: Self.Subviews) + + func spacing(subviews: Self.Subviews, cache: inout Self.Cache) -> ViewSpacing + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Self.Subviews, + cache: inout Self.Cache + ) -> CGSize + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Self.Subviews, + cache: inout Self.Cache + ) + + func explicitAlignment( + of guide: HorizontalAlignment, + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Self.Subviews, + cache: inout Self.Cache + ) -> CGFloat? + + func explicitAlignment( + of guide: VerticalAlignment, + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Self.Subviews, + cache: inout Self.Cache + ) -> CGFloat? +} + +public extension Layout { + func _makeActions() -> LayoutActions { + .init(self) + } +} + +public extension Layout where Self.Cache == () { + func makeCache(subviews: Self.Subviews) -> Self.Cache { + () + } +} + +public extension Layout { + static var layoutProperties: LayoutProperties { + .init() + } + + func updateCache(_ cache: inout Self.Cache, subviews: Self.Subviews) {} + func explicitAlignment( + of guide: HorizontalAlignment, + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Self.Subviews, + cache: inout Self.Cache + ) -> CGFloat? { + nil + } + + func explicitAlignment( + of guide: VerticalAlignment, + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Self.Subviews, + cache: inout Self.Cache + ) -> CGFloat? { + nil + } + + func spacing(subviews: Self.Subviews, cache: inout Self.Cache) -> ViewSpacing { + .init() + } +} + +public struct LayoutProperties { + public var stackOrientation: Axis? + + public init() { + stackOrientation = nil + } +} + +@frozen +public struct ProposedViewSize: Equatable { + public var width: CGFloat? + public var height: CGFloat? + public static let zero: ProposedViewSize = .init(width: 0, height: 0) + public static let unspecified: ProposedViewSize = .init(width: nil, height: nil) + public static let infinity: ProposedViewSize = .init(width: .infinity, height: .infinity) + @inlinable + public init(width: CGFloat?, height: CGFloat?) { + (self.width, self.height) = (width, height) + } + + @inlinable + public init(_ size: CGSize) { + self.init(width: size.width, height: size.height) + } + + @inlinable + public func replacingUnspecifiedDimensions(by size: CGSize = CGSize( + width: 10, + height: 10 + )) -> CGSize { + CGSize(width: width ?? size.width, height: height ?? size.height) + } +} + +public struct ViewSpacing { + private var top: CGFloat + private var leading: CGFloat + private var bottom: CGFloat + private var trailing: CGFloat + + public static let zero: ViewSpacing = .init() + + public init() { + top = 0 + leading = 0 + bottom = 0 + trailing = 0 + } + + public mutating func formUnion(_ other: ViewSpacing, edges: Edge.Set = .all) { + if edges.contains(.top) { + top = max(top, other.top) + } + if edges.contains(.leading) { + leading = max(leading, other.leading) + } + if edges.contains(.bottom) { + bottom = max(bottom, other.bottom) + } + if edges.contains(.trailing) { + trailing = max(trailing, other.trailing) + } + } + + public func union(_ other: ViewSpacing, edges: Edge.Set = .all) -> ViewSpacing { + var spacing = self + spacing.formUnion(other, edges: edges) + return spacing + } + + /// The smallest spacing that accommodates the preferences of `self` and `next`. + public func distance(to next: ViewSpacing, along axis: Axis) -> CGFloat { + // Assume `next` comes after `self` either horizontally or vertically. + switch axis { + case .horizontal: + return max(trailing, next.leading) + case .vertical: + return max(bottom, next.top) + } + } +} + +public struct LayoutSubviews: Equatable, RandomAccessCollection { + public var layoutDirection: LayoutDirection + var storage: [LayoutSubview] + + init(layoutDirection: LayoutDirection, storage: [LayoutSubview]) { + self.layoutDirection = layoutDirection + self.storage = storage + } + + init(_ node: FiberReconciler.Fiber) { + self.init( + layoutDirection: node.outputs.environment.environment.layoutDirection, + storage: [] + ) + } + + public typealias SubSequence = LayoutSubviews + public typealias Element = LayoutSubview + public typealias Index = Int + public typealias Indices = Range + public typealias Iterator = IndexingIterator + + public var startIndex: Int { + storage.startIndex + } + + public var endIndex: Int { + storage.endIndex + } + + public subscript(index: Int) -> LayoutSubviews.Element { + storage[index] + } + + public subscript(bounds: Range) -> LayoutSubviews { + .init(layoutDirection: layoutDirection, storage: .init(storage[bounds])) + } + + public subscript(indices: S) -> LayoutSubviews where S: Sequence, S.Element == Int { + .init( + layoutDirection: layoutDirection, + storage: storage.enumerated() + .filter { indices.contains($0.offset) } + .map(\.element) + ) + } +} + +public struct LayoutSubview: Equatable { + private let id: ObjectIdentifier + public static func == (lhs: LayoutSubview, rhs: LayoutSubview) -> Bool { + lhs.id == rhs.id + } + + private let sizeThatFits: (ProposedViewSize) -> CGSize + private let dimensions: (ProposedViewSize) -> ViewDimensions + private let place: (CGPoint, UnitPoint, ProposedViewSize) -> () + + init( + id: ObjectIdentifier, + sizeThatFits: @escaping (ProposedViewSize) -> CGSize, + dimensions: @escaping (ProposedViewSize) -> ViewDimensions, + place: @escaping (CGPoint, UnitPoint, ProposedViewSize) -> () + ) { + self.id = id + self.sizeThatFits = sizeThatFits + self.dimensions = dimensions + self.place = place + } + + public func _trait(key: K.Type) -> K.Value where K: _ViewTraitKey { + fatalError("Implement \(#function)") + } + + public subscript(key: K.Type) -> K.Value where K: LayoutValueKey { + fatalError("Implement \(#function)") + } + + public var priority: Double { + fatalError("Implement \(#function)") + } + + public func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize { + sizeThatFits(proposal) + } + + public func dimensions(in proposal: ProposedViewSize) -> ViewDimensions { + dimensions(proposal) + } + + public var spacing: ViewSpacing { + ViewSpacing() + } + + public func place( + at position: CGPoint, + anchor: UnitPoint = .topLeading, + proposal: ProposedViewSize + ) { + place(position, anchor, proposal) + } +} + +public enum LayoutDirection: Hashable, CaseIterable { + case leftToRight + case rightToLeft +} + +extension EnvironmentValues { + private enum LayoutDirectionKey: EnvironmentKey { + static var defaultValue: LayoutDirection = .leftToRight + } + + public var layoutDirection: LayoutDirection { + get { self[LayoutDirectionKey.self] } + set { self[LayoutDirectionKey.self] = newValue } + } +} + +public protocol LayoutValueKey { + associatedtype Value + static var defaultValue: Self.Value { get } +} + +public extension View { + @inlinable + func layoutValue(key: K.Type, value: K.Value) -> some View where K: LayoutValueKey { + _trait(_LayoutTrait.self, value) + } +} + +public struct _LayoutTrait: _ViewTraitKey where K: LayoutValueKey { + public static var defaultValue: K.Value { + K.defaultValue + } +} + +public extension Layout { + func callAsFunction(@ViewBuilder _ content: () -> V) -> some View where V: View { + LayoutView(layout: self, content: content()) + } +} + +@_spi(TokamakCore) +public struct LayoutView: View, Layout { + let layout: L + let content: Content + + public typealias Cache = L.Cache + + public func makeCache(subviews: Subviews) -> L.Cache { + layout.makeCache(subviews: subviews) + } + + public func updateCache(_ cache: inout L.Cache, subviews: Subviews) { + layout.updateCache(&cache, subviews: subviews) + } + + public func spacing(subviews: Subviews, cache: inout L.Cache) -> ViewSpacing { + layout.spacing(subviews: subviews, cache: &cache) + } + + public func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) -> CGSize { + layout.sizeThatFits(proposal: proposal, subviews: subviews, cache: &cache) + } + + public func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) { + layout.placeSubviews(in: bounds, proposal: proposal, subviews: subviews, cache: &cache) + } + + public func explicitAlignment( + of guide: HorizontalAlignment, + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout L.Cache + ) -> CGFloat? { + layout.explicitAlignment( + of: guide, in: bounds, proposal: proposal, subviews: subviews, cache: &cache + ) + } + + public func explicitAlignment( + of guide: VerticalAlignment, + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout L.Cache + ) -> CGFloat? { + layout.explicitAlignment( + of: guide, in: bounds, proposal: proposal, subviews: subviews, cache: &cache + ) + } + + public var body: some View { + content + } +} + +struct DefaultLayout: Layout { + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let size = subviews.first?.sizeThatFits(proposal) ?? .zero + return size + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + for subview in subviews { + subview.place(at: bounds.origin, proposal: proposal) + } + } +} + +// TODO: AnyLayout +// class AnyLayoutBox { +// +// } +// +// final class ConcreteLayoutBox: AnyLayoutBox { +// +// } + +// @frozen public struct AnyLayout: Layout { +// internal var storage: AnyLayoutBox +// +// public init(_ layout: L) where L: Layout { +// storage = ConcreteLayoutBox(layout) +// } +// +// public struct Cache { +// } +// +// public typealias AnimatableData = _AnyAnimatableData +// public func makeCache(subviews: AnyLayout.Subviews) -> AnyLayout.Cache +// public func updateCache(_ cache: inout AnyLayout.Cache, subviews: AnyLayout.Subviews) +// public func spacing(subviews: AnyLayout.Subviews, cache: inout AnyLayout.Cache) -> ViewSpacing +// public func sizeThatFits(proposal: ProposedViewSize, subviews: AnyLayout.Subviews, cache: inout AnyLayout.Cache) -> CGSize +// public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: AnyLayout.Subviews, cache: inout AnyLayout.Cache) +// public func explicitAlignment(of guide: HorizontalAlignment, in bounds: CGRect, proposal: ProposedViewSize, subviews: AnyLayout.Subviews, cache: inout AnyLayout.Cache) -> CGFloat? +// public func explicitAlignment(of guide: VerticalAlignment, in bounds: CGRect, proposal: ProposedViewSize, subviews: AnyLayout.Subviews, cache: inout AnyLayout.Cache) -> CGFloat? +// public var animatableData: AnyLayout.AnimatableData { +// get +// set +// } +// } diff --git a/Sources/TokamakCore/Fiber/Scene/SceneArguments.swift b/Sources/TokamakCore/Fiber/Scene/SceneArguments.swift index fdb943c7e..061f61c4c 100644 --- a/Sources/TokamakCore/Fiber/Scene/SceneArguments.swift +++ b/Sources/TokamakCore/Fiber/Scene/SceneArguments.swift @@ -20,9 +20,6 @@ import Foundation public extension Scene { // By default, we simply pass the inputs through without modifications. static func _makeScene(_ inputs: SceneInputs) -> SceneOutputs { - .init( - inputs: inputs, - layoutComputer: RootLayoutComputer.init - ) + .init(inputs: inputs) } } diff --git a/Sources/TokamakCore/Fiber/ViewArguments.swift b/Sources/TokamakCore/Fiber/ViewArguments.swift index 8cc66ee07..44afa1df1 100644 --- a/Sources/TokamakCore/Fiber/ViewArguments.swift +++ b/Sources/TokamakCore/Fiber/ViewArguments.swift @@ -30,9 +30,6 @@ public struct ViewOutputs { /// This is stored as a reference to avoid copying the environment when unnecessary. let environment: EnvironmentBox let preferences: _PreferenceStore - let makeLayoutComputer: (CGSize) -> LayoutComputer - /// The `LayoutComputer` used to propose sizes for the children of this view. - var layoutComputer: LayoutComputer! } @_spi(TokamakCore) @@ -48,16 +45,12 @@ public extension ViewOutputs { init( inputs: ViewInputs, environment: EnvironmentValues? = nil, - preferences: _PreferenceStore? = nil, - layoutComputer: ((CGSize) -> LayoutComputer)? = nil + preferences: _PreferenceStore? = nil ) { // 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() - makeLayoutComputer = layoutComputer ?? { proposedSize in - ShrinkWrapLayoutComputer(proposedSize: proposedSize) - } } } diff --git a/Sources/TokamakCore/Shapes/Shape.swift b/Sources/TokamakCore/Shapes/Shape.swift index 17c1dd6ca..331a55443 100644 --- a/Sources/TokamakCore/Shapes/Shape.swift +++ b/Sources/TokamakCore/Shapes/Shape.swift @@ -54,7 +54,9 @@ public struct FillStyle: Equatable { } } -public struct _ShapeView: _PrimitiveView where Content: Shape, Style: ShapeStyle { +public struct _ShapeView: _PrimitiveView, Layout where Content: Shape, + Style: ShapeStyle +{ @Environment(\.self) public var environment @@ -71,8 +73,26 @@ public struct _ShapeView: _PrimitiveView where Content: Shape, S self.fillStyle = fillStyle } - public static func _makeView(_ inputs: ViewInputs<_ShapeView>) -> ViewOutputs { - .init(inputs: inputs, layoutComputer: FlexLayoutComputer.init) + public func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) -> CGSize { + proposal.replacingUnspecifiedDimensions() + } + + public func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + for subview in subviews { + subview.place( + at: bounds.origin, + proposal: .init(width: bounds.width, height: bounds.height) + ) + } } } diff --git a/Sources/TokamakCore/Views/Layout/HStack.swift b/Sources/TokamakCore/Views/Layout/HStack.swift index 5c335c476..e7698dd6b 100644 --- a/Sources/TokamakCore/Views/Layout/HStack.swift +++ b/Sources/TokamakCore/Views/Layout/HStack.swift @@ -27,7 +27,7 @@ public let defaultStackSpacing: CGFloat = 8 /// } public struct HStack: View where Content: View { public let alignment: VerticalAlignment - let spacing: CGFloat + let spacing: CGFloat? public let content: Content public init( @@ -36,7 +36,7 @@ public struct HStack: View where Content: View { @ViewBuilder content: () -> Content ) { self.alignment = alignment - self.spacing = spacing ?? defaultStackSpacing + self.spacing = spacing self.content = content() } @@ -61,5 +61,5 @@ public struct _HStackProxy where Content: View { public init(_ subject: HStack) { self.subject = subject } - public var spacing: CGFloat { subject.spacing } + public var spacing: CGFloat { subject.spacing ?? defaultStackSpacing } } diff --git a/Sources/TokamakCore/Views/Layout/VStack.swift b/Sources/TokamakCore/Views/Layout/VStack.swift index 8efd49a9b..41a62fe3e 100644 --- a/Sources/TokamakCore/Views/Layout/VStack.swift +++ b/Sources/TokamakCore/Views/Layout/VStack.swift @@ -22,7 +22,7 @@ import Foundation /// } public struct VStack: View where Content: View { public let alignment: HorizontalAlignment - let spacing: CGFloat + let spacing: CGFloat? public let content: Content public init( @@ -31,7 +31,7 @@ public struct VStack: View where Content: View { @ViewBuilder content: () -> Content ) { self.alignment = alignment - self.spacing = spacing ?? defaultStackSpacing + self.spacing = spacing self.content = content() } @@ -56,5 +56,5 @@ public struct _VStackProxy where Content: View { public init(_ subject: VStack) { self.subject = subject } - public var spacing: CGFloat { subject.spacing } + public var spacing: CGFloat { subject.spacing ?? defaultStackSpacing } } diff --git a/Sources/TokamakDOM/DOMFiberRenderer.swift b/Sources/TokamakDOM/DOMFiberRenderer.swift index bbcbd4742..6deb29cdf 100644 --- a/Sources/TokamakDOM/DOMFiberRenderer.swift +++ b/Sources/TokamakDOM/DOMFiberRenderer.swift @@ -146,12 +146,16 @@ public struct DOMFiberRenderer: FiberRenderer { public func measureText( _ text: Text, - proposedSize: CGSize, + proposal: ProposedViewSize, in environment: EnvironmentValues ) -> CGSize { let element = createElement(.init(from: .init(from: text, useDynamicLayout: true))) - _ = element.style.setProperty("maxWidth", "\(proposedSize.width)px") - _ = element.style.setProperty("maxHeight", "\(proposedSize.height)px") + if let width = proposal.width { + _ = element.style.setProperty("maxWidth", "\(width)px") + } + if let height = proposal.height { + _ = element.style.setProperty("maxHeight", "\(height)px") + } _ = document.body.appendChild(element) let rect = element.getBoundingClientRect!() let size = CGSize( diff --git a/Sources/TokamakStaticHTML/Shapes/Path.swift b/Sources/TokamakStaticHTML/Shapes/Path.swift index a5735535d..1938f5d94 100644 --- a/Sources/TokamakStaticHTML/Shapes/Path.swift +++ b/Sources/TokamakStaticHTML/Shapes/Path.swift @@ -51,20 +51,10 @@ extension Path: _HTMLPrimitive { "height": flexibleHeight ?? "\(max(0, rect.size.height))", "x": "\(rect.origin.x - (rect.size.width / 2))", "y": "\(rect.origin.y - (rect.size.height / 2))", - ].merging(stroke, uniquingKeysWith: uniqueKeys), - layoutComputer: { - if sizing == .flexible { - return FlexLayoutComputer(proposedSize: $0) - } else { - return FrameLayoutComputer( - proposedSize: $0, - width: max(0, rect.size.width), - height: max(0, rect.size.height), - alignment: .center - ) - } - } - ) + ].merging(stroke, uniquingKeysWith: uniqueKeys) + ) { proposal, _ in + proposal.replacingUnspecifiedDimensions() + } case let .ellipse(rect): return HTML( "ellipse", @@ -74,7 +64,9 @@ extension Path: _HTMLPrimitive { "rx": flexibleCenterX ?? "\(rect.size.width)", "ry": flexibleCenterY ?? "\(rect.size.height)"] .merging(stroke, uniquingKeysWith: uniqueKeys) - ) + ) { proposal, _ in + proposal.replacingUnspecifiedDimensions() + } case let .roundedRect(roundedRect): // When cornerRadius is nil we use 50% rx. let size = roundedRect.rect.size @@ -100,7 +92,9 @@ extension Path: _HTMLPrimitive { ] .merging(cornerRadius, uniquingKeysWith: uniqueKeys) .merging(stroke, uniquingKeysWith: uniqueKeys) - ) + ) { proposal, _ in + proposal.replacingUnspecifiedDimensions() + } case let .stroked(stroked): return stroked.path.svgBody(strokeStyle: stroked.style) case let .trimmed(trimmed): diff --git a/Sources/TokamakStaticHTML/StaticHTMLFiberRenderer.swift b/Sources/TokamakStaticHTML/StaticHTMLFiberRenderer.swift index ccff8f56c..f9b5eb66e 100644 --- a/Sources/TokamakStaticHTML/StaticHTMLFiberRenderer.swift +++ b/Sources/TokamakStaticHTML/StaticHTMLFiberRenderer.swift @@ -150,6 +150,14 @@ extension HStack: HTMLConvertible { } } +@_spi(TokamakCore) +extension LayoutView: HTMLConvertible { + public var tag: String { "div" } + public func attributes(useDynamicLayout: Bool) -> [HTMLAttribute: String] { + [:] + } +} + public struct StaticHTMLFiberRenderer: FiberRenderer { public let rootElement: HTMLElement public let defaultEnvironment: EnvironmentValues @@ -195,7 +203,7 @@ public struct StaticHTMLFiberRenderer: FiberRenderer { public func measureText( _ text: Text, - proposedSize: CGSize, + proposal: ProposedViewSize, in environment: EnvironmentValues ) -> CGSize { .zero diff --git a/Sources/TokamakStaticHTML/Views/HTML.swift b/Sources/TokamakStaticHTML/Views/HTML.swift index 2577681ec..974fb7120 100644 --- a/Sources/TokamakStaticHTML/Views/HTML.swift +++ b/Sources/TokamakStaticHTML/Views/HTML.swift @@ -87,12 +87,12 @@ public extension AnyHTML { } } -public struct HTML: View, AnyHTML { +public struct HTML: View, AnyHTML, Layout { public let tag: String public let namespace: String? public let attributes: [HTMLAttribute: String] + let sizeThatFits: ((ProposedViewSize, LayoutSubviews) -> CGSize)? let content: Content - let layoutComputer: (CGSize) -> LayoutComputer let visitContent: (ViewVisitor) -> () fileprivate let cachedInnerHTML: String? @@ -111,7 +111,26 @@ public struct HTML: View, AnyHTML { } public static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { - .init(inputs: inputs, layoutComputer: inputs.content.layoutComputer) + .init(inputs: inputs) + } + + public func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) -> CGSize { + sizeThatFits?(proposal, subviews) ?? subviews.first?.sizeThatFits(proposal) ?? .zero + } + + public func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + for subview in subviews { + subview.place(at: bounds.origin, proposal: proposal) + } } } @@ -120,13 +139,14 @@ public extension HTML where Content: StringProtocol { _ tag: String, namespace: String? = nil, _ attributes: [HTMLAttribute: String] = [:], + sizeThatFits: ((ProposedViewSize, LayoutSubviews) -> CGSize)? = nil, content: Content ) { self.tag = tag self.namespace = namespace self.attributes = attributes + self.sizeThatFits = sizeThatFits self.content = content - layoutComputer = ShrinkWrapLayoutComputer.init cachedInnerHTML = String(content) visitContent = { _ in } } @@ -137,30 +157,14 @@ extension HTML: ParentView where Content: View { _ tag: String, namespace: String? = nil, _ attributes: [HTMLAttribute: String] = [:], + sizeThatFits: ((ProposedViewSize, LayoutSubviews) -> CGSize)? = nil, @ViewBuilder content: @escaping () -> Content ) { self.tag = tag self.namespace = namespace self.attributes = attributes + self.sizeThatFits = sizeThatFits self.content = content() - layoutComputer = ShrinkWrapLayoutComputer.init - cachedInnerHTML = nil - visitContent = { $0.visit(content()) } - } - - @_spi(TokamakCore) - public init( - _ tag: String, - namespace: String? = nil, - _ attributes: [HTMLAttribute: String] = [:], - layoutComputer: @escaping (CGSize) -> LayoutComputer, - @ViewBuilder content: @escaping () -> Content - ) { - self.tag = tag - self.namespace = namespace - self.attributes = attributes - self.content = content() - self.layoutComputer = layoutComputer cachedInnerHTML = nil visitContent = { $0.visit(content()) } } @@ -172,24 +176,13 @@ extension HTML: ParentView where Content: View { } public extension HTML where Content == EmptyView { - init( - _ tag: String, - namespace: String? = nil, - _ attributes: [HTMLAttribute: String] = [:] - ) { - self = HTML(tag, namespace: namespace, attributes) { EmptyView() } - } - - @_spi(TokamakCore) init( _ tag: String, namespace: String? = nil, _ attributes: [HTMLAttribute: String] = [:], - layoutComputer: @escaping (CGSize) -> LayoutComputer + sizeThatFits: ((ProposedViewSize, LayoutSubviews) -> CGSize)? = nil ) { - self = HTML(tag, namespace: namespace, attributes, layoutComputer: layoutComputer) { - EmptyView() - } + self = HTML(tag, namespace: namespace, attributes, sizeThatFits: sizeThatFits) { EmptyView() } } } diff --git a/Sources/TokamakTestRenderer/TestFiberRenderer.swift b/Sources/TokamakTestRenderer/TestFiberRenderer.swift index 8e201cba2..7be440379 100644 --- a/Sources/TokamakTestRenderer/TestFiberRenderer.swift +++ b/Sources/TokamakTestRenderer/TestFiberRenderer.swift @@ -122,10 +122,10 @@ public struct TestFiberRenderer: FiberRenderer { public func measureText( _ text: Text, - proposedSize: CGSize, + proposal: ProposedViewSize, in environment: EnvironmentValues ) -> CGSize { - proposedSize + proposal.replacingUnspecifiedDimensions() } public typealias ElementType = TestFiberElement From 378b23bdefb1d6e10b6f7022ae993fbc223c1627 Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Wed, 15 Jun 2022 21:09:36 -0400 Subject: [PATCH 02/38] Move layout into a separate pass --- .../TokamakCore/Fiber/FiberReconciler.swift | 150 ++++++++++-------- .../TokamakCore/Fiber/LayoutComputer.swift | 8 +- 2 files changed, 84 insertions(+), 74 deletions(-) diff --git a/Sources/TokamakCore/Fiber/FiberReconciler.swift b/Sources/TokamakCore/Fiber/FiberReconciler.swift index d4ef31b8e..3e98252e5 100644 --- a/Sources/TokamakCore/Fiber/FiberReconciler.swift +++ b/Sources/TokamakCore/Fiber/FiberReconciler.swift @@ -297,7 +297,6 @@ public final class FiberReconciler { guard let fiber = fiber else { return .zero } let subviews = layoutSubviews[key, default: .init(fiber)] var cache = caches[key, default: fiber.makeCache(subviews: subviews)] - print("Size \(fiber)") let size = fiber.sizeThatFits( proposal: $0, subviews: subviews, @@ -321,9 +320,7 @@ public final class FiberReconciler { }, place: { [weak self, weak fiber, weak element] position, anchor, proposal in guard let self = self, let fiber = fiber, let element = element else { return } - let parentOrigin = fiber.elementParent?.geometry?.origin.origin ?? .zero var cache = caches[key, default: fiber.makeCache(subviews: subviews)] - print("Place \(fiber)") let dimensions = ViewDimensions( size: fiber.sizeThatFits( proposal: proposal, @@ -336,8 +333,8 @@ public final class FiberReconciler { let geometry = ViewGeometry( // Shift to the anchor point in the parent's coordinate space. origin: .init(origin: .init( - x: position.x - (dimensions.width * anchor.x) - parentOrigin.x, - y: position.y - (dimensions.height * anchor.y) - parentOrigin.y + x: position.x - (dimensions.width * anchor.x), + y: position.y - (dimensions.height * anchor.y) )), dimensions: dimensions ) @@ -402,89 +399,102 @@ public final class FiberReconciler { } alternateSibling = alternateSibling?.sibling } - // We `size` and `position` when we are walking back up the tree. - if reconciler.renderer.useDynamicLayout, - let fiber = node.fiber - { - // Compute our size, proposing the entire scene. - // This is done to get an initial value for the size of this fiber before - // being recomputed by our elementParent when it calls `placeSubviews`. -// sizeThatFits(fiber, proposal: .init(reconciler.renderer.sceneSize)) - // Loop through each (potentially nested) child fiber with an `element`, - // and position them in our coordinate space. This ensures children are - // positioned in order. - let key = ObjectIdentifier(fiber) - if let subviews = layoutSubviews[key] { - var cache = caches[key, default: fiber.makeCache(subviews: subviews)] - fiber.placeSubviews( - in: .init(origin: .zero, size: reconciler.renderer.sceneSize), - proposal: .unspecified, - subviews: subviews, - cache: &cache - ) - caches[key] = cache - } - } 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.useDynamicLayout, - let fiber = node.fiber - { - // Size this fiber proposing the scene size. -// sizeThatFits(fiber, proposal: .init(reconciler.renderer.sceneSize)) - // Position our children in order. - if let subviews = layoutSubviews[ObjectIdentifier(fiber)] { - let key = ObjectIdentifier(fiber) - var cache = caches[key, default: fiber.makeCache(subviews: subviews)] - fiber.placeSubviews( - in: .init(origin: .zero, size: reconciler.renderer.sceneSize), - proposal: .unspecified, - subviews: subviews, - cache: &cache - ) - caches[key] = cache - } - } - // Walk across to the sibling, and repeat. node = node.sibling! } } mainLoop() - if reconciler.renderer.useDynamicLayout { - // 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 subviews = layoutSubviews[ObjectIdentifier(current)] { - if let geometry = current.geometry { - let key = ObjectIdentifier(current) - var cache = caches[key, default: current.makeCache(subviews: subviews)] - current.placeSubviews( - in: .init(origin: geometry.origin.origin, size: geometry.dimensions.size), - proposal: .unspecified, + // Layout from the top down. + if reconciler.renderer.useDynamicLayout, + let root = rootResult.fiber + { + var fiber = root + + func layoutLoop() { + while true { + sizeThatFits( + fiber, + proposal: .init( + fiber.elementParent?.geometry?.dimensions.size ?? reconciler.renderer.sceneSize + ) + ) + + if let child = fiber.child { + fiber = child + continue + } + + while fiber.sibling == nil { + // Before we walk back up, place our children in our bounds. + let key = ObjectIdentifier(fiber) + let subviews = layoutSubviews[key, default: .init(fiber)] + var cache = caches[key, default: fiber.makeCache(subviews: subviews)] + fiber.placeSubviews( + in: .init( + origin: .zero, + size: fiber.geometry?.dimensions.size ?? reconciler.renderer.sceneSize + ), + proposal: .init( + fiber.elementParent?.geometry?.dimensions.size ?? reconciler.renderer.sceneSize + ), subviews: subviews, cache: &cache ) caches[key] = cache + // Exit at the top of the View tree + guard let parent = fiber.parent else { return } + guard parent !== currentRoot.alternate else { return } + // Walk up to the next parent. + fiber = parent } + + let key = ObjectIdentifier(fiber) + let subviews = layoutSubviews[key, default: .init(fiber)] + var cache = caches[key, default: fiber.makeCache(subviews: subviews)] + fiber.placeSubviews( + in: .init( + origin: .zero, + size: fiber.geometry?.dimensions.size ?? reconciler.renderer.sceneSize + ), + proposal: .init( + fiber.elementParent?.geometry?.dimensions.size ?? reconciler.renderer.sceneSize + ), + subviews: subviews, + cache: &cache + ) + caches[key] = cache + + fiber = fiber.sibling! } - 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 - } + } + layoutLoop() + + var layoutNode: Fiber? = fiber + while let fiber = layoutNode { + let key = ObjectIdentifier(fiber) + let subviews = layoutSubviews[key, default: .init(fiber)] + var cache = caches[key, default: fiber.makeCache(subviews: subviews)] + fiber.placeSubviews( + in: .init( + origin: .zero, + size: fiber.geometry?.dimensions.size ?? reconciler.renderer.sceneSize + ), + proposal: .init( + fiber.elementParent?.geometry?.dimensions.size ?? reconciler.renderer.sceneSize + ), + subviews: subviews, + cache: &cache + ) + caches[key] = cache + + layoutNode = fiber.parent } } } diff --git a/Sources/TokamakCore/Fiber/LayoutComputer.swift b/Sources/TokamakCore/Fiber/LayoutComputer.swift index fa5fe1837..43dcf2b76 100644 --- a/Sources/TokamakCore/Fiber/LayoutComputer.swift +++ b/Sources/TokamakCore/Fiber/LayoutComputer.swift @@ -150,10 +150,10 @@ public struct ViewSpacing { public static let zero: ViewSpacing = .init() public init() { - top = 0 - leading = 0 - bottom = 0 - trailing = 0 + top = 10 + leading = 10 + bottom = 10 + trailing = 10 } public mutating func formUnion(_ other: ViewSpacing, edges: Edge.Set = .all) { From d85e1cbe5d61235fa33fd53fd0f375b4bf62eeb7 Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Thu, 16 Jun 2022 08:30:07 -0400 Subject: [PATCH 03/38] Split reconciler into separate FiberReconcilerPass-es --- .../TokamakCore/Fiber/FiberReconciler.swift | 395 ++---------------- .../Fiber/FiberReconcilerPass.swift | 58 +++ Sources/TokamakCore/Fiber/LayoutPass.swift | 130 ++++++ Sources/TokamakCore/Fiber/ReconcilePass.swift | 242 +++++++++++ 4 files changed, 461 insertions(+), 364 deletions(-) create mode 100644 Sources/TokamakCore/Fiber/FiberReconcilerPass.swift create mode 100644 Sources/TokamakCore/Fiber/LayoutPass.swift create mode 100644 Sources/TokamakCore/Fiber/ReconcilePass.swift diff --git a/Sources/TokamakCore/Fiber/FiberReconciler.swift b/Sources/TokamakCore/Fiber/FiberReconciler.swift index 3e98252e5..f6f850ca2 100644 --- a/Sources/TokamakCore/Fiber/FiberReconciler.swift +++ b/Sources/TokamakCore/Fiber/FiberReconciler.swift @@ -31,6 +31,8 @@ public final class FiberReconciler { /// The `FiberRenderer` used to create and update the `Element`s on screen. public let renderer: Renderer + let passes: [FiberReconcilerPass] + struct RootView: View { let content: Content let renderer: Renderer @@ -76,6 +78,11 @@ public final class FiberReconciler { public init(_ renderer: Renderer, _ view: V) { self.renderer = renderer + if renderer.useDynamicLayout { + passes = [.reconcile, .layout] + } else { + passes = [.reconcile] + } var view = RootView(content: view, renderer: renderer) current = .init( &view, @@ -92,6 +99,11 @@ public final class FiberReconciler { public init(_ renderer: Renderer, _ app: A) { self.renderer = renderer + if renderer.useDynamicLayout { + passes = [.reconcile, .layout] + } else { + passes = [.reconcile] + } var environment = renderer.defaultEnvironment environment.measureText = renderer.measureText var app = app @@ -106,397 +118,52 @@ public final class FiberReconciler { reconcile(from: current) } + /// A visitor that performs each pass used by the `FiberReconciler`. final class ReconcilerVisitor: AppVisitor, SceneVisitor, ViewVisitor { - unowned let reconciler: FiberReconciler - /// The current, mounted `Fiber`. - var currentRoot: Fiber + let root: Fiber + unowned let reconciler: FiberReconciler var mutations = [Mutation]() - init(root: Fiber, reconciler: FiberReconciler) { + init(root: Fiber, reconciler: FiberReconciler) { + self.root = root self.reconciler = reconciler - currentRoot = root } - func visit(_ view: V) where V: View { - visitAny(view, visitChildren: reconciler.renderer.viewVisitor(for: view)) + func visit(_ app: A) where A: App { + visitAny(app) { $0.visit(app.body) } } func visit(_ scene: S) where S: Scene { - visitAny(scene, visitChildren: scene._visitChildren) + visitAny(scene, scene._visitChildren) } - func visit(_ app: A) where A: App { - visitAny(app, visitChildren: { $0.visit(app.body) }) + func visit(_ view: V) where V: View { + visitAny(view, reconciler.renderer.viewVisitor(for: view)) } - /// Walk the current tree, recomputing at each step to check for discrepancies. - /// - /// Parent-first depth-first traversal. - /// Take this `View` tree for example. - /// ```swift - /// VStack { - /// HStack { - /// Text("A") - /// Text("B") - /// } - /// Text("C") - /// } - /// ``` - /// Basically, we read it like this: - /// 1. `VStack` has children, so we go to it's first child, `HStack`. - /// 2. `HStack` has children, so we go further to it's first child, `Text`. - /// 3. `Text` has no child, but has a sibling, so we go to that. - /// 4. `Text` has no child and no sibling, so we return to the `HStack`. - /// 5. We've already read the children, so we look for a sibling, `Text`. - /// 6. `Text` has no children and no sibling, so we return to the `VStack.` - /// We finish once we've returned to the root element. - /// ``` - /// ┌──────┐ - /// │VStack│ - /// └──┬───┘ - /// ▲ 1 │ - /// │ └──►┌──────┐ - /// │ │HStack│ - /// │ ┌─┴───┬──┘ - /// │ │ ▲ │ 2 - /// │ │ │ │ ┌────┐ - /// │ │ │ └─►│Text├─┐ - /// 6 │ │ 4 │ └────┘ │ - /// │ │ │ │ 3 - /// │ 5 │ │ ┌────┐ │ - /// │ │ └────┤Text│◄┘ - /// │ │ └────┘ - /// │ │ - /// │ └►┌────┐ - /// │ │Text│ - /// └───────┴────┘ - /// ``` private func visitAny( - _ value: Any, - visitChildren: @escaping (TreeReducer.SceneVisitor) -> () + _ content: Any, + _ visitChildren: @escaping (TreeReducer.SceneVisitor) -> () ) { let alternateRoot: Fiber? - if let alternate = currentRoot.alternate { + if let alternate = root.alternate { alternateRoot = alternate } else { - alternateRoot = currentRoot.createAndBindAlternate?() + alternateRoot = root.createAndBindAlternate?() } let rootResult = TreeReducer.Result( fiber: alternateRoot, // The alternate is the WIP node. visitChildren: visitChildren, parent: nil, child: alternateRoot?.child, - alternateChild: currentRoot.child, + alternateChild: root.child, elementIndices: [:] ) - var node = rootResult - - /// A dictionary keyed by the unique ID of an element, with a value indicating what index - /// 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 `Cache` for a fiber's layout. - var caches = [ObjectIdentifier: Any]() - /// The `LayoutSubviews` for each fiber. - var layoutSubviews = [ObjectIdentifier: LayoutSubviews]() - /// 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 - { - 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, - let previous = node.fiber?.alternate?.element - { - // This is a completely different type of view. - mutations.append(.replace(parent: parent, previous: previous, replacement: element)) - } else if let newContent = node.newContent, - newContent != element.content - { - // This is the same type of view, but its backing data has changed. - mutations.append(.update( - previous: element, - newContent: newContent, - geometry: node.fiber?.geometry ?? .init( - origin: .init(origin: .zero), - dimensions: .init(size: .zero, alignmentGuides: [:]) - ) - )) - } - } - } - - /// Request a size from the fiber's `elementParent`. - func sizeThatFits(_ node: Fiber, proposal: ProposedViewSize) { - guard node.element != nil - else { return } - - let key = ObjectIdentifier(node) - - // Compute our required size. - // This does not have to respect the elementParent's proposed size. - let subviews = layoutSubviews[key, default: .init(node)] - var cache = caches[key, default: node.makeCache(subviews: subviews)] - let size = node.sizeThatFits( - proposal: proposal, - subviews: subviews, - cache: &cache - ) - caches[key] = cache - let dimensions = ViewDimensions(size: size, alignmentGuides: [:]) - - // Update our geometry - node.geometry = .init( - origin: node.geometry?.origin ?? .init(origin: .zero), - dimensions: dimensions - ) - } - - /// The main reconciler loop. - func mainLoop() { - while true { - // If this fiber has an element, set its `elementIndex` - // and increment the `elementIndices` value for its `elementParent`. - if node.fiber?.element != nil, - let elementParent = node.fiber?.elementParent - { - let key = ObjectIdentifier(elementParent) - node.fiber?.elementIndex = elementIndices[key, default: 0] - elementIndices[key] = elementIndices[key, default: 0] + 1 - } - - // Perform work on the node. - reconcile(node) - - // Ensure the TreeReducer can access the `elementIndices`. - node.elementIndices = elementIndices - - // Compute the children of the node. - let reducer = TreeReducer.SceneVisitor(initialResult: node) - node.visitChildren(reducer) - - if reconciler.renderer.useDynamicLayout, - let fiber = node.fiber - { - if let element = fiber.element, - let elementParent = fiber.elementParent - { - let parentKey = ObjectIdentifier(elementParent) - elementChildren[parentKey] = elementChildren[parentKey, default: []] + [fiber] - var subviews = layoutSubviews[parentKey, default: .init(elementParent)] - let key = ObjectIdentifier(fiber) - subviews.storage.append(LayoutSubview( - id: ObjectIdentifier(node), - sizeThatFits: { [weak fiber] in - guard let fiber = fiber else { return .zero } - let subviews = layoutSubviews[key, default: .init(fiber)] - var cache = caches[key, default: fiber.makeCache(subviews: subviews)] - let size = fiber.sizeThatFits( - proposal: $0, - subviews: subviews, - cache: &cache - ) - caches[key] = cache - return size - }, - dimensions: { [weak fiber] in - guard let fiber = fiber else { return .init(size: .zero, alignmentGuides: [:]) } - let subviews = layoutSubviews[key, default: .init(fiber)] - var cache = caches[key, default: fiber.makeCache(subviews: subviews)] - let size = fiber.sizeThatFits( - proposal: $0, - subviews: subviews, - cache: &cache - ) - caches[key] = cache - // TODO: Add `alignmentGuide` modifier and pass into `ViewDimensions` - return ViewDimensions(size: size, alignmentGuides: [:]) - }, - place: { [weak self, weak fiber, weak element] position, anchor, proposal in - guard let self = self, let fiber = fiber, let element = element else { return } - var cache = caches[key, default: fiber.makeCache(subviews: subviews)] - let dimensions = ViewDimensions( - size: fiber.sizeThatFits( - proposal: proposal, - subviews: layoutSubviews[key, default: .init(fiber)], - cache: &cache - ), - alignmentGuides: [:] - ) - caches[key] = cache - let geometry = ViewGeometry( - // Shift to the anchor point in the parent's coordinate space. - origin: .init(origin: .init( - x: position.x - (dimensions.width * anchor.x), - y: position.y - (dimensions.height * anchor.y) - )), - dimensions: dimensions - ) - // Push a layout mutation if needed. - if geometry != fiber.alternate?.geometry { - self.mutations.append(.layout(element: element, geometry: geometry)) - } - // Update ours and our alternate's geometry - fiber.geometry = geometry - fiber.alternate?.geometry = geometry - } - )) - layoutSubviews[parentKey] = subviews - } - } - - // 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 - } - } - 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 - } - - // 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 - } - guard let parent = node.parent else { return } - // When we walk back to the root, exit - guard parent !== currentRoot.alternate else { return } - node = parent - } - - // Walk across to the sibling, and repeat. - node = node.sibling! - } - } - mainLoop() - - // Layout from the top down. - if reconciler.renderer.useDynamicLayout, - let root = rootResult.fiber - { - var fiber = root - - func layoutLoop() { - while true { - sizeThatFits( - fiber, - proposal: .init( - fiber.elementParent?.geometry?.dimensions.size ?? reconciler.renderer.sceneSize - ) - ) - - if let child = fiber.child { - fiber = child - continue - } - - while fiber.sibling == nil { - // Before we walk back up, place our children in our bounds. - let key = ObjectIdentifier(fiber) - let subviews = layoutSubviews[key, default: .init(fiber)] - var cache = caches[key, default: fiber.makeCache(subviews: subviews)] - fiber.placeSubviews( - in: .init( - origin: .zero, - size: fiber.geometry?.dimensions.size ?? reconciler.renderer.sceneSize - ), - proposal: .init( - fiber.elementParent?.geometry?.dimensions.size ?? reconciler.renderer.sceneSize - ), - subviews: subviews, - cache: &cache - ) - caches[key] = cache - // Exit at the top of the View tree - guard let parent = fiber.parent else { return } - guard parent !== currentRoot.alternate else { return } - // Walk up to the next parent. - fiber = parent - } - - let key = ObjectIdentifier(fiber) - let subviews = layoutSubviews[key, default: .init(fiber)] - var cache = caches[key, default: fiber.makeCache(subviews: subviews)] - fiber.placeSubviews( - in: .init( - origin: .zero, - size: fiber.geometry?.dimensions.size ?? reconciler.renderer.sceneSize - ), - proposal: .init( - fiber.elementParent?.geometry?.dimensions.size ?? reconciler.renderer.sceneSize - ), - subviews: subviews, - cache: &cache - ) - caches[key] = cache - - fiber = fiber.sibling! - } - } - layoutLoop() - - var layoutNode: Fiber? = fiber - while let fiber = layoutNode { - let key = ObjectIdentifier(fiber) - let subviews = layoutSubviews[key, default: .init(fiber)] - var cache = caches[key, default: fiber.makeCache(subviews: subviews)] - fiber.placeSubviews( - in: .init( - origin: .zero, - size: fiber.geometry?.dimensions.size ?? reconciler.renderer.sceneSize - ), - proposal: .init( - fiber.elementParent?.geometry?.dimensions.size ?? reconciler.renderer.sceneSize - ), - subviews: subviews, - cache: &cache - ) - caches[key] = cache - - layoutNode = fiber.parent - } + let caches = Caches() + for pass in reconciler.passes { + pass.run(in: reconciler, root: rootResult, caches: caches) } + mutations = caches.mutations } } diff --git a/Sources/TokamakCore/Fiber/FiberReconcilerPass.swift b/Sources/TokamakCore/Fiber/FiberReconcilerPass.swift new file mode 100644 index 000000000..b29125dd7 --- /dev/null +++ b/Sources/TokamakCore/Fiber/FiberReconcilerPass.swift @@ -0,0 +1,58 @@ +// +// File.swift +// +// +// Created by Carson Katri on 6/16/22. +// + +import Foundation + +extension FiberReconciler { + final class Caches { + var elementIndices = [ObjectIdentifier: Int]() + var layoutCaches = [ObjectIdentifier: Any]() + var layoutSubviews = [ObjectIdentifier: LayoutSubviews]() + var elementChildren = [ObjectIdentifier: [Fiber]]() + var mutations = [Mutation]() + + @inlinable + func updateLayoutCache(for fiber: Fiber, _ action: (inout Any) -> R) -> R { + let key = ObjectIdentifier(fiber) + var cache = layoutCaches[ + key, + default: fiber.makeCache(subviews: layoutSubviews(for: fiber)) + ] + defer { layoutCaches[key] = cache } + return action(&cache) + } + + @inlinable + func layoutSubviews(for fiber: Fiber) -> LayoutSubviews { + layoutSubviews[ObjectIdentifier(fiber), default: .init(fiber)] + } + + @inlinable + func elementIndex(for fiber: Fiber, increment: Bool = false) -> Int { + let key = ObjectIdentifier(fiber) + let result = elementIndices[key, default: 0] + if increment { + elementIndices[key] = result + 1 + } + return result + } + + @inlinable + func appendChild(parent: Fiber, child: Fiber) { + let key = ObjectIdentifier(parent) + elementChildren[key] = elementChildren[key, default: []] + [child] + } + } +} + +protocol FiberReconcilerPass { + func run( + in reconciler: FiberReconciler, + root: FiberReconciler.TreeReducer.Result, + caches: FiberReconciler.Caches + ) +} diff --git a/Sources/TokamakCore/Fiber/LayoutPass.swift b/Sources/TokamakCore/Fiber/LayoutPass.swift new file mode 100644 index 000000000..195ebb50f --- /dev/null +++ b/Sources/TokamakCore/Fiber/LayoutPass.swift @@ -0,0 +1,130 @@ +// +// File.swift +// +// +// Created by Carson Katri on 6/16/22. +// + +import Foundation + +// Layout from the top down. +struct LayoutPass: FiberReconcilerPass { + func run( + in reconciler: FiberReconciler, + root: FiberReconciler.TreeReducer.Result, + caches: FiberReconciler.Caches + ) where R: FiberRenderer { + if let root = root.fiber { + var fiber = root + + func layoutLoop() { + while true { + sizeThatFits( + fiber, + caches: caches, + proposal: .init( + fiber.elementParent?.geometry?.dimensions.size ?? reconciler.renderer.sceneSize + ) + ) + + if let child = fiber.child { + fiber = child + continue + } + + while fiber.sibling == nil { + // Before we walk back up, place our children in our bounds. + caches.updateLayoutCache(for: fiber) { cache in + fiber.placeSubviews( + in: .init( + origin: .zero, + size: fiber.geometry?.dimensions.size ?? reconciler.renderer.sceneSize + ), + proposal: .init( + fiber.elementParent?.geometry?.dimensions.size ?? reconciler.renderer + .sceneSize + ), + subviews: caches.layoutSubviews(for: fiber), + cache: &cache + ) + } + // Exit at the top of the View tree + guard let parent = fiber.parent else { return } + guard parent !== root.alternate else { return } + // Walk up to the next parent. + fiber = parent + } + + caches.updateLayoutCache(for: fiber) { cache in + fiber.placeSubviews( + in: .init( + origin: .zero, + size: fiber.geometry?.dimensions.size ?? reconciler.renderer.sceneSize + ), + proposal: .init( + fiber.elementParent?.geometry?.dimensions.size ?? reconciler.renderer + .sceneSize + ), + subviews: caches.layoutSubviews(for: fiber), + cache: &cache + ) + } + + fiber = fiber.sibling! + } + } + layoutLoop() + + var layoutNode: FiberReconciler.Fiber? = fiber + while let fiber = layoutNode { + caches.updateLayoutCache(for: fiber) { cache in + fiber.placeSubviews( + in: .init( + origin: .zero, + size: fiber.geometry?.dimensions.size ?? reconciler.renderer.sceneSize + ), + proposal: .init( + fiber.elementParent?.geometry?.dimensions.size ?? reconciler.renderer + .sceneSize + ), + subviews: caches.layoutSubviews(for: fiber), + cache: &cache + ) + } + + layoutNode = fiber.parent + } + } + } + + /// Request a size from the fiber's `elementParent`. + func sizeThatFits( + _ node: FiberReconciler.Fiber, + caches: FiberReconciler.Caches, + proposal: ProposedViewSize + ) { + guard node.element != nil + else { return } + + // Compute our required size. + // This does not have to respect the elementParent's proposed size. + let size = caches.updateLayoutCache(for: node) { cache in + node.sizeThatFits( + proposal: proposal, + subviews: caches.layoutSubviews(for: node), + cache: &cache + ) + } + let dimensions = ViewDimensions(size: size, alignmentGuides: [:]) + + // Update our geometry + node.geometry = .init( + origin: node.geometry?.origin ?? .init(origin: .zero), + dimensions: dimensions + ) + } +} + +extension FiberReconcilerPass where Self == LayoutPass { + static var layout: LayoutPass { .init() } +} diff --git a/Sources/TokamakCore/Fiber/ReconcilePass.swift b/Sources/TokamakCore/Fiber/ReconcilePass.swift new file mode 100644 index 000000000..aa0326cab --- /dev/null +++ b/Sources/TokamakCore/Fiber/ReconcilePass.swift @@ -0,0 +1,242 @@ +// +// File.swift +// +// +// Created by Carson Katri on 6/16/22. +// + +import Foundation + +/// Walk the current tree, recomputing at each step to check for discrepancies. +/// +/// Parent-first depth-first traversal. +/// Take this `View` tree for example. +/// ```swift +/// VStack { +/// HStack { +/// Text("A") +/// Text("B") +/// } +/// Text("C") +/// } +/// ``` +/// Basically, we read it like this: +/// 1. `VStack` has children, so we go to it's first child, `HStack`. +/// 2. `HStack` has children, so we go further to it's first child, `Text`. +/// 3. `Text` has no child, but has a sibling, so we go to that. +/// 4. `Text` has no child and no sibling, so we return to the `HStack`. +/// 5. We've already read the children, so we look for a sibling, `Text`. +/// 6. `Text` has no children and no sibling, so we return to the `VStack.` +/// We finish once we've returned to the root element. +/// ``` +/// ┌──────┐ +/// │VStack│ +/// └──┬───┘ +/// ▲ 1 │ +/// │ └──►┌──────┐ +/// │ │HStack│ +/// │ ┌─┴───┬──┘ +/// │ │ ▲ │ 2 +/// │ │ │ │ ┌────┐ +/// │ │ │ └─►│Text├─┐ +/// 6 │ │ 4 │ └────┘ │ +/// │ │ │ │ 3 +/// │ 5 │ │ ┌────┐ │ +/// │ │ └────┤Text│◄┘ +/// │ │ └────┘ +/// │ │ +/// │ └►┌────┐ +/// │ │Text│ +/// └───────┴────┘ +/// ``` +struct ReconcilePass: FiberReconcilerPass { + func run( + in reconciler: FiberReconciler, + root: FiberReconciler.TreeReducer.Result, + caches: FiberReconciler.Caches + ) where R: FiberRenderer { + var node = root + + /// Compare `node` with its alternate, and add any mutations to the list. + func reconcile(_ node: FiberReconciler.TreeReducer.Result) { + if let element = node.fiber?.element, + let index = node.fiber?.elementIndex, + let parent = node.fiber?.elementParent?.element + { + if node.fiber?.alternate == nil { // This didn't exist before (no alternate) + caches.mutations.append(.insert(element: element, parent: parent, index: index)) + } else if node.fiber?.typeInfo?.type != node.fiber?.alternate?.typeInfo?.type, + let previous = node.fiber?.alternate?.element + { + // This is a completely different type of view. + caches.mutations + .append(.replace(parent: parent, previous: previous, replacement: element)) + } else if let newContent = node.newContent, + newContent != element.content + { + // This is the same type of view, but its backing data has changed. + caches.mutations.append(.update( + previous: element, + newContent: newContent, + geometry: node.fiber?.geometry ?? .init( + origin: .init(origin: .zero), + dimensions: .init(size: .zero, alignmentGuides: [:]) + ) + )) + } + } + } + + /// The main reconciler loop. + func mainLoop() { + while true { + // If this fiber has an element, set its `elementIndex` + // and increment the `elementIndices` value for its `elementParent`. + if node.fiber?.element != nil, + let elementParent = node.fiber?.elementParent + { + node.fiber?.elementIndex = caches.elementIndex(for: elementParent, increment: true) + } + + // Perform work on the node. + reconcile(node) + + // Ensure the TreeReducer can access the `elementIndices`. + node.elementIndices = caches.elementIndices + + // Compute the children of the node. + let reducer = FiberReconciler.TreeReducer.SceneVisitor(initialResult: node) + node.visitChildren(reducer) + + if reconciler.renderer.useDynamicLayout, + let fiber = node.fiber + { + if let element = fiber.element, + let elementParent = fiber.elementParent + { + caches.appendChild(parent: elementParent, child: fiber) + let parentKey = ObjectIdentifier(elementParent) + var subviews = caches.layoutSubviews[parentKey, default: .init(elementParent)] + let key = ObjectIdentifier(fiber) + subviews.storage.append(LayoutSubview( + id: ObjectIdentifier(node), + sizeThatFits: { [weak fiber, unowned caches] proposal in + guard let fiber = fiber else { return .zero } + return caches.updateLayoutCache(for: fiber) { cache in + fiber.sizeThatFits( + proposal: proposal, + subviews: caches.layoutSubviews(for: fiber), + cache: &cache + ) + } + }, + dimensions: { [weak fiber, unowned caches] proposal in + guard let fiber = fiber else { return .init(size: .zero, alignmentGuides: [:]) } + let size = caches.updateLayoutCache(for: fiber) { cache in + fiber.sizeThatFits( + proposal: proposal, + subviews: caches.layoutSubviews(for: fiber), + cache: &cache + ) + } + // TODO: Add `alignmentGuide` modifier and pass into `ViewDimensions` + return ViewDimensions(size: size, alignmentGuides: [:]) + }, + place: { [weak fiber, weak element, unowned caches] position, anchor, proposal in + guard let fiber = fiber, let element = element else { return } + let dimensions = caches.updateLayoutCache(for: fiber) { cache in + ViewDimensions( + size: fiber.sizeThatFits( + proposal: proposal, + subviews: caches.layoutSubviews(for: fiber), + cache: &cache + ), + alignmentGuides: [:] + ) + } + let geometry = ViewGeometry( + // Shift to the anchor point in the parent's coordinate space. + origin: .init(origin: .init( + x: position.x - (dimensions.width * anchor.x), + y: position.y - (dimensions.height * anchor.y) + )), + dimensions: dimensions + ) + // Push a layout mutation if needed. + if geometry != fiber.alternate?.geometry { + caches.mutations.append(.layout(element: element, geometry: geometry)) + } + // Update ours and our alternate's geometry + fiber.geometry = geometry + fiber.alternate?.geometry = geometry + } + )) + caches.layoutSubviews[parentKey] = subviews + } + } + + // 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. + caches.mutations.insert(.remove(element: element, parent: parent), at: 0) + } + return true + } + } + 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 === root { + 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. + caches.mutations.insert(.remove(element: element, parent: parent), at: 0) + } + alternateSibling = alternateSibling?.sibling + } + guard let parent = node.parent else { return } + // When we walk back to the root, exit + guard parent !== root.fiber?.alternate else { return } + node = parent + } + + // Walk across to the sibling, and repeat. + node = node.sibling! + } + } + mainLoop() + } +} + +extension FiberReconcilerPass where Self == ReconcilePass { + static var reconcile: ReconcilePass { ReconcilePass() } +} From 89c9624d04693b5229398f129746e9be1167d85e Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Thu, 16 Jun 2022 08:33:09 -0400 Subject: [PATCH 04/38] Cleanup reconcile pass --- Sources/TokamakCore/Fiber/ReconcilePass.swift | 305 +++++++++--------- 1 file changed, 152 insertions(+), 153 deletions(-) diff --git a/Sources/TokamakCore/Fiber/ReconcilePass.swift b/Sources/TokamakCore/Fiber/ReconcilePass.swift index aa0326cab..35f25e728 100644 --- a/Sources/TokamakCore/Fiber/ReconcilePass.swift +++ b/Sources/TokamakCore/Fiber/ReconcilePass.swift @@ -57,183 +57,182 @@ struct ReconcilePass: FiberReconcilerPass { ) where R: FiberRenderer { var node = root - /// Compare `node` with its alternate, and add any mutations to the list. - func reconcile(_ node: FiberReconciler.TreeReducer.Result) { - if let element = node.fiber?.element, - let index = node.fiber?.elementIndex, - let parent = node.fiber?.elementParent?.element + while true { + // If this fiber has an element, set its `elementIndex` + // and increment the `elementIndices` value for its `elementParent`. + if node.fiber?.element != nil, + let elementParent = node.fiber?.elementParent { - if node.fiber?.alternate == nil { // This didn't exist before (no alternate) - caches.mutations.append(.insert(element: element, parent: parent, index: index)) - } else if node.fiber?.typeInfo?.type != node.fiber?.alternate?.typeInfo?.type, - let previous = node.fiber?.alternate?.element - { - // This is a completely different type of view. - caches.mutations - .append(.replace(parent: parent, previous: previous, replacement: element)) - } else if let newContent = node.newContent, - newContent != element.content - { - // This is the same type of view, but its backing data has changed. - caches.mutations.append(.update( - previous: element, - newContent: newContent, - geometry: node.fiber?.geometry ?? .init( - origin: .init(origin: .zero), - dimensions: .init(size: .zero, alignmentGuides: [:]) - ) - )) - } + node.fiber?.elementIndex = caches.elementIndex(for: elementParent, increment: true) } - } - /// The main reconciler loop. - func mainLoop() { - while true { - // If this fiber has an element, set its `elementIndex` - // and increment the `elementIndices` value for its `elementParent`. - if node.fiber?.element != nil, - let elementParent = node.fiber?.elementParent - { - node.fiber?.elementIndex = caches.elementIndex(for: elementParent, increment: true) - } - - // Perform work on the node. - reconcile(node) + // Perform work on the node. + if let mutation = reconcile(node) { + caches.mutations.append(mutation) + } - // Ensure the TreeReducer can access the `elementIndices`. - node.elementIndices = caches.elementIndices + // Ensure the TreeReducer can access the `elementIndices`. + node.elementIndices = caches.elementIndices - // Compute the children of the node. - let reducer = FiberReconciler.TreeReducer.SceneVisitor(initialResult: node) - node.visitChildren(reducer) + // Compute the children of the node. + let reducer = FiberReconciler.TreeReducer.SceneVisitor(initialResult: node) + node.visitChildren(reducer) - if reconciler.renderer.useDynamicLayout, - let fiber = node.fiber + if reconciler.renderer.useDynamicLayout, + let fiber = node.fiber + { + if let element = fiber.element, + let elementParent = fiber.elementParent { - if let element = fiber.element, - let elementParent = fiber.elementParent - { - caches.appendChild(parent: elementParent, child: fiber) - let parentKey = ObjectIdentifier(elementParent) - var subviews = caches.layoutSubviews[parentKey, default: .init(elementParent)] - let key = ObjectIdentifier(fiber) - subviews.storage.append(LayoutSubview( - id: ObjectIdentifier(node), - sizeThatFits: { [weak fiber, unowned caches] proposal in - guard let fiber = fiber else { return .zero } - return caches.updateLayoutCache(for: fiber) { cache in - fiber.sizeThatFits( - proposal: proposal, - subviews: caches.layoutSubviews(for: fiber), - cache: &cache - ) - } - }, - dimensions: { [weak fiber, unowned caches] proposal in - guard let fiber = fiber else { return .init(size: .zero, alignmentGuides: [:]) } - let size = caches.updateLayoutCache(for: fiber) { cache in - fiber.sizeThatFits( + caches.appendChild(parent: elementParent, child: fiber) + let parentKey = ObjectIdentifier(elementParent) + var subviews = caches.layoutSubviews[parentKey, default: .init(elementParent)] + subviews.storage.append(LayoutSubview( + id: ObjectIdentifier(node), + sizeThatFits: { [weak fiber, unowned caches] proposal in + guard let fiber = fiber else { return .zero } + return caches.updateLayoutCache(for: fiber) { cache in + fiber.sizeThatFits( + proposal: proposal, + subviews: caches.layoutSubviews(for: fiber), + cache: &cache + ) + } + }, + dimensions: { [weak fiber, unowned caches] proposal in + guard let fiber = fiber else { return .init(size: .zero, alignmentGuides: [:]) } + let size = caches.updateLayoutCache(for: fiber) { cache in + fiber.sizeThatFits( + proposal: proposal, + subviews: caches.layoutSubviews(for: fiber), + cache: &cache + ) + } + // TODO: Add `alignmentGuide` modifier and pass into `ViewDimensions` + return ViewDimensions(size: size, alignmentGuides: [:]) + }, + place: { [weak fiber, weak element, unowned caches] position, anchor, proposal in + guard let fiber = fiber, let element = element else { return } + let dimensions = caches.updateLayoutCache(for: fiber) { cache in + ViewDimensions( + size: fiber.sizeThatFits( proposal: proposal, subviews: caches.layoutSubviews(for: fiber), cache: &cache - ) - } - // TODO: Add `alignmentGuide` modifier and pass into `ViewDimensions` - return ViewDimensions(size: size, alignmentGuides: [:]) - }, - place: { [weak fiber, weak element, unowned caches] position, anchor, proposal in - guard let fiber = fiber, let element = element else { return } - let dimensions = caches.updateLayoutCache(for: fiber) { cache in - ViewDimensions( - size: fiber.sizeThatFits( - proposal: proposal, - subviews: caches.layoutSubviews(for: fiber), - cache: &cache - ), - alignmentGuides: [:] - ) - } - let geometry = ViewGeometry( - // Shift to the anchor point in the parent's coordinate space. - origin: .init(origin: .init( - x: position.x - (dimensions.width * anchor.x), - y: position.y - (dimensions.height * anchor.y) - )), - dimensions: dimensions + ), + alignmentGuides: [:] ) - // Push a layout mutation if needed. - if geometry != fiber.alternate?.geometry { - caches.mutations.append(.layout(element: element, geometry: geometry)) - } - // Update ours and our alternate's geometry - fiber.geometry = geometry - fiber.alternate?.geometry = geometry } - )) - caches.layoutSubviews[parentKey] = subviews - } + let geometry = ViewGeometry( + // Shift to the anchor point in the parent's coordinate space. + origin: .init(origin: .init( + x: position.x - (dimensions.width * anchor.x), + y: position.y - (dimensions.height * anchor.y) + )), + dimensions: dimensions + ) + // Push a layout mutation if needed. + if geometry != fiber.alternate?.geometry { + caches.mutations.append(.layout(element: element, geometry: geometry)) + } + // Update ours and our alternate's geometry + fiber.geometry = geometry + fiber.alternate?.geometry = geometry + } + )) + caches.layoutSubviews[parentKey] = subviews } + } - // Setup the alternate if it doesn't exist yet. - if node.fiber?.alternate == nil { - _ = node.fiber?.createAndBindAlternate?() - } + // 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. - caches.mutations.insert(.remove(element: element, parent: parent), at: 0) - } - return true + // 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. + caches.mutations.insert(.remove(element: element, parent: parent), at: 0) } + return true } - 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 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 === root { - return - } + // If we've made it back to the root, then exit. + if node === root { + 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. - caches.mutations.insert(.remove(element: element, parent: parent), at: 0) - } - alternateSibling = alternateSibling?.sibling + // 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. + caches.mutations.insert(.remove(element: element, parent: parent), at: 0) } - guard let parent = node.parent else { return } - // When we walk back to the root, exit - guard parent !== root.fiber?.alternate else { return } - node = parent + alternateSibling = alternateSibling?.sibling } + guard let parent = node.parent else { return } + // When we walk back to the root, exit + guard parent !== root.fiber?.alternate else { return } + node = parent + } - // Walk across to the sibling, and repeat. - node = node.sibling! + // Walk across to the sibling, and repeat. + node = node.sibling! + } + } + + /// Compare `node` with its alternate, and add any mutations to the list. + func reconcile( + _ node: FiberReconciler.TreeReducer.Result + ) -> Mutation? { + if let element = node.fiber?.element, + let index = node.fiber?.elementIndex, + let parent = node.fiber?.elementParent?.element + { + if node.fiber?.alternate == nil { // This didn't exist before (no alternate) + return .insert(element: element, parent: parent, index: index) + } else if node.fiber?.typeInfo?.type != node.fiber?.alternate?.typeInfo?.type, + let previous = node.fiber?.alternate?.element + { + // This is a completely different type of view. + return .replace(parent: parent, previous: previous, replacement: element) + } else if let newContent = node.newContent, + newContent != element.content + { + // This is the same type of view, but its backing data has changed. + return .update( + previous: element, + newContent: newContent, + geometry: node.fiber?.geometry ?? .init( + origin: .init(origin: .zero), + dimensions: .init(size: .zero, alignmentGuides: [:]) + ) + ) } } - mainLoop() + return nil } } From 2f018a22ed7ffbbe87aec7c157a77483d09ab093 Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Fri, 17 Jun 2022 21:59:22 -0400 Subject: [PATCH 05/38] Simplify cache implementation --- Sources/TokamakCore/Fiber/FiberReconcilerPass.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Sources/TokamakCore/Fiber/FiberReconcilerPass.swift b/Sources/TokamakCore/Fiber/FiberReconcilerPass.swift index b29125dd7..094052385 100644 --- a/Sources/TokamakCore/Fiber/FiberReconcilerPass.swift +++ b/Sources/TokamakCore/Fiber/FiberReconcilerPass.swift @@ -43,8 +43,7 @@ extension FiberReconciler { @inlinable func appendChild(parent: Fiber, child: Fiber) { - let key = ObjectIdentifier(parent) - elementChildren[key] = elementChildren[key, default: []] + [child] + elementChildren[ObjectIdentifier(parent), default: []].append(child) } } } From c5ba1ff7f443dde8bb71f3c3a767dbf89238220f Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Fri, 17 Jun 2022 23:00:40 -0400 Subject: [PATCH 06/38] Optimize array capacity and persist cache between runs --- Sources/TokamakCore/Fiber/Fiber.swift | 21 ++++++++++++------- .../TokamakCore/Fiber/FiberReconciler.swift | 12 +++++++---- .../Fiber/FiberReconcilerPass.swift | 7 +++++++ .../TokamakCore/Fiber/LayoutComputer.swift | 8 ++++++- Sources/TokamakCore/Fiber/LayoutPass.swift | 21 +++++++++++++++---- Sources/TokamakCore/Fiber/ReconcilePass.swift | 12 +++++++---- Sources/TokamakCore/Fiber/ViewArguments.swift | 6 ++++++ .../Views/Containers/ForEach.swift | 4 ++++ .../Views/Containers/TupleView.swift | 16 ++++++++++++++ Sources/TokamakCore/Views/View.swift | 4 ++++ 10 files changed, 91 insertions(+), 20 deletions(-) diff --git a/Sources/TokamakCore/Fiber/Fiber.swift b/Sources/TokamakCore/Fiber/Fiber.swift index 86981e5d9..996a9205e 100644 --- a/Sources/TokamakCore/Fiber/Fiber.swift +++ b/Sources/TokamakCore/Fiber/Fiber.swift @@ -80,6 +80,8 @@ public extension FiberReconciler { /// Boxes that store `State` data. var state: [PropertyInfo: MutableStorage] = [:] + var childrenCount: Int? + /// The computed dimensions and origin. var geometry: ViewGeometry? @@ -129,12 +131,12 @@ public extension FiberReconciler { let environment = parent?.outputs.environment ?? .init(.init()) state = bindProperties(to: &view, typeInfo, environment.environment) - outputs = V._makeView( - .init( - content: view, - environment: environment - ) + let viewInputs = ViewInputs( + content: view, + environment: environment ) + outputs = V._makeView(viewInputs) + childrenCount = V._viewChildrenCount(viewInputs) content = content(for: view) @@ -160,6 +162,7 @@ public extension FiberReconciler { layoutActions: self.layout, alternate: self, outputs: self.outputs, + childrenCount: self.childrenCount, typeInfo: self.typeInfo, element: self.element, parent: self.parent?.alternate, @@ -189,6 +192,7 @@ public extension FiberReconciler { layoutActions: LayoutActions, alternate: Fiber, outputs: ViewOutputs, + childrenCount: Int?, typeInfo: TypeInfo?, element: Renderer.ElementType?, parent: FiberReconciler.Fiber?, @@ -204,6 +208,7 @@ public extension FiberReconciler { self.elementParent = elementParent self.typeInfo = typeInfo self.outputs = outputs + self.childrenCount = childrenCount layout = layoutActions content = content(for: view) } @@ -252,10 +257,12 @@ public extension FiberReconciler { let environment = parent?.outputs.environment ?? .init(.init()) state = bindProperties(to: &view, typeInfo, environment.environment) content = content(for: view) - outputs = V._makeView(.init( + let inputs = ViewInputs( content: view, environment: environment - )) + ) + outputs = V._makeView(inputs) + childrenCount = V._viewChildrenCount(inputs) if Renderer.isPrimitive(view) { return .init(from: view, useDynamicLayout: reconciler?.renderer.useDynamicLayout ?? false) diff --git a/Sources/TokamakCore/Fiber/FiberReconciler.swift b/Sources/TokamakCore/Fiber/FiberReconciler.swift index f6f850ca2..d8269616e 100644 --- a/Sources/TokamakCore/Fiber/FiberReconciler.swift +++ b/Sources/TokamakCore/Fiber/FiberReconciler.swift @@ -31,7 +31,9 @@ public final class FiberReconciler { /// The `FiberRenderer` used to create and update the `Element`s on screen. public let renderer: Renderer - let passes: [FiberReconcilerPass] + private let passes: [FiberReconcilerPass] + + private let caches: Caches struct RootView: View { let content: Content @@ -83,6 +85,7 @@ public final class FiberReconciler { } else { passes = [.reconcile] } + caches = Caches() var view = RootView(content: view, renderer: renderer) current = .init( &view, @@ -104,6 +107,7 @@ public final class FiberReconciler { } else { passes = [.reconcile] } + caches = Caches() var environment = renderer.defaultEnvironment environment.measureText = renderer.measureText var app = app @@ -159,11 +163,11 @@ public final class FiberReconciler { alternateChild: root.child, elementIndices: [:] ) - let caches = Caches() + reconciler.caches.clear() for pass in reconciler.passes { - pass.run(in: reconciler, root: rootResult, caches: caches) + pass.run(in: reconciler, root: rootResult, caches: reconciler.caches) } - mutations = caches.mutations + mutations = reconciler.caches.mutations } } diff --git a/Sources/TokamakCore/Fiber/FiberReconcilerPass.swift b/Sources/TokamakCore/Fiber/FiberReconcilerPass.swift index 094052385..2e3980be2 100644 --- a/Sources/TokamakCore/Fiber/FiberReconcilerPass.swift +++ b/Sources/TokamakCore/Fiber/FiberReconcilerPass.swift @@ -15,6 +15,13 @@ extension FiberReconciler { var elementChildren = [ObjectIdentifier: [Fiber]]() var mutations = [Mutation]() + func clear() { + elementIndices = [:] + layoutSubviews = [:] + elementChildren = [:] + mutations = [] + } + @inlinable func updateLayoutCache(for fiber: Fiber, _ action: (inout Any) -> R) -> R { let key = ObjectIdentifier(fiber) diff --git a/Sources/TokamakCore/Fiber/LayoutComputer.swift b/Sources/TokamakCore/Fiber/LayoutComputer.swift index 43dcf2b76..0ea58a989 100644 --- a/Sources/TokamakCore/Fiber/LayoutComputer.swift +++ b/Sources/TokamakCore/Fiber/LayoutComputer.swift @@ -199,9 +199,15 @@ public struct LayoutSubviews: Equatable, RandomAccessCollection { } init(_ node: FiberReconciler.Fiber) { + var storage = [LayoutSubview]() + if let childrenCount = node.childrenCount { + storage.reserveCapacity(childrenCount) + } else { + storage.reserveCapacity(1) + } self.init( layoutDirection: node.outputs.environment.environment.layoutDirection, - storage: [] + storage: storage ) } diff --git a/Sources/TokamakCore/Fiber/LayoutPass.swift b/Sources/TokamakCore/Fiber/LayoutPass.swift index 195ebb50f..d2a096462 100644 --- a/Sources/TokamakCore/Fiber/LayoutPass.swift +++ b/Sources/TokamakCore/Fiber/LayoutPass.swift @@ -1,6 +1,16 @@ +// Copyright 2022 Tokamak contributors // -// File.swift +// 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 6/16/22. // @@ -108,10 +118,13 @@ struct LayoutPass: FiberReconcilerPass { // Compute our required size. // This does not have to respect the elementParent's proposed size. - let size = caches.updateLayoutCache(for: node) { cache in - node.sizeThatFits( + let size = caches.updateLayoutCache(for: node) { cache -> CGSize in + let subviews = caches.layoutSubviews(for: node) + // Update the cache so it's ready for the new values. + node.updateCache(&cache, subviews: subviews) + return node.sizeThatFits( proposal: proposal, - subviews: caches.layoutSubviews(for: node), + subviews: subviews, cache: &cache ) } diff --git a/Sources/TokamakCore/Fiber/ReconcilePass.swift b/Sources/TokamakCore/Fiber/ReconcilePass.swift index 35f25e728..c1144df05 100644 --- a/Sources/TokamakCore/Fiber/ReconcilePass.swift +++ b/Sources/TokamakCore/Fiber/ReconcilePass.swift @@ -60,10 +60,14 @@ struct ReconcilePass: FiberReconcilerPass { while true { // If this fiber has an element, set its `elementIndex` // and increment the `elementIndices` value for its `elementParent`. - if node.fiber?.element != nil, - let elementParent = node.fiber?.elementParent - { - node.fiber?.elementIndex = caches.elementIndex(for: elementParent, increment: true) + if let elementParent = node.fiber?.elementParent { + if node.fiber?.element != nil { + node.fiber?.elementIndex = caches.elementIndex(for: elementParent, increment: true) + } else if let childrenCount = node.fiber?.childrenCount { + // This fiber does not have an element, so we should transfer our `_viewChildrenCount` + // up to the `elementParent`. + elementParent.childrenCount = (elementParent.childrenCount ?? 0) + childrenCount + } } // Perform work on the node. diff --git a/Sources/TokamakCore/Fiber/ViewArguments.swift b/Sources/TokamakCore/Fiber/ViewArguments.swift index 44afa1df1..c6c2b3ea9 100644 --- a/Sources/TokamakCore/Fiber/ViewArguments.swift +++ b/Sources/TokamakCore/Fiber/ViewArguments.swift @@ -60,6 +60,12 @@ public extension View { static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { .init(inputs: inputs) } + + // By default, specify that we don't know how many children will exist. + // This will prevent any `reserveCapacity` calls. + static func _viewChildrenCount(_ inputs: ViewInputs) -> Int? { + nil + } } public extension ModifiedContent where Content: View, Modifier: ViewModifier { diff --git a/Sources/TokamakCore/Views/Containers/ForEach.swift b/Sources/TokamakCore/Views/Containers/ForEach.swift index ef2bf6ef9..8d3925ad2 100644 --- a/Sources/TokamakCore/Views/Containers/ForEach.swift +++ b/Sources/TokamakCore/Views/Containers/ForEach.swift @@ -54,6 +54,10 @@ public struct ForEach: _PrimitiveView where Data: RandomAcces visitor.visit(content(element)) } } + + public static func _viewChildrenCount(_ inputs: ViewInputs) -> Int? { + inputs.content.data.count + } } extension ForEach: ForEachProtocol where Data.Index == Int { diff --git a/Sources/TokamakCore/Views/Containers/TupleView.swift b/Sources/TokamakCore/Views/Containers/TupleView.swift index 6945589a5..cc4d6f422 100644 --- a/Sources/TokamakCore/Views/Containers/TupleView.swift +++ b/Sources/TokamakCore/Views/Containers/TupleView.swift @@ -22,17 +22,20 @@ public struct TupleView: _PrimitiveView { public let value: T let _children: [AnyView] + private let childCount: Int private let visit: (ViewVisitor) -> () public init(_ value: T) { self.value = value _children = [] + childCount = 0 visit = { _ in } } public init(_ value: T, children: [AnyView]) { self.value = value _children = children + childCount = children.count visit = { for child in children { $0.visit(child) @@ -44,9 +47,14 @@ public struct TupleView: _PrimitiveView { visit(visitor) } + public static func _viewChildrenCount(_ inputs: ViewInputs) -> Int? { + inputs.content.childCount + } + init(_ v1: T1, _ v2: T2) where T == (T1, T2) { value = (v1, v2) _children = [AnyView(v1), AnyView(v2)] + childCount = 2 visit = { $0.visit(v1) $0.visit(v2) @@ -57,6 +65,7 @@ public struct TupleView: _PrimitiveView { init(_ v1: T1, _ v2: T2, _ v3: T3) where T == (T1, T2, T3) { value = (v1, v2, v3) _children = [AnyView(v1), AnyView(v2), AnyView(v3)] + childCount = 3 visit = { $0.visit(v1) $0.visit(v2) @@ -69,6 +78,7 @@ public struct TupleView: _PrimitiveView { { value = (v1, v2, v3, v4) _children = [AnyView(v1), AnyView(v2), AnyView(v3), AnyView(v4)] + childCount = 4 visit = { $0.visit(v1) $0.visit(v2) @@ -86,6 +96,7 @@ public struct TupleView: _PrimitiveView { ) where T == (T1, T2, T3, T4, T5) { value = (v1, v2, v3, v4, v5) _children = [AnyView(v1), AnyView(v2), AnyView(v3), AnyView(v4), AnyView(v5)] + childCount = 5 visit = { $0.visit(v1) $0.visit(v2) @@ -105,6 +116,7 @@ public struct TupleView: _PrimitiveView { ) where T == (T1, T2, T3, T4, T5, T6) { value = (v1, v2, v3, v4, v5, v6) _children = [AnyView(v1), AnyView(v2), AnyView(v3), AnyView(v4), AnyView(v5), AnyView(v6)] + childCount = 6 visit = { $0.visit(v1) $0.visit(v2) @@ -134,6 +146,7 @@ public struct TupleView: _PrimitiveView { AnyView(v6), AnyView(v7), ] + childCount = 7 visit = { $0.visit(v1) $0.visit(v2) @@ -166,6 +179,7 @@ public struct TupleView: _PrimitiveView { AnyView(v7), AnyView(v8), ] + childCount = 8 visit = { $0.visit(v1) $0.visit(v2) @@ -201,6 +215,7 @@ public struct TupleView: _PrimitiveView { AnyView(v8), AnyView(v9), ] + childCount = 9 visit = { $0.visit(v1) $0.visit(v2) @@ -250,6 +265,7 @@ public struct TupleView: _PrimitiveView { AnyView(v9), AnyView(v10), ] + childCount = 10 visit = { $0.visit(v1) $0.visit(v2) diff --git a/Sources/TokamakCore/Views/View.swift b/Sources/TokamakCore/Views/View.swift index 08b0a222f..8b0245f5f 100644 --- a/Sources/TokamakCore/Views/View.swift +++ b/Sources/TokamakCore/Views/View.swift @@ -28,6 +28,10 @@ public protocol View { /// Create `ViewOutputs`, including any modifications to the environment, preferences, or a custom /// `LayoutComputer` from the `ViewInputs`. static func _makeView(_ inputs: ViewInputs) -> ViewOutputs + + /// The number of child `View`s contained in this type. Used to pre-allocate storage for + /// subview collections. + static func _viewChildrenCount(_ inputs: ViewInputs) -> Int? } public extension Never { From c39455c2d0005282706042a55cd8c16e88cf8611 Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Fri, 17 Jun 2022 23:20:31 -0400 Subject: [PATCH 07/38] Revert viewChildrenCount --- Sources/TokamakCore/Fiber/Fiber.swift | 7 ------- Sources/TokamakCore/Fiber/LayoutComputer.swift | 8 +------- Sources/TokamakCore/Fiber/ReconcilePass.swift | 12 ++++-------- Sources/TokamakCore/Fiber/ViewArguments.swift | 6 ------ .../TokamakCore/Views/Containers/ForEach.swift | 4 ---- .../TokamakCore/Views/Containers/TupleView.swift | 16 ---------------- Sources/TokamakCore/Views/View.swift | 4 ---- 7 files changed, 5 insertions(+), 52 deletions(-) diff --git a/Sources/TokamakCore/Fiber/Fiber.swift b/Sources/TokamakCore/Fiber/Fiber.swift index 996a9205e..ef29b5700 100644 --- a/Sources/TokamakCore/Fiber/Fiber.swift +++ b/Sources/TokamakCore/Fiber/Fiber.swift @@ -80,8 +80,6 @@ public extension FiberReconciler { /// Boxes that store `State` data. var state: [PropertyInfo: MutableStorage] = [:] - var childrenCount: Int? - /// The computed dimensions and origin. var geometry: ViewGeometry? @@ -136,7 +134,6 @@ public extension FiberReconciler { environment: environment ) outputs = V._makeView(viewInputs) - childrenCount = V._viewChildrenCount(viewInputs) content = content(for: view) @@ -162,7 +159,6 @@ public extension FiberReconciler { layoutActions: self.layout, alternate: self, outputs: self.outputs, - childrenCount: self.childrenCount, typeInfo: self.typeInfo, element: self.element, parent: self.parent?.alternate, @@ -192,7 +188,6 @@ public extension FiberReconciler { layoutActions: LayoutActions, alternate: Fiber, outputs: ViewOutputs, - childrenCount: Int?, typeInfo: TypeInfo?, element: Renderer.ElementType?, parent: FiberReconciler.Fiber?, @@ -208,7 +203,6 @@ public extension FiberReconciler { self.elementParent = elementParent self.typeInfo = typeInfo self.outputs = outputs - self.childrenCount = childrenCount layout = layoutActions content = content(for: view) } @@ -262,7 +256,6 @@ public extension FiberReconciler { environment: environment ) outputs = V._makeView(inputs) - childrenCount = V._viewChildrenCount(inputs) if Renderer.isPrimitive(view) { return .init(from: view, useDynamicLayout: reconciler?.renderer.useDynamicLayout ?? false) diff --git a/Sources/TokamakCore/Fiber/LayoutComputer.swift b/Sources/TokamakCore/Fiber/LayoutComputer.swift index 0ea58a989..43dcf2b76 100644 --- a/Sources/TokamakCore/Fiber/LayoutComputer.swift +++ b/Sources/TokamakCore/Fiber/LayoutComputer.swift @@ -199,15 +199,9 @@ public struct LayoutSubviews: Equatable, RandomAccessCollection { } init(_ node: FiberReconciler.Fiber) { - var storage = [LayoutSubview]() - if let childrenCount = node.childrenCount { - storage.reserveCapacity(childrenCount) - } else { - storage.reserveCapacity(1) - } self.init( layoutDirection: node.outputs.environment.environment.layoutDirection, - storage: storage + storage: [] ) } diff --git a/Sources/TokamakCore/Fiber/ReconcilePass.swift b/Sources/TokamakCore/Fiber/ReconcilePass.swift index c1144df05..35f25e728 100644 --- a/Sources/TokamakCore/Fiber/ReconcilePass.swift +++ b/Sources/TokamakCore/Fiber/ReconcilePass.swift @@ -60,14 +60,10 @@ struct ReconcilePass: FiberReconcilerPass { while true { // If this fiber has an element, set its `elementIndex` // and increment the `elementIndices` value for its `elementParent`. - if let elementParent = node.fiber?.elementParent { - if node.fiber?.element != nil { - node.fiber?.elementIndex = caches.elementIndex(for: elementParent, increment: true) - } else if let childrenCount = node.fiber?.childrenCount { - // This fiber does not have an element, so we should transfer our `_viewChildrenCount` - // up to the `elementParent`. - elementParent.childrenCount = (elementParent.childrenCount ?? 0) + childrenCount - } + if node.fiber?.element != nil, + let elementParent = node.fiber?.elementParent + { + node.fiber?.elementIndex = caches.elementIndex(for: elementParent, increment: true) } // Perform work on the node. diff --git a/Sources/TokamakCore/Fiber/ViewArguments.swift b/Sources/TokamakCore/Fiber/ViewArguments.swift index c6c2b3ea9..44afa1df1 100644 --- a/Sources/TokamakCore/Fiber/ViewArguments.swift +++ b/Sources/TokamakCore/Fiber/ViewArguments.swift @@ -60,12 +60,6 @@ public extension View { static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { .init(inputs: inputs) } - - // By default, specify that we don't know how many children will exist. - // This will prevent any `reserveCapacity` calls. - static func _viewChildrenCount(_ inputs: ViewInputs) -> Int? { - nil - } } public extension ModifiedContent where Content: View, Modifier: ViewModifier { diff --git a/Sources/TokamakCore/Views/Containers/ForEach.swift b/Sources/TokamakCore/Views/Containers/ForEach.swift index 8d3925ad2..ef2bf6ef9 100644 --- a/Sources/TokamakCore/Views/Containers/ForEach.swift +++ b/Sources/TokamakCore/Views/Containers/ForEach.swift @@ -54,10 +54,6 @@ public struct ForEach: _PrimitiveView where Data: RandomAcces visitor.visit(content(element)) } } - - public static func _viewChildrenCount(_ inputs: ViewInputs) -> Int? { - inputs.content.data.count - } } extension ForEach: ForEachProtocol where Data.Index == Int { diff --git a/Sources/TokamakCore/Views/Containers/TupleView.swift b/Sources/TokamakCore/Views/Containers/TupleView.swift index cc4d6f422..6945589a5 100644 --- a/Sources/TokamakCore/Views/Containers/TupleView.swift +++ b/Sources/TokamakCore/Views/Containers/TupleView.swift @@ -22,20 +22,17 @@ public struct TupleView: _PrimitiveView { public let value: T let _children: [AnyView] - private let childCount: Int private let visit: (ViewVisitor) -> () public init(_ value: T) { self.value = value _children = [] - childCount = 0 visit = { _ in } } public init(_ value: T, children: [AnyView]) { self.value = value _children = children - childCount = children.count visit = { for child in children { $0.visit(child) @@ -47,14 +44,9 @@ public struct TupleView: _PrimitiveView { visit(visitor) } - public static func _viewChildrenCount(_ inputs: ViewInputs) -> Int? { - inputs.content.childCount - } - init(_ v1: T1, _ v2: T2) where T == (T1, T2) { value = (v1, v2) _children = [AnyView(v1), AnyView(v2)] - childCount = 2 visit = { $0.visit(v1) $0.visit(v2) @@ -65,7 +57,6 @@ public struct TupleView: _PrimitiveView { init(_ v1: T1, _ v2: T2, _ v3: T3) where T == (T1, T2, T3) { value = (v1, v2, v3) _children = [AnyView(v1), AnyView(v2), AnyView(v3)] - childCount = 3 visit = { $0.visit(v1) $0.visit(v2) @@ -78,7 +69,6 @@ public struct TupleView: _PrimitiveView { { value = (v1, v2, v3, v4) _children = [AnyView(v1), AnyView(v2), AnyView(v3), AnyView(v4)] - childCount = 4 visit = { $0.visit(v1) $0.visit(v2) @@ -96,7 +86,6 @@ public struct TupleView: _PrimitiveView { ) where T == (T1, T2, T3, T4, T5) { value = (v1, v2, v3, v4, v5) _children = [AnyView(v1), AnyView(v2), AnyView(v3), AnyView(v4), AnyView(v5)] - childCount = 5 visit = { $0.visit(v1) $0.visit(v2) @@ -116,7 +105,6 @@ public struct TupleView: _PrimitiveView { ) where T == (T1, T2, T3, T4, T5, T6) { value = (v1, v2, v3, v4, v5, v6) _children = [AnyView(v1), AnyView(v2), AnyView(v3), AnyView(v4), AnyView(v5), AnyView(v6)] - childCount = 6 visit = { $0.visit(v1) $0.visit(v2) @@ -146,7 +134,6 @@ public struct TupleView: _PrimitiveView { AnyView(v6), AnyView(v7), ] - childCount = 7 visit = { $0.visit(v1) $0.visit(v2) @@ -179,7 +166,6 @@ public struct TupleView: _PrimitiveView { AnyView(v7), AnyView(v8), ] - childCount = 8 visit = { $0.visit(v1) $0.visit(v2) @@ -215,7 +201,6 @@ public struct TupleView: _PrimitiveView { AnyView(v8), AnyView(v9), ] - childCount = 9 visit = { $0.visit(v1) $0.visit(v2) @@ -265,7 +250,6 @@ public struct TupleView: _PrimitiveView { AnyView(v9), AnyView(v10), ] - childCount = 10 visit = { $0.visit(v1) $0.visit(v2) diff --git a/Sources/TokamakCore/Views/View.swift b/Sources/TokamakCore/Views/View.swift index 8b0245f5f..08b0a222f 100644 --- a/Sources/TokamakCore/Views/View.swift +++ b/Sources/TokamakCore/Views/View.swift @@ -28,10 +28,6 @@ public protocol View { /// Create `ViewOutputs`, including any modifications to the environment, preferences, or a custom /// `LayoutComputer` from the `ViewInputs`. static func _makeView(_ inputs: ViewInputs) -> ViewOutputs - - /// The number of child `View`s contained in this type. Used to pre-allocate storage for - /// subview collections. - static func _viewChildrenCount(_ inputs: ViewInputs) -> Int? } public extension Never { From 4611246eebf6ff790739fdaa0760b369e37968af Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Sun, 19 Jun 2022 22:06:53 -0400 Subject: [PATCH 08/38] Improve accuracy of stack layout --- .../Fiber/Layout/StackLayoutComputer.swift | 299 +++++++++++------- .../TokamakCore/Fiber/LayoutComputer.swift | 3 +- Sources/TokamakCore/Fiber/LayoutPass.swift | 7 +- Sources/TokamakCore/Fiber/ReconcilePass.swift | 24 +- Sources/TokamakCore/Views/Layout/HStack.swift | 3 +- Sources/TokamakCore/Views/Layout/VStack.swift | 3 +- 6 files changed, 221 insertions(+), 118 deletions(-) diff --git a/Sources/TokamakCore/Fiber/Layout/StackLayoutComputer.swift b/Sources/TokamakCore/Fiber/Layout/StackLayoutComputer.swift index 3fe6fb972..cbe34076b 100644 --- a/Sources/TokamakCore/Fiber/Layout/StackLayoutComputer.swift +++ b/Sources/TokamakCore/Fiber/Layout/StackLayoutComputer.swift @@ -17,19 +17,8 @@ import Foundation -struct StackLayoutCache { - var largestSubview: LayoutSubview? - var minSize: CGFloat - var flexibleSubviews: Int -} - -protocol StackLayout: Layout where Cache == StackLayoutCache { - static var orientation: Axis { get } - var stackAlignment: Alignment { get } - var spacing: CGFloat? { get } -} - private extension ViewDimensions { + /// Access the guide value of an `Alignment` for a particular `Axis`. subscript(alignment alignment: Alignment, in axis: Axis) -> CGFloat { switch axis { case .horizontal: return self[alignment.vertical] @@ -38,144 +27,240 @@ private extension ViewDimensions { } } -extension StackLayout { - public static var layoutProperties: LayoutProperties { +/// The `Layout.Cache` for `StackLayout` conforming types. +@_spi(TokamakCore) +public struct StackLayoutCache { + /// The widest/tallest (depending on the `axis`) subview. + /// Used to place subviews along the `alignment`. + var maxSubview: ViewDimensions? + /// The ideal size for each subview as computed in `sizeThatFits`. + var idealSizes = [CGSize]() +} + +/// An internal structure used to store layout information about +/// `LayoutSubview`s of a `StackLayout` that will later be sorted +private struct MeasuredSubview { + let view: LayoutSubview + let index: Int + let min: CGSize + let max: CGSize + let infiniteMainAxis: Bool + let spacing: CGFloat +} + +/// The protocol all built-in stacks conform to. +/// Provides a shared implementation for stack layout logic. +@_spi(TokamakCore) +public protocol StackLayout: Layout where Cache == StackLayoutCache { + /// The direction of this stack. `vertical` for `VStack`, `horizontal` for `HStack`. + static var orientation: Axis { get } + /// The full `Alignment` with an ignored value for the main axis. + var _alignment: Alignment { get } + var spacing: CGFloat? { get } +} + +@_spi(TokamakCore) +public extension StackLayout { + static var layoutProperties: LayoutProperties { var properties = LayoutProperties() - properties.stackOrientation = orientation + properties.stackOrientation = Self.orientation return properties } - public func makeCache(subviews: Subviews) -> Cache { - .init( - largestSubview: nil, - minSize: .zero, - flexibleSubviews: 0 - ) - } - + /// The `CGSize` component for the current `axis`. + /// + /// A `vertical` axis will return `height`. + /// A `horizontal` axis will return `width`. static var mainAxis: WritableKeyPath { - switch orientation { + switch Self.orientation { case .vertical: return \.height case .horizontal: return \.width } } + /// The `CGSize` component for the axis opposite `axis`. + /// + /// A `vertical` axis will return `width`. + /// A `horizontal` axis will return `height`. static var crossAxis: WritableKeyPath { - switch orientation { + switch Self.orientation { case .vertical: return \.width case .horizontal: return \.height } } - public func sizeThatFits( - proposal: ProposedViewSize, - subviews: Subviews, - cache: inout Cache - ) -> CGSize { - cache.largestSubview = subviews - .map { ($0, $0.sizeThatFits(proposal)) } - .max { a, b in - a.1[keyPath: Self.crossAxis] < b.1[keyPath: Self.crossAxis] - }?.0 - let largestSize = cache.largestSubview?.sizeThatFits(proposal) ?? .zero - - var last: Subviews.Element? - cache.minSize = .zero - cache.flexibleSubviews = 0 - for subview in subviews { - let sizeThatFits = subview.sizeThatFits(.infinity) - if sizeThatFits[keyPath: Self.mainAxis] == .infinity { - cache.flexibleSubviews += 1 - } else { - cache.minSize += sizeThatFits[keyPath: Self.mainAxis] - } - if let last = last { - if let spacing = spacing { - cache.minSize += spacing + func makeCache(subviews: Subviews) -> Cache { + // Ensure we have enough space in `idealSizes` for each subview. + .init(maxSubview: nil, idealSizes: Array(repeating: .zero, count: subviews.count)) + } + + func updateCache(_ cache: inout Cache, subviews: Subviews) { + cache.maxSubview = nil + // Ensure we have enough space in `idealSizes` for each subview. + cache.idealSizes = Array(repeating: .zero, count: subviews.count) + } + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize { + let proposal = proposal.replacingUnspecifiedDimensions() + + /// The minimum size of each `View` on the main axis. + var minSize = CGFloat.zero + + /// The aggregate `ViewSpacing` distances. + var totalSpacing = CGFloat.zero + + /// The number of `View`s with a given priority. + var priorityCount = [Double: Int]() + + /// The aggregate minimum size of each `View` with a given priority. + var prioritySize = [Double: CGFloat]() + + let measuredSubviews = subviews.enumerated().map { index, view -> MeasuredSubview in + priorityCount[view.priority, default: 0] += 1 + + var minProposal = CGSize(width: CGFloat.infinity, height: CGFloat.infinity) + minProposal[keyPath: Self.crossAxis] = proposal[keyPath: Self.crossAxis] + minProposal[keyPath: Self.mainAxis] = 0 + /// The minimum size for this subview along the `mainAxis`. + /// Uses `dimensions(in:)` to collect the alignment guides for use in `placeSubviews`. + let min = view.dimensions(in: .init(minProposal)) + + // Aggregate the minimum size of the stack for the combined subviews. + minSize += min.size[keyPath: Self.mainAxis] + + // Aggregate the minimum size of this priority to divvy up space later. + prioritySize[view.priority, default: 0] += min.size[keyPath: Self.mainAxis] + + var maxProposal = CGSize(width: CGFloat.infinity, height: CGFloat.infinity) + maxProposal[keyPath: Self.crossAxis] = minProposal[keyPath: Self.crossAxis] + /// The maximum size for this subview along the `mainAxis`. + let max = view.sizeThatFits(.init(maxProposal)) + + /// The spacing around this `View` and its previous (if it is not first). + let spacing: CGFloat + if subviews.indices.contains(index - 1) { + if let overrideSpacing = self.spacing { + spacing = overrideSpacing } else { - cache.minSize += last.spacing.distance( - to: subview.spacing, - along: Self.orientation - ) + spacing = subviews[index - 1].spacing.distance(to: view.spacing, along: Self.orientation) } + } else { + spacing = .zero + } + // Aggregate all spacing values. + totalSpacing += spacing + + // If this `View` is the widest, save it to the cache for access in `placeSubviews`. + if min.size[keyPath: Self.crossAxis] > cache.maxSubview?.size[keyPath: Self.crossAxis] + ?? .zero + { + cache.maxSubview = min } - last = subview + + return MeasuredSubview( + view: view, + index: index, + min: min.size, + max: max, + infiniteMainAxis: max[keyPath: Self.mainAxis] == .infinity, + spacing: spacing + ) } + + // Calculate ideal sizes for each View based on their min/max sizes and the space available. + var available = proposal[keyPath: Self.mainAxis] - minSize - totalSpacing + /// The final resulting size. var size = CGSize.zero - if cache.flexibleSubviews > 0 { - size[keyPath: Self.mainAxis] = max( - cache.minSize, - proposal.replacingUnspecifiedDimensions()[keyPath: Self.mainAxis] - ) - size[keyPath: Self.crossAxis] = largestSize[keyPath: Self.crossAxis] - } else { - size[keyPath: Self.mainAxis] = cache.minSize - size[keyPath: Self.crossAxis] = largestSize[keyPath: Self.crossAxis] == .infinity - ? proposal.replacingUnspecifiedDimensions()[keyPath: Self.crossAxis] - : largestSize[keyPath: Self.crossAxis] + size[keyPath: Self.crossAxis] = cache.maxSubview?.size[keyPath: Self.crossAxis] ?? .zero + for subview in measuredSubviews.sorted(by: { + // Sort by priority descending. + if $0.view.priority == $1.view.priority { + // If the priorities match, allow non-flexible `View`s to size first. + return $1.infiniteMainAxis && !$0.infiniteMainAxis + } else { + return $0.view.priority > $1.view.priority + } + }) { + /// The amount of space available to `View`s with this priority value. + let priorityAvailable = available + prioritySize[subview.view.priority, default: 0] + /// The number of `View`s with this priority value remaining as a `CGFloat`. + let priorityRemaining = CGFloat(priorityCount[subview.view.priority, default: 1]) + // Propose the full `crossAxis`, but only the remaining `mainAxis`. + // Divvy up the available space between each remaining `View` with this priority value. + var divviedSize = proposal + divviedSize[keyPath: Self.mainAxis] = priorityAvailable / priorityRemaining + let idealSize = subview.view.sizeThatFits(.init(divviedSize)) + cache.idealSizes[subview.index] = idealSize + size[keyPath: Self.mainAxis] += idealSize[keyPath: Self.mainAxis] + subview.spacing + // Remove our `idealSize` from the `available` space. + available -= idealSize[keyPath: Self.mainAxis] + // Decrement the number of `View`s left with this priority so space can be evenly divided + // between the remaining `View`s. + priorityCount[subview.view.priority, default: 1] -= 1 } return size } - public func placeSubviews( + func spacing(subviews: Subviews, cache: inout Cache) -> ViewSpacing { + subviews.reduce(into: .zero) { $0.formUnion($1.spacing) } + } + + func placeSubviews( in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache ) { - var last: Subviews.Element? - var offset = CGFloat.zero - let alignmentOffset = cache.largestSubview? - .dimensions(in: proposal)[alignment: stackAlignment, in: Self.orientation] ?? .zero - let flexibleSize = (bounds.size[keyPath: Self.mainAxis] - cache.minSize) / - CGFloat(cache.flexibleSubviews) - for subview in subviews { - if let last = last { - if let spacing = spacing { - offset += spacing + /// The current progress along the `mainAxis`. + var position = CGFloat.zero + /// The offset of the `_alignment` in the `maxSubview`, + /// used as the reference point for alignments along this axis. + let alignmentOffset = cache.maxSubview?[alignment: _alignment, in: Self.orientation] ?? .zero + for (index, view) in subviews.enumerated() { + // Add a gap for the spacing distance from the previous subview to this one. + let spacing: CGFloat + if subviews.indices.contains(index - 1) { + if let overrideSpacing = self.spacing { + spacing = overrideSpacing } else { - offset += last.spacing.distance(to: subview.spacing, along: Self.orientation) + spacing = subviews[index - 1].spacing.distance(to: view.spacing, along: Self.orientation) } + } else { + spacing = .zero } + position += spacing - let dimensions = subview.dimensions( - in: .init( - width: Self.orientation == .horizontal ? .infinity : bounds.width, - height: Self.orientation == .vertical ? .infinity : bounds.height - ) - ) - var position = CGSize(width: bounds.minX, height: bounds.minY) - position[keyPath: Self.mainAxis] += offset - position[keyPath: Self.crossAxis] += alignmentOffset - dimensions[ - alignment: stackAlignment, - in: Self.orientation - ] - var size = CGSize.zero - size[keyPath: Self.mainAxis] = dimensions.size[keyPath: Self.mainAxis] == .infinity - ? flexibleSize - : bounds.size[keyPath: Self.mainAxis] - size[keyPath: Self.crossAxis] = bounds.size[keyPath: Self.crossAxis] - subview.place( - at: .init(x: position.width, y: position.height), - proposal: .init(width: size.width, height: size.height) - ) + let proposal = ProposedViewSize(cache.idealSizes[index]) + let size = view.dimensions(in: proposal) - if dimensions.size[keyPath: Self.mainAxis] == .infinity { - offset += flexibleSize - } else { - offset += dimensions.size[keyPath: Self.mainAxis] - } - last = subview + // Offset the placement along the `crossAxis` to align with the + // `alignment` of the `maxSubview`. + var placement = CGSize(width: bounds.minX, height: bounds.minY) + placement[keyPath: Self.mainAxis] += position + placement[keyPath: Self.crossAxis] += alignmentOffset + - size[alignment: _alignment, in: Self.orientation] + + view.place( + at: .init( + x: placement.width, + y: placement.height + ), + proposal: proposal + ) + // Move further along the stack's `mainAxis`. + position += size.size[keyPath: Self.mainAxis] } } } +@_spi(TokamakCore) extension VStack: StackLayout { public static var orientation: Axis { .vertical } - public var stackAlignment: Alignment { .init(horizontal: alignment, vertical: .center) } + public var _alignment: Alignment { .init(horizontal: alignment, vertical: .center) } } +@_spi(TokamakCore) extension HStack: StackLayout { public static var orientation: Axis { .horizontal } - public var stackAlignment: Alignment { .init(horizontal: .center, vertical: alignment) } + public var _alignment: Alignment { .init(horizontal: .center, vertical: alignment) } } diff --git a/Sources/TokamakCore/Fiber/LayoutComputer.swift b/Sources/TokamakCore/Fiber/LayoutComputer.swift index 43dcf2b76..3954a2b1c 100644 --- a/Sources/TokamakCore/Fiber/LayoutComputer.swift +++ b/Sources/TokamakCore/Fiber/LayoutComputer.swift @@ -268,7 +268,8 @@ public struct LayoutSubview: Equatable { } public var priority: Double { - fatalError("Implement \(#function)") +// fatalError("Implement \(#function)") + 0 } public func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize { diff --git a/Sources/TokamakCore/Fiber/LayoutPass.swift b/Sources/TokamakCore/Fiber/LayoutPass.swift index d2a096462..7349f1816 100644 --- a/Sources/TokamakCore/Fiber/LayoutPass.swift +++ b/Sources/TokamakCore/Fiber/LayoutPass.swift @@ -119,12 +119,9 @@ struct LayoutPass: FiberReconcilerPass { // Compute our required size. // This does not have to respect the elementParent's proposed size. let size = caches.updateLayoutCache(for: node) { cache -> CGSize in - let subviews = caches.layoutSubviews(for: node) - // Update the cache so it's ready for the new values. - node.updateCache(&cache, subviews: subviews) - return node.sizeThatFits( + node.sizeThatFits( proposal: proposal, - subviews: subviews, + subviews: caches.layoutSubviews(for: node), cache: &cache ) } diff --git a/Sources/TokamakCore/Fiber/ReconcilePass.swift b/Sources/TokamakCore/Fiber/ReconcilePass.swift index 35f25e728..7bec244f5 100644 --- a/Sources/TokamakCore/Fiber/ReconcilePass.swift +++ b/Sources/TokamakCore/Fiber/ReconcilePass.swift @@ -179,10 +179,12 @@ struct ReconcilePass: FiberReconcilerPass { // Now walk back up the tree until we find a sibling. while node.sibling == nil { + // Update the layout cache so it's ready for this render. + updateCache(for: node, in: reconciler, caches: caches) + var alternateSibling = node.fiber?.alternate?.sibling - while alternateSibling != - nil - { // The alternate had siblings that no longer exist. + // The alternate had siblings that no longer exist. + while alternateSibling != nil { if let element = alternateSibling?.element, let parent = alternateSibling?.elementParent?.element { @@ -198,6 +200,8 @@ struct ReconcilePass: FiberReconcilerPass { node = parent } + updateCache(for: node, in: reconciler, caches: caches) + // Walk across to the sibling, and repeat. node = node.sibling! } @@ -234,6 +238,20 @@ struct ReconcilePass: FiberReconcilerPass { } return nil } + + /// Update the layout cache for a `Fiber`. + func updateCache( + for node: FiberReconciler.TreeReducer.Result, + in reconciler: FiberReconciler, + caches: FiberReconciler.Caches + ) { + guard reconciler.renderer.useDynamicLayout, + let fiber = node.fiber + else { return } + caches.updateLayoutCache(for: fiber) { cache in + fiber.updateCache(&cache, subviews: caches.layoutSubviews(for: fiber)) + } + } } extension FiberReconcilerPass where Self == ReconcilePass { diff --git a/Sources/TokamakCore/Views/Layout/HStack.swift b/Sources/TokamakCore/Views/Layout/HStack.swift index e7698dd6b..5082e229d 100644 --- a/Sources/TokamakCore/Views/Layout/HStack.swift +++ b/Sources/TokamakCore/Views/Layout/HStack.swift @@ -27,7 +27,8 @@ public let defaultStackSpacing: CGFloat = 8 /// } public struct HStack: View where Content: View { public let alignment: VerticalAlignment - let spacing: CGFloat? + @_spi(TokamakCore) + public let spacing: CGFloat? public let content: Content public init( diff --git a/Sources/TokamakCore/Views/Layout/VStack.swift b/Sources/TokamakCore/Views/Layout/VStack.swift index 41a62fe3e..4f8e5d557 100644 --- a/Sources/TokamakCore/Views/Layout/VStack.swift +++ b/Sources/TokamakCore/Views/Layout/VStack.swift @@ -22,7 +22,8 @@ import Foundation /// } public struct VStack: View where Content: View { public let alignment: HorizontalAlignment - let spacing: CGFloat? + @_spi(TokamakCore) + public let spacing: CGFloat? public let content: Content public init( From 044aeebeba0f6d80ff6fd605bf6425017643e083 Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Mon, 20 Jun 2022 10:29:41 -0400 Subject: [PATCH 09/38] Try caching sizeThatFits --- .../Fiber/FiberReconcilerPass.swift | 33 ++++++- .../TokamakCore/Fiber/LayoutComputer.swift | 12 +-- Sources/TokamakCore/Fiber/LayoutPass.swift | 11 ++- Sources/TokamakCore/Fiber/ReconcilePass.swift | 95 +++++++++++++------ 4 files changed, 108 insertions(+), 43 deletions(-) diff --git a/Sources/TokamakCore/Fiber/FiberReconcilerPass.swift b/Sources/TokamakCore/Fiber/FiberReconcilerPass.swift index 2e3980be2..911b74890 100644 --- a/Sources/TokamakCore/Fiber/FiberReconcilerPass.swift +++ b/Sources/TokamakCore/Fiber/FiberReconcilerPass.swift @@ -10,11 +10,33 @@ import Foundation extension FiberReconciler { final class Caches { var elementIndices = [ObjectIdentifier: Int]() - var layoutCaches = [ObjectIdentifier: Any]() + var layoutCaches = [ObjectIdentifier: LayoutCache]() var layoutSubviews = [ObjectIdentifier: LayoutSubviews]() var elementChildren = [ObjectIdentifier: [Fiber]]() var mutations = [Mutation]() + struct LayoutCache { + var cache: Any + var sizeThatFits: [SizeThatFitsRequest: CGSize] + var dimensions: [SizeThatFitsRequest: ViewDimensions] + var isDirty: Bool + + struct SizeThatFitsRequest: Hashable { + let proposal: ProposedViewSize + + @inlinable + init(_ proposal: ProposedViewSize) { + self.proposal = proposal + } + + func hash(into hasher: inout Hasher) { + hasher.combine(proposal.width) + hasher.combine(proposal.height) + } + } + } + + @inlinable func clear() { elementIndices = [:] layoutSubviews = [:] @@ -23,11 +45,16 @@ extension FiberReconciler { } @inlinable - func updateLayoutCache(for fiber: Fiber, _ action: (inout Any) -> R) -> R { + func updateLayoutCache(for fiber: Fiber, _ action: (inout LayoutCache) -> R) -> R { let key = ObjectIdentifier(fiber) var cache = layoutCaches[ key, - default: fiber.makeCache(subviews: layoutSubviews(for: fiber)) + default: .init( + cache: fiber.makeCache(subviews: layoutSubviews(for: fiber)), + sizeThatFits: [:], + dimensions: [:], + isDirty: false + ) ] defer { layoutCaches[key] = cache } return action(&cache) diff --git a/Sources/TokamakCore/Fiber/LayoutComputer.swift b/Sources/TokamakCore/Fiber/LayoutComputer.swift index 3954a2b1c..18a195662 100644 --- a/Sources/TokamakCore/Fiber/LayoutComputer.swift +++ b/Sources/TokamakCore/Fiber/LayoutComputer.swift @@ -244,14 +244,14 @@ public struct LayoutSubview: Equatable { } private let sizeThatFits: (ProposedViewSize) -> CGSize - private let dimensions: (ProposedViewSize) -> ViewDimensions - private let place: (CGPoint, UnitPoint, ProposedViewSize) -> () + private let dimensions: (CGSize) -> ViewDimensions + private let place: (ViewDimensions, CGPoint, UnitPoint) -> () init( id: ObjectIdentifier, sizeThatFits: @escaping (ProposedViewSize) -> CGSize, - dimensions: @escaping (ProposedViewSize) -> ViewDimensions, - place: @escaping (CGPoint, UnitPoint, ProposedViewSize) -> () + dimensions: @escaping (CGSize) -> ViewDimensions, + place: @escaping (ViewDimensions, CGPoint, UnitPoint) -> () ) { self.id = id self.sizeThatFits = sizeThatFits @@ -277,7 +277,7 @@ public struct LayoutSubview: Equatable { } public func dimensions(in proposal: ProposedViewSize) -> ViewDimensions { - dimensions(proposal) + dimensions(sizeThatFits(proposal)) } public var spacing: ViewSpacing { @@ -289,7 +289,7 @@ public struct LayoutSubview: Equatable { anchor: UnitPoint = .topLeading, proposal: ProposedViewSize ) { - place(position, anchor, proposal) + place(dimensions(in: proposal), position, anchor) } } diff --git a/Sources/TokamakCore/Fiber/LayoutPass.swift b/Sources/TokamakCore/Fiber/LayoutPass.swift index 7349f1816..44ddfd26e 100644 --- a/Sources/TokamakCore/Fiber/LayoutPass.swift +++ b/Sources/TokamakCore/Fiber/LayoutPass.swift @@ -55,7 +55,7 @@ struct LayoutPass: FiberReconcilerPass { .sceneSize ), subviews: caches.layoutSubviews(for: fiber), - cache: &cache + cache: &cache.cache ) } // Exit at the top of the View tree @@ -76,7 +76,7 @@ struct LayoutPass: FiberReconcilerPass { .sceneSize ), subviews: caches.layoutSubviews(for: fiber), - cache: &cache + cache: &cache.cache ) } @@ -98,7 +98,7 @@ struct LayoutPass: FiberReconcilerPass { .sceneSize ), subviews: caches.layoutSubviews(for: fiber), - cache: &cache + cache: &cache.cache ) } @@ -119,10 +119,11 @@ struct LayoutPass: FiberReconcilerPass { // Compute our required size. // This does not have to respect the elementParent's proposed size. let size = caches.updateLayoutCache(for: node) { cache -> CGSize in - node.sizeThatFits( + cache.isDirty = false + return node.sizeThatFits( proposal: proposal, subviews: caches.layoutSubviews(for: node), - cache: &cache + cache: &cache.cache ) } let dimensions = ViewDimensions(size: size, alignmentGuides: [:]) diff --git a/Sources/TokamakCore/Fiber/ReconcilePass.swift b/Sources/TokamakCore/Fiber/ReconcilePass.swift index 7bec244f5..a038bad92 100644 --- a/Sources/TokamakCore/Fiber/ReconcilePass.swift +++ b/Sources/TokamakCore/Fiber/ReconcilePass.swift @@ -67,7 +67,7 @@ struct ReconcilePass: FiberReconcilerPass { } // Perform work on the node. - if let mutation = reconcile(node) { + if let mutation = reconcile(node, in: reconciler, caches: caches) { caches.mutations.append(mutation) } @@ -92,37 +92,25 @@ struct ReconcilePass: FiberReconcilerPass { sizeThatFits: { [weak fiber, unowned caches] proposal in guard let fiber = fiber else { return .zero } return caches.updateLayoutCache(for: fiber) { cache in - fiber.sizeThatFits( - proposal: proposal, - subviews: caches.layoutSubviews(for: fiber), - cache: &cache - ) + if let size = cache.sizeThatFits[.init(proposal)] { + return size + } else { + let size = fiber.sizeThatFits( + proposal: proposal, + subviews: caches.layoutSubviews(for: fiber), + cache: &cache.cache + ) + cache.sizeThatFits[.init(proposal)] = size + return size + } } }, - dimensions: { [weak fiber, unowned caches] proposal in - guard let fiber = fiber else { return .init(size: .zero, alignmentGuides: [:]) } - let size = caches.updateLayoutCache(for: fiber) { cache in - fiber.sizeThatFits( - proposal: proposal, - subviews: caches.layoutSubviews(for: fiber), - cache: &cache - ) - } + dimensions: { sizeThatFits in // TODO: Add `alignmentGuide` modifier and pass into `ViewDimensions` - return ViewDimensions(size: size, alignmentGuides: [:]) + ViewDimensions(size: sizeThatFits, alignmentGuides: [:]) }, - place: { [weak fiber, weak element, unowned caches] position, anchor, proposal in + place: { [weak fiber, weak element, unowned caches] dimensions, position, anchor in guard let fiber = fiber, let element = element else { return } - let dimensions = caches.updateLayoutCache(for: fiber) { cache in - ViewDimensions( - size: fiber.sizeThatFits( - proposal: proposal, - subviews: caches.layoutSubviews(for: fiber), - cache: &cache - ), - alignmentGuides: [:] - ) - } let geometry = ViewGeometry( // Shift to the anchor point in the parent's coordinate space. origin: .init(origin: .init( @@ -159,6 +147,9 @@ struct ReconcilePass: FiberReconcilerPass { if let element = node.element, let parent = node.elementParent?.element { + if let node = node.elementParent { + invalidateCache(for: node, in: reconciler, caches: caches) + } // Removals must happen in reverse order, so a child element // is removed before its parent. caches.mutations.insert(.remove(element: element, parent: parent), at: 0) @@ -188,6 +179,9 @@ struct ReconcilePass: FiberReconcilerPass { if let element = alternateSibling?.element, let parent = alternateSibling?.elementParent?.element { + if let fiber = alternateSibling?.elementParent { + invalidateCache(for: fiber, in: reconciler, caches: caches) + } // Removals happen in reverse order, so a child element is removed before // its parent. caches.mutations.insert(.remove(element: element, parent: parent), at: 0) @@ -209,22 +203,33 @@ struct ReconcilePass: FiberReconcilerPass { /// Compare `node` with its alternate, and add any mutations to the list. func reconcile( - _ node: FiberReconciler.TreeReducer.Result + _ node: FiberReconciler.TreeReducer.Result, + in reconciler: FiberReconciler, + caches: FiberReconciler.Caches ) -> Mutation? { if let element = node.fiber?.element, let index = node.fiber?.elementIndex, let parent = node.fiber?.elementParent?.element { if node.fiber?.alternate == nil { // This didn't exist before (no alternate) + if let fiber = node.fiber { + invalidateCache(for: fiber, in: reconciler, caches: caches) + } return .insert(element: element, parent: parent, index: index) } else if node.fiber?.typeInfo?.type != node.fiber?.alternate?.typeInfo?.type, let previous = node.fiber?.alternate?.element { + if let fiber = node.fiber { + invalidateCache(for: fiber, in: reconciler, caches: caches) + } // This is a completely different type of view. return .replace(parent: parent, previous: previous, replacement: element) } else if let newContent = node.newContent, newContent != element.content { + if let fiber = node.fiber { + invalidateCache(for: fiber, in: reconciler, caches: caches) + } // This is the same type of view, but its backing data has changed. return .update( previous: element, @@ -249,7 +254,39 @@ struct ReconcilePass: FiberReconcilerPass { let fiber = node.fiber else { return } caches.updateLayoutCache(for: fiber) { cache in - fiber.updateCache(&cache, subviews: caches.layoutSubviews(for: fiber)) + fiber.updateCache(&cache.cache, subviews: caches.layoutSubviews(for: fiber)) + if let child = fiber.child, + let childCache = caches.layoutCaches[.init(child)], + childCache.isDirty + { + cache.sizeThatFits.removeAll() + cache.isDirty = childCache.isDirty + if let alternate = fiber.alternate { + caches.updateLayoutCache(for: alternate) { cache in + cache.sizeThatFits.removeAll() + cache.isDirty = childCache.isDirty + } + } + } + } + } + + /// Remove cached size values if something changed. + func invalidateCache( + for fiber: FiberReconciler.Fiber, + in reconciler: FiberReconciler, + caches: FiberReconciler.Caches + ) { + guard reconciler.renderer.useDynamicLayout else { return } + caches.updateLayoutCache(for: fiber) { cache in + cache.sizeThatFits.removeAll() + cache.isDirty = true + } + if let alternate = fiber.alternate { + caches.updateLayoutCache(for: alternate) { cache in + cache.sizeThatFits.removeAll() + cache.isDirty = true + } } } } From 792a480dd1bb7aae2e7ab57f08880ecb9f14d85b Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Mon, 20 Jun 2022 12:04:06 -0400 Subject: [PATCH 10/38] Revise caching of sizeThatFits --- Sources/TokamakCore/Fiber/LayoutPass.swift | 38 +++++++++++------ Sources/TokamakCore/Fiber/ReconcilePass.swift | 41 +++++++++++-------- 2 files changed, 50 insertions(+), 29 deletions(-) diff --git a/Sources/TokamakCore/Fiber/LayoutPass.swift b/Sources/TokamakCore/Fiber/LayoutPass.swift index 44ddfd26e..2692db62a 100644 --- a/Sources/TokamakCore/Fiber/LayoutPass.swift +++ b/Sources/TokamakCore/Fiber/LayoutPass.swift @@ -36,6 +36,7 @@ struct LayoutPass: FiberReconcilerPass { fiber.elementParent?.geometry?.dimensions.size ?? reconciler.renderer.sceneSize ) ) + clean(fiber, caches: caches) if let child = fiber.child { fiber = child @@ -58,7 +59,7 @@ struct LayoutPass: FiberReconcilerPass { cache: &cache.cache ) } - // Exit at the top of the View tree + // Exit at the top of the `View` tree guard let parent = fiber.parent else { return } guard parent !== root.alternate else { return } // Walk up to the next parent. @@ -72,8 +73,7 @@ struct LayoutPass: FiberReconcilerPass { size: fiber.geometry?.dimensions.size ?? reconciler.renderer.sceneSize ), proposal: .init( - fiber.elementParent?.geometry?.dimensions.size ?? reconciler.renderer - .sceneSize + fiber.elementParent?.geometry?.dimensions.size ?? reconciler.renderer.sceneSize ), subviews: caches.layoutSubviews(for: fiber), cache: &cache.cache @@ -94,8 +94,7 @@ struct LayoutPass: FiberReconcilerPass { size: fiber.geometry?.dimensions.size ?? reconciler.renderer.sceneSize ), proposal: .init( - fiber.elementParent?.geometry?.dimensions.size ?? reconciler.renderer - .sceneSize + fiber.elementParent?.geometry?.dimensions.size ?? reconciler.renderer.sceneSize ), subviews: caches.layoutSubviews(for: fiber), cache: &cache.cache @@ -107,30 +106,43 @@ struct LayoutPass: FiberReconcilerPass { } } + func clean( + _ fiber: FiberReconciler.Fiber, + caches: FiberReconciler.Caches + ) { + caches.updateLayoutCache(for: fiber) { cache in + cache.isDirty = false + } + if let alternate = fiber.alternate { + caches.updateLayoutCache(for: alternate) { cache in + cache.isDirty = false + } + } + } + /// Request a size from the fiber's `elementParent`. func sizeThatFits( - _ node: FiberReconciler.Fiber, + _ fiber: FiberReconciler.Fiber, caches: FiberReconciler.Caches, proposal: ProposedViewSize ) { - guard node.element != nil + guard fiber.element != nil else { return } // Compute our required size. // This does not have to respect the elementParent's proposed size. - let size = caches.updateLayoutCache(for: node) { cache -> CGSize in - cache.isDirty = false - return node.sizeThatFits( + let size = caches.updateLayoutCache(for: fiber) { cache -> CGSize in + fiber.sizeThatFits( proposal: proposal, - subviews: caches.layoutSubviews(for: node), + subviews: caches.layoutSubviews(for: fiber), cache: &cache.cache ) } let dimensions = ViewDimensions(size: size, alignmentGuides: [:]) // Update our geometry - node.geometry = .init( - origin: node.geometry?.origin ?? .init(origin: .zero), + fiber.geometry = .init( + origin: fiber.geometry?.origin ?? .init(origin: .zero), dimensions: dimensions ) } diff --git a/Sources/TokamakCore/Fiber/ReconcilePass.swift b/Sources/TokamakCore/Fiber/ReconcilePass.swift index a038bad92..7a22b2cdb 100644 --- a/Sources/TokamakCore/Fiber/ReconcilePass.swift +++ b/Sources/TokamakCore/Fiber/ReconcilePass.swift @@ -101,6 +101,11 @@ struct ReconcilePass: FiberReconcilerPass { cache: &cache.cache ) cache.sizeThatFits[.init(proposal)] = size + if let alternate = fiber.alternate { + caches.updateLayoutCache(for: alternate) { cache in + cache.sizeThatFits[.init(proposal)] = size + } + } return size } } @@ -143,13 +148,13 @@ struct ReconcilePass: FiberReconcilerPass { continue } else if let alternateChild = node.fiber?.alternate?.child { // The alternate has a child that no longer exists. + if let parent = node.fiber { + invalidateCache(for: parent, in: reconciler, caches: caches) + } walk(alternateChild) { node in if let element = node.element, let parent = node.elementParent?.element { - if let node = node.elementParent { - invalidateCache(for: node, in: reconciler, caches: caches) - } // Removals must happen in reverse order, so a child element // is removed before its parent. caches.mutations.insert(.remove(element: element, parent: parent), at: 0) @@ -176,12 +181,12 @@ struct ReconcilePass: FiberReconcilerPass { var alternateSibling = node.fiber?.alternate?.sibling // The alternate had siblings that no longer exist. while alternateSibling != nil { + if let fiber = alternateSibling?.parent { + invalidateCache(for: fiber, in: reconciler, caches: caches) + } if let element = alternateSibling?.element, let parent = alternateSibling?.elementParent?.element { - if let fiber = alternateSibling?.elementParent { - invalidateCache(for: fiber, in: reconciler, caches: caches) - } // Removals happen in reverse order, so a child element is removed before // its parent. caches.mutations.insert(.remove(element: element, parent: parent), at: 0) @@ -255,17 +260,21 @@ struct ReconcilePass: FiberReconcilerPass { else { return } caches.updateLayoutCache(for: fiber) { cache in fiber.updateCache(&cache.cache, subviews: caches.layoutSubviews(for: fiber)) - if let child = fiber.child, - let childCache = caches.layoutCaches[.init(child)], - childCache.isDirty - { - cache.sizeThatFits.removeAll() - cache.isDirty = childCache.isDirty - if let alternate = fiber.alternate { - caches.updateLayoutCache(for: alternate) { cache in - cache.sizeThatFits.removeAll() - cache.isDirty = childCache.isDirty + var sibling = fiber.child + while let fiber = sibling { + sibling = fiber.sibling + if let childCache = caches.layoutCaches[.init(fiber)], + childCache.isDirty + { + cache.sizeThatFits.removeAll() + cache.isDirty = true + if let alternate = fiber.alternate { + caches.updateLayoutCache(for: alternate) { cache in + cache.sizeThatFits.removeAll() + cache.isDirty = true + } } + return } } } From 2a84f1f819e8761fabfaa98fd4fd524fce2a8572 Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Mon, 20 Jun 2022 16:39:04 -0400 Subject: [PATCH 11/38] Cleanup layout files and split up --- .../Layout/BackgroundLayoutComputer.swift | 109 -------- .../Layout.swift} | 247 ++---------------- .../Fiber/Layout/LayoutProperties.swift | 25 ++ .../Fiber/Layout/LayoutSubviews.swift | 121 +++++++++ .../Fiber/Layout/LayoutValueKey.swift | 36 +++ ...puter.swift => PaddingLayout+Layout.swift} | 0 .../Fiber/Layout/ProposedViewSize.swift | 44 ++++ ...LayoutComputer.swift => StackLayout.swift} | 0 .../Fiber/Layout/ViewSpacing.swift | 71 +++++ .../Layout/_BackgroundLayout+Layout.swift | 42 +++ ...mputer.swift => _FrameLayout+Layout.swift} | 0 .../TokamakCore/Tokens/LayoutDirection.swift | 32 +++ 12 files changed, 394 insertions(+), 333 deletions(-) delete mode 100644 Sources/TokamakCore/Fiber/Layout/BackgroundLayoutComputer.swift rename Sources/TokamakCore/Fiber/{LayoutComputer.swift => Layout/Layout.swift} (52%) create mode 100644 Sources/TokamakCore/Fiber/Layout/LayoutProperties.swift create mode 100644 Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift create mode 100644 Sources/TokamakCore/Fiber/Layout/LayoutValueKey.swift rename Sources/TokamakCore/Fiber/Layout/{PaddingLayoutComputer.swift => PaddingLayout+Layout.swift} (100%) create mode 100644 Sources/TokamakCore/Fiber/Layout/ProposedViewSize.swift rename Sources/TokamakCore/Fiber/Layout/{StackLayoutComputer.swift => StackLayout.swift} (100%) create mode 100644 Sources/TokamakCore/Fiber/Layout/ViewSpacing.swift create mode 100644 Sources/TokamakCore/Fiber/Layout/_BackgroundLayout+Layout.swift rename Sources/TokamakCore/Fiber/Layout/{FrameLayoutComputer.swift => _FrameLayout+Layout.swift} (100%) create mode 100644 Sources/TokamakCore/Tokens/LayoutDirection.swift diff --git a/Sources/TokamakCore/Fiber/Layout/BackgroundLayoutComputer.swift b/Sources/TokamakCore/Fiber/Layout/BackgroundLayoutComputer.swift deleted file mode 100644 index 341ecacc3..000000000 --- a/Sources/TokamakCore/Fiber/Layout/BackgroundLayoutComputer.swift +++ /dev/null @@ -1,109 +0,0 @@ -// Copyright 2021 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 constrains a background to a foreground. -// final class BackgroundLayoutComputer: LayoutComputer { -// let proposedSize: CGSize -// let alignment: Alignment -// -// init(proposedSize: CGSize, alignment: Alignment) { -// self.proposedSize = proposedSize -// self.alignment = alignment -// } -// -// func proposeSize(for child: V, at index: Int, in context: LayoutContext) -> CGSize -// where V: View -// { -// if index == 0 { -// // The foreground can pick their size. -// return proposedSize -// } else { -// // The background is constrained to the foreground. -// return context.children.first?.dimensions.size ?? .zero -// } -// } -// -// func position(_ child: LayoutContext.Child, in context: LayoutContext) -> CGPoint { -// let foregroundSize = ViewDimensions( -// size: .init( -// width: context.children.first?.dimensions.width ?? 0, -// height: context.children.first?.dimensions.height ?? 0 -// ), -// alignmentGuides: [:] -// ) -// return .init( -// x: foregroundSize[alignment.horizontal] - child.dimensions[alignment.horizontal], -// y: foregroundSize[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: childSize.width, height: childSize.height) -// } -// } -// -// public extension _BackgroundLayout { -// static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { -// .init( -// inputs: inputs, -// layoutComputer: { -// BackgroundLayoutComputer(proposedSize: $0, alignment: inputs.content.alignment) -// } -// ) -// } -// } -// -// public extension _BackgroundStyleModifier { -// static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { -// .init( -// inputs: inputs, -// layoutComputer: { BackgroundLayoutComputer(proposedSize: $0, alignment: .center) } -// ) -// } -// } - -extension _BackgroundLayout: Layout { - public func sizeThatFits( - proposal: ProposedViewSize, - subviews: Subviews, - cache: inout () - ) -> CGSize { - subviews.first?.sizeThatFits(proposal) ?? .zero - } - - public func placeSubviews( - in bounds: CGRect, - proposal: ProposedViewSize, - subviews: Subviews, - cache: inout () - ) { - for subview in subviews { - subview.place( - at: bounds.origin, - proposal: .init(width: bounds.width, height: bounds.height) - ) - } - } -} diff --git a/Sources/TokamakCore/Fiber/LayoutComputer.swift b/Sources/TokamakCore/Fiber/Layout/Layout.swift similarity index 52% rename from Sources/TokamakCore/Fiber/LayoutComputer.swift rename to Sources/TokamakCore/Fiber/Layout/Layout.swift index 18a195662..69232ceff 100644 --- a/Sources/TokamakCore/Fiber/LayoutComputer.swift +++ b/Sources/TokamakCore/Fiber/Layout/Layout.swift @@ -28,18 +28,31 @@ public protocol Layout: Animatable, _AnyLayout { typealias Subviews = LayoutSubviews + /// Create a fresh `Cache`. Use it to store complex operations, + /// or to pass data between `sizeThatFits` and `placeSubviews`. + /// + /// - Note: There are no guarantees about when the cache will be recreated, + /// and the behavior could change at any time. func makeCache(subviews: Self.Subviews) -> Self.Cache + /// Update the existing `Cache` before each layout pass. func updateCache(_ cache: inout Self.Cache, subviews: Self.Subviews) + /// The preferred spacing for this `View` and its subviews. func spacing(subviews: Self.Subviews, cache: inout Self.Cache) -> ViewSpacing + /// Request a size to contain the subviews and fit within `proposal`. + /// If you provide a size that does not fit within `proposal`, the parent will still respect it. func sizeThatFits( proposal: ProposedViewSize, subviews: Self.Subviews, cache: inout Self.Cache ) -> CGSize + /// Place each subview with `LayoutSubview.place(at:anchor:proposal:)`. + /// + /// - Note: The bounds are not necessarily at `(0, 0)`, so use `bounds.minX` and `bounds.minY` + /// to correctly position relative to the container. func placeSubviews( in bounds: CGRect, proposal: ProposedViewSize, @@ -47,6 +60,7 @@ public protocol Layout: Animatable, _AnyLayout { cache: inout Self.Cache ) + /// Override the value of a `HorizontalAlignment` value. func explicitAlignment( of guide: HorizontalAlignment, in bounds: CGRect, @@ -55,6 +69,7 @@ public protocol Layout: Animatable, _AnyLayout { cache: inout Self.Cache ) -> CGFloat? + /// Override the value of a `VerticalAlignment` value. func explicitAlignment( of guide: VerticalAlignment, in bounds: CGRect, @@ -82,6 +97,11 @@ public extension Layout { } func updateCache(_ cache: inout Self.Cache, subviews: Self.Subviews) {} + + func spacing(subviews: Self.Subviews, cache: inout Self.Cache) -> ViewSpacing { + .init() + } + func explicitAlignment( of guide: HorizontalAlignment, in bounds: CGRect, @@ -101,238 +121,16 @@ public extension Layout { ) -> CGFloat? { nil } - - func spacing(subviews: Self.Subviews, cache: inout Self.Cache) -> ViewSpacing { - .init() - } -} - -public struct LayoutProperties { - public var stackOrientation: Axis? - - public init() { - stackOrientation = nil - } -} - -@frozen -public struct ProposedViewSize: Equatable { - public var width: CGFloat? - public var height: CGFloat? - public static let zero: ProposedViewSize = .init(width: 0, height: 0) - public static let unspecified: ProposedViewSize = .init(width: nil, height: nil) - public static let infinity: ProposedViewSize = .init(width: .infinity, height: .infinity) - @inlinable - public init(width: CGFloat?, height: CGFloat?) { - (self.width, self.height) = (width, height) - } - - @inlinable - public init(_ size: CGSize) { - self.init(width: size.width, height: size.height) - } - - @inlinable - public func replacingUnspecifiedDimensions(by size: CGSize = CGSize( - width: 10, - height: 10 - )) -> CGSize { - CGSize(width: width ?? size.width, height: height ?? size.height) - } -} - -public struct ViewSpacing { - private var top: CGFloat - private var leading: CGFloat - private var bottom: CGFloat - private var trailing: CGFloat - - public static let zero: ViewSpacing = .init() - - public init() { - top = 10 - leading = 10 - bottom = 10 - trailing = 10 - } - - public mutating func formUnion(_ other: ViewSpacing, edges: Edge.Set = .all) { - if edges.contains(.top) { - top = max(top, other.top) - } - if edges.contains(.leading) { - leading = max(leading, other.leading) - } - if edges.contains(.bottom) { - bottom = max(bottom, other.bottom) - } - if edges.contains(.trailing) { - trailing = max(trailing, other.trailing) - } - } - - public func union(_ other: ViewSpacing, edges: Edge.Set = .all) -> ViewSpacing { - var spacing = self - spacing.formUnion(other, edges: edges) - return spacing - } - - /// The smallest spacing that accommodates the preferences of `self` and `next`. - public func distance(to next: ViewSpacing, along axis: Axis) -> CGFloat { - // Assume `next` comes after `self` either horizontally or vertically. - switch axis { - case .horizontal: - return max(trailing, next.leading) - case .vertical: - return max(bottom, next.top) - } - } -} - -public struct LayoutSubviews: Equatable, RandomAccessCollection { - public var layoutDirection: LayoutDirection - var storage: [LayoutSubview] - - init(layoutDirection: LayoutDirection, storage: [LayoutSubview]) { - self.layoutDirection = layoutDirection - self.storage = storage - } - - init(_ node: FiberReconciler.Fiber) { - self.init( - layoutDirection: node.outputs.environment.environment.layoutDirection, - storage: [] - ) - } - - public typealias SubSequence = LayoutSubviews - public typealias Element = LayoutSubview - public typealias Index = Int - public typealias Indices = Range - public typealias Iterator = IndexingIterator - - public var startIndex: Int { - storage.startIndex - } - - public var endIndex: Int { - storage.endIndex - } - - public subscript(index: Int) -> LayoutSubviews.Element { - storage[index] - } - - public subscript(bounds: Range) -> LayoutSubviews { - .init(layoutDirection: layoutDirection, storage: .init(storage[bounds])) - } - - public subscript(indices: S) -> LayoutSubviews where S: Sequence, S.Element == Int { - .init( - layoutDirection: layoutDirection, - storage: storage.enumerated() - .filter { indices.contains($0.offset) } - .map(\.element) - ) - } -} - -public struct LayoutSubview: Equatable { - private let id: ObjectIdentifier - public static func == (lhs: LayoutSubview, rhs: LayoutSubview) -> Bool { - lhs.id == rhs.id - } - - private let sizeThatFits: (ProposedViewSize) -> CGSize - private let dimensions: (CGSize) -> ViewDimensions - private let place: (ViewDimensions, CGPoint, UnitPoint) -> () - - init( - id: ObjectIdentifier, - sizeThatFits: @escaping (ProposedViewSize) -> CGSize, - dimensions: @escaping (CGSize) -> ViewDimensions, - place: @escaping (ViewDimensions, CGPoint, UnitPoint) -> () - ) { - self.id = id - self.sizeThatFits = sizeThatFits - self.dimensions = dimensions - self.place = place - } - - public func _trait(key: K.Type) -> K.Value where K: _ViewTraitKey { - fatalError("Implement \(#function)") - } - - public subscript(key: K.Type) -> K.Value where K: LayoutValueKey { - fatalError("Implement \(#function)") - } - - public var priority: Double { -// fatalError("Implement \(#function)") - 0 - } - - public func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize { - sizeThatFits(proposal) - } - - public func dimensions(in proposal: ProposedViewSize) -> ViewDimensions { - dimensions(sizeThatFits(proposal)) - } - - public var spacing: ViewSpacing { - ViewSpacing() - } - - public func place( - at position: CGPoint, - anchor: UnitPoint = .topLeading, - proposal: ProposedViewSize - ) { - place(dimensions(in: proposal), position, anchor) - } -} - -public enum LayoutDirection: Hashable, CaseIterable { - case leftToRight - case rightToLeft -} - -extension EnvironmentValues { - private enum LayoutDirectionKey: EnvironmentKey { - static var defaultValue: LayoutDirection = .leftToRight - } - - public var layoutDirection: LayoutDirection { - get { self[LayoutDirectionKey.self] } - set { self[LayoutDirectionKey.self] = newValue } - } -} - -public protocol LayoutValueKey { - associatedtype Value - static var defaultValue: Self.Value { get } -} - -public extension View { - @inlinable - func layoutValue(key: K.Type, value: K.Value) -> some View where K: LayoutValueKey { - _trait(_LayoutTrait.self, value) - } -} - -public struct _LayoutTrait: _ViewTraitKey where K: LayoutValueKey { - public static var defaultValue: K.Value { - K.defaultValue - } } public extension Layout { + /// Render `content` using `self` as the layout container. func callAsFunction(@ViewBuilder _ content: () -> V) -> some View where V: View { LayoutView(layout: self, content: content()) } } +/// A `View` that renders its children with a `Layout`. @_spi(TokamakCore) public struct LayoutView: View, Layout { let layout: L @@ -398,6 +196,7 @@ public struct LayoutView: View, Layout { } } +/// A default `Layout` that fits to the first subview and places its children at its origin. struct DefaultLayout: Layout { func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { let size = subviews.first?.sizeThatFits(proposal) ?? .zero diff --git a/Sources/TokamakCore/Fiber/Layout/LayoutProperties.swift b/Sources/TokamakCore/Fiber/Layout/LayoutProperties.swift new file mode 100644 index 000000000..f0d545dce --- /dev/null +++ b/Sources/TokamakCore/Fiber/Layout/LayoutProperties.swift @@ -0,0 +1,25 @@ +// 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 6/20/22. +// + +/// Metadata about a `Layout`. +public struct LayoutProperties { + public var stackOrientation: Axis? + + public init() { + stackOrientation = nil + } +} diff --git a/Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift b/Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift new file mode 100644 index 000000000..4ea5df3d6 --- /dev/null +++ b/Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift @@ -0,0 +1,121 @@ +// 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 6/20/22. +// + +import Foundation + +public struct LayoutSubviews: Equatable, RandomAccessCollection { + public var layoutDirection: LayoutDirection + var storage: [LayoutSubview] + + init(layoutDirection: LayoutDirection, storage: [LayoutSubview]) { + self.layoutDirection = layoutDirection + self.storage = storage + } + + init(_ node: FiberReconciler.Fiber) { + self.init( + layoutDirection: node.outputs.environment.environment.layoutDirection, + storage: [] + ) + } + + public typealias SubSequence = LayoutSubviews + public typealias Element = LayoutSubview + public typealias Index = Int + public typealias Indices = Range + public typealias Iterator = IndexingIterator + + public var startIndex: Int { + storage.startIndex + } + + public var endIndex: Int { + storage.endIndex + } + + public subscript(index: Int) -> LayoutSubviews.Element { + storage[index] + } + + public subscript(bounds: Range) -> LayoutSubviews { + .init(layoutDirection: layoutDirection, storage: .init(storage[bounds])) + } + + public subscript(indices: S) -> LayoutSubviews where S: Sequence, S.Element == Int { + .init( + layoutDirection: layoutDirection, + storage: storage.enumerated() + .filter { indices.contains($0.offset) } + .map(\.element) + ) + } +} + +public struct LayoutSubview: Equatable { + private let id: ObjectIdentifier + public static func == (lhs: LayoutSubview, rhs: LayoutSubview) -> Bool { + lhs.id == rhs.id + } + + private let sizeThatFits: (ProposedViewSize) -> CGSize + private let dimensions: (CGSize) -> ViewDimensions + private let place: (ViewDimensions, CGPoint, UnitPoint) -> () + + init( + id: ObjectIdentifier, + sizeThatFits: @escaping (ProposedViewSize) -> CGSize, + dimensions: @escaping (CGSize) -> ViewDimensions, + place: @escaping (ViewDimensions, CGPoint, UnitPoint) -> () + ) { + self.id = id + self.sizeThatFits = sizeThatFits + self.dimensions = dimensions + self.place = place + } + + public func _trait(key: K.Type) -> K.Value where K: _ViewTraitKey { + fatalError("Implement \(#function)") + } + + public subscript(key: K.Type) -> K.Value where K: LayoutValueKey { + fatalError("Implement \(#function)") + } + + public var priority: Double { + 0 + } + + public func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize { + sizeThatFits(proposal) + } + + public func dimensions(in proposal: ProposedViewSize) -> ViewDimensions { + dimensions(sizeThatFits(proposal)) + } + + public var spacing: ViewSpacing { + ViewSpacing() + } + + public func place( + at position: CGPoint, + anchor: UnitPoint = .topLeading, + proposal: ProposedViewSize + ) { + place(dimensions(in: proposal), position, anchor) + } +} diff --git a/Sources/TokamakCore/Fiber/Layout/LayoutValueKey.swift b/Sources/TokamakCore/Fiber/Layout/LayoutValueKey.swift new file mode 100644 index 000000000..5c334ad3c --- /dev/null +++ b/Sources/TokamakCore/Fiber/Layout/LayoutValueKey.swift @@ -0,0 +1,36 @@ +// 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 6/20/22. +// + +/// A key that stores a value that can be accessed via a `LayoutSubview`. +public protocol LayoutValueKey { + associatedtype Value + static var defaultValue: Self.Value { get } +} + +public extension View { + @inlinable + func layoutValue(key: K.Type, value: K.Value) -> some View where K: LayoutValueKey { + // LayoutValueKey uses trait keys under the hood. + _trait(_LayoutTrait.self, value) + } +} + +public struct _LayoutTrait: _ViewTraitKey where K: LayoutValueKey { + public static var defaultValue: K.Value { + K.defaultValue + } +} diff --git a/Sources/TokamakCore/Fiber/Layout/PaddingLayoutComputer.swift b/Sources/TokamakCore/Fiber/Layout/PaddingLayout+Layout.swift similarity index 100% rename from Sources/TokamakCore/Fiber/Layout/PaddingLayoutComputer.swift rename to Sources/TokamakCore/Fiber/Layout/PaddingLayout+Layout.swift diff --git a/Sources/TokamakCore/Fiber/Layout/ProposedViewSize.swift b/Sources/TokamakCore/Fiber/Layout/ProposedViewSize.swift new file mode 100644 index 000000000..22798c911 --- /dev/null +++ b/Sources/TokamakCore/Fiber/Layout/ProposedViewSize.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 6/20/22. +// + +import Foundation + +@frozen +public struct ProposedViewSize: Equatable { + public var width: CGFloat? + public var height: CGFloat? + public static let zero: ProposedViewSize = .init(width: 0, height: 0) + public static let unspecified: ProposedViewSize = .init(width: nil, height: nil) + public static let infinity: ProposedViewSize = .init(width: .infinity, height: .infinity) + @inlinable + public init(width: CGFloat?, height: CGFloat?) { + (self.width, self.height) = (width, height) + } + + @inlinable + public init(_ size: CGSize) { + self.init(width: size.width, height: size.height) + } + + @inlinable + public func replacingUnspecifiedDimensions(by size: CGSize = CGSize( + width: 10, + height: 10 + )) -> CGSize { + CGSize(width: width ?? size.width, height: height ?? size.height) + } +} diff --git a/Sources/TokamakCore/Fiber/Layout/StackLayoutComputer.swift b/Sources/TokamakCore/Fiber/Layout/StackLayout.swift similarity index 100% rename from Sources/TokamakCore/Fiber/Layout/StackLayoutComputer.swift rename to Sources/TokamakCore/Fiber/Layout/StackLayout.swift diff --git a/Sources/TokamakCore/Fiber/Layout/ViewSpacing.swift b/Sources/TokamakCore/Fiber/Layout/ViewSpacing.swift new file mode 100644 index 000000000..9e6e5e09e --- /dev/null +++ b/Sources/TokamakCore/Fiber/Layout/ViewSpacing.swift @@ -0,0 +1,71 @@ +// 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 6/20/22. +// + +import Foundation + +/// The preferred spacing around a `View`. +/// +/// When computing spacing in a custom `Layout`, use `distance(to:along:)` +/// to find the smallest spacing needed to accommodate the preferences +/// of the `View`s you are aligning. +public struct ViewSpacing { + private var top: CGFloat + private var leading: CGFloat + private var bottom: CGFloat + private var trailing: CGFloat + + public static let zero: ViewSpacing = .init() + + public init() { + top = 10 + leading = 10 + bottom = 10 + trailing = 10 + } + + public mutating func formUnion(_ other: ViewSpacing, edges: Edge.Set = .all) { + if edges.contains(.top) { + top = max(top, other.top) + } + if edges.contains(.leading) { + leading = max(leading, other.leading) + } + if edges.contains(.bottom) { + bottom = max(bottom, other.bottom) + } + if edges.contains(.trailing) { + trailing = max(trailing, other.trailing) + } + } + + public func union(_ other: ViewSpacing, edges: Edge.Set = .all) -> ViewSpacing { + var spacing = self + spacing.formUnion(other, edges: edges) + return spacing + } + + /// The smallest spacing that accommodates the preferences of `self` and `next`. + public func distance(to next: ViewSpacing, along axis: Axis) -> CGFloat { + // Assume `next` comes after `self` either horizontally or vertically. + switch axis { + case .horizontal: + return max(trailing, next.leading) + case .vertical: + return max(bottom, next.top) + } + } +} diff --git a/Sources/TokamakCore/Fiber/Layout/_BackgroundLayout+Layout.swift b/Sources/TokamakCore/Fiber/Layout/_BackgroundLayout+Layout.swift new file mode 100644 index 000000000..a6512fa32 --- /dev/null +++ b/Sources/TokamakCore/Fiber/Layout/_BackgroundLayout+Layout.swift @@ -0,0 +1,42 @@ +// 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 + +extension _BackgroundLayout: Layout { + public func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) -> CGSize { + subviews.first?.sizeThatFits(proposal) ?? .zero + } + + public func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + for subview in subviews { + subview.place( + at: bounds.origin, + proposal: .init(width: bounds.width, height: bounds.height) + ) + } + } +} diff --git a/Sources/TokamakCore/Fiber/Layout/FrameLayoutComputer.swift b/Sources/TokamakCore/Fiber/Layout/_FrameLayout+Layout.swift similarity index 100% rename from Sources/TokamakCore/Fiber/Layout/FrameLayoutComputer.swift rename to Sources/TokamakCore/Fiber/Layout/_FrameLayout+Layout.swift diff --git a/Sources/TokamakCore/Tokens/LayoutDirection.swift b/Sources/TokamakCore/Tokens/LayoutDirection.swift new file mode 100644 index 000000000..dd1017be6 --- /dev/null +++ b/Sources/TokamakCore/Tokens/LayoutDirection.swift @@ -0,0 +1,32 @@ +// 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 6/20/22. +// + +public enum LayoutDirection: Hashable, CaseIterable { + case leftToRight + case rightToLeft +} + +extension EnvironmentValues { + private enum LayoutDirectionKey: EnvironmentKey { + static var defaultValue: LayoutDirection = .leftToRight + } + + public var layoutDirection: LayoutDirection { + get { self[LayoutDirectionKey.self] } + set { self[LayoutDirectionKey.self] = newValue } + } +} From 9e8c1b9f5fa9beb3917b0fd4839712d251faa7b0 Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Mon, 20 Jun 2022 16:46:50 -0400 Subject: [PATCH 12/38] Further cleanup --- Sources/TokamakCore/Fiber/Layout/Layout.swift | 5 +++++ Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift | 8 ++++++++ .../Fiber/{ => Passes}/FiberReconcilerPass.swift | 0 Sources/TokamakCore/Fiber/{ => Passes}/LayoutPass.swift | 0 .../TokamakCore/Fiber/{ => Passes}/ReconcilePass.swift | 0 5 files changed, 13 insertions(+) rename Sources/TokamakCore/Fiber/{ => Passes}/FiberReconcilerPass.swift (100%) rename Sources/TokamakCore/Fiber/{ => Passes}/LayoutPass.swift (100%) rename Sources/TokamakCore/Fiber/{ => Passes}/ReconcilePass.swift (100%) diff --git a/Sources/TokamakCore/Fiber/Layout/Layout.swift b/Sources/TokamakCore/Fiber/Layout/Layout.swift index 69232ceff..c7ffeb031 100644 --- a/Sources/TokamakCore/Fiber/Layout/Layout.swift +++ b/Sources/TokamakCore/Fiber/Layout/Layout.swift @@ -21,11 +21,16 @@ public protocol _AnyLayout { func _makeActions() -> LayoutActions } +/// A type that participates in the layout pass. +/// +/// Any `View` or `Scene` that implements this protocol will be used to computed layout in +/// a `FiberRenderer` with `useDynamicLayout` enabled. public protocol Layout: Animatable, _AnyLayout { static var layoutProperties: LayoutProperties { get } associatedtype Cache = () + /// Proxies for the children of this container. typealias Subviews = LayoutSubviews /// Create a fresh `Cache`. Use it to store complex operations, diff --git a/Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift b/Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift index 4ea5df3d6..19be58cd9 100644 --- a/Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift +++ b/Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift @@ -17,6 +17,7 @@ import Foundation +/// A collection of `LayoutSubview` proxies. public struct LayoutSubviews: Equatable, RandomAccessCollection { public var layoutDirection: LayoutDirection var storage: [LayoutSubview] @@ -65,6 +66,13 @@ public struct LayoutSubviews: Equatable, RandomAccessCollection { } } +/// A proxy representing a child of a `Layout`. +/// +/// Access size requests, alignment guide values, spacing preferences, and any layout values using +/// this proxy. +/// +/// `Layout` types are expected to call `place(at:anchor:proposal:)` on all subviews. +/// If `place(at:anchor:proposal:)` is not called, the center will be used as its position. public struct LayoutSubview: Equatable { private let id: ObjectIdentifier public static func == (lhs: LayoutSubview, rhs: LayoutSubview) -> Bool { diff --git a/Sources/TokamakCore/Fiber/FiberReconcilerPass.swift b/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift similarity index 100% rename from Sources/TokamakCore/Fiber/FiberReconcilerPass.swift rename to Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift diff --git a/Sources/TokamakCore/Fiber/LayoutPass.swift b/Sources/TokamakCore/Fiber/Passes/LayoutPass.swift similarity index 100% rename from Sources/TokamakCore/Fiber/LayoutPass.swift rename to Sources/TokamakCore/Fiber/Passes/LayoutPass.swift diff --git a/Sources/TokamakCore/Fiber/ReconcilePass.swift b/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift similarity index 100% rename from Sources/TokamakCore/Fiber/ReconcilePass.swift rename to Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift From 876c6056413af2f658742e4f7ed6d5d1db777965 Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Mon, 20 Jun 2022 19:03:55 -0400 Subject: [PATCH 13/38] Add ContainedZLayout --- .../Fiber/Layout/ContainedZLayout.swift | 136 ++++++++++++++++++ .../Layout/_BackgroundLayout+Layout.swift | 42 ------ .../Modifiers/StyleModifiers.swift | 8 +- 3 files changed, 142 insertions(+), 44 deletions(-) create mode 100644 Sources/TokamakCore/Fiber/Layout/ContainedZLayout.swift delete mode 100644 Sources/TokamakCore/Fiber/Layout/_BackgroundLayout+Layout.swift diff --git a/Sources/TokamakCore/Fiber/Layout/ContainedZLayout.swift b/Sources/TokamakCore/Fiber/Layout/ContainedZLayout.swift new file mode 100644 index 000000000..7d78cf77a --- /dev/null +++ b/Sources/TokamakCore/Fiber/Layout/ContainedZLayout.swift @@ -0,0 +1,136 @@ +// 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 6/20/22. +// + +import Foundation + +/// The cache for a `ContainedZLayout`. +@_spi(TokamakCore) +public struct ContainedZLayoutCache { + /// The result of `dimensions(in:)` for the primary subview. + var primaryDimensions: ViewDimensions? +} + +/// A layout that fits secondary subviews to the size of a primary subview. +/// +/// Used to implement `_BackgroundLayout` and `_OverlayLayout`. +@_spi(TokamakCore) +public protocol ContainedZLayout: Layout where Cache == ContainedZLayoutCache { + var alignment: Alignment { get } + /// An accessor for the primary subview from a `LayoutSubviews` collection. + static var primarySubview: KeyPath { get } +} + +@_spi(TokamakCore) +public extension ContainedZLayout { + func makeCache(subviews: Subviews) -> Cache { + .init() + } + + func updateCache(_ cache: inout Cache, subviews: Subviews) { + cache.primaryDimensions = nil + } + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) -> CGSize { + // Assume the dimensions of the primary subview. + cache.primaryDimensions = subviews[keyPath: Self.primarySubview]?.dimensions(in: proposal) + return .init( + width: cache.primaryDimensions?.width ?? .zero, + height: cache.primaryDimensions?.height ?? .zero + ) + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) { + let proposal = ProposedViewSize(bounds.size) + + // Place the foreground at the origin. + subviews[keyPath: Self.primarySubview]?.place(at: bounds.origin, proposal: proposal) + + let backgroundSubviews = subviews[keyPath: Self.primarySubview] == subviews.first + ? subviews.dropFirst(1) + : subviews.dropLast(1) + + /// The `ViewDimensions` of the subview with the greatest `width`, used to follow `alignment`. + var widest: ViewDimensions? + /// The `ViewDimensions` of the subview with the greatest `height`. + var tallest: ViewDimensions? + + let dimensions = backgroundSubviews.map { subview -> ViewDimensions in + let dimensions = subview.dimensions(in: proposal) + if dimensions.width > (widest?.width ?? .zero) { + widest = dimensions + } + if dimensions.height > (tallest?.height ?? .zero) { + tallest = dimensions + } + return dimensions + } + + /// The alignment guide values of the primary subview. + let primaryOffset = CGSize( + width: cache.primaryDimensions?[alignment.horizontal] ?? .zero, + height: cache.primaryDimensions?[alignment.vertical] ?? .zero + ) + /// The alignment guide values of the secondary subviews (background/overlay). + /// Uses the widest/tallest element to get the full extents. + let secondaryOffset = CGSize( + width: widest?[alignment.horizontal] ?? .zero, + height: tallest?[alignment.vertical] ?? .zero + ) + /// The center offset of the secondary subviews. + let secondaryCenter = CGSize( + width: widest?[HorizontalAlignment.center] ?? .zero, + height: tallest?[VerticalAlignment.center] ?? .zero + ) + /// The origin of the secondary subviews with alignment. + let secondaryOrigin = CGPoint( + x: bounds.minX + primaryOffset.width - secondaryOffset.width + secondaryCenter.width, + y: bounds.minY + primaryOffset.height - secondaryOffset.height + secondaryCenter.height + ) + for (index, subview) in backgroundSubviews.enumerated() { + // Background elements are centered between each other, but placed with `alignment` + // all together on the foreground. + subview.place( + at: .init( + x: secondaryOrigin.x - dimensions[index][HorizontalAlignment.center], + y: secondaryOrigin.y - dimensions[index][VerticalAlignment.center] + ), + proposal: proposal + ) + } + } +} + +/// Expects the primary subview to be last. +@_spi(TokamakCore) +extension _BackgroundLayout: ContainedZLayout { + public static var primarySubview: KeyPath { \.last } +} + +/// Expects the primary subview to be the first. +@_spi(TokamakCore) +extension _OverlayLayout: ContainedZLayout { + public static var primarySubview: KeyPath { \.first } +} diff --git a/Sources/TokamakCore/Fiber/Layout/_BackgroundLayout+Layout.swift b/Sources/TokamakCore/Fiber/Layout/_BackgroundLayout+Layout.swift deleted file mode 100644 index a6512fa32..000000000 --- a/Sources/TokamakCore/Fiber/Layout/_BackgroundLayout+Layout.swift +++ /dev/null @@ -1,42 +0,0 @@ -// 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 - -extension _BackgroundLayout: Layout { - public func sizeThatFits( - proposal: ProposedViewSize, - subviews: Subviews, - cache: inout () - ) -> CGSize { - subviews.first?.sizeThatFits(proposal) ?? .zero - } - - public func placeSubviews( - in bounds: CGRect, - proposal: ProposedViewSize, - subviews: Subviews, - cache: inout () - ) { - for subview in subviews { - subview.place( - at: bounds.origin, - proposal: .init(width: bounds.width, height: bounds.height) - ) - } - } -} diff --git a/Sources/TokamakCore/Modifiers/StyleModifiers.swift b/Sources/TokamakCore/Modifiers/StyleModifiers.swift index 46433bec4..4da9296e4 100644 --- a/Sources/TokamakCore/Modifiers/StyleModifiers.swift +++ b/Sources/TokamakCore/Modifiers/StyleModifiers.swift @@ -33,9 +33,8 @@ public struct _BackgroundLayout: _PrimitiveView } public func _visitChildren(_ visitor: V) where V: ViewVisitor { - // Visit the content first so it can request its size before laying out the background - visitor.visit(content) visitor.visit(background) + visitor.visit(content) } } @@ -143,6 +142,11 @@ public struct _OverlayLayout: _PrimitiveView public let content: Content public let overlay: Overlay public let alignment: Alignment + + public func _visitChildren(_ visitor: V) where V: ViewVisitor { + visitor.visit(content) + visitor.visit(overlay) + } } public struct _OverlayModifier: ViewModifier, EnvironmentReader From 86a13df4a1b38d9421a8c50aee1ce552c985190c Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Mon, 20 Jun 2022 20:51:22 -0400 Subject: [PATCH 14/38] Add frame layouts --- .../Layout/_FlexFrameLayout+Layout.swift | 132 ++++++++++++++++++ .../Fiber/Layout/_FrameLayout+Layout.swift | 77 +++++++++- .../Modifiers/LayoutModifiers.swift | 92 ++++++++---- 3 files changed, 272 insertions(+), 29 deletions(-) create mode 100644 Sources/TokamakCore/Fiber/Layout/_FlexFrameLayout+Layout.swift diff --git a/Sources/TokamakCore/Fiber/Layout/_FlexFrameLayout+Layout.swift b/Sources/TokamakCore/Fiber/Layout/_FlexFrameLayout+Layout.swift new file mode 100644 index 000000000..c16b3fc6a --- /dev/null +++ b/Sources/TokamakCore/Fiber/Layout/_FlexFrameLayout+Layout.swift @@ -0,0 +1,132 @@ +// 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 6/20/22. +// + +import Foundation + +private struct FlexFrameLayout: Layout { + let minWidth: CGFloat? + let idealWidth: CGFloat? + let maxWidth: CGFloat? + let minHeight: CGFloat? + let idealHeight: CGFloat? + let maxHeight: CGFloat? + let alignment: Alignment + + struct Cache { + var dimensions = [ViewDimensions]() + } + + func makeCache(subviews: Subviews) -> Cache { + .init() + } + + func updateCache(_ cache: inout Cache, subviews: Subviews) { + cache.dimensions.removeAll() + } + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) -> CGSize { + let bounds = CGSize( + width: min( + max(minWidth ?? .zero, proposal.width ?? idealWidth ?? .zero), + maxWidth ?? CGFloat.infinity + ), + height: min( + max(minHeight ?? .zero, proposal.height ?? idealHeight ?? .zero), + maxHeight ?? CGFloat.infinity + ) + ) + let proposal = ProposedViewSize(bounds) + + var subviewSizes = CGSize.zero + cache.dimensions = subviews.map { subview -> ViewDimensions in + let dimensions = subview.dimensions(in: proposal) + if dimensions.width > subviewSizes.width { + subviewSizes.width = dimensions.width + } + if dimensions.height > subviewSizes.height { + subviewSizes.height = dimensions.height + } + return dimensions + } + + var size = CGSize.zero + if let minWidth = minWidth, + bounds.width < subviewSizes.width + { + size.width = max(bounds.width, minWidth) + } else if let maxWidth = maxWidth, + bounds.width > subviewSizes.width + { + size.width = min(bounds.width, maxWidth) + } else { + size.width = subviewSizes.width + } + if let minHeight = minHeight, + bounds.height < subviewSizes.height + { + size.height = max(bounds.height, minHeight) + } else if let maxHeight = maxHeight, + bounds.height > subviewSizes.height + { + size.height = min(bounds.height, maxHeight) + } else { + size.height = subviewSizes.height + } + return size + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) { + let proposal = ProposedViewSize(bounds.size) + let frameDimensions = ViewDimensions( + size: .init(width: bounds.width, height: bounds.height), + alignmentGuides: [:] + ) + + for (index, subview) in subviews.enumerated() { + subview.place( + at: .init( + x: bounds.minX + frameDimensions[alignment.horizontal] + - cache.dimensions[index][alignment.horizontal], + y: bounds.minY + frameDimensions[alignment.vertical] + - cache.dimensions[index][alignment.vertical] + ), + proposal: proposal + ) + } + } +} + +public extension _FlexFrameLayout { + func _visitChildren(_ visitor: V, content: Content) where V: ViewVisitor { + visitor.visit(FlexFrameLayout( + minWidth: minWidth, idealWidth: idealWidth, maxWidth: maxWidth, + minHeight: minHeight, idealHeight: idealHeight, maxHeight: maxHeight, + alignment: alignment + ).callAsFunction { + content + }) + } +} diff --git a/Sources/TokamakCore/Fiber/Layout/_FrameLayout+Layout.swift b/Sources/TokamakCore/Fiber/Layout/_FrameLayout+Layout.swift index b349dbf3d..a01e8fab9 100644 --- a/Sources/TokamakCore/Fiber/Layout/_FrameLayout+Layout.swift +++ b/Sources/TokamakCore/Fiber/Layout/_FrameLayout+Layout.swift @@ -17,4 +17,79 @@ import Foundation -extension _FrameLayout {} +private struct FrameLayout: Layout { + let width: CGFloat? + let height: CGFloat? + let alignment: Alignment + + struct Cache { + var dimensions = [ViewDimensions]() + } + + func makeCache(subviews: Subviews) -> Cache { + .init() + } + + func updateCache(_ cache: inout Cache, subviews: Subviews) { + cache.dimensions.removeAll() + } + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) -> CGSize { + var size = CGSize.zero + let proposal = ProposedViewSize( + width: width ?? proposal.width, + height: height ?? proposal.height + ) + cache.dimensions = subviews.map { subview -> ViewDimensions in + let dimensions = subview.dimensions(in: proposal) + if dimensions.width > size.width { + size.width = dimensions.width + } + if dimensions.height > size.height { + size.height = dimensions.height + } + return dimensions + } + return .init( + width: width ?? size.width, + height: height ?? size.height + ) + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) { + let proposal = ProposedViewSize(bounds.size) + let frameDimensions = ViewDimensions( + size: .init(width: bounds.width, height: bounds.height), + alignmentGuides: [:] + ) + + for (index, subview) in subviews.enumerated() { + subview.place( + at: .init( + x: bounds.minX + frameDimensions[alignment.horizontal] + - cache.dimensions[index][alignment.horizontal], + y: bounds.minY + frameDimensions[alignment.vertical] + - cache.dimensions[index][alignment.vertical] + ), + proposal: proposal + ) + } + } +} + +public extension _FrameLayout { + func _visitChildren(_ visitor: V, content: Content) where V: ViewVisitor { + visitor.visit(FrameLayout(width: width, height: height, alignment: alignment).callAsFunction { + content + }) + } +} diff --git a/Sources/TokamakStaticHTML/Modifiers/LayoutModifiers.swift b/Sources/TokamakStaticHTML/Modifiers/LayoutModifiers.swift index bc810d8db..84c119a19 100644 --- a/Sources/TokamakStaticHTML/Modifiers/LayoutModifiers.swift +++ b/Sources/TokamakStaticHTML/Modifiers/LayoutModifiers.swift @@ -77,9 +77,7 @@ extension _FrameLayout: DOMViewModifier { extension _FrameLayout: HTMLConvertible { public var tag: String { "div" } public func attributes(useDynamicLayout: Bool) -> [HTMLAttribute: String] { - guard !useDynamicLayout else { return [ - "style": "overflow: hidden;", - ] } + guard !useDynamicLayout else { return [:] } return attributes } } @@ -106,6 +104,15 @@ extension _FlexFrameLayout: DOMViewModifier { } } +@_spi(TokamakStaticHTML) +extension _FlexFrameLayout: HTMLConvertible { + public var tag: String { "div" } + public func attributes(useDynamicLayout: Bool) -> [HTMLAttribute: String] { + guard !useDynamicLayout else { return [:] } + return attributes + } +} + private extension Edge { var cssValue: String { switch self { @@ -221,32 +228,26 @@ extension _BackgroundLayout: HTMLConvertible { } public func primitiveVisitor(useDynamicLayout: Bool) -> ((V) -> ())? where V: ViewVisitor { - if useDynamicLayout { - return { - $0.visit(HTML("div", ["style": "z-index: 1;"]) { content }) - $0.visit(background) - } - } else { - return { - $0.visit(HTML( - "div", - ["style": """ - display: flex; - justify-content: \(alignment.horizontal.flexAlignment); - align-items: \(alignment.vertical.flexAlignment); - grid-area: a; + guard !useDynamicLayout else { return nil } + return { + $0.visit(HTML( + "div", + ["style": """ + display: flex; + justify-content: \(alignment.horizontal.flexAlignment); + align-items: \(alignment.vertical.flexAlignment); + grid-area: a; - width: 0; min-width: 100%; - height: 0; min-height: 100%; - overflow: hidden; - """] - ) { - background - }) - $0.visit(HTML("div", ["style": "grid-area: a;"]) { - content - }) - } + width: 0; min-width: 100%; + height: 0; min-height: 100%; + overflow: hidden; + """] + ) { + background + }) + $0.visit(HTML("div", ["style": "grid-area: a;"]) { + content + }) } } } @@ -280,3 +281,38 @@ extension _OverlayLayout: _HTMLPrimitive { ) } } + +@_spi(TokamakStaticHTML) +extension _OverlayLayout: HTMLConvertible { + public var tag: String { "div" } + public func attributes(useDynamicLayout: Bool) -> [HTMLAttribute: String] { + guard !useDynamicLayout else { return [:] } + return ["style": "display: inline-grid; grid-template-columns: auto auto;"] + } + + public func primitiveVisitor(useDynamicLayout: Bool) -> ((V) -> ())? where V: ViewVisitor { + guard !useDynamicLayout else { return nil } + return { + $0.visit(HTML("div", ["style": "grid-area: a;"]) { + content + }) + $0.visit( + HTML( + "div", + ["style": """ + display: flex; + justify-content: \(alignment.horizontal.flexAlignment); + align-items: \(alignment.vertical.flexAlignment); + grid-area: a; + + width: 0; min-width: 100%; + height: 0; min-height: 100%; + overflow: hidden; + """] + ) { + overlay + } + ) + } + } +} From 85dc54595ed81bf76e4d6ab38edbbd8a2a2d3108 Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Mon, 20 Jun 2022 22:56:19 -0400 Subject: [PATCH 15/38] Add snapshots tests that compare against native SwiftUI --- .../TokamakCore/Fiber/FiberReconciler.swift | 6 +- Sources/TokamakCore/Fiber/FiberRenderer.swift | 2 + .../Fiber/Layout/ViewSpacing.swift | 8 +- Sources/TokamakStaticHTML/Core.swift | 5 + .../StaticHTMLFiberRenderer.swift | 45 ++++++- .../Alignment+allCases.swift | 33 +++++ .../ContainedZLayoutTests.swift | 89 ++++++++++++ Tests/TokamakLayoutTests/FrameTests.swift | 127 ++++++++++++++++++ Tests/TokamakLayoutTests/StackTests.swift | 116 ++++++++++++++++ Tests/TokamakLayoutTests/compare.swift | 115 ++++++++++++++++ 10 files changed, 536 insertions(+), 10 deletions(-) create mode 100644 Tests/TokamakLayoutTests/Alignment+allCases.swift create mode 100644 Tests/TokamakLayoutTests/ContainedZLayoutTests.swift create mode 100644 Tests/TokamakLayoutTests/FrameTests.swift create mode 100644 Tests/TokamakLayoutTests/StackTests.swift create mode 100644 Tests/TokamakLayoutTests/compare.swift diff --git a/Sources/TokamakCore/Fiber/FiberReconciler.swift b/Sources/TokamakCore/Fiber/FiberReconciler.swift index d8269616e..eca419c8b 100644 --- a/Sources/TokamakCore/Fiber/FiberReconciler.swift +++ b/Sources/TokamakCore/Fiber/FiberReconciler.swift @@ -46,8 +46,10 @@ public final class FiberReconciler { } var body: some View { - content - .environmentValues(environment) + RootLayout(renderer: renderer).callAsFunction { + content + .environmentValues(environment) + } } } diff --git a/Sources/TokamakCore/Fiber/FiberRenderer.swift b/Sources/TokamakCore/Fiber/FiberRenderer.swift index f6c569843..72ae8af26 100644 --- a/Sources/TokamakCore/Fiber/FiberRenderer.swift +++ b/Sources/TokamakCore/Fiber/FiberRenderer.swift @@ -62,11 +62,13 @@ public extension FiberRenderer { } @discardableResult + @_disfavoredOverload func render(_ view: V) -> FiberReconciler { .init(self, view) } @discardableResult + @_disfavoredOverload func render(_ app: A) -> FiberReconciler { .init(self, app) } diff --git a/Sources/TokamakCore/Fiber/Layout/ViewSpacing.swift b/Sources/TokamakCore/Fiber/Layout/ViewSpacing.swift index 9e6e5e09e..90aca1db9 100644 --- a/Sources/TokamakCore/Fiber/Layout/ViewSpacing.swift +++ b/Sources/TokamakCore/Fiber/Layout/ViewSpacing.swift @@ -31,10 +31,10 @@ public struct ViewSpacing { public static let zero: ViewSpacing = .init() public init() { - top = 10 - leading = 10 - bottom = 10 - trailing = 10 + top = 8 + leading = 8 + bottom = 8 + trailing = 8 } public mutating func formUnion(_ other: ViewSpacing, edges: Edge.Set = .all) { diff --git a/Sources/TokamakStaticHTML/Core.swift b/Sources/TokamakStaticHTML/Core.swift index 77134a1e5..9bb6af377 100644 --- a/Sources/TokamakStaticHTML/Core.swift +++ b/Sources/TokamakStaticHTML/Core.swift @@ -64,6 +64,11 @@ public typealias AngularGradient = TokamakCore.AngularGradient public typealias Color = TokamakCore.Color public typealias Font = TokamakCore.Font +public typealias Alignment = TokamakCore.Alignment +public typealias AlignmentID = TokamakCore.AlignmentID +public typealias HorizontalAlignment = TokamakCore.HorizontalAlignment +public typealias VerticalAlignment = TokamakCore.VerticalAlignment + #if !canImport(CoreGraphics) public typealias CGAffineTransform = TokamakCore.CGAffineTransform #endif diff --git a/Sources/TokamakStaticHTML/StaticHTMLFiberRenderer.swift b/Sources/TokamakStaticHTML/StaticHTMLFiberRenderer.swift index f9b5eb66e..ccd9e02b2 100644 --- a/Sources/TokamakStaticHTML/StaticHTMLFiberRenderer.swift +++ b/Sources/TokamakStaticHTML/StaticHTMLFiberRenderer.swift @@ -161,11 +161,22 @@ extension LayoutView: HTMLConvertible { public struct StaticHTMLFiberRenderer: FiberRenderer { public let rootElement: HTMLElement public let defaultEnvironment: EnvironmentValues - public let sceneSize: CGSize = .zero - public let useDynamicLayout: Bool = false + public let sceneSize: CGSize + public let useDynamicLayout: Bool public init() { - rootElement = .init(tag: "body", attributes: [:], innerHTML: nil, children: []) + self.init(useDynamicLayout: false, sceneSize: .zero) + } + + /// An internal initializer used for testing dynamic layout with the `StaticHTMLFiberRenderer`. + /// Normal use cases for dynamic layout require `TokamakDOM`. + @_spi(TokamakStaticHTML) + public init(useDynamicLayout: Bool, sceneSize: CGSize) { + self.useDynamicLayout = useDynamicLayout + self.sceneSize = sceneSize + rootElement = .init( + tag: "body", attributes: ["style": "margin: 0;"], innerHTML: nil, children: [] + ) var environment = EnvironmentValues() environment[_ColorSchemeKey.self] = .light defaultEnvironment = environment @@ -196,7 +207,13 @@ public struct StaticHTMLFiberRenderer: FiberRenderer { case let .update(previous, newContent, _): previous.update(with: newContent) case let .layout(element, data): - print("Received layout message \(data) for \(element)") + element.content.attributes["style", default: ""] += """ + position: absolute; + left: \(data.origin.x)px; + top: \(data.origin.y)px; + width: \(data.dimensions.width)px; + height: \(data.dimensions.height)px; + """ } } } @@ -208,4 +225,24 @@ public struct StaticHTMLFiberRenderer: FiberRenderer { ) -> CGSize { .zero } + + public func render(_ app: A) -> String { + _ = FiberReconciler(self, app) + return """ + + + \(rootElement.description) + + """ + } + + public func render(_ view: V) -> String { + _ = FiberReconciler(self, view) + return """ + + + \(rootElement.description) + + """ + } } diff --git a/Tests/TokamakLayoutTests/Alignment+allCases.swift b/Tests/TokamakLayoutTests/Alignment+allCases.swift new file mode 100644 index 000000000..6a96c177d --- /dev/null +++ b/Tests/TokamakLayoutTests/Alignment+allCases.swift @@ -0,0 +1,33 @@ +// 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 6/20/22. +// + +#if os(macOS) +import SwiftUI +import TokamakStaticHTML + +extension SwiftUI.HorizontalAlignment { + static var allCases: [(SwiftUI.HorizontalAlignment, TokamakStaticHTML.HorizontalAlignment)] { + [(.leading, .leading), (.center, .center), (.trailing, .trailing)] + } +} + +extension SwiftUI.VerticalAlignment { + static var allCases: [(SwiftUI.VerticalAlignment, TokamakStaticHTML.VerticalAlignment)] { + [(.top, .top), (.center, .center), (.bottom, .bottom)] + } +} +#endif diff --git a/Tests/TokamakLayoutTests/ContainedZLayoutTests.swift b/Tests/TokamakLayoutTests/ContainedZLayoutTests.swift new file mode 100644 index 000000000..14638cbe1 --- /dev/null +++ b/Tests/TokamakLayoutTests/ContainedZLayoutTests.swift @@ -0,0 +1,89 @@ +// 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 6/20/22. +// + +#if os(macOS) +import SwiftUI +import TokamakStaticHTML +import XCTest + +@available(macOS 12.0, *) +final class ContainedZLayoutTests: XCTestCase { + func testBackground() async { + for (nativeHorizontal, tokamakHorizontal) in SwiftUI.HorizontalAlignment.allCases { + for (nativeVertical, tokamakVertical) in SwiftUI.VerticalAlignment.allCases { + await compare(size: .init(width: 500, height: 500)) { + SwiftUI.Rectangle() + .fill(SwiftUI.Color(white: 127 / 255)) + .frame(width: 100, height: 100) + .background(alignment: .init( + horizontal: nativeHorizontal, + vertical: nativeVertical + )) { + SwiftUI.Rectangle() + .fill(SwiftUI.Color(white: 0)) + .frame(width: 50, height: 50) + } + } to: { + TokamakStaticHTML.Rectangle() + .fill(TokamakStaticHTML.Color(white: 127 / 255)) + .frame(width: 100, height: 100) + .background(alignment: .init( + horizontal: tokamakHorizontal, + vertical: tokamakVertical + )) { + TokamakStaticHTML.Rectangle() + .fill(TokamakStaticHTML.Color(white: 0)) + .frame(width: 50, height: 50) + } + } + } + } + } + + func testOverlay() async { + for (nativeHorizontal, tokamakHorizontal) in SwiftUI.HorizontalAlignment.allCases { + for (nativeVertical, tokamakVertical) in SwiftUI.VerticalAlignment.allCases { + await compare(size: .init(width: 500, height: 500)) { + SwiftUI.Rectangle() + .fill(SwiftUI.Color(white: 127 / 255)) + .frame(width: 100, height: 100) + .overlay(alignment: .init( + horizontal: nativeHorizontal, + vertical: nativeVertical + )) { + SwiftUI.Rectangle() + .fill(SwiftUI.Color(white: 0)) + .frame(width: 50, height: 50) + } + } to: { + TokamakStaticHTML.Rectangle() + .fill(TokamakStaticHTML.Color(white: 127 / 255)) + .frame(width: 100, height: 100) + .overlay(alignment: .init( + horizontal: tokamakHorizontal, + vertical: tokamakVertical + )) { + TokamakStaticHTML.Rectangle() + .fill(TokamakStaticHTML.Color(white: 0)) + .frame(width: 50, height: 50) + } + } + } + } + } +} +#endif diff --git a/Tests/TokamakLayoutTests/FrameTests.swift b/Tests/TokamakLayoutTests/FrameTests.swift new file mode 100644 index 000000000..7427cd46c --- /dev/null +++ b/Tests/TokamakLayoutTests/FrameTests.swift @@ -0,0 +1,127 @@ +// 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 6/20/22. +// + +#if os(macOS) +import SwiftUI +import TokamakStaticHTML +import XCTest + +final class FrameTests: XCTestCase { + func testFixedFrame() async { + await compare(size: .init(width: 500, height: 500)) { + SwiftUI.Rectangle() + .fill(SwiftUI.Color(white: 0)) + .frame(width: 200, height: 100) + } to: { + TokamakStaticHTML.Rectangle() + .fill(TokamakStaticHTML.Color(white: 0)) + .frame(width: 200, height: 100) + } + } + + func testFixedWidth() async { + await compare(size: .init(width: 500, height: 500)) { + SwiftUI.Rectangle() + .fill(SwiftUI.Color(white: 0)) + .frame(width: 200) + } to: { + TokamakStaticHTML.Rectangle() + .fill(TokamakStaticHTML.Color(white: 0)) + .frame(width: 200) + } + } + + func testFixedHeight() async { + await compare(size: .init(width: 500, height: 500)) { + SwiftUI.Rectangle() + .fill(SwiftUI.Color(white: 0)) + .frame(height: 200) + } to: { + TokamakStaticHTML.Rectangle() + .fill(TokamakStaticHTML.Color(white: 0)) + .frame(height: 200) + } + } + + func testFlexibleFrame() async { + await compare(size: .init(width: 500, height: 500)) { + SwiftUI.Rectangle() + .fill(SwiftUI.Color(white: 0)) + .frame(minWidth: 0, maxWidth: 200, minHeight: 0, maxHeight: 100) + } to: { + TokamakStaticHTML.Rectangle() + .fill(TokamakStaticHTML.Color(white: 0)) + .frame(minWidth: 0, maxWidth: 200, minHeight: 0, maxHeight: 100) + } + } + + func testFlexibleWidth() async { + await compare(size: .init(width: 500, height: 500)) { + SwiftUI.Rectangle() + .fill(SwiftUI.Color(white: 0)) + .frame(minWidth: 0, maxWidth: 200) + } to: { + TokamakStaticHTML.Rectangle() + .fill(TokamakStaticHTML.Color(white: 0)) + .frame(minWidth: 0, maxWidth: 200) + } + } + + func testFlexibleHeight() async { + await compare(size: .init(width: 500, height: 500)) { + SwiftUI.Rectangle() + .fill(SwiftUI.Color(white: 0)) + .frame(minHeight: 0, maxHeight: 200) + } to: { + TokamakStaticHTML.Rectangle() + .fill(TokamakStaticHTML.Color(white: 0)) + .frame(minHeight: 0, maxHeight: 200) + } + } + + func testAlignment() async { + for (nativeHorizontal, tokamakHorizontal) in SwiftUI.HorizontalAlignment.allCases { + for (nativeVertical, tokamakVertical) in SwiftUI.VerticalAlignment.allCases { + await compare(size: .init(width: 500, height: 500)) { + SwiftUI.Rectangle() + .fill(SwiftUI.Color(white: 0)) + .frame(width: 100, height: 100) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .init(horizontal: nativeHorizontal, vertical: nativeVertical) + ) + .background(Color(white: 127 / 255)) + } to: { + TokamakStaticHTML.Rectangle() + .fill(TokamakStaticHTML.Color(white: 0)) + .frame(width: 100, height: 100) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .init( + horizontal: tokamakHorizontal, + vertical: tokamakVertical + ) + ) + .background(Color(white: 127 / 255)) + } + } + } + } +} +#endif diff --git a/Tests/TokamakLayoutTests/StackTests.swift b/Tests/TokamakLayoutTests/StackTests.swift new file mode 100644 index 000000000..0c1319c31 --- /dev/null +++ b/Tests/TokamakLayoutTests/StackTests.swift @@ -0,0 +1,116 @@ +// 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 6/20/22. +// + +#if os(macOS) +import SwiftUI +import TokamakStaticHTML +import XCTest + +final class StackTests: XCTestCase { + func testVStack() async { + await compare(size: .init(width: 500, height: 500)) { + SwiftUI.VStack { + SwiftUI.Rectangle().fill(SwiftUI.Color(white: 0)) + SwiftUI.Rectangle().fill(SwiftUI.Color(white: 127 / 255)) + } + } to: { + TokamakStaticHTML.VStack { + TokamakStaticHTML.Rectangle().fill(TokamakStaticHTML.Color(white: 0)) + TokamakStaticHTML.Rectangle().fill(TokamakStaticHTML.Color(white: 127 / 255)) + } + } + } + + func testHStack() async { + await compare(size: .init(width: 500, height: 500)) { + SwiftUI.HStack { + SwiftUI.Rectangle().fill(SwiftUI.Color(white: 0)) + SwiftUI.Rectangle().fill(SwiftUI.Color(white: 127 / 255)) + } + } to: { + TokamakStaticHTML.HStack { + TokamakStaticHTML.Rectangle().fill(TokamakStaticHTML.Color(white: 0)) + TokamakStaticHTML.Rectangle().fill(TokamakStaticHTML.Color(white: 127 / 255)) + } + } + } + + func testVStackSpacing() async { + await compare(size: .init(width: 500, height: 500)) { + SwiftUI.VStack(spacing: 0) { + SwiftUI.Rectangle().fill(SwiftUI.Color(white: 0)) + SwiftUI.Rectangle().fill(SwiftUI.Color(white: 127 / 255)) + } + } to: { + TokamakStaticHTML.VStack(spacing: 0) { + TokamakStaticHTML.Rectangle().fill(TokamakStaticHTML.Color(white: 0)) + TokamakStaticHTML.Rectangle().fill(TokamakStaticHTML.Color(white: 127 / 255)) + } + } + } + + func testHStackSpacing() async { + await compare(size: .init(width: 500, height: 500)) { + SwiftUI.HStack(spacing: 0) { + SwiftUI.Rectangle().fill(SwiftUI.Color(white: 0)) + SwiftUI.Rectangle().fill(SwiftUI.Color(white: 127 / 255)) + } + } to: { + TokamakStaticHTML.HStack(spacing: 0) { + TokamakStaticHTML.Rectangle().fill(TokamakStaticHTML.Color(white: 0)) + TokamakStaticHTML.Rectangle().fill(TokamakStaticHTML.Color(white: 127 / 255)) + } + } + } + + func testVStackAlignment() async { + for (nativeAlignment, tokamakAlignment) in SwiftUI.HorizontalAlignment.allCases { + await compare(size: .init(width: 500, height: 500)) { + SwiftUI.VStack(alignment: nativeAlignment) { + SwiftUI.Rectangle().fill(SwiftUI.Color(white: 0)) + SwiftUI.Rectangle().fill(SwiftUI.Color(white: 127 / 255)) + .frame(width: 50) + } + } to: { + TokamakStaticHTML.VStack(alignment: tokamakAlignment) { + TokamakStaticHTML.Rectangle().fill(TokamakStaticHTML.Color(white: 0)) + TokamakStaticHTML.Rectangle().fill(TokamakStaticHTML.Color(white: 127 / 255)) + .frame(width: 50) + } + } + } + } + + func testHStackAlignment() async { + for (nativeAlignment, tokamakAlignment) in SwiftUI.VerticalAlignment.allCases { + await compare(size: .init(width: 500, height: 500)) { + SwiftUI.HStack(alignment: nativeAlignment) { + SwiftUI.Rectangle().fill(SwiftUI.Color(white: 0)) + SwiftUI.Rectangle().fill(SwiftUI.Color(white: 127 / 255)) + .frame(height: 50) + } + } to: { + TokamakStaticHTML.HStack(alignment: tokamakAlignment) { + TokamakStaticHTML.Rectangle().fill(TokamakStaticHTML.Color(white: 0)) + TokamakStaticHTML.Rectangle().fill(TokamakStaticHTML.Color(white: 127 / 255)) + .frame(height: 50) + } + } + } + } +} +#endif diff --git a/Tests/TokamakLayoutTests/compare.swift b/Tests/TokamakLayoutTests/compare.swift new file mode 100644 index 000000000..130afe4a0 --- /dev/null +++ b/Tests/TokamakLayoutTests/compare.swift @@ -0,0 +1,115 @@ +// 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 6/20/22. +// + +#if os(macOS) +import SwiftUI +@_spi(TokamakStaticHTML) import TokamakStaticHTML +import XCTest + +func compare( + size: CGSize, + @SwiftUI.ViewBuilder _ native: () -> A, + @TokamakStaticHTML.ViewBuilder to tokamak: () -> B +) async { + let nativePNG = await render(size: size) { + native() + } + let tokamakPNG = await render(size: size) { + tokamak() + } + + let match = nativePNG == tokamakPNG + XCTAssert(match) + + if !match { + let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + // swiftlint:disable:next force_try + try! nativePNG.write(to: cwd.appendingPathComponent("_layouttests_native.png")) + // swiftlint:disable:next force_try + try! tokamakPNG.write(to: cwd.appendingPathComponent("_layouttests_tokamak.png")) + print("You can view the diffs at \(cwd.absoluteString)") + } +} + +@MainActor +private func render( + size: CGSize, + @SwiftUI.ViewBuilder _ view: () -> V +) -> Data { + let bounds = CGRect(origin: .zero, size: .init(width: size.width, height: size.height)) + + let view = NSHostingView(rootView: view().preferredColorScheme(.light)) + view.setFrameSize(bounds.size) + view.layer?.backgroundColor = .white + + let bitmap = view.bitmapImageRepForCachingDisplay(in: bounds)! + view.cacheDisplay(in: bounds, to: bitmap) + + let scale = 1 / (NSScreen.main?.backingScaleFactor ?? 1) + return CIContext().pngRepresentation( + of: CIImage(bitmapImageRep: bitmap)! + .transformed(by: .init(scaleX: scale, y: scale)), + format: .RGBA8, + colorSpace: CGColorSpace(name: CGColorSpace.sRGB)! + )! +} + +private func render( + size: CGSize, + @TokamakStaticHTML.ViewBuilder _ view: () -> V +) async -> Data { + await withCheckedContinuation { (continuation: CheckedContinuation) in + let renderer = StaticHTMLFiberRenderer(useDynamicLayout: true, sceneSize: size) + let html = Data(renderer.render(view()).utf8) + let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + let renderedPath = cwd.appendingPathComponent("rendered.html") + + // swiftlint:disable:next force_try + try! html.write(to: renderedPath) + let browser = Process() + browser + .launchPath = "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" + + var arguments = [ + "--headless", + "--disable-gpu", + "--force-device-scale-factor=1.0", + "--force-color-profile=srgb", + "--hide-scrollbars", + "--screenshot", + renderedPath.path, + ] + + arguments.append("--window-size=\(Int(size.width)),\(Int(size.height))") + + browser.arguments = arguments + browser.terminationHandler = { _ in + let cgImage = NSImage( + contentsOf: cwd.appendingPathComponent("screenshot.png") + )! + .cgImage(forProposedRect: nil, context: nil, hints: nil)! + let png = CIContext().pngRepresentation( + of: CIImage(cgImage: cgImage), + format: .RGBA8, + colorSpace: CGColorSpace(name: CGColorSpace.sRGB)! + )! + continuation.resume(returning: png) + } + browser.launch() + } +} +#endif From 4ce7db70e86a978598f37ab3010c3b1391366184 Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Tue, 21 Jun 2022 10:24:02 -0400 Subject: [PATCH 16/38] Perform updates from the top of the view hierarchy for dynamic layout --- Sources/TokamakCore/Fiber/Fiber.swift | 26 +++- .../TokamakCore/Fiber/FiberReconciler.swift | 29 +++- .../Fiber/Passes/FiberReconcilerPass.swift | 11 ++ .../TokamakCore/Fiber/Passes/LayoutPass.swift | 141 +++++++++--------- .../Fiber/Passes/ReconcilePass.swift | 12 +- 5 files changed, 133 insertions(+), 86 deletions(-) diff --git a/Sources/TokamakCore/Fiber/Fiber.swift b/Sources/TokamakCore/Fiber/Fiber.swift index ef29b5700..93ab1f3dd 100644 --- a/Sources/TokamakCore/Fiber/Fiber.swift +++ b/Sources/TokamakCore/Fiber/Fiber.swift @@ -156,6 +156,7 @@ public extension FiberReconciler { // Create the alternate lazily let alternate = Fiber( bound: alternateView, + state: self.state, layoutActions: self.layout, alternate: self, outputs: self.outputs, @@ -185,6 +186,7 @@ public extension FiberReconciler { init( bound view: V, + state: [PropertyInfo: MutableStorage], layoutActions: LayoutActions, alternate: Fiber, outputs: ViewOutputs, @@ -203,6 +205,7 @@ public extension FiberReconciler { self.elementParent = elementParent self.typeInfo = typeInfo self.outputs = outputs + self.state = state layout = layoutActions content = content(for: view) } @@ -218,10 +221,13 @@ public extension FiberReconciler { for property in typeInfo.properties where property.type is DynamicProperty.Type { var value = property.get(from: content) if var storage = value as? WritableValueStorage { - let box = MutableStorage(initialValue: storage.anyInitialValue, onSet: { [weak self] in - guard let self = self else { return } - self.reconciler?.reconcile(from: self) - }) + let box = self.state[property] ?? MutableStorage( + initialValue: storage.anyInitialValue, + onSet: { [weak self] in + guard let self = self else { return } + self.reconciler?.reconcile(from: self) + } + ) state[property] = box storage.getter = { box.value } storage.setter = { box.setValue($0, with: $1) } @@ -292,7 +298,8 @@ public extension FiberReconciler { // Create the alternate lazily let alternate = Fiber( bound: alternateApp, - layout: self.layout, + state: self.state, + layoutActions: self.layout, alternate: self, outputs: self.outputs, typeInfo: self.typeInfo, @@ -306,7 +313,8 @@ public extension FiberReconciler { init( bound app: A, - layout: LayoutActions, + state: [PropertyInfo: MutableStorage], + layoutActions: LayoutActions, alternate: Fiber, outputs: SceneOutputs, typeInfo: TypeInfo?, @@ -322,7 +330,8 @@ public extension FiberReconciler { elementParent = nil self.typeInfo = typeInfo self.outputs = outputs - self.layout = layout + self.state = state + layout = layoutActions content = content(for: app) } @@ -360,6 +369,7 @@ public extension FiberReconciler { // Create the alternate lazily let alternate = Fiber( bound: alternateScene, + state: self.state, layout: self.layout, alternate: self, outputs: self.outputs, @@ -389,6 +399,7 @@ public extension FiberReconciler { init( bound scene: S, + state: [PropertyInfo: MutableStorage], layout: LayoutActions, alternate: Fiber, outputs: SceneOutputs, @@ -407,6 +418,7 @@ public extension FiberReconciler { self.elementParent = elementParent self.typeInfo = typeInfo self.outputs = outputs + self.state = state self.layout = layout content = content(for: scene) } diff --git a/Sources/TokamakCore/Fiber/FiberReconciler.swift b/Sources/TokamakCore/Fiber/FiberReconciler.swift index eca419c8b..3c0fce0c2 100644 --- a/Sources/TokamakCore/Fiber/FiberReconciler.swift +++ b/Sources/TokamakCore/Fiber/FiberReconciler.swift @@ -127,11 +127,13 @@ public final class FiberReconciler { /// A visitor that performs each pass used by the `FiberReconciler`. final class ReconcilerVisitor: AppVisitor, SceneVisitor, ViewVisitor { let root: Fiber + let reconcileRoot: Fiber unowned let reconciler: FiberReconciler var mutations = [Mutation]() - init(root: Fiber, reconciler: FiberReconciler) { + init(root: Fiber, reconcileRoot: Fiber, reconciler: FiberReconciler) { self.root = root + self.reconcileRoot = reconcileRoot self.reconciler = reconciler } @@ -157,6 +159,13 @@ public final class FiberReconciler { } else { alternateRoot = root.createAndBindAlternate?() } + let alternateReconcileRoot: Fiber? + if let alternate = reconcileRoot.alternate { + alternateReconcileRoot = alternate + } else { + alternateReconcileRoot = reconcileRoot.createAndBindAlternate?() + } + guard let alternateReconcileRoot = alternateReconcileRoot else { return } let rootResult = TreeReducer.Result( fiber: alternateRoot, // The alternate is the WIP node. visitChildren: visitChildren, @@ -167,15 +176,27 @@ public final class FiberReconciler { ) reconciler.caches.clear() for pass in reconciler.passes { - pass.run(in: reconciler, root: rootResult, caches: reconciler.caches) + pass.run( + in: reconciler, + root: rootResult, + reconcileRoot: alternateReconcileRoot, + caches: reconciler.caches + ) } mutations = reconciler.caches.mutations } } - func reconcile(from root: Fiber) { + func reconcile(from updateRoot: Fiber) { + let root: Fiber + if renderer.useDynamicLayout { + // We need to re-layout from the top down when using dynamic layout. + root = current + } else { + root = updateRoot + } // Create a list of mutations. - let visitor = ReconcilerVisitor(root: root, reconciler: self) + let visitor = ReconcilerVisitor(root: root, reconcileRoot: updateRoot, reconciler: self) switch root.content { case let .view(_, visit): visit(visitor) diff --git a/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift b/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift index 911b74890..6fefee018 100644 --- a/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift +++ b/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift @@ -83,9 +83,20 @@ extension FiberReconciler { } protocol FiberReconcilerPass { + /// Run this pass with the given inputs. + /// + /// - Parameter reconciler: The `FiberReconciler` running this pass. + /// - Parameter root: The node to start the pass from. + /// The top of the `View` hierarchy when `useDynamicLayout` is enabled. + /// Otherwise, the same as `reconcileRoot`. + /// - Parameter reconcileRoot: The topmost node that needs reconciliation. + /// When `useDynamicLayout` is enabled, this can be used to limit + /// the number of operations performed during reconciliation. + /// - Parameter caches: The shared cache data for this and other passes. func run( in reconciler: FiberReconciler, root: FiberReconciler.TreeReducer.Result, + reconcileRoot: FiberReconciler.Fiber, caches: FiberReconciler.Caches ) } diff --git a/Sources/TokamakCore/Fiber/Passes/LayoutPass.swift b/Sources/TokamakCore/Fiber/Passes/LayoutPass.swift index 2692db62a..5e5571a35 100644 --- a/Sources/TokamakCore/Fiber/Passes/LayoutPass.swift +++ b/Sources/TokamakCore/Fiber/Passes/LayoutPass.swift @@ -22,90 +22,61 @@ struct LayoutPass: FiberReconcilerPass { func run( in reconciler: FiberReconciler, root: FiberReconciler.TreeReducer.Result, + reconcileRoot: FiberReconciler.Fiber, caches: FiberReconciler.Caches ) where R: FiberRenderer { - if let root = root.fiber { - var fiber = root + guard let root = root.fiber else { return } + var fiber = root - func layoutLoop() { - while true { - sizeThatFits( - fiber, - caches: caches, - proposal: .init( - fiber.elementParent?.geometry?.dimensions.size ?? reconciler.renderer.sceneSize - ) - ) - clean(fiber, caches: caches) + func layoutLoop() { + while true { + // As we walk down the tree, ask each `View` for its ideal size. + sizeThatFits( + fiber, + in: reconciler, + caches: caches + ) + clean(fiber, caches: caches) - if let child = fiber.child { - fiber = child - continue - } + if let child = fiber.child { + // Continue down the tree. + fiber = child + continue + } - while fiber.sibling == nil { - // Before we walk back up, place our children in our bounds. - caches.updateLayoutCache(for: fiber) { cache in - fiber.placeSubviews( - in: .init( - origin: .zero, - size: fiber.geometry?.dimensions.size ?? reconciler.renderer.sceneSize - ), - proposal: .init( - fiber.elementParent?.geometry?.dimensions.size ?? reconciler.renderer - .sceneSize - ), - subviews: caches.layoutSubviews(for: fiber), - cache: &cache.cache - ) - } - // Exit at the top of the `View` tree - guard let parent = fiber.parent else { return } - guard parent !== root.alternate else { return } - // Walk up to the next parent. - fiber = parent - } + while fiber.sibling == nil { + // After collecting all of our subviews, place them in our bounds on the way + // back up the tree. + placeSubviews(fiber, in: reconciler, caches: caches) + // Exit at the top of the `View` tree + guard let parent = fiber.parent else { return } + guard parent !== root else { return } + // Walk up to the next parent. + fiber = parent + } - caches.updateLayoutCache(for: fiber) { cache in - fiber.placeSubviews( - in: .init( - origin: .zero, - size: fiber.geometry?.dimensions.size ?? reconciler.renderer.sceneSize - ), - proposal: .init( - fiber.elementParent?.geometry?.dimensions.size ?? reconciler.renderer.sceneSize - ), - subviews: caches.layoutSubviews(for: fiber), - cache: &cache.cache - ) - } + // We also place our subviews when moving across to a new sibling. + placeSubviews(fiber, in: reconciler, caches: caches) - fiber = fiber.sibling! - } + fiber = fiber.sibling! } - layoutLoop() - - var layoutNode: FiberReconciler.Fiber? = fiber - while let fiber = layoutNode { - caches.updateLayoutCache(for: fiber) { cache in - fiber.placeSubviews( - in: .init( - origin: .zero, - size: fiber.geometry?.dimensions.size ?? reconciler.renderer.sceneSize - ), - proposal: .init( - fiber.elementParent?.geometry?.dimensions.size ?? reconciler.renderer.sceneSize - ), - subviews: caches.layoutSubviews(for: fiber), - cache: &cache.cache - ) - } + } + layoutLoop() - layoutNode = fiber.parent + // Continue past the root element to the top of the View hierarchy + // to ensure everything is placed correctly. + var layoutNode: FiberReconciler.Fiber? = fiber + while let fiber = layoutNode { + caches.updateLayoutCache(for: fiber) { cache in + fiber.updateCache(&cache.cache, subviews: caches.layoutSubviews(for: fiber)) } + sizeThatFits(fiber, in: reconciler, caches: caches) + placeSubviews(fiber, in: reconciler, caches: caches) + layoutNode = fiber.parent } } + /// Mark any `View`s that are dirty as clean after laying them out. func clean( _ fiber: FiberReconciler.Fiber, caches: FiberReconciler.Caches @@ -123,8 +94,8 @@ struct LayoutPass: FiberReconcilerPass { /// Request a size from the fiber's `elementParent`. func sizeThatFits( _ fiber: FiberReconciler.Fiber, - caches: FiberReconciler.Caches, - proposal: ProposedViewSize + in reconciler: FiberReconciler, + caches: FiberReconciler.Caches ) { guard fiber.element != nil else { return } @@ -133,7 +104,9 @@ struct LayoutPass: FiberReconcilerPass { // This does not have to respect the elementParent's proposed size. let size = caches.updateLayoutCache(for: fiber) { cache -> CGSize in fiber.sizeThatFits( - proposal: proposal, + proposal: .init( + fiber.elementParent?.geometry?.dimensions.size ?? reconciler.renderer.sceneSize + ), subviews: caches.layoutSubviews(for: fiber), cache: &cache.cache ) @@ -146,6 +119,26 @@ struct LayoutPass: FiberReconcilerPass { dimensions: dimensions ) } + + func placeSubviews( + _ fiber: FiberReconciler.Fiber, + in reconciler: FiberReconciler, + caches: FiberReconciler.Caches + ) { + caches.updateLayoutCache(for: fiber) { cache in + fiber.placeSubviews( + in: .init( + origin: .zero, + size: fiber.geometry?.dimensions.size ?? reconciler.renderer.sceneSize + ), + proposal: .init( + fiber.elementParent?.geometry?.dimensions.size ?? reconciler.renderer.sceneSize + ), + subviews: caches.layoutSubviews(for: fiber), + cache: &cache.cache + ) + } + } } extension FiberReconcilerPass where Self == LayoutPass { diff --git a/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift b/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift index 7a22b2cdb..0d5f1d017 100644 --- a/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift +++ b/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift @@ -53,11 +53,19 @@ struct ReconcilePass: FiberReconcilerPass { func run( in reconciler: FiberReconciler, root: FiberReconciler.TreeReducer.Result, + reconcileRoot: FiberReconciler.Fiber, caches: FiberReconciler.Caches ) where R: FiberRenderer { var node = root + /// Enabled when we reach the `reconcileRoot`. + var shouldReconcile = false + while true { + if node.fiber === reconcileRoot || node.fiber?.alternate === reconcileRoot { + shouldReconcile = true + } + // If this fiber has an element, set its `elementIndex` // and increment the `elementIndices` value for its `elementParent`. if node.fiber?.element != nil, @@ -67,7 +75,9 @@ struct ReconcilePass: FiberReconcilerPass { } // Perform work on the node. - if let mutation = reconcile(node, in: reconciler, caches: caches) { + if shouldReconcile, + let mutation = reconcile(node, in: reconciler, caches: caches) + { caches.mutations.append(mutation) } From 91ad0a49bee3b0d13ff722bc7f9c21d517dcc19f Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Tue, 21 Jun 2022 10:24:29 -0400 Subject: [PATCH 17/38] Add Package.swift --- Package.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Package.swift b/Package.swift index 93a97067d..c062c573c 100644 --- a/Package.swift +++ b/Package.swift @@ -195,6 +195,18 @@ let package = Package( name: "TokamakTestRenderer", dependencies: ["TokamakCore"] ), + .testTarget( + name: "TokamakLayoutTests", + dependencies: [ + "TokamakCore", + "TokamakStaticHTML", + .product( + name: "SnapshotTesting", + package: "swift-snapshot-testing", + condition: .when(platforms: [.macOS]) + ), + ] + ), .testTarget( name: "TokamakReconcilerTests", dependencies: [ From d9ba6e453f445bd66fdbff5eb78d59f28ec3a039 Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Wed, 22 Jun 2022 08:17:46 -0400 Subject: [PATCH 18/38] Fix reconciler bug --- Sources/TokamakCore/Fiber/FiberReconciler+TreeReducer.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/TokamakCore/Fiber/FiberReconciler+TreeReducer.swift b/Sources/TokamakCore/Fiber/FiberReconciler+TreeReducer.swift index 4c907f97c..7b12df393 100644 --- a/Sources/TokamakCore/Fiber/FiberReconciler+TreeReducer.swift +++ b/Sources/TokamakCore/Fiber/FiberReconciler+TreeReducer.swift @@ -132,6 +132,7 @@ extension FiberReconciler { elementIndices: partialResult.elementIndices ) partialResult.nextExisting = existing.sibling + partialResult.nextExistingAlternate = partialResult.nextExistingAlternate?.sibling } else { let elementParent = partialResult.fiber?.element != nil ? partialResult.fiber From 3d6991abffc38ced94203ada3aa45d6d8dde0721 Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Wed, 22 Jun 2022 08:40:20 -0400 Subject: [PATCH 19/38] Add test case for reconciler insert bug --- .../TestFiberRenderer.swift | 24 +- .../TokamakReconcilerTests/VisitorTests.swift | 212 ++++++++++++++---- 2 files changed, 188 insertions(+), 48 deletions(-) diff --git a/Sources/TokamakTestRenderer/TestFiberRenderer.swift b/Sources/TokamakTestRenderer/TestFiberRenderer.swift index 7be440379..7b1eb9cd3 100644 --- a/Sources/TokamakTestRenderer/TestFiberRenderer.swift +++ b/Sources/TokamakTestRenderer/TestFiberRenderer.swift @@ -96,17 +96,25 @@ public final class TestFiberElement: FiberElement, CustomStringConvertible { } public var content: Content + public var children: [TestFiberElement] + public var geometry: ViewGeometry? public init(from content: Content) { self.content = content + children = [] } public var description: String { - "\(content.renderedValue)\(content.closingTag)" + """ + \(content.renderedValue) + \(children.map { " \($0.description)" }.joined(separator: "\n")) + \(content.closingTag) + """ } public init(renderedValue: String, closingTag: String) { content = .init(renderedValue: renderedValue, closingTag: closingTag) + children = [] } public func update(with content: Content) { @@ -132,7 +140,7 @@ public struct TestFiberRenderer: FiberRenderer { public let rootElement: ElementType - public init(_ rootElement: ElementType, size: CGSize, useDynamicLayout: Bool = true) { + public init(_ rootElement: ElementType, size: CGSize, useDynamicLayout: Bool = false) { self.rootElement = rootElement sceneSize = size self.useDynamicLayout = useDynamicLayout @@ -145,8 +153,16 @@ public struct TestFiberRenderer: FiberRenderer { public func commit(_ mutations: [Mutation]) { for mutation in mutations { switch mutation { - case .insert, .remove, .replace, .layout: - break + case let .insert(element, parent, index): + parent.children.insert(element, at: index) + case let .remove(element, parent): + parent?.children.removeAll(where: { $0 === element }) + case let .replace(parent, previous, replacement): + guard let index = parent.children.firstIndex(where: { $0 === previous }) + else { continue } + parent.children[index] = replacement + case let .layout(element, geometry): + element.geometry = geometry case let .update(previous, newContent, _): previous.update(with: newContent) } diff --git a/Tests/TokamakReconcilerTests/VisitorTests.swift b/Tests/TokamakReconcilerTests/VisitorTests.swift index 4d7a763af..ecafc85e5 100644 --- a/Tests/TokamakReconcilerTests/VisitorTests.swift +++ b/Tests/TokamakReconcilerTests/VisitorTests.swift @@ -20,46 +20,75 @@ import XCTest @_spi(TokamakCore) @testable import TokamakCore import TokamakTestRenderer -private struct TestView: View { - struct Counter: View { - @State - private var count = 0 - - var body: some View { - VStack { - Text("\(count)") - HStack { - if count > 0 { - Button("Decrement") { - print("Decrement") - count -= 1 - } - } - if count < 5 { - Button("Increment") { - print("Increment") - if count + 1 >= 5 { - print("Hit 5") - } - count += 1 - } - } - } - } +extension FiberReconciler { + /// Expect a `Fiber` to represent a particular `View` type. + func expect( + _ fiber: Fiber?, + represents viewType: V.Type, + _ message: String? = nil + ) where V: View { + guard case let .view(view, _) = fiber?.content else { + return XCTAssert(false, "Fiber does not exit") + } + if let message = message { + XCTAssert(type(of: view) == viewType, message) + } else { + XCTAssert(type(of: view) == viewType) } } - public var body: some View { - Counter() + /// Expect a `Fiber` to represent a `View` matching`testView`. + func expect( + _ fiber: Fiber?, + equals testView: V, + _ message: String? = nil + ) where V: View & Equatable { + guard case let .view(fiberView, _) = fiber?.content else { + return XCTAssert(false, "Fiber does not exit") + } + if let message = message { + XCTAssertEqual(fiberView as? V, testView, message) + } else { + XCTAssertEqual(fiberView as? V, testView) + } } } final class VisitorTests: XCTestCase { - func testRenderer() { + func testCounter() { + struct TestView: View { + struct Counter: View { + @State + private var count = 0 + + var body: some View { + VStack { + Text("\(count)") + HStack { + if count > 0 { + Button("Decrement") { + count -= 1 + } + } + if count < 5 { + Button("Increment") { + count += 1 + } + } + } + } + } + } + + public var body: some View { + Counter() + } + } let reconciler = TestFiberRenderer(.root, size: .init(width: 500, height: 500)) .render(TestView()) - func decrement() { - guard case let .view(view, _) = reconciler.current // RootView + var hStack: FiberReconciler.Fiber? { + reconciler.current // RootView + .child? // LayoutView .child? // ModifiedContent .child? // _ViewModifier_Content .child? // TestView @@ -67,33 +96,128 @@ final class VisitorTests: XCTestCase { .child? // VStack .child? // TupleView .child?.sibling? // HStack + .child // TupleView + } + var text: FiberReconciler.Fiber? { + reconciler.current // RootView + .child? // LayoutView + .child? // ModifiedContent + .child? // _ViewModifier_Content + .child? // TestView + .child? // Counter + .child? // VStack .child? // TupleView + .child // Text + } + var decrementButton: FiberReconciler.Fiber? { + hStack? .child? // Optional - .child? // Button - .content + .child // Button + } + var incrementButton: FiberReconciler.Fiber? { + hStack? + .child?.sibling? // Optional + .child // Button + } + func decrement() { + guard case let .view(view, _) = decrementButton?.content else { return } (view as? Button)?.action() } func increment() { - guard case let .view(view, _) = reconciler.current // RootView + guard case let .view(view, _) = incrementButton?.content + else { return } + (view as? Button)?.action() + } + // The decrement button is removed when count is < 0 + XCTAssertNil(decrementButton, "'Decrement' should be hidden when count <= 0") + // Count up to 5 + for i in 0..<5 { + reconciler.expect(text, equals: Text("\(i)")) + increment() + } + XCTAssertNil(incrementButton, "'Increment' should be hidden when count >= 5") + reconciler.expect( + decrementButton, + represents: Button.self, + "'Decrement' should be visible when count > 0" + ) + // Count down to 0. + for i in 0..<5 { + reconciler.expect(text, equals: Text("\(5 - i)")) + decrement() + } + XCTAssertNil(decrementButton, "'Decrement' should be hidden when count <= 0") + reconciler.expect( + incrementButton, + represents: Button.self, + "'Increment' should be visible when count < 5" + ) + } + + func testForEach() { + struct TestView: View { + @State + private var count = 0 + + var body: some View { + VStack { + Button("Add Item") { count += 1 } + ForEach(Array(0...Fiber? { + reconciler.current // RootView + .child? // LayoutView .child? // ModifiedContent .child? // _ViewModifier_Content .child? // TestView - .child? // Counter .child? // VStack .child? // TupleView - .child?.sibling? // HStack + .child // Button + } + var forEachFiber: FiberReconciler.Fiber? { + reconciler.current // RootView + .child? // LayoutView + .child? // ModifiedContent + .child? // _ViewModifier_Content + .child? // TestView + .child? // VStack .child? // TupleView - .child? // Optional - .sibling? // Optional - .child? // Button - .content + .child?.sibling // ForEach + } + func item(at index: Int) -> FiberReconciler.Fiber? { + var node = forEachFiber?.child + for _ in 0..)?.action() } - for _ in 0..<5 { - increment() - } - decrement() + reconciler.expect(addItemFiber, represents: Button.self) + reconciler.expect(forEachFiber, represents: ForEach<[Int], Int, Text>.self) + addItem() + reconciler.expect(item(at: 0), equals: Text("Item 0")) + XCTAssertEqual(reconciler.renderer.rootElement.children[0].children.count, 2) + addItem() + reconciler.expect(item(at: 1), equals: Text("Item 1")) + XCTAssertEqual(reconciler.renderer.rootElement.children[0].children.count, 3) + addItem() + reconciler.expect(item(at: 2), equals: Text("Item 2")) + XCTAssertEqual(reconciler.renderer.rootElement.children[0].children.count, 4) } } From 5fe075f9b6a49ae9bfe78df70b604a837a1d872c Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Wed, 22 Jun 2022 09:09:21 -0400 Subject: [PATCH 20/38] Respect spacing preferences --- Sources/TokamakCore/Fiber/Fiber+Layout.swift | 8 +++++++- Sources/TokamakCore/Fiber/Layout/Layout.swift | 2 +- .../Fiber/Layout/LayoutSubviews.swift | 7 +++++-- .../TokamakCore/Fiber/Layout/ViewSpacing.swift | 16 +++++++++++++++- .../TokamakCore/Fiber/Passes/ReconcilePass.swift | 10 ++++++++++ 5 files changed, 38 insertions(+), 5 deletions(-) diff --git a/Sources/TokamakCore/Fiber/Fiber+Layout.swift b/Sources/TokamakCore/Fiber/Fiber+Layout.swift index d73ead112..226883332 100644 --- a/Sources/TokamakCore/Fiber/Fiber+Layout.swift +++ b/Sources/TokamakCore/Fiber/Fiber+Layout.swift @@ -27,7 +27,13 @@ extension FiberReconciler.Fiber: Layout { } public func spacing(subviews: Subviews, cache: inout Any) -> ViewSpacing { - layout.spacing(subviews, &cache) + if case let .view(view, _) = content, + view is Text + { + return .init(top: 0, leading: 8, bottom: 0, trailing: 8) + } else { + return layout.spacing(subviews, &cache) + } } public func sizeThatFits( diff --git a/Sources/TokamakCore/Fiber/Layout/Layout.swift b/Sources/TokamakCore/Fiber/Layout/Layout.swift index c7ffeb031..615ee7139 100644 --- a/Sources/TokamakCore/Fiber/Layout/Layout.swift +++ b/Sources/TokamakCore/Fiber/Layout/Layout.swift @@ -104,7 +104,7 @@ public extension Layout { func updateCache(_ cache: inout Self.Cache, subviews: Self.Subviews) {} func spacing(subviews: Self.Subviews, cache: inout Self.Cache) -> ViewSpacing { - .init() + subviews.reduce(into: .zero) { $0.formUnion($1.spacing) } } func explicitAlignment( diff --git a/Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift b/Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift index 19be58cd9..31e89fe7c 100644 --- a/Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift +++ b/Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift @@ -82,17 +82,20 @@ public struct LayoutSubview: Equatable { private let sizeThatFits: (ProposedViewSize) -> CGSize private let dimensions: (CGSize) -> ViewDimensions private let place: (ViewDimensions, CGPoint, UnitPoint) -> () + private let computeSpacing: () -> ViewSpacing init( id: ObjectIdentifier, sizeThatFits: @escaping (ProposedViewSize) -> CGSize, dimensions: @escaping (CGSize) -> ViewDimensions, - place: @escaping (ViewDimensions, CGPoint, UnitPoint) -> () + place: @escaping (ViewDimensions, CGPoint, UnitPoint) -> (), + spacing: @escaping () -> ViewSpacing ) { self.id = id self.sizeThatFits = sizeThatFits self.dimensions = dimensions self.place = place + computeSpacing = spacing } public func _trait(key: K.Type) -> K.Value where K: _ViewTraitKey { @@ -116,7 +119,7 @@ public struct LayoutSubview: Equatable { } public var spacing: ViewSpacing { - ViewSpacing() + computeSpacing() } public func place( diff --git a/Sources/TokamakCore/Fiber/Layout/ViewSpacing.swift b/Sources/TokamakCore/Fiber/Layout/ViewSpacing.swift index 90aca1db9..6003a48d8 100644 --- a/Sources/TokamakCore/Fiber/Layout/ViewSpacing.swift +++ b/Sources/TokamakCore/Fiber/Layout/ViewSpacing.swift @@ -28,8 +28,9 @@ public struct ViewSpacing { private var bottom: CGFloat private var trailing: CGFloat - public static let zero: ViewSpacing = .init() + public static let zero: ViewSpacing = .init(top: 0, leading: 0, bottom: 0, trailing: 0) + /// Create a `ViewSpacing` instance with default values. public init() { top = 8 leading = 8 @@ -37,6 +38,19 @@ public struct ViewSpacing { trailing = 8 } + @_spi(TokamakCore) + public init( + top: CGFloat, + leading: CGFloat, + bottom: CGFloat, + trailing: CGFloat + ) { + self.top = top + self.leading = leading + self.bottom = bottom + self.trailing = trailing + } + public mutating func formUnion(_ other: ViewSpacing, edges: Edge.Set = .all) { if edges.contains(.top) { top = max(top, other.top) diff --git a/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift b/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift index 0d5f1d017..4809cbb6e 100644 --- a/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift +++ b/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift @@ -141,6 +141,16 @@ struct ReconcilePass: FiberReconcilerPass { // Update ours and our alternate's geometry fiber.geometry = geometry fiber.alternate?.geometry = geometry + }, + spacing: { [weak fiber, unowned caches] in + guard let fiber = fiber else { return .init() } + + return caches.updateLayoutCache(for: fiber) { cache in + fiber.spacing( + subviews: caches.layoutSubviews(for: fiber), + cache: &cache.cache + ) + } } )) caches.layoutSubviews[parentKey] = subviews From 376fccca189fcc9223de6b7ef468a05fad630ff6 Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Wed, 22 Jun 2022 18:21:35 -0400 Subject: [PATCH 21/38] Revise cache and spacing logic to match SwiftUI default implementations --- .../Fiber/Layout/ContainedZLayout.swift | 4 +- Sources/TokamakCore/Fiber/Layout/Layout.swift | 8 ++- .../Fiber/Passes/FiberReconcilerPass.swift | 33 +++++++++++- .../TokamakCore/Fiber/Passes/LayoutPass.swift | 7 ++- .../Fiber/Passes/ReconcilePass.swift | 54 +++++-------------- Sources/TokamakCore/Shapes/Shape.swift | 4 ++ 6 files changed, 60 insertions(+), 50 deletions(-) diff --git a/Sources/TokamakCore/Fiber/Layout/ContainedZLayout.swift b/Sources/TokamakCore/Fiber/Layout/ContainedZLayout.swift index 7d78cf77a..6bc701af0 100644 --- a/Sources/TokamakCore/Fiber/Layout/ContainedZLayout.swift +++ b/Sources/TokamakCore/Fiber/Layout/ContainedZLayout.swift @@ -40,8 +40,8 @@ public extension ContainedZLayout { .init() } - func updateCache(_ cache: inout Cache, subviews: Subviews) { - cache.primaryDimensions = nil + func spacing(subviews: LayoutSubviews, cache: inout Cache) -> ViewSpacing { + subviews[keyPath: Self.primarySubview]?.spacing ?? .init() } func sizeThatFits( diff --git a/Sources/TokamakCore/Fiber/Layout/Layout.swift b/Sources/TokamakCore/Fiber/Layout/Layout.swift index 615ee7139..6eaa0f5a7 100644 --- a/Sources/TokamakCore/Fiber/Layout/Layout.swift +++ b/Sources/TokamakCore/Fiber/Layout/Layout.swift @@ -101,7 +101,9 @@ public extension Layout { .init() } - func updateCache(_ cache: inout Self.Cache, subviews: Self.Subviews) {} + func updateCache(_ cache: inout Self.Cache, subviews: Self.Subviews) { + cache = makeCache(subviews: subviews) + } func spacing(subviews: Self.Subviews, cache: inout Self.Cache) -> ViewSpacing { subviews.reduce(into: .zero) { $0.formUnion($1.spacing) } @@ -203,6 +205,10 @@ public struct LayoutView: View, Layout { /// A default `Layout` that fits to the first subview and places its children at its origin. struct DefaultLayout: Layout { + func spacing(subviews: Subviews, cache: inout ()) -> ViewSpacing { + subviews.reduce(into: .zero) { $0.formUnion($1.spacing) } + } + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { let size = subviews.first?.sizeThatFits(proposal) ?? .zero return size diff --git a/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift b/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift index 6fefee018..ae1c8d477 100644 --- a/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift +++ b/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift @@ -1,6 +1,16 @@ +// Copyright 2022 Tokamak contributors // -// File.swift +// 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 6/16/22. // @@ -16,11 +26,24 @@ extension FiberReconciler { var mutations = [Mutation]() struct LayoutCache { + /// The erased `Layout.Cache` value. var cache: Any + /// Cached values for `sizeThatFits` calls. var sizeThatFits: [SizeThatFitsRequest: CGSize] + /// Cached values for `dimensions(in:)` calls. var dimensions: [SizeThatFitsRequest: ViewDimensions] + /// Does this cache need to be updated before using? + /// Set to `true` whenever the subviews or the container changes. var isDirty: Bool + /// Empty the cached values and flag the cache as dirty. + @inlinable + mutating func markDirty() { + isDirty = true + sizeThatFits.removeAll() + dimensions.removeAll() + } + struct SizeThatFitsRequest: Hashable { let proposal: ProposedViewSize @@ -46,16 +69,22 @@ extension FiberReconciler { @inlinable func updateLayoutCache(for fiber: Fiber, _ action: (inout LayoutCache) -> R) -> R { + let subviews = layoutSubviews(for: fiber) let key = ObjectIdentifier(fiber) var cache = layoutCaches[ key, default: .init( - cache: fiber.makeCache(subviews: layoutSubviews(for: fiber)), + cache: fiber.makeCache(subviews: subviews), sizeThatFits: [:], dimensions: [:], isDirty: false ) ] + // If the cache is dirty, update it before calling `action`. + if cache.isDirty { + fiber.updateCache(&cache.cache, subviews: subviews) + cache.isDirty = false + } defer { layoutCaches[key] = cache } return action(&cache) } diff --git a/Sources/TokamakCore/Fiber/Passes/LayoutPass.swift b/Sources/TokamakCore/Fiber/Passes/LayoutPass.swift index 5e5571a35..700c21564 100644 --- a/Sources/TokamakCore/Fiber/Passes/LayoutPass.swift +++ b/Sources/TokamakCore/Fiber/Passes/LayoutPass.swift @@ -30,13 +30,14 @@ struct LayoutPass: FiberReconcilerPass { func layoutLoop() { while true { + clean(fiber, caches: caches) + // As we walk down the tree, ask each `View` for its ideal size. sizeThatFits( fiber, in: reconciler, caches: caches ) - clean(fiber, caches: caches) if let child = fiber.child { // Continue down the tree. @@ -67,9 +68,7 @@ struct LayoutPass: FiberReconcilerPass { // to ensure everything is placed correctly. var layoutNode: FiberReconciler.Fiber? = fiber while let fiber = layoutNode { - caches.updateLayoutCache(for: fiber) { cache in - fiber.updateCache(&cache.cache, subviews: caches.layoutSubviews(for: fiber)) - } + clean(fiber, caches: caches) sizeThatFits(fiber, in: reconciler, caches: caches) placeSubviews(fiber, in: reconciler, caches: caches) layoutNode = fiber.parent diff --git a/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift b/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift index 4809cbb6e..557ca501a 100644 --- a/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift +++ b/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift @@ -1,6 +1,16 @@ +// Copyright 2022 Tokamak contributors // -// File.swift +// 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 6/16/22. // @@ -195,9 +205,6 @@ struct ReconcilePass: FiberReconcilerPass { // Now walk back up the tree until we find a sibling. while node.sibling == nil { - // Update the layout cache so it's ready for this render. - updateCache(for: node, in: reconciler, caches: caches) - var alternateSibling = node.fiber?.alternate?.sibling // The alternate had siblings that no longer exist. while alternateSibling != nil { @@ -219,8 +226,6 @@ struct ReconcilePass: FiberReconcilerPass { node = parent } - updateCache(for: node, in: reconciler, caches: caches) - // Walk across to the sibling, and repeat. node = node.sibling! } @@ -269,37 +274,6 @@ struct ReconcilePass: FiberReconcilerPass { return nil } - /// Update the layout cache for a `Fiber`. - func updateCache( - for node: FiberReconciler.TreeReducer.Result, - in reconciler: FiberReconciler, - caches: FiberReconciler.Caches - ) { - guard reconciler.renderer.useDynamicLayout, - let fiber = node.fiber - else { return } - caches.updateLayoutCache(for: fiber) { cache in - fiber.updateCache(&cache.cache, subviews: caches.layoutSubviews(for: fiber)) - var sibling = fiber.child - while let fiber = sibling { - sibling = fiber.sibling - if let childCache = caches.layoutCaches[.init(fiber)], - childCache.isDirty - { - cache.sizeThatFits.removeAll() - cache.isDirty = true - if let alternate = fiber.alternate { - caches.updateLayoutCache(for: alternate) { cache in - cache.sizeThatFits.removeAll() - cache.isDirty = true - } - } - return - } - } - } - } - /// Remove cached size values if something changed. func invalidateCache( for fiber: FiberReconciler.Fiber, @@ -308,13 +282,11 @@ struct ReconcilePass: FiberReconcilerPass { ) { guard reconciler.renderer.useDynamicLayout else { return } caches.updateLayoutCache(for: fiber) { cache in - cache.sizeThatFits.removeAll() - cache.isDirty = true + cache.markDirty() } if let alternate = fiber.alternate { caches.updateLayoutCache(for: alternate) { cache in - cache.sizeThatFits.removeAll() - cache.isDirty = true + cache.markDirty() } } } diff --git a/Sources/TokamakCore/Shapes/Shape.swift b/Sources/TokamakCore/Shapes/Shape.swift index 331a55443..92d135dbb 100644 --- a/Sources/TokamakCore/Shapes/Shape.swift +++ b/Sources/TokamakCore/Shapes/Shape.swift @@ -73,6 +73,10 @@ public struct _ShapeView: _PrimitiveView, Layout where Content: self.fillStyle = fillStyle } + public func spacing(subviews: Subviews, cache: inout ()) -> ViewSpacing { + .init() + } + public func sizeThatFits( proposal: ProposedViewSize, subviews: Subviews, From 13ea406fe3c8685b9fc69fc5a835f8b867b8cb61 Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Wed, 22 Jun 2022 18:58:51 -0400 Subject: [PATCH 22/38] Allow spacing changes based on adjacent View type --- Sources/TokamakCore/Fiber/Fiber+Layout.swift | 9 ++- Sources/TokamakCore/Fiber/Layout/Layout.swift | 16 ++++-- .../Fiber/Layout/PaddingLayout+Layout.swift | 4 ++ .../Fiber/Layout/ViewSpacing.swift | 57 ++++++++++++------- 4 files changed, 61 insertions(+), 25 deletions(-) diff --git a/Sources/TokamakCore/Fiber/Fiber+Layout.swift b/Sources/TokamakCore/Fiber/Fiber+Layout.swift index 226883332..2974829f6 100644 --- a/Sources/TokamakCore/Fiber/Fiber+Layout.swift +++ b/Sources/TokamakCore/Fiber/Fiber+Layout.swift @@ -30,7 +30,14 @@ extension FiberReconciler.Fiber: Layout { if case let .view(view, _) = content, view is Text { - return .init(top: 0, leading: 8, bottom: 0, trailing: 8) + let spacing = ViewSpacing( + viewType: Text.self, + top: { $0.viewType == Text.self ? 0 : ViewSpacing.defaultValue }, + leading: { _ in ViewSpacing.defaultValue }, + bottom: { $0.viewType == Text.self ? 0 : ViewSpacing.defaultValue }, + trailing: { _ in ViewSpacing.defaultValue } + ) + return spacing } else { return layout.spacing(subviews, &cache) } diff --git a/Sources/TokamakCore/Fiber/Layout/Layout.swift b/Sources/TokamakCore/Fiber/Layout/Layout.swift index 6eaa0f5a7..4b33015d6 100644 --- a/Sources/TokamakCore/Fiber/Layout/Layout.swift +++ b/Sources/TokamakCore/Fiber/Layout/Layout.swift @@ -106,7 +106,17 @@ public extension Layout { } func spacing(subviews: Self.Subviews, cache: inout Self.Cache) -> ViewSpacing { - subviews.reduce(into: .zero) { $0.formUnion($1.spacing) } + subviews.reduce( + into: subviews.first.map { + .init( + viewType: $0.spacing.viewType, + top: { _ in 0 }, + leading: { _ in 0 }, + bottom: { _ in 0 }, + trailing: { _ in 0 } + ) + } ?? .zero + ) { $0.formUnion($1.spacing) } } func explicitAlignment( @@ -205,10 +215,6 @@ public struct LayoutView: View, Layout { /// A default `Layout` that fits to the first subview and places its children at its origin. struct DefaultLayout: Layout { - func spacing(subviews: Subviews, cache: inout ()) -> ViewSpacing { - subviews.reduce(into: .zero) { $0.formUnion($1.spacing) } - } - func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { let size = subviews.first?.sizeThatFits(proposal) ?? .zero return size diff --git a/Sources/TokamakCore/Fiber/Layout/PaddingLayout+Layout.swift b/Sources/TokamakCore/Fiber/Layout/PaddingLayout+Layout.swift index 8ddb95fad..f10f76097 100644 --- a/Sources/TokamakCore/Fiber/Layout/PaddingLayout+Layout.swift +++ b/Sources/TokamakCore/Fiber/Layout/PaddingLayout+Layout.swift @@ -32,6 +32,10 @@ private struct PaddingLayout: Layout { let edges: Edge.Set let insets: EdgeInsets? + func spacing(subviews: Subviews, cache: inout ()) -> ViewSpacing { + .init() + } + public func sizeThatFits( proposal: ProposedViewSize, subviews: Subviews, diff --git a/Sources/TokamakCore/Fiber/Layout/ViewSpacing.swift b/Sources/TokamakCore/Fiber/Layout/ViewSpacing.swift index 6003a48d8..aafe4d9e2 100644 --- a/Sources/TokamakCore/Fiber/Layout/ViewSpacing.swift +++ b/Sources/TokamakCore/Fiber/Layout/ViewSpacing.swift @@ -23,28 +23,40 @@ import Foundation /// to find the smallest spacing needed to accommodate the preferences /// of the `View`s you are aligning. public struct ViewSpacing { - private var top: CGFloat - private var leading: CGFloat - private var bottom: CGFloat - private var trailing: CGFloat + /// The `View` type this `ViewSpacing` is for. + /// Some `View`s prefer different spacing based on the `View` they are adjacent to. + @_spi(TokamakCore) + public var viewType: Any.Type? + private var top: (ViewSpacing) -> CGFloat + private var leading: (ViewSpacing) -> CGFloat + private var bottom: (ViewSpacing) -> CGFloat + private var trailing: (ViewSpacing) -> CGFloat - public static let zero: ViewSpacing = .init(top: 0, leading: 0, bottom: 0, trailing: 0) + public static let zero: ViewSpacing = .init( + viewType: nil, + top: { _ in 0 }, + leading: { _ in 0 }, + bottom: { _ in 0 }, + trailing: { _ in 0 } + ) /// Create a `ViewSpacing` instance with default values. public init() { - top = 8 - leading = 8 - bottom = 8 - trailing = 8 + self.init(viewType: nil) } + @_spi(TokamakCore) + public static let defaultValue: CGFloat = 8 + @_spi(TokamakCore) public init( - top: CGFloat, - leading: CGFloat, - bottom: CGFloat, - trailing: CGFloat + viewType: Any.Type?, + top: @escaping (ViewSpacing) -> CGFloat = { _ in Self.defaultValue }, + leading: @escaping (ViewSpacing) -> CGFloat = { _ in Self.defaultValue }, + bottom: @escaping (ViewSpacing) -> CGFloat = { _ in Self.defaultValue }, + trailing: @escaping (ViewSpacing) -> CGFloat = { _ in Self.defaultValue } ) { + self.viewType = viewType self.top = top self.leading = leading self.bottom = bottom @@ -52,17 +64,24 @@ public struct ViewSpacing { } public mutating func formUnion(_ other: ViewSpacing, edges: Edge.Set = .all) { + if viewType != other.viewType { + viewType = nil + } if edges.contains(.top) { - top = max(top, other.top) + let current = top + top = { max(current($0), other.top($0)) } } if edges.contains(.leading) { - leading = max(leading, other.leading) + let current = leading + leading = { max(current($0), other.leading($0)) } } if edges.contains(.bottom) { - bottom = max(bottom, other.bottom) + let current = bottom + bottom = { max(current($0), other.bottom($0)) } } if edges.contains(.trailing) { - trailing = max(trailing, other.trailing) + let current = trailing + trailing = { max(current($0), other.trailing($0)) } } } @@ -77,9 +96,9 @@ public struct ViewSpacing { // Assume `next` comes after `self` either horizontally or vertically. switch axis { case .horizontal: - return max(trailing, next.leading) + return max(trailing(next), next.leading(self)) case .vertical: - return max(bottom, next.top) + return max(bottom(next), next.top(self)) } } } From 8ffef50dc0ce23da9f21ae1baa643a12e8405249 Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Wed, 22 Jun 2022 19:36:28 -0400 Subject: [PATCH 23/38] Support view traits in FiberReconciler (and LayoutValueKey by extension) --- Sources/TokamakCore/Fiber/Fiber.swift | 18 +++++--- .../Fiber/FiberReconciler+TreeReducer.swift | 43 +++++++++++++------ .../TokamakCore/Fiber/FiberReconciler.swift | 4 +- .../Fiber/Layout/LayoutPriority.swift | 31 +++++++++++++ .../Fiber/Layout/LayoutSubviews.swift | 9 ++-- .../Fiber/Passes/ReconcilePass.swift | 20 ++++++++- Sources/TokamakCore/Fiber/ViewArguments.swift | 12 +++++- .../ViewTraits/_ViewTraitKey.swift | 8 ++++ .../ViewTraits/_ViewTraitStore.swift | 8 ++-- 9 files changed, 124 insertions(+), 29 deletions(-) create mode 100644 Sources/TokamakCore/Fiber/Layout/LayoutPriority.swift diff --git a/Sources/TokamakCore/Fiber/Fiber.swift b/Sources/TokamakCore/Fiber/Fiber.swift index 93ab1f3dd..a7a8222d8 100644 --- a/Sources/TokamakCore/Fiber/Fiber.swift +++ b/Sources/TokamakCore/Fiber/Fiber.swift @@ -117,6 +117,7 @@ public extension FiberReconciler { parent: Fiber?, elementParent: Fiber?, elementIndex: Int?, + traits: _ViewTraitStore?, reconciler: FiberReconciler? ) { self.reconciler = reconciler @@ -131,7 +132,8 @@ public extension FiberReconciler { state = bindProperties(to: &view, typeInfo, environment.environment) let viewInputs = ViewInputs( content: view, - environment: environment + environment: environment, + traits: traits ) outputs = V._makeView(viewInputs) @@ -248,7 +250,8 @@ public extension FiberReconciler { func update( with view: inout V, - elementIndex: Int? + elementIndex: Int?, + traits: _ViewTraitStore? ) -> Renderer.ElementType.Content? { typeInfo = TokamakCore.typeInfo(of: V.self) @@ -259,7 +262,8 @@ public extension FiberReconciler { content = content(for: view) let inputs = ViewInputs( content: view, - environment: environment + environment: environment, + traits: traits ) outputs = V._makeView(inputs) @@ -287,7 +291,7 @@ public extension FiberReconciler { layout = .init(RootLayout(renderer: reconciler.renderer)) state = bindProperties(to: &app, typeInfo, rootEnvironment) outputs = .init( - inputs: .init(content: app, environment: .init(rootEnvironment)) + inputs: .init(content: app, environment: .init(rootEnvironment), traits: .init()) ) content = content(for: app) @@ -357,7 +361,8 @@ public extension FiberReconciler { outputs = S._makeScene( .init( content: scene, - environment: environment + environment: environment, + traits: .init() ) ) @@ -433,7 +438,8 @@ public extension FiberReconciler { content = content(for: scene) outputs = S._makeScene(.init( content: scene, - environment: environment + environment: environment, + traits: .init() )) return nil diff --git a/Sources/TokamakCore/Fiber/FiberReconciler+TreeReducer.swift b/Sources/TokamakCore/Fiber/FiberReconciler+TreeReducer.swift index 7b12df393..46db9e2de 100644 --- a/Sources/TokamakCore/Fiber/FiberReconciler+TreeReducer.swift +++ b/Sources/TokamakCore/Fiber/FiberReconciler+TreeReducer.swift @@ -34,6 +34,7 @@ extension FiberReconciler { var lastSibling: Result? var nextExisting: Fiber? var nextExistingAlternate: Fiber? + var pendingTraits: _ViewTraitStore init( fiber: Fiber?, @@ -42,7 +43,8 @@ extension FiberReconciler { child: Fiber?, alternateChild: Fiber?, newContent: Renderer.ElementType.Content? = nil, - elementIndices: [ObjectIdentifier: Int] + elementIndices: [ObjectIdentifier: Int], + pendingTraits: _ViewTraitStore ) { self.fiber = fiber self.visitChildren = visitChildren @@ -51,6 +53,7 @@ extension FiberReconciler { nextExistingAlternate = alternateChild self.newContent = newContent self.elementIndices = elementIndices + self.pendingTraits = pendingTraits } } @@ -58,7 +61,7 @@ extension FiberReconciler { Self.reduce( into: &partialResult, nextValue: nextScene, - createFiber: { scene, element, parent, elementParent, _, reconciler in + createFiber: { scene, element, parent, elementParent, _, _, reconciler in Fiber( &scene, element: element, @@ -68,7 +71,7 @@ extension FiberReconciler { reconciler: reconciler ) }, - update: { fiber, scene, _ in + update: { fiber, scene, _, _ in fiber.update(with: &scene) }, visitChildren: { $1._visitChildren } @@ -79,18 +82,23 @@ extension FiberReconciler { Self.reduce( into: &partialResult, nextValue: nextView, - createFiber: { view, element, parent, elementParent, elementIndex, reconciler in + createFiber: { view, element, parent, elementParent, elementIndex, traits, reconciler in Fiber( &view, element: element, parent: parent, elementParent: elementParent, elementIndex: elementIndex, + traits: traits, reconciler: reconciler ) }, - update: { fiber, view, elementIndex in - fiber.update(with: &view, elementIndex: elementIndex) + update: { fiber, view, elementIndex, traits in + fiber.update( + with: &view, + elementIndex: elementIndex, + traits: fiber.element != nil ? traits : nil + ) }, visitChildren: { reconciler, view in reconciler?.renderer.viewVisitor(for: view) ?? view._visitChildren @@ -101,9 +109,16 @@ extension FiberReconciler { static func reduce( into partialResult: inout Result, nextValue: T, - createFiber: (inout T, Renderer.ElementType?, Fiber?, Fiber?, Int?, FiberReconciler?) - -> Fiber, - update: (Fiber, inout T, Int?) -> Renderer.ElementType.Content?, + createFiber: ( + inout T, + Renderer.ElementType?, + Fiber?, + Fiber?, + Int?, + _ViewTraitStore, + FiberReconciler? + ) -> Fiber, + update: (Fiber, inout T, Int?, _ViewTraitStore) -> Renderer.ElementType.Content?, visitChildren: (FiberReconciler?, T) -> (TreeReducer.SceneVisitor) -> () ) { // Create the node and its element. @@ -120,7 +135,8 @@ extension FiberReconciler { let newContent = update( existing, &nextValue, - key.map { partialResult.elementIndices[$0, default: 0] } + key.map { partialResult.elementIndices[$0, default: 0] }, + partialResult.pendingTraits ) resultChild = Result( fiber: existing, @@ -129,7 +145,8 @@ extension FiberReconciler { child: existing.child, alternateChild: existing.alternate?.child, newContent: newContent, - elementIndices: partialResult.elementIndices + elementIndices: partialResult.elementIndices, + pendingTraits: existing.element != nil ? .init() : partialResult.pendingTraits ) partialResult.nextExisting = existing.sibling partialResult.nextExistingAlternate = partialResult.nextExistingAlternate?.sibling @@ -150,6 +167,7 @@ extension FiberReconciler { partialResult.fiber, elementParent, key.map { partialResult.elementIndices[$0, default: 0] }, + partialResult.pendingTraits, partialResult.fiber?.reconciler ) // If a fiber already exists for an alternate, link them. @@ -163,7 +181,8 @@ extension FiberReconciler { parent: partialResult, child: nil, alternateChild: fiber.alternate?.child, - elementIndices: partialResult.elementIndices + elementIndices: partialResult.elementIndices, + pendingTraits: fiber.element != nil ? .init() : partialResult.pendingTraits ) } // Get the last child element we've processed, and add the new child as its sibling. diff --git a/Sources/TokamakCore/Fiber/FiberReconciler.swift b/Sources/TokamakCore/Fiber/FiberReconciler.swift index 3c0fce0c2..46b8cb991 100644 --- a/Sources/TokamakCore/Fiber/FiberReconciler.swift +++ b/Sources/TokamakCore/Fiber/FiberReconciler.swift @@ -95,6 +95,7 @@ public final class FiberReconciler { parent: nil, elementParent: nil, elementIndex: 0, + traits: nil, reconciler: self ) // Start by building the initial tree. @@ -172,7 +173,8 @@ public final class FiberReconciler { parent: nil, child: alternateRoot?.child, alternateChild: root.child, - elementIndices: [:] + elementIndices: [:], + pendingTraits: .init() ) reconciler.caches.clear() for pass in reconciler.passes { diff --git a/Sources/TokamakCore/Fiber/Layout/LayoutPriority.swift b/Sources/TokamakCore/Fiber/Layout/LayoutPriority.swift new file mode 100644 index 000000000..079ceeac4 --- /dev/null +++ b/Sources/TokamakCore/Fiber/Layout/LayoutPriority.swift @@ -0,0 +1,31 @@ +// 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 6/22/22. +// + +import Foundation + +@usableFromInline +enum LayoutPriorityTraitKey: _ViewTraitKey { + @inlinable + static var defaultValue: Double { 0 } +} + +public extension View { + @inlinable + func layoutPriority(_ value: Double) -> some View { + _trait(LayoutPriorityTraitKey.self, value) + } +} diff --git a/Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift b/Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift index 31e89fe7c..9dae37c85 100644 --- a/Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift +++ b/Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift @@ -79,6 +79,7 @@ public struct LayoutSubview: Equatable { lhs.id == rhs.id } + private let traits: _ViewTraitStore private let sizeThatFits: (ProposedViewSize) -> CGSize private let dimensions: (CGSize) -> ViewDimensions private let place: (ViewDimensions, CGPoint, UnitPoint) -> () @@ -86,12 +87,14 @@ public struct LayoutSubview: Equatable { init( id: ObjectIdentifier, + traits: _ViewTraitStore, sizeThatFits: @escaping (ProposedViewSize) -> CGSize, dimensions: @escaping (CGSize) -> ViewDimensions, place: @escaping (ViewDimensions, CGPoint, UnitPoint) -> (), spacing: @escaping () -> ViewSpacing ) { self.id = id + self.traits = traits self.sizeThatFits = sizeThatFits self.dimensions = dimensions self.place = place @@ -99,15 +102,15 @@ public struct LayoutSubview: Equatable { } public func _trait(key: K.Type) -> K.Value where K: _ViewTraitKey { - fatalError("Implement \(#function)") + traits.value(forKey: key) } public subscript(key: K.Type) -> K.Value where K: LayoutValueKey { - fatalError("Implement \(#function)") + _trait(key: _LayoutTrait.self) } public var priority: Double { - 0 + _trait(key: LayoutPriorityTraitKey.self) } public func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize { diff --git a/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift b/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift index 557ca501a..9dda891e3 100644 --- a/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift +++ b/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift @@ -71,6 +71,9 @@ struct ReconcilePass: FiberReconcilerPass { /// Enabled when we reach the `reconcileRoot`. var shouldReconcile = false + /// Traits that should be attached to the nearest rendered child. + var pendingTraits = _ViewTraitStore() + while true { if node.fiber === reconcileRoot || node.fiber?.alternate === reconcileRoot { shouldReconcile = true @@ -91,8 +94,22 @@ struct ReconcilePass: FiberReconcilerPass { caches.mutations.append(mutation) } - // Ensure the TreeReducer can access the `elementIndices`. + // Pass view traits down to the nearest element fiber. + if let traits = node.fiber?.outputs.traits, + !traits.values.isEmpty + { + if node.fiber?.element == nil { + pendingTraits = traits + } + } + // Clear the pending traits once they have been applied to the target. + if node.fiber?.element != nil && !pendingTraits.values.isEmpty { + pendingTraits = .init() + } + + // Ensure the TreeReducer can access any necessary state. node.elementIndices = caches.elementIndices + node.pendingTraits = pendingTraits // Compute the children of the node. let reducer = FiberReconciler.TreeReducer.SceneVisitor(initialResult: node) @@ -109,6 +126,7 @@ struct ReconcilePass: FiberReconcilerPass { var subviews = caches.layoutSubviews[parentKey, default: .init(elementParent)] subviews.storage.append(LayoutSubview( id: ObjectIdentifier(node), + traits: node.fiber?.outputs.traits ?? .init(), sizeThatFits: { [weak fiber, unowned caches] proposal in guard let fiber = fiber else { return .zero } return caches.updateLayoutCache(for: fiber) { cache in diff --git a/Sources/TokamakCore/Fiber/ViewArguments.swift b/Sources/TokamakCore/Fiber/ViewArguments.swift index 44afa1df1..d29c2dc19 100644 --- a/Sources/TokamakCore/Fiber/ViewArguments.swift +++ b/Sources/TokamakCore/Fiber/ViewArguments.swift @@ -22,6 +22,7 @@ public struct ViewInputs { public let content: V @_spi(TokamakCore) public let environment: EnvironmentBox + public let traits: _ViewTraitStore? } /// Data used to reconcile and render a `View` and its children. @@ -30,6 +31,7 @@ public struct ViewOutputs { /// This is stored as a reference to avoid copying the environment when unnecessary. let environment: EnvironmentBox let preferences: _PreferenceStore + let traits: _ViewTraitStore? } @_spi(TokamakCore) @@ -45,12 +47,14 @@ public extension ViewOutputs { init( inputs: ViewInputs, environment: EnvironmentValues? = nil, - preferences: _PreferenceStore? = nil + preferences: _PreferenceStore? = nil, + traits: _ViewTraitStore? = nil ) { // 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.traits = traits ?? inputs.traits } } @@ -64,7 +68,11 @@ public extension View { public extension ModifiedContent where Content: View, Modifier: ViewModifier { static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { - Modifier._makeView(.init(content: inputs.content.modifier, environment: inputs.environment)) + Modifier._makeView(.init( + content: inputs.content.modifier, + environment: inputs.environment, + traits: inputs.traits + )) } func _visitChildren(_ visitor: V) where V: ViewVisitor { diff --git a/Sources/TokamakCore/ViewTraits/_ViewTraitKey.swift b/Sources/TokamakCore/ViewTraits/_ViewTraitKey.swift index 64c3a82dc..a49a3d84b 100644 --- a/Sources/TokamakCore/ViewTraits/_ViewTraitKey.swift +++ b/Sources/TokamakCore/ViewTraits/_ViewTraitKey.swift @@ -41,6 +41,14 @@ public struct _TraitWritingModifier: ViewModifier, _TraitWritingModifierP public func modifyViewTraitStore(_ viewTraitStore: inout _ViewTraitStore) { viewTraitStore.insert(value, forKey: Trait.self) } + + public static func _makeView(_ inputs: ViewInputs<_TraitWritingModifier>) + -> ViewOutputs + { + var store = inputs.traits ?? .init() + store.insert(inputs.content.value, forKey: Trait.self) + return .init(inputs: inputs, traits: store) + } } extension ModifiedContent: _TraitWritingModifierProtocol diff --git a/Sources/TokamakCore/ViewTraits/_ViewTraitStore.swift b/Sources/TokamakCore/ViewTraits/_ViewTraitStore.swift index 12cb8deb6..3dcb468ad 100644 --- a/Sources/TokamakCore/ViewTraits/_ViewTraitStore.swift +++ b/Sources/TokamakCore/ViewTraits/_ViewTraitStore.swift @@ -16,21 +16,21 @@ // public struct _ViewTraitStore { - public var values = [String: Any]() + public var values = [ObjectIdentifier: Any]() - public init(values: [String: Any] = [:]) { + public init(values: [ObjectIdentifier: Any] = [:]) { self.values = values } public func value(forKey key: Key.Type = Key.self) -> Key.Value where Key: _ViewTraitKey { - values[String(reflecting: key)] as? Key.Value ?? Key.defaultValue + values[ObjectIdentifier(key)] as? Key.Value ?? Key.defaultValue } public mutating func insert(_ value: Key.Value, forKey key: Key.Type = Key.self) where Key: _ViewTraitKey { - values[String(reflecting: key)] = value + values[ObjectIdentifier(key)] = value } } From f2827e15b1f17f2aa050746870117457985e7760 Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Wed, 22 Jun 2022 22:05:51 -0400 Subject: [PATCH 24/38] Propagate cache invalidation --- .../Fiber/Passes/FiberReconcilerPass.swift | 13 ++++++++++ .../Fiber/Passes/ReconcilePass.swift | 26 ++++++++++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift b/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift index ae1c8d477..b6a9fecc4 100644 --- a/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift +++ b/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift @@ -67,6 +67,19 @@ extension FiberReconciler { mutations = [] } + @inlinable + func layoutCache(for fiber: Fiber) -> LayoutCache { + layoutCaches[ + ObjectIdentifier(fiber), + default: .init( + cache: fiber.makeCache(subviews: layoutSubviews(for: fiber)), + sizeThatFits: [:], + dimensions: [:], + isDirty: false + ) + ] + } + @inlinable func updateLayoutCache(for fiber: Fiber, _ action: (inout LayoutCache) -> R) -> R { let subviews = layoutSubviews(for: fiber) diff --git a/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift b/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift index 9dda891e3..06a3f039d 100644 --- a/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift +++ b/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift @@ -223,6 +223,10 @@ struct ReconcilePass: FiberReconcilerPass { // Now walk back up the tree until we find a sibling. while node.sibling == nil { + if let fiber = node.fiber { + propagateCacheInvalidation(for: fiber, in: reconciler, caches: caches) + } + var alternateSibling = node.fiber?.alternate?.sibling // The alternate had siblings that no longer exist. while alternateSibling != nil { @@ -244,6 +248,10 @@ struct ReconcilePass: FiberReconcilerPass { node = parent } + if let fiber = node.fiber { + propagateCacheInvalidation(for: fiber, in: reconciler, caches: caches) + } + // Walk across to the sibling, and repeat. node = node.sibling! } @@ -260,14 +268,14 @@ struct ReconcilePass: FiberReconcilerPass { let parent = node.fiber?.elementParent?.element { if node.fiber?.alternate == nil { // This didn't exist before (no alternate) - if let fiber = node.fiber { + if let fiber = node.fiber?.parent { invalidateCache(for: fiber, in: reconciler, caches: caches) } return .insert(element: element, parent: parent, index: index) } else if node.fiber?.typeInfo?.type != node.fiber?.alternate?.typeInfo?.type, let previous = node.fiber?.alternate?.element { - if let fiber = node.fiber { + if let fiber = node.fiber?.parent { invalidateCache(for: fiber, in: reconciler, caches: caches) } // This is a completely different type of view. @@ -275,7 +283,7 @@ struct ReconcilePass: FiberReconcilerPass { } else if let newContent = node.newContent, newContent != element.content { - if let fiber = node.fiber { + if let fiber = node.fiber?.parent { invalidateCache(for: fiber, in: reconciler, caches: caches) } // This is the same type of view, but its backing data has changed. @@ -308,6 +316,18 @@ struct ReconcilePass: FiberReconcilerPass { } } } + + @inlinable + func propagateCacheInvalidation( + for fiber: FiberReconciler.Fiber, + in reconciler: FiberReconciler, + caches: FiberReconciler.Caches + ) { + guard caches.layoutCache(for: fiber).isDirty, + let parent = fiber.parent + else { return } + invalidateCache(for: parent, in: reconciler, caches: caches) + } } extension FiberReconcilerPass where Self == ReconcilePass { From 93813ffb6b2eb147fd148d794ce98fe522b25248 Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Wed, 22 Jun 2022 22:07:00 -0400 Subject: [PATCH 25/38] Cleanup attributes --- Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift | 7 ------- 1 file changed, 7 deletions(-) diff --git a/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift b/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift index b6a9fecc4..a3d74e802 100644 --- a/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift +++ b/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift @@ -37,7 +37,6 @@ extension FiberReconciler { var isDirty: Bool /// Empty the cached values and flag the cache as dirty. - @inlinable mutating func markDirty() { isDirty = true sizeThatFits.removeAll() @@ -59,7 +58,6 @@ extension FiberReconciler { } } - @inlinable func clear() { elementIndices = [:] layoutSubviews = [:] @@ -67,7 +65,6 @@ extension FiberReconciler { mutations = [] } - @inlinable func layoutCache(for fiber: Fiber) -> LayoutCache { layoutCaches[ ObjectIdentifier(fiber), @@ -80,7 +77,6 @@ extension FiberReconciler { ] } - @inlinable func updateLayoutCache(for fiber: Fiber, _ action: (inout LayoutCache) -> R) -> R { let subviews = layoutSubviews(for: fiber) let key = ObjectIdentifier(fiber) @@ -102,12 +98,10 @@ extension FiberReconciler { return action(&cache) } - @inlinable func layoutSubviews(for fiber: Fiber) -> LayoutSubviews { layoutSubviews[ObjectIdentifier(fiber), default: .init(fiber)] } - @inlinable func elementIndex(for fiber: Fiber, increment: Bool = false) -> Int { let key = ObjectIdentifier(fiber) let result = elementIndices[key, default: 0] @@ -117,7 +111,6 @@ extension FiberReconciler { return result } - @inlinable func appendChild(parent: Fiber, child: Fiber) { elementChildren[ObjectIdentifier(parent), default: []].append(child) } From 52743759d93178495567f384561fa77c29fd6fa4 Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Thu, 23 Jun 2022 15:09:43 -0400 Subject: [PATCH 26/38] Simplify LayoutPass and improve accuracy --- .../Fiber+CustomDebugStringConvertible.swift | 4 +- .../TokamakCore/Fiber/FiberReconciler.swift | 12 +- .../Fiber/Layout/LayoutSubviews.swift | 6 +- .../Layout/_FlexFrameLayout+Layout.swift | 4 + .../TokamakCore/Fiber/Passes/LayoutPass.swift | 128 ++++-------------- .../Fiber/Passes/ReconcilePass.swift | 13 +- Sources/TokamakCore/Fiber/ViewGeometry.swift | 2 + 7 files changed, 55 insertions(+), 114 deletions(-) diff --git a/Sources/TokamakCore/Fiber/Fiber+CustomDebugStringConvertible.swift b/Sources/TokamakCore/Fiber/Fiber+CustomDebugStringConvertible.swift index dec9df180..82b69a634 100644 --- a/Sources/TokamakCore/Fiber/Fiber+CustomDebugStringConvertible.swift +++ b/Sources/TokamakCore/Fiber/Fiber+CustomDebugStringConvertible.swift @@ -28,7 +28,9 @@ extension FiberReconciler.Fiber: CustomDebugStringConvertible { 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: [:]) + origin: .init(origin: .zero), + dimensions: .init(size: .zero, alignmentGuides: [:]), + proposal: .unspecified ) return """ \(spaces)\(String(describing: typeInfo?.type ?? Any.self) diff --git a/Sources/TokamakCore/Fiber/FiberReconciler.swift b/Sources/TokamakCore/Fiber/FiberReconciler.swift index 46b8cb991..89bccd6cf 100644 --- a/Sources/TokamakCore/Fiber/FiberReconciler.swift +++ b/Sources/TokamakCore/Fiber/FiberReconciler.swift @@ -217,8 +217,14 @@ public final class FiberReconciler { // Essentially, making the work in progress tree the current, // and leaving the current available to be the work in progress // on our next update. - let child = root.child - root.child = root.alternate?.child - root.alternate?.child = child + if root === current { + let alternate = alternate + self.alternate = current + current = alternate + } else { + let child = root.child + root.child = root.alternate?.child + root.alternate?.child = child + } } } diff --git a/Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift b/Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift index 9dae37c85..12ee1ebcc 100644 --- a/Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift +++ b/Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift @@ -82,7 +82,7 @@ public struct LayoutSubview: Equatable { private let traits: _ViewTraitStore private let sizeThatFits: (ProposedViewSize) -> CGSize private let dimensions: (CGSize) -> ViewDimensions - private let place: (ViewDimensions, CGPoint, UnitPoint) -> () + private let place: (ProposedViewSize, ViewDimensions, CGPoint, UnitPoint) -> () private let computeSpacing: () -> ViewSpacing init( @@ -90,7 +90,7 @@ public struct LayoutSubview: Equatable { traits: _ViewTraitStore, sizeThatFits: @escaping (ProposedViewSize) -> CGSize, dimensions: @escaping (CGSize) -> ViewDimensions, - place: @escaping (ViewDimensions, CGPoint, UnitPoint) -> (), + place: @escaping (ProposedViewSize, ViewDimensions, CGPoint, UnitPoint) -> (), spacing: @escaping () -> ViewSpacing ) { self.id = id @@ -130,6 +130,6 @@ public struct LayoutSubview: Equatable { anchor: UnitPoint = .topLeading, proposal: ProposedViewSize ) { - place(dimensions(in: proposal), position, anchor) + place(proposal, dimensions(in: proposal), position, anchor) } } diff --git a/Sources/TokamakCore/Fiber/Layout/_FlexFrameLayout+Layout.swift b/Sources/TokamakCore/Fiber/Layout/_FlexFrameLayout+Layout.swift index c16b3fc6a..f13f817b0 100644 --- a/Sources/TokamakCore/Fiber/Layout/_FlexFrameLayout+Layout.swift +++ b/Sources/TokamakCore/Fiber/Layout/_FlexFrameLayout+Layout.swift @@ -90,6 +90,7 @@ private struct FlexFrameLayout: Layout { } else { size.height = subviewSizes.height } + return size } @@ -105,6 +106,9 @@ private struct FlexFrameLayout: Layout { alignmentGuides: [:] ) + print("=== PLACE \(subviews.count) SUBVIEWS (FlexFrameLayout) ===") + print(bounds.size) + for (index, subview) in subviews.enumerated() { subview.place( at: .init( diff --git a/Sources/TokamakCore/Fiber/Passes/LayoutPass.swift b/Sources/TokamakCore/Fiber/Passes/LayoutPass.swift index 700c21564..55f58301c 100644 --- a/Sources/TokamakCore/Fiber/Passes/LayoutPass.swift +++ b/Sources/TokamakCore/Fiber/Passes/LayoutPass.swift @@ -28,114 +28,38 @@ struct LayoutPass: FiberReconcilerPass { guard let root = root.fiber else { return } var fiber = root - func layoutLoop() { - while true { - clean(fiber, caches: caches) - - // As we walk down the tree, ask each `View` for its ideal size. - sizeThatFits( - fiber, - in: reconciler, - caches: caches - ) - - if let child = fiber.child { - // Continue down the tree. - fiber = child - continue - } - - while fiber.sibling == nil { - // After collecting all of our subviews, place them in our bounds on the way - // back up the tree. - placeSubviews(fiber, in: reconciler, caches: caches) - // Exit at the top of the `View` tree - guard let parent = fiber.parent else { return } - guard parent !== root else { return } - // Walk up to the next parent. - fiber = parent + while true { + // Place subviews for each element fiber as we walk the tree. + if fiber.element != nil { + caches.updateLayoutCache(for: fiber) { cache in + fiber.placeSubviews( + in: .init( + origin: .zero, + size: fiber.geometry?.dimensions.size ?? reconciler.renderer.sceneSize + ), + proposal: fiber.geometry?.proposal ?? .unspecified, + subviews: caches.layoutSubviews(for: fiber), + cache: &cache.cache + ) } - - // We also place our subviews when moving across to a new sibling. - placeSubviews(fiber, in: reconciler, caches: caches) - - fiber = fiber.sibling! } - } - layoutLoop() - - // Continue past the root element to the top of the View hierarchy - // to ensure everything is placed correctly. - var layoutNode: FiberReconciler.Fiber? = fiber - while let fiber = layoutNode { - clean(fiber, caches: caches) - sizeThatFits(fiber, in: reconciler, caches: caches) - placeSubviews(fiber, in: reconciler, caches: caches) - layoutNode = fiber.parent - } - } - /// Mark any `View`s that are dirty as clean after laying them out. - func clean( - _ fiber: FiberReconciler.Fiber, - caches: FiberReconciler.Caches - ) { - caches.updateLayoutCache(for: fiber) { cache in - cache.isDirty = false - } - if let alternate = fiber.alternate { - caches.updateLayoutCache(for: alternate) { cache in - cache.isDirty = false + if let child = fiber.child { + // Continue down the tree. + fiber = child + continue } - } - } - - /// Request a size from the fiber's `elementParent`. - func sizeThatFits( - _ fiber: FiberReconciler.Fiber, - in reconciler: FiberReconciler, - caches: FiberReconciler.Caches - ) { - guard fiber.element != nil - else { return } - // Compute our required size. - // This does not have to respect the elementParent's proposed size. - let size = caches.updateLayoutCache(for: fiber) { cache -> CGSize in - fiber.sizeThatFits( - proposal: .init( - fiber.elementParent?.geometry?.dimensions.size ?? reconciler.renderer.sceneSize - ), - subviews: caches.layoutSubviews(for: fiber), - cache: &cache.cache - ) - } - let dimensions = ViewDimensions(size: size, alignmentGuides: [:]) - - // Update our geometry - fiber.geometry = .init( - origin: fiber.geometry?.origin ?? .init(origin: .zero), - dimensions: dimensions - ) - } + while fiber.sibling == nil { + // Exit at the top of the `View` tree + guard let parent = fiber.parent else { return } + guard parent !== root else { return } + // Walk up to the next parent. + fiber = parent + } - func placeSubviews( - _ fiber: FiberReconciler.Fiber, - in reconciler: FiberReconciler, - caches: FiberReconciler.Caches - ) { - caches.updateLayoutCache(for: fiber) { cache in - fiber.placeSubviews( - in: .init( - origin: .zero, - size: fiber.geometry?.dimensions.size ?? reconciler.renderer.sceneSize - ), - proposal: .init( - fiber.elementParent?.geometry?.dimensions.size ?? reconciler.renderer.sceneSize - ), - subviews: caches.layoutSubviews(for: fiber), - cache: &cache.cache - ) + // Walk across to the next sibling. + fiber = fiber.sibling! } } } diff --git a/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift b/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift index 06a3f039d..6d1874709 100644 --- a/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift +++ b/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift @@ -152,7 +152,8 @@ struct ReconcilePass: FiberReconcilerPass { // TODO: Add `alignmentGuide` modifier and pass into `ViewDimensions` ViewDimensions(size: sizeThatFits, alignmentGuides: [:]) }, - place: { [weak fiber, weak element, unowned caches] dimensions, position, anchor in + place: { [weak fiber, weak element, unowned caches] + proposal, dimensions, position, anchor in guard let fiber = fiber, let element = element else { return } let geometry = ViewGeometry( // Shift to the anchor point in the parent's coordinate space. @@ -160,7 +161,8 @@ struct ReconcilePass: FiberReconcilerPass { x: position.x - (dimensions.width * anchor.x), y: position.y - (dimensions.height * anchor.y) )), - dimensions: dimensions + dimensions: dimensions, + proposal: proposal ) // Push a layout mutation if needed. if geometry != fiber.alternate?.geometry { @@ -292,7 +294,8 @@ struct ReconcilePass: FiberReconcilerPass { newContent: newContent, geometry: node.fiber?.geometry ?? .init( origin: .init(origin: .zero), - dimensions: .init(size: .zero, alignmentGuides: [:]) + dimensions: .init(size: .zero, alignmentGuides: [:]), + proposal: .unspecified ) ) } @@ -324,9 +327,9 @@ struct ReconcilePass: FiberReconcilerPass { caches: FiberReconciler.Caches ) { guard caches.layoutCache(for: fiber).isDirty, - let parent = fiber.parent + let elementParent = fiber.elementParent else { return } - invalidateCache(for: parent, in: reconciler, caches: caches) + invalidateCache(for: elementParent, in: reconciler, caches: caches) } } diff --git a/Sources/TokamakCore/Fiber/ViewGeometry.swift b/Sources/TokamakCore/Fiber/ViewGeometry.swift index 0a0adf8bb..845aa30b8 100644 --- a/Sources/TokamakCore/Fiber/ViewGeometry.swift +++ b/Sources/TokamakCore/Fiber/ViewGeometry.swift @@ -23,6 +23,8 @@ public struct ViewGeometry: Equatable { @_spi(TokamakCore) public let dimensions: ViewDimensions + + let proposal: ProposedViewSize } /// The position of the `View` relative to its parent. From 28c6141310dccb5527bc4da6de8ab20c5118e7d1 Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Thu, 23 Jun 2022 15:10:05 -0400 Subject: [PATCH 27/38] Cleanup logs --- Sources/TokamakCore/Fiber/Layout/_FlexFrameLayout+Layout.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Sources/TokamakCore/Fiber/Layout/_FlexFrameLayout+Layout.swift b/Sources/TokamakCore/Fiber/Layout/_FlexFrameLayout+Layout.swift index f13f817b0..3230ab965 100644 --- a/Sources/TokamakCore/Fiber/Layout/_FlexFrameLayout+Layout.swift +++ b/Sources/TokamakCore/Fiber/Layout/_FlexFrameLayout+Layout.swift @@ -106,9 +106,6 @@ private struct FlexFrameLayout: Layout { alignmentGuides: [:] ) - print("=== PLACE \(subviews.count) SUBVIEWS (FlexFrameLayout) ===") - print(bounds.size) - for (index, subview) in subviews.enumerated() { subview.place( at: .init( From 1f659166e7c65d40e3f2757b898d05cf358798fa Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Thu, 23 Jun 2022 15:12:18 -0400 Subject: [PATCH 28/38] Add layoutPriority tests --- Tests/TokamakLayoutTests/StackTests.swift | 36 +++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/Tests/TokamakLayoutTests/StackTests.swift b/Tests/TokamakLayoutTests/StackTests.swift index 0c1319c31..66c1e9908 100644 --- a/Tests/TokamakLayoutTests/StackTests.swift +++ b/Tests/TokamakLayoutTests/StackTests.swift @@ -112,5 +112,41 @@ final class StackTests: XCTestCase { } } } + + func testVStackPriority() async { + await compare(size: .init(width: 500, height: 500)) { + SwiftUI.VStack { + SwiftUI.Rectangle().fill(SwiftUI.Color(white: 0)) + .frame(minHeight: 50) + SwiftUI.Rectangle().fill(SwiftUI.Color(white: 127 / 255)) + .layoutPriority(1) + } + } to: { + TokamakStaticHTML.VStack { + TokamakStaticHTML.Rectangle().fill(TokamakStaticHTML.Color(white: 0)) + .frame(minHeight: 50) + TokamakStaticHTML.Rectangle().fill(TokamakStaticHTML.Color(white: 127 / 255)) + .layoutPriority(1) + } + } + } + + func testHStackPriority() async { + await compare(size: .init(width: 500, height: 500)) { + SwiftUI.HStack { + SwiftUI.Rectangle().fill(SwiftUI.Color(white: 0)) + .frame(minHeight: 50) + SwiftUI.Rectangle().fill(SwiftUI.Color(white: 127 / 255)) + .layoutPriority(1) + } + } to: { + TokamakStaticHTML.HStack { + TokamakStaticHTML.Rectangle().fill(TokamakStaticHTML.Color(white: 0)) + .frame(minHeight: 50) + TokamakStaticHTML.Rectangle().fill(TokamakStaticHTML.Color(white: 127 / 255)) + .layoutPriority(1) + } + } + } } #endif From dbbfb16e6c29997f0866e3141bfcc667a5c70b15 Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Fri, 24 Jun 2022 11:01:34 -0400 Subject: [PATCH 29/38] Revise conflict with main --- .../TokamakCore/Layout/ProposedViewSize.swift | 49 ------------------- 1 file changed, 49 deletions(-) delete mode 100644 Sources/TokamakCore/Layout/ProposedViewSize.swift diff --git a/Sources/TokamakCore/Layout/ProposedViewSize.swift b/Sources/TokamakCore/Layout/ProposedViewSize.swift deleted file mode 100644 index 5920db3b6..000000000 --- a/Sources/TokamakCore/Layout/ProposedViewSize.swift +++ /dev/null @@ -1,49 +0,0 @@ -// 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. - -import Foundation - -public struct ProposedViewSize: Equatable, Sendable { - public var width: CGFloat? - public var height: CGFloat? - - @inlinable - public init(width: CGFloat?, height: CGFloat?) { - self.width = width - self.height = height - } -} - -public extension ProposedViewSize { - @inlinable - init(_ size: CGSize) { - self.init(width: size.width, height: size.height) - } - - static let unspecified = ProposedViewSize(width: nil, height: nil) - static let zero = ProposedViewSize(width: 0, height: 0) - static let infinity = ProposedViewSize(width: .infinity, height: .infinity) -} - -public extension ProposedViewSize { - @inlinable - func replacingUnspecifiedDimensions( - by size: CGSize = CGSize(width: 10, height: 10) - ) -> CGSize { - CGSize( - width: width ?? size.width, - height: height ?? size.height - ) - } -} From 50b741274b4b5b1061a96cb90e2afcd217f15647 Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Fri, 24 Jun 2022 11:53:00 -0400 Subject: [PATCH 30/38] Dictionary performance catch --- .../Fiber/Passes/FiberReconcilerPass.swift | 4 ++-- .../TokamakCore/Fiber/Passes/ReconcilePass.swift | 15 ++++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift b/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift index a3d74e802..fdc91fdec 100644 --- a/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift +++ b/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift @@ -39,8 +39,8 @@ extension FiberReconciler { /// Empty the cached values and flag the cache as dirty. mutating func markDirty() { isDirty = true - sizeThatFits.removeAll() - dimensions.removeAll() + sizeThatFits.removeAll(keepingCapacity: true) + dimensions.removeAll(keepingCapacity: true) } struct SizeThatFitsRequest: Hashable { diff --git a/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift b/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift index 6d1874709..f1b168e93 100644 --- a/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift +++ b/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift @@ -123,14 +123,15 @@ struct ReconcilePass: FiberReconcilerPass { { caches.appendChild(parent: elementParent, child: fiber) let parentKey = ObjectIdentifier(elementParent) - var subviews = caches.layoutSubviews[parentKey, default: .init(elementParent)] - subviews.storage.append(LayoutSubview( + let subview = LayoutSubview( id: ObjectIdentifier(node), traits: node.fiber?.outputs.traits ?? .init(), sizeThatFits: { [weak fiber, unowned caches] proposal in guard let fiber = fiber else { return .zero } + let request = FiberReconciler.Caches.LayoutCache + .SizeThatFitsRequest(proposal) return caches.updateLayoutCache(for: fiber) { cache in - if let size = cache.sizeThatFits[.init(proposal)] { + if let size = cache.sizeThatFits[request] { return size } else { let size = fiber.sizeThatFits( @@ -138,10 +139,10 @@ struct ReconcilePass: FiberReconcilerPass { subviews: caches.layoutSubviews(for: fiber), cache: &cache.cache ) - cache.sizeThatFits[.init(proposal)] = size + cache.sizeThatFits[request] = size if let alternate = fiber.alternate { caches.updateLayoutCache(for: alternate) { cache in - cache.sizeThatFits[.init(proposal)] = size + cache.sizeThatFits[request] = size } } return size @@ -182,8 +183,8 @@ struct ReconcilePass: FiberReconcilerPass { ) } } - )) - caches.layoutSubviews[parentKey] = subviews + ) + caches.layoutSubviews[parentKey, default: .init(elementParent)].storage.append(subview) } } From 2dca03c69c104cd1235c789b1239467d09564c0f Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Fri, 24 Jun 2022 12:08:49 -0400 Subject: [PATCH 31/38] Remove unneccesary capacity preservation --- Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift b/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift index fdc91fdec..a3d74e802 100644 --- a/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift +++ b/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift @@ -39,8 +39,8 @@ extension FiberReconciler { /// Empty the cached values and flag the cache as dirty. mutating func markDirty() { isDirty = true - sizeThatFits.removeAll(keepingCapacity: true) - dimensions.removeAll(keepingCapacity: true) + sizeThatFits.removeAll() + dimensions.removeAll() } struct SizeThatFitsRequest: Hashable { From 135d8a09020b941e70a47f9dea3aaadc7bed1d50 Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Fri, 24 Jun 2022 15:58:12 -0400 Subject: [PATCH 32/38] Update TokamakCoreBenchmark to handle LayoutView addition at hierarchy root --- Sources/TokamakCoreBenchmark/main.swift | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Sources/TokamakCoreBenchmark/main.swift b/Sources/TokamakCoreBenchmark/main.swift index 351a476bc..cac2cc04d 100644 --- a/Sources/TokamakCoreBenchmark/main.swift +++ b/Sources/TokamakCoreBenchmark/main.swift @@ -66,9 +66,10 @@ benchmark("update wide (FiberReconciler)") { state in let reconciler = TestFiberRenderer( .root, size: .init(width: 500, height: 500), - useDynamicLayout: false + useDynamicLayout: true ).render(view) guard case let .view(view, _) = reconciler.current // RootView + .child? // LayoutView .child? // ModifiedContent .child? // _ViewModifier_Content .child? // UpdateLast @@ -127,9 +128,10 @@ benchmark("update narrow (FiberReconciler)") { state in let reconciler = TestFiberRenderer( .root, size: .init(width: 500, height: 500), - useDynamicLayout: false + useDynamicLayout: true ).render(view) guard case let .view(view, _) = reconciler.current // RootView + .child? // LayoutView .child? // ModifiedContent .child? // _ViewModifier_Content .child? // UpdateLast @@ -199,7 +201,7 @@ benchmark("update deep (FiberReconciler)") { state in let reconciler = TestFiberRenderer( .root, size: .init(width: 500, height: 500), - useDynamicLayout: false + useDynamicLayout: true ).render(view) guard case let .view(view, _) = reconciler.current // RootView .child? // ModifiedContent @@ -242,7 +244,7 @@ struct UpdateShallow: View { var body: some View { VStack { Text(update) - RecursiveView(500) + RecursiveView(1000) Button("Update") { update = "B" } @@ -269,7 +271,7 @@ benchmark("update shallow (FiberReconciler)") { _ in let reconciler = TestFiberRenderer( .root, size: .init(width: 500, height: 500), - useDynamicLayout: false + useDynamicLayout: true ).render(view) guard case let .view(view, _) = reconciler.current // RootView .child? // ModifiedContent From ed5764f2f01cc88114376f5b15b0b2f44009d2de Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Fri, 24 Jun 2022 16:42:38 -0400 Subject: [PATCH 33/38] Implement AnyLayout and replace LayoutActions --- Sources/TokamakCore/Fiber/Fiber+Layout.swift | 146 ---------- Sources/TokamakCore/Fiber/Fiber.swift | 37 ++- Sources/TokamakCore/Fiber/Layout/Layout.swift | 264 +++++++++++++++--- .../Fiber/Passes/FiberReconcilerPass.swift | 8 +- .../TokamakCore/Fiber/Passes/LayoutPass.swift | 2 +- .../Fiber/Passes/ReconcilePass.swift | 4 +- Sources/TokamakCore/Shapes/AnyShape.swift | 4 +- Sources/TokamakCore/Shapes/Shape.swift | 5 +- Sources/TokamakCore/Views/Text/Text.swift | 21 ++ 9 files changed, 285 insertions(+), 206 deletions(-) delete mode 100644 Sources/TokamakCore/Fiber/Fiber+Layout.swift diff --git a/Sources/TokamakCore/Fiber/Fiber+Layout.swift b/Sources/TokamakCore/Fiber/Fiber+Layout.swift deleted file mode 100644 index 2974829f6..000000000 --- a/Sources/TokamakCore/Fiber/Fiber+Layout.swift +++ /dev/null @@ -1,146 +0,0 @@ -// 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 6/15/22. -// - -import Foundation - -extension FiberReconciler.Fiber: Layout { - public func makeCache(subviews: Subviews) -> Any { - layout.makeCache(subviews) - } - - public func updateCache(_ cache: inout Any, subviews: Subviews) { - layout.updateCache(&cache, subviews) - } - - public func spacing(subviews: Subviews, cache: inout Any) -> ViewSpacing { - if case let .view(view, _) = content, - view is Text - { - let spacing = ViewSpacing( - viewType: Text.self, - top: { $0.viewType == Text.self ? 0 : ViewSpacing.defaultValue }, - leading: { _ in ViewSpacing.defaultValue }, - bottom: { $0.viewType == Text.self ? 0 : ViewSpacing.defaultValue }, - trailing: { _ in ViewSpacing.defaultValue } - ) - return spacing - } else { - return layout.spacing(subviews, &cache) - } - } - - public func sizeThatFits( - proposal: ProposedViewSize, - subviews: Subviews, - cache: inout Any - ) -> CGSize { - if case let .view(view, _) = content, - let text = view as? Text - { - return self.reconciler?.renderer.measureText( - text, proposal: proposal, in: self.outputs.environment.environment - ) ?? .zero - } else { - return layout.sizeThatFits(proposal, subviews, &cache) - } - } - - public func placeSubviews( - in bounds: CGRect, - proposal: ProposedViewSize, - subviews: Subviews, - cache: inout Any - ) { - layout.placeSubviews(bounds, proposal, subviews, &cache) - } - - public func explicitAlignment( - of guide: HorizontalAlignment, - in bounds: CGRect, - proposal: ProposedViewSize, - subviews: Subviews, - cache: inout Any - ) -> CGFloat? { - layout.explicitHorizontalAlignment(guide, bounds, proposal, subviews, &cache) - } - - public func explicitAlignment( - of guide: VerticalAlignment, - in bounds: CGRect, - proposal: ProposedViewSize, - subviews: Subviews, - cache: inout Any - ) -> CGFloat? { - layout.explicitVerticalAlignment(guide, bounds, proposal, subviews, &cache) - } -} - -public struct LayoutActions { - let makeCache: (LayoutSubviews) -> Any - let updateCache: (inout Any, LayoutSubviews) -> () - let spacing: (LayoutSubviews, inout Any) -> ViewSpacing - let sizeThatFits: (ProposedViewSize, LayoutSubviews, inout Any) -> CGSize - let placeSubviews: (CGRect, ProposedViewSize, LayoutSubviews, inout Any) -> () - let explicitHorizontalAlignment: ( - HorizontalAlignment, CGRect, ProposedViewSize, LayoutSubviews, inout Any - ) -> CGFloat? - let explicitVerticalAlignment: ( - VerticalAlignment, CGRect, ProposedViewSize, LayoutSubviews, inout Any - ) -> CGFloat? - - private static func useCache(_ cache: inout Any, _ action: (inout C) -> R) -> R { - guard var typedCache = cache as? C else { fatalError("Cache mismatch") } - let result = action(&typedCache) - cache = typedCache - return result - } - - init(_ layout: L) { - makeCache = { layout.makeCache(subviews: $0) } - updateCache = { cache, subviews in - Self.useCache(&cache) { layout.updateCache(&$0, subviews: subviews) } - } - spacing = { subviews, cache in - Self.useCache(&cache) { layout.spacing(subviews: subviews, cache: &$0) } - } - sizeThatFits = { proposal, subviews, cache in - Self - .useCache(&cache) { - layout.sizeThatFits(proposal: proposal, subviews: subviews, cache: &$0) - } - } - placeSubviews = { bounds, proposal, subviews, cache in - Self.useCache(&cache) { - layout.placeSubviews(in: bounds, proposal: proposal, subviews: subviews, cache: &$0) - } - } - explicitHorizontalAlignment = { alignment, bounds, proposal, subviews, cache in - Self.useCache(&cache) { - layout.explicitAlignment( - of: alignment, in: bounds, proposal: proposal, subviews: subviews, cache: &$0 - ) - } - } - explicitVerticalAlignment = { alignment, bounds, proposal, subviews, cache in - Self.useCache(&cache) { - layout.explicitAlignment( - of: alignment, in: bounds, proposal: proposal, subviews: subviews, cache: &$0 - ) - } - } - } -} diff --git a/Sources/TokamakCore/Fiber/Fiber.swift b/Sources/TokamakCore/Fiber/Fiber.swift index a7a8222d8..b76e30ae5 100644 --- a/Sources/TokamakCore/Fiber/Fiber.swift +++ b/Sources/TokamakCore/Fiber/Fiber.swift @@ -54,12 +54,15 @@ public extension FiberReconciler { /// all stored properties be set before using. /// `outputs` is guaranteed to be set in the initializer. var outputs: ViewOutputs! - var layout: LayoutActions + /// The erased `Layout` to use for this content. + /// + /// Stored as an IUO because it uses `bindProperties` to create the underlying instance. + var layout: AnyLayout! /// The identity of this `View` var id: Identity? - /// The mounted element, if this is a Renderer primitive. + /// The mounted element, if this is a `Renderer` primitive. var element: Renderer.ElementType? - /// The index of this element in its elementParent + /// The index of this element in its `elementParent` var elementIndex: Int? /// The first child node. @_spi(TokamakCore) @@ -112,7 +115,6 @@ public extension FiberReconciler { init( _ view: inout V, - layoutActions: LayoutActions? = nil, element: Renderer.ElementType?, parent: Fiber?, elementParent: Fiber?, @@ -125,7 +127,6 @@ public extension FiberReconciler { sibling = nil self.parent = parent self.elementParent = elementParent - layout = (view as? _AnyLayout)?._makeActions() ?? .init(DefaultLayout()) typeInfo = TokamakCore.typeInfo(of: V.self) let environment = parent?.outputs.environment ?? .init(.init()) @@ -139,6 +140,8 @@ public extension FiberReconciler { content = content(for: view) + layout = (view as? _AnyLayout)?._erased() ?? .init(DefaultLayout()) + if let element = element { self.element = element } else if Renderer.isPrimitive(view) { @@ -159,7 +162,7 @@ public extension FiberReconciler { let alternate = Fiber( bound: alternateView, state: self.state, - layoutActions: self.layout, + layout: self.layout, alternate: self, outputs: self.outputs, typeInfo: self.typeInfo, @@ -189,7 +192,7 @@ public extension FiberReconciler { init( bound view: V, state: [PropertyInfo: MutableStorage], - layoutActions: LayoutActions, + layout: AnyLayout, alternate: Fiber, outputs: ViewOutputs, typeInfo: TypeInfo?, @@ -208,7 +211,7 @@ public extension FiberReconciler { self.typeInfo = typeInfo self.outputs = outputs self.state = state - layout = layoutActions + self.layout = layout content = content(for: view) } @@ -267,6 +270,8 @@ public extension FiberReconciler { ) outputs = V._makeView(inputs) + layout = (view as? _AnyLayout)?._erased() ?? .init(DefaultLayout()) + if Renderer.isPrimitive(view) { return .init(from: view, useDynamicLayout: reconciler?.renderer.useDynamicLayout ?? false) } else { @@ -288,7 +293,6 @@ public extension FiberReconciler { elementParent = nil element = rootElement typeInfo = TokamakCore.typeInfo(of: A.self) - layout = .init(RootLayout(renderer: reconciler.renderer)) state = bindProperties(to: &app, typeInfo, rootEnvironment) outputs = .init( inputs: .init(content: app, environment: .init(rootEnvironment), traits: .init()) @@ -296,6 +300,8 @@ public extension FiberReconciler { content = content(for: app) + layout = .init(RootLayout(renderer: reconciler.renderer)) + let alternateApp = app createAndBindAlternate = { [weak self] in guard let self = self else { return nil } @@ -303,7 +309,7 @@ public extension FiberReconciler { let alternate = Fiber( bound: alternateApp, state: self.state, - layoutActions: self.layout, + layout: self.layout, alternate: self, outputs: self.outputs, typeInfo: self.typeInfo, @@ -318,7 +324,7 @@ public extension FiberReconciler { init( bound app: A, state: [PropertyInfo: MutableStorage], - layoutActions: LayoutActions, + layout: AnyLayout, alternate: Fiber, outputs: SceneOutputs, typeInfo: TypeInfo?, @@ -335,7 +341,7 @@ public extension FiberReconciler { self.typeInfo = typeInfo self.outputs = outputs self.state = state - layout = layoutActions + self.layout = layout content = content(for: app) } @@ -353,7 +359,6 @@ public extension FiberReconciler { self.parent = parent self.elementParent = elementParent self.element = element - layout = (scene as? _AnyLayout)?._makeActions() ?? .init(DefaultLayout()) typeInfo = TokamakCore.typeInfo(of: S.self) let environment = environment ?? parent?.outputs.environment ?? .init(.init()) @@ -368,6 +373,8 @@ public extension FiberReconciler { content = content(for: scene) + layout = (scene as? _AnyLayout)?._erased() ?? .init(DefaultLayout()) + let alternateScene = scene createAndBindAlternate = { [weak self] in guard let self = self else { return nil } @@ -405,7 +412,7 @@ public extension FiberReconciler { init( bound scene: S, state: [PropertyInfo: MutableStorage], - layout: LayoutActions, + layout: AnyLayout, alternate: Fiber, outputs: SceneOutputs, typeInfo: TypeInfo?, @@ -442,6 +449,8 @@ public extension FiberReconciler { traits: .init() )) + layout = (scene as? _AnyLayout)?._erased() ?? .init(DefaultLayout()) + return nil } } diff --git a/Sources/TokamakCore/Fiber/Layout/Layout.swift b/Sources/TokamakCore/Fiber/Layout/Layout.swift index 4b33015d6..9e64f164b 100644 --- a/Sources/TokamakCore/Fiber/Layout/Layout.swift +++ b/Sources/TokamakCore/Fiber/Layout/Layout.swift @@ -18,7 +18,7 @@ import Foundation public protocol _AnyLayout { - func _makeActions() -> LayoutActions + func _erased() -> AnyLayout } /// A type that participates in the layout pass. @@ -85,7 +85,7 @@ public protocol Layout: Animatable, _AnyLayout { } public extension Layout { - func _makeActions() -> LayoutActions { + func _erased() -> AnyLayout { .init(self) } } @@ -232,35 +232,233 @@ struct DefaultLayout: Layout { } } -// TODO: AnyLayout -// class AnyLayoutBox { -// -// } -// -// final class ConcreteLayoutBox: AnyLayoutBox { -// -// } +@usableFromInline +protocol AnyLayoutBox { + var layoutProperties: LayoutProperties { get } -// @frozen public struct AnyLayout: Layout { -// internal var storage: AnyLayoutBox -// -// public init(_ layout: L) where L: Layout { -// storage = ConcreteLayoutBox(layout) -// } -// -// public struct Cache { -// } -// -// public typealias AnimatableData = _AnyAnimatableData -// public func makeCache(subviews: AnyLayout.Subviews) -> AnyLayout.Cache -// public func updateCache(_ cache: inout AnyLayout.Cache, subviews: AnyLayout.Subviews) -// public func spacing(subviews: AnyLayout.Subviews, cache: inout AnyLayout.Cache) -> ViewSpacing -// public func sizeThatFits(proposal: ProposedViewSize, subviews: AnyLayout.Subviews, cache: inout AnyLayout.Cache) -> CGSize -// public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: AnyLayout.Subviews, cache: inout AnyLayout.Cache) -// public func explicitAlignment(of guide: HorizontalAlignment, in bounds: CGRect, proposal: ProposedViewSize, subviews: AnyLayout.Subviews, cache: inout AnyLayout.Cache) -> CGFloat? -// public func explicitAlignment(of guide: VerticalAlignment, in bounds: CGRect, proposal: ProposedViewSize, subviews: AnyLayout.Subviews, cache: inout AnyLayout.Cache) -> CGFloat? -// public var animatableData: AnyLayout.AnimatableData { -// get -// set -// } -// } + typealias Subviews = LayoutSubviews + typealias Cache = Any + + func makeCache(subviews: Self.Subviews) -> Self.Cache + + func updateCache(_ cache: inout Self.Cache, subviews: Self.Subviews) + + func spacing(subviews: Self.Subviews, cache: inout Self.Cache) -> ViewSpacing + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Self.Subviews, + cache: inout Self.Cache + ) -> CGSize + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Self.Subviews, + cache: inout Self.Cache + ) + + func explicitAlignment( + of guide: HorizontalAlignment, + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Self.Subviews, + cache: inout Self.Cache + ) -> CGFloat? + + func explicitAlignment( + of guide: VerticalAlignment, + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Self.Subviews, + cache: inout Self.Cache + ) -> CGFloat? + + var animatableData: _AnyAnimatableData { get set } +} + +struct ConcreteLayoutBox: AnyLayoutBox { + var base: L + + var layoutProperties: LayoutProperties { L.layoutProperties } + + func makeCache(subviews: Subviews) -> Cache { + base.makeCache(subviews: subviews) + } + + private func typedCache( + subviews: Subviews, + erasedCache: inout Cache, + _ action: (inout L.Cache) -> R + ) -> R { + var typedCache = erasedCache as? L.Cache ?? base.makeCache(subviews: subviews) + defer { erasedCache = typedCache } + return action(&typedCache) + } + + func updateCache(_ cache: inout Cache, subviews: Subviews) { + typedCache(subviews: subviews, erasedCache: &cache) { + base.updateCache(&$0, subviews: subviews) + } + } + + func spacing(subviews: Subviews, cache: inout Cache) -> ViewSpacing { + typedCache(subviews: subviews, erasedCache: &cache) { + base.spacing(subviews: subviews, cache: &$0) + } + } + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) -> CGSize { + typedCache(subviews: subviews, erasedCache: &cache) { + base.sizeThatFits(proposal: proposal, subviews: subviews, cache: &$0) + } + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) { + typedCache(subviews: subviews, erasedCache: &cache) { + base.placeSubviews(in: bounds, proposal: proposal, subviews: subviews, cache: &$0) + } + } + + func explicitAlignment( + of guide: HorizontalAlignment, + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) -> CGFloat? { + typedCache(subviews: subviews, erasedCache: &cache) { + base.explicitAlignment( + of: guide, + in: bounds, + proposal: proposal, + subviews: subviews, + cache: &$0 + ) + } + } + + func explicitAlignment( + of guide: VerticalAlignment, + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) -> CGFloat? { + typedCache(subviews: subviews, erasedCache: &cache) { + base.explicitAlignment( + of: guide, + in: bounds, + proposal: proposal, + subviews: subviews, + cache: &$0 + ) + } + } + + var animatableData: _AnyAnimatableData { + get { + .init(base.animatableData) + } + set { + guard let newData = newValue.value as? L.AnimatableData else { return } + base.animatableData = newData + } + } +} + +@frozen +public struct AnyLayout: Layout { + var storage: AnyLayoutBox + + public init(_ layout: L) where L: Layout { + storage = ConcreteLayoutBox(base: layout) + } + + public struct Cache { + var erasedCache: Any + } + + public func makeCache(subviews: AnyLayout.Subviews) -> AnyLayout.Cache { + .init(erasedCache: storage.makeCache(subviews: subviews)) + } + + public func updateCache(_ cache: inout AnyLayout.Cache, subviews: AnyLayout.Subviews) { + storage.updateCache(&cache.erasedCache, subviews: subviews) + } + + public func spacing(subviews: AnyLayout.Subviews, cache: inout AnyLayout.Cache) -> ViewSpacing { + storage.spacing(subviews: subviews, cache: &cache.erasedCache) + } + + public func sizeThatFits( + proposal: ProposedViewSize, + subviews: AnyLayout.Subviews, + cache: inout AnyLayout.Cache + ) -> CGSize { + storage.sizeThatFits(proposal: proposal, subviews: subviews, cache: &cache.erasedCache) + } + + public func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: AnyLayout.Subviews, + cache: inout AnyLayout.Cache + ) { + storage.placeSubviews( + in: bounds, + proposal: proposal, + subviews: subviews, + cache: &cache.erasedCache + ) + } + + public func explicitAlignment( + of guide: HorizontalAlignment, + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: AnyLayout.Subviews, + cache: inout AnyLayout.Cache + ) -> CGFloat? { + storage.explicitAlignment( + of: guide, + in: bounds, + proposal: proposal, + subviews: subviews, + cache: &cache.erasedCache + ) + } + + public func explicitAlignment( + of guide: VerticalAlignment, + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: AnyLayout.Subviews, + cache: inout AnyLayout.Cache + ) -> CGFloat? { + storage.explicitAlignment( + of: guide, in: bounds, + proposal: proposal, + subviews: subviews, + cache: &cache.erasedCache + ) + } + + public var animatableData: _AnyAnimatableData { + get { + _AnyAnimatableData(storage.animatableData) + } + set { + storage.animatableData = newValue + } + } +} diff --git a/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift b/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift index a3d74e802..91bebfa24 100644 --- a/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift +++ b/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift @@ -27,7 +27,7 @@ extension FiberReconciler { struct LayoutCache { /// The erased `Layout.Cache` value. - var cache: Any + var cache: AnyLayout.Cache /// Cached values for `sizeThatFits` calls. var sizeThatFits: [SizeThatFitsRequest: CGSize] /// Cached values for `dimensions(in:)` calls. @@ -69,7 +69,7 @@ extension FiberReconciler { layoutCaches[ ObjectIdentifier(fiber), default: .init( - cache: fiber.makeCache(subviews: layoutSubviews(for: fiber)), + cache: fiber.layout.makeCache(subviews: layoutSubviews(for: fiber)), sizeThatFits: [:], dimensions: [:], isDirty: false @@ -83,7 +83,7 @@ extension FiberReconciler { var cache = layoutCaches[ key, default: .init( - cache: fiber.makeCache(subviews: subviews), + cache: fiber.layout.makeCache(subviews: subviews), sizeThatFits: [:], dimensions: [:], isDirty: false @@ -91,7 +91,7 @@ extension FiberReconciler { ] // If the cache is dirty, update it before calling `action`. if cache.isDirty { - fiber.updateCache(&cache.cache, subviews: subviews) + fiber.layout.updateCache(&cache.cache, subviews: subviews) cache.isDirty = false } defer { layoutCaches[key] = cache } diff --git a/Sources/TokamakCore/Fiber/Passes/LayoutPass.swift b/Sources/TokamakCore/Fiber/Passes/LayoutPass.swift index 55f58301c..b3ad0934e 100644 --- a/Sources/TokamakCore/Fiber/Passes/LayoutPass.swift +++ b/Sources/TokamakCore/Fiber/Passes/LayoutPass.swift @@ -32,7 +32,7 @@ struct LayoutPass: FiberReconcilerPass { // Place subviews for each element fiber as we walk the tree. if fiber.element != nil { caches.updateLayoutCache(for: fiber) { cache in - fiber.placeSubviews( + fiber.layout.placeSubviews( in: .init( origin: .zero, size: fiber.geometry?.dimensions.size ?? reconciler.renderer.sceneSize diff --git a/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift b/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift index f1b168e93..0ed5c34ea 100644 --- a/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift +++ b/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift @@ -134,7 +134,7 @@ struct ReconcilePass: FiberReconcilerPass { if let size = cache.sizeThatFits[request] { return size } else { - let size = fiber.sizeThatFits( + let size = fiber.layout.sizeThatFits( proposal: proposal, subviews: caches.layoutSubviews(for: fiber), cache: &cache.cache @@ -177,7 +177,7 @@ struct ReconcilePass: FiberReconcilerPass { guard let fiber = fiber else { return .init() } return caches.updateLayoutCache(for: fiber) { cache in - fiber.spacing( + fiber.layout.spacing( subviews: caches.layoutSubviews(for: fiber), cache: &cache.cache ) diff --git a/Sources/TokamakCore/Shapes/AnyShape.swift b/Sources/TokamakCore/Shapes/AnyShape.swift index 411d83aeb..45b6cbb4a 100644 --- a/Sources/TokamakCore/Shapes/AnyShape.swift +++ b/Sources/TokamakCore/Shapes/AnyShape.swift @@ -14,7 +14,7 @@ import Foundation -internal protocol AnyShapeBox { +protocol AnyShapeBox { var animatableDataBox: _AnyAnimatableData { get set } func path(in rect: CGRect) -> Path @@ -49,7 +49,7 @@ private struct ConcreteAnyShapeBox: AnyShapeBox { } public struct AnyShape: Shape { - internal var box: AnyShapeBox + var box: AnyShapeBox private init(_ box: AnyShapeBox) { self.box = box diff --git a/Sources/TokamakCore/Shapes/Shape.swift b/Sources/TokamakCore/Shapes/Shape.swift index 3b8d7c326..fb4649edc 100644 --- a/Sources/TokamakCore/Shapes/Shape.swift +++ b/Sources/TokamakCore/Shapes/Shape.swift @@ -31,10 +31,7 @@ public extension Shape { // SwiftUI seems to not compute the path at all and just return // the following. - CGSize( - width: proposal.width ?? 10, - height: proposal.height ?? 10 - ) + proposal.replacingUnspecifiedDimensions() } } diff --git a/Sources/TokamakCore/Views/Text/Text.swift b/Sources/TokamakCore/Views/Text/Text.swift index 349f1f895..a32bb2890 100644 --- a/Sources/TokamakCore/Views/Text/Text.swift +++ b/Sources/TokamakCore/Views/Text/Text.swift @@ -195,3 +195,24 @@ public extension Text { ])) } } + +extension Text: Layout { + public func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) -> CGSize { + environment.measureText(self, proposal, environment) + } + + public func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + for subview in subviews { + subview.place(at: bounds.origin, proposal: proposal) + } + } +} From d1554c093b35bd728c86393de9691627a684ec76 Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Fri, 24 Jun 2022 16:52:32 -0400 Subject: [PATCH 34/38] Allow VStack/HStack to be created without children --- .../Fiber/Layout/StackLayout.swift | 22 +++++++++++++++++++ Sources/TokamakDOM/Core.swift | 4 ++++ Sources/TokamakStaticHTML/Core.swift | 5 +++++ 3 files changed, 31 insertions(+) diff --git a/Sources/TokamakCore/Fiber/Layout/StackLayout.swift b/Sources/TokamakCore/Fiber/Layout/StackLayout.swift index cbe34076b..cf15839ad 100644 --- a/Sources/TokamakCore/Fiber/Layout/StackLayout.swift +++ b/Sources/TokamakCore/Fiber/Layout/StackLayout.swift @@ -259,8 +259,30 @@ extension VStack: StackLayout { public var _alignment: Alignment { .init(horizontal: alignment, vertical: .center) } } +public extension VStack where Content == EmptyView { + init( + alignment: HorizontalAlignment = .center, + spacing: CGFloat? = nil + ) { + self.alignment = alignment + self.spacing = spacing + content = EmptyView() + } +} + @_spi(TokamakCore) extension HStack: StackLayout { public static var orientation: Axis { .horizontal } public var _alignment: Alignment { .init(horizontal: .center, vertical: alignment) } } + +public extension HStack where Content == EmptyView { + init( + alignment: VerticalAlignment = .center, + spacing: CGFloat? = nil + ) { + self.alignment = alignment + self.spacing = spacing + content = EmptyView() + } +} diff --git a/Sources/TokamakDOM/Core.swift b/Sources/TokamakDOM/Core.swift index b2d981899..803616c5d 100644 --- a/Sources/TokamakDOM/Core.swift +++ b/Sources/TokamakDOM/Core.swift @@ -117,6 +117,7 @@ public typealias CGAffineTransform = TokamakCore.CGAffineTransform #endif public typealias Angle = TokamakCore.Angle +public typealias Axis = TokamakCore.Axis public typealias UnitPoint = TokamakCore.UnitPoint public typealias Edge = TokamakCore.Edge @@ -179,6 +180,9 @@ public typealias View = TokamakCore.View public typealias AnyView = TokamakCore.AnyView public typealias EmptyView = TokamakCore.EmptyView +public typealias Layout = TokamakCore.Layout +public typealias AnyLayout = TokamakCore.AnyLayout + // MARK: Toolbars public typealias ToolbarItem = TokamakCore.ToolbarItem diff --git a/Sources/TokamakStaticHTML/Core.swift b/Sources/TokamakStaticHTML/Core.swift index 9bb6af377..9820ebeab 100644 --- a/Sources/TokamakStaticHTML/Core.swift +++ b/Sources/TokamakStaticHTML/Core.swift @@ -61,6 +61,8 @@ public typealias AngularGradient = TokamakCore.AngularGradient // MARK: Primitive values +public typealias Axis = TokamakCore.Axis + public typealias Color = TokamakCore.Color public typealias Font = TokamakCore.Font @@ -98,6 +100,9 @@ public typealias View = TokamakCore.View public typealias AnyView = TokamakCore.AnyView public typealias EmptyView = TokamakCore.EmptyView +public typealias Layout = TokamakCore.Layout +public typealias AnyLayout = TokamakCore.AnyLayout + // MARK: Toolbars public typealias ToolbarItem = TokamakCore.ToolbarItem From 5eda2a37dc8a803849aa70186c6629b728f91aea Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Sat, 25 Jun 2022 09:25:42 -0400 Subject: [PATCH 35/38] Fix padding implementation --- .../TokamakCore/Fiber/Layout/PaddingLayout+Layout.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Sources/TokamakCore/Fiber/Layout/PaddingLayout+Layout.swift b/Sources/TokamakCore/Fiber/Layout/PaddingLayout+Layout.swift index f10f76097..0a35abfde 100644 --- a/Sources/TokamakCore/Fiber/Layout/PaddingLayout+Layout.swift +++ b/Sources/TokamakCore/Fiber/Layout/PaddingLayout+Layout.swift @@ -41,8 +41,14 @@ private struct PaddingLayout: Layout { subviews: Subviews, cache: inout () ) -> CGSize { - let subviewSize = (subviews.first?.sizeThatFits(proposal) ?? .zero) + let proposal = proposal.replacingUnspecifiedDimensions() let insets = EdgeInsets(applying: edges, to: insets ?? .init(_all: 10)) + let subviewSize = subviews.first?.sizeThatFits( + .init( + width: proposal.width - insets.leading - insets.trailing, + height: proposal.height - insets.top - insets.bottom + ) + ) ?? .zero return .init( width: subviewSize.width + insets.leading + insets.trailing, height: subviewSize.height + insets.top + insets.bottom From ca76ece96495e3ccd3b1519a103469cda818a82b Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Sat, 25 Jun 2022 13:57:59 -0400 Subject: [PATCH 36/38] Formatting fixes --- Sources/TokamakCore/Fiber/Fiber.swift | 11 +++++++++++ Sources/TokamakCore/Fiber/FiberReconciler.swift | 6 ++++++ Sources/TokamakCore/Fiber/Layout/Layout.swift | 10 ++++++++-- .../TokamakCore/Fiber/Layout/StackLayout.swift | 15 +++++++++------ .../TokamakCore/Fiber/Layout/ViewSpacing.swift | 1 + .../Fiber/Layout/_FlexFrameLayout+Layout.swift | 6 ++++++ .../Fiber/Layout/_FrameLayout+Layout.swift | 8 ++++++++ .../Fiber/Passes/FiberReconcilerPass.swift | 3 +++ .../TokamakCore/Fiber/Passes/ReconcilePass.swift | 8 ++++---- Sources/TokamakCore/Views/Layout/HStack.swift | 2 ++ Sources/TokamakCore/Views/Layout/VStack.swift | 2 ++ 11 files changed, 60 insertions(+), 12 deletions(-) diff --git a/Sources/TokamakCore/Fiber/Fiber.swift b/Sources/TokamakCore/Fiber/Fiber.swift index b76e30ae5..9edef115b 100644 --- a/Sources/TokamakCore/Fiber/Fiber.swift +++ b/Sources/TokamakCore/Fiber/Fiber.swift @@ -47,6 +47,7 @@ public extension FiberReconciler { /// which requires all stored properties be set before capturing. @_spi(TokamakCore) public var content: Content! + /// Outputs from evaluating `View._makeView` /// /// Stored as an IUO because creating `ViewOutputs` depends on @@ -54,32 +55,42 @@ public extension FiberReconciler { /// all stored properties be set before using. /// `outputs` is guaranteed to be set in the initializer. var outputs: ViewOutputs! + /// The erased `Layout` to use for this content. /// /// Stored as an IUO because it uses `bindProperties` to create the underlying instance. var layout: AnyLayout! + /// The identity of this `View` 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. @_spi(TokamakCore) public var sibling: Fiber? + /// An unowned reference to the parent node. /// /// Parent references are `unowned` (as opposed to `weak`) /// because the parent will always exist if a child does. /// If the parent is released, the child is released with it. unowned var parent: Fiber? + /// The nearest parent that can be mounted on. unowned var elementParent: Fiber? + /// The cached type information for the underlying `View`. var typeInfo: TypeInfo? + /// Boxes that store `State` data. var state: [PropertyInfo: MutableStorage] = [:] diff --git a/Sources/TokamakCore/Fiber/FiberReconciler.swift b/Sources/TokamakCore/Fiber/FiberReconciler.swift index 89bccd6cf..0c5089e87 100644 --- a/Sources/TokamakCore/Fiber/FiberReconciler.swift +++ b/Sources/TokamakCore/Fiber/FiberReconciler.swift @@ -23,14 +23,17 @@ 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! + /// The alternate of `current`, or the work in progress tree root. /// /// We must keep a strong reference to both the current and alternate tree roots, /// as they only keep weak references to each other. private var alternate: Fiber! + /// The `FiberRenderer` used to create and update the `Element`s on screen. public let renderer: Renderer + /// Enabled passes to run on each `reconcile(from:)` call. private let passes: [FiberReconcilerPass] private let caches: Caches @@ -53,6 +56,9 @@ public final class FiberReconciler { } } + /// The `Layout` container for the root of a `View` hierarchy. + /// + /// Simply places each `View` in the center of its bounds. struct RootLayout: Layout { let renderer: Renderer diff --git a/Sources/TokamakCore/Fiber/Layout/Layout.swift b/Sources/TokamakCore/Fiber/Layout/Layout.swift index 9e64f164b..cec49e5ae 100644 --- a/Sources/TokamakCore/Fiber/Layout/Layout.swift +++ b/Sources/TokamakCore/Fiber/Layout/Layout.swift @@ -17,14 +17,17 @@ import Foundation +/// Erase a `Layout` conformance to an `AnyLayout`. +/// +/// This could potentially be removed in Swift 5.7 in favor of `any Layout`. public protocol _AnyLayout { func _erased() -> AnyLayout } /// A type that participates in the layout pass. /// -/// Any `View` or `Scene` that implements this protocol will be used to computed layout in -/// a `FiberRenderer` with `useDynamicLayout` enabled. +/// Any `View` or `Scene` that implements this protocol will be used to compute layout in +/// a `FiberRenderer` with `useDynamicLayout` set to `true`. public protocol Layout: Animatable, _AnyLayout { static var layoutProperties: LayoutProperties { get } @@ -232,6 +235,9 @@ struct DefaultLayout: Layout { } } +/// Describes a container for an erased `Layout` type. +/// +/// Matches the `Layout` protocol with `Cache` erased to `Any`. @usableFromInline protocol AnyLayoutBox { var layoutProperties: LayoutProperties { get } diff --git a/Sources/TokamakCore/Fiber/Layout/StackLayout.swift b/Sources/TokamakCore/Fiber/Layout/StackLayout.swift index cf15839ad..b8da37c32 100644 --- a/Sources/TokamakCore/Fiber/Layout/StackLayout.swift +++ b/Sources/TokamakCore/Fiber/Layout/StackLayout.swift @@ -33,6 +33,7 @@ public struct StackLayoutCache { /// The widest/tallest (depending on the `axis`) subview. /// Used to place subviews along the `alignment`. var maxSubview: ViewDimensions? + /// The ideal size for each subview as computed in `sizeThatFits`. var idealSizes = [CGSize]() } @@ -54,8 +55,10 @@ private struct MeasuredSubview { public protocol StackLayout: Layout where Cache == StackLayoutCache { /// The direction of this stack. `vertical` for `VStack`, `horizontal` for `HStack`. static var orientation: Axis { get } + /// The full `Alignment` with an ignored value for the main axis. var _alignment: Alignment { get } + var spacing: CGFloat? { get } } @@ -169,7 +172,7 @@ public extension StackLayout { // Calculate ideal sizes for each View based on their min/max sizes and the space available. var available = proposal[keyPath: Self.mainAxis] - minSize - totalSpacing - /// The final resulting size. + // The final resulting size. var size = CGSize.zero size[keyPath: Self.crossAxis] = cache.maxSubview?.size[keyPath: Self.crossAxis] ?? .zero for subview in measuredSubviews.sorted(by: { @@ -181,9 +184,9 @@ public extension StackLayout { return $0.view.priority > $1.view.priority } }) { - /// The amount of space available to `View`s with this priority value. + // The amount of space available to `View`s with this priority value. let priorityAvailable = available + prioritySize[subview.view.priority, default: 0] - /// The number of `View`s with this priority value remaining as a `CGFloat`. + // The number of `View`s with this priority value remaining as a `CGFloat`. let priorityRemaining = CGFloat(priorityCount[subview.view.priority, default: 1]) // Propose the full `crossAxis`, but only the remaining `mainAxis`. // Divvy up the available space between each remaining `View` with this priority value. @@ -211,10 +214,10 @@ public extension StackLayout { subviews: Subviews, cache: inout Cache ) { - /// The current progress along the `mainAxis`. + // The current progress along the `mainAxis`. var position = CGFloat.zero - /// The offset of the `_alignment` in the `maxSubview`, - /// used as the reference point for alignments along this axis. + // The offset of the `_alignment` in the `maxSubview`, + // used as the reference point for alignments along this axis. let alignmentOffset = cache.maxSubview?[alignment: _alignment, in: Self.orientation] ?? .zero for (index, view) in subviews.enumerated() { // Add a gap for the spacing distance from the previous subview to this one. diff --git a/Sources/TokamakCore/Fiber/Layout/ViewSpacing.swift b/Sources/TokamakCore/Fiber/Layout/ViewSpacing.swift index aafe4d9e2..527d12c6a 100644 --- a/Sources/TokamakCore/Fiber/Layout/ViewSpacing.swift +++ b/Sources/TokamakCore/Fiber/Layout/ViewSpacing.swift @@ -27,6 +27,7 @@ public struct ViewSpacing { /// Some `View`s prefer different spacing based on the `View` they are adjacent to. @_spi(TokamakCore) public var viewType: Any.Type? + private var top: (ViewSpacing) -> CGFloat private var leading: (ViewSpacing) -> CGFloat private var bottom: (ViewSpacing) -> CGFloat diff --git a/Sources/TokamakCore/Fiber/Layout/_FlexFrameLayout+Layout.swift b/Sources/TokamakCore/Fiber/Layout/_FlexFrameLayout+Layout.swift index 3230ab965..f7b06ae51 100644 --- a/Sources/TokamakCore/Fiber/Layout/_FlexFrameLayout+Layout.swift +++ b/Sources/TokamakCore/Fiber/Layout/_FlexFrameLayout+Layout.swift @@ -17,6 +17,12 @@ import Foundation +/// A `Layout` container that creates a frame with constraints. +/// +/// The children are proposed the full proposal given to this container +/// clamped to the specified minimum and maximum values. +/// +/// Then the children are placed with `alignment` in the container. private struct FlexFrameLayout: Layout { let minWidth: CGFloat? let idealWidth: CGFloat? diff --git a/Sources/TokamakCore/Fiber/Layout/_FrameLayout+Layout.swift b/Sources/TokamakCore/Fiber/Layout/_FrameLayout+Layout.swift index a01e8fab9..89c8dc8e9 100644 --- a/Sources/TokamakCore/Fiber/Layout/_FrameLayout+Layout.swift +++ b/Sources/TokamakCore/Fiber/Layout/_FrameLayout+Layout.swift @@ -17,6 +17,14 @@ import Foundation +/// A `Layout` container that requests a specific size on one or more axes. +/// +/// The container proposes the constrained size to its children, +/// then places them with `alignment` in the constrained bounds. +/// +/// Children request their own size, so they may overflow this container. +/// +/// If no fixed size is specified for a an axis, the container will use the size of its children. private struct FrameLayout: Layout { let width: CGFloat? let height: CGFloat? diff --git a/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift b/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift index 91bebfa24..a5254b523 100644 --- a/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift +++ b/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift @@ -28,10 +28,13 @@ extension FiberReconciler { struct LayoutCache { /// The erased `Layout.Cache` value. var cache: AnyLayout.Cache + /// Cached values for `sizeThatFits` calls. var sizeThatFits: [SizeThatFitsRequest: CGSize] + /// Cached values for `dimensions(in:)` calls. var dimensions: [SizeThatFitsRequest: ViewDimensions] + /// Does this cache need to be updated before using? /// Set to `true` whenever the subviews or the container changes. var isDirty: Bool diff --git a/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift b/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift index 0ed5c34ea..f403f083e 100644 --- a/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift +++ b/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift @@ -68,10 +68,10 @@ struct ReconcilePass: FiberReconcilerPass { ) where R: FiberRenderer { var node = root - /// Enabled when we reach the `reconcileRoot`. + // Enabled when we reach the `reconcileRoot`. var shouldReconcile = false - /// Traits that should be attached to the nearest rendered child. + // Traits that should be attached to the nearest rendered child. var pendingTraits = _ViewTraitStore() while true { @@ -107,7 +107,7 @@ struct ReconcilePass: FiberReconcilerPass { pendingTraits = .init() } - // Ensure the TreeReducer can access any necessary state. + // Ensure the `TreeReducer` can access any necessary state. node.elementIndices = caches.elementIndices node.pendingTraits = pendingTraits @@ -193,7 +193,7 @@ struct ReconcilePass: FiberReconcilerPass { _ = node.fiber?.createAndBindAlternate?() } - // Walk all down all the way into the deepest child. + // Walk down all the way into the deepest child. if let child = reducer.result.child { node = child continue diff --git a/Sources/TokamakCore/Views/Layout/HStack.swift b/Sources/TokamakCore/Views/Layout/HStack.swift index 5082e229d..2d9ad1f37 100644 --- a/Sources/TokamakCore/Views/Layout/HStack.swift +++ b/Sources/TokamakCore/Views/Layout/HStack.swift @@ -27,8 +27,10 @@ public let defaultStackSpacing: CGFloat = 8 /// } public struct HStack: View where Content: View { public let alignment: VerticalAlignment + @_spi(TokamakCore) public let spacing: CGFloat? + public let content: Content public init( diff --git a/Sources/TokamakCore/Views/Layout/VStack.swift b/Sources/TokamakCore/Views/Layout/VStack.swift index 4f8e5d557..e190480f3 100644 --- a/Sources/TokamakCore/Views/Layout/VStack.swift +++ b/Sources/TokamakCore/Views/Layout/VStack.swift @@ -22,8 +22,10 @@ import Foundation /// } public struct VStack: View where Content: View { public let alignment: HorizontalAlignment + @_spi(TokamakCore) public let spacing: CGFloat? + public let content: Content public init( From c6c4d48d3174ddf85d68ca4147d4c23ac0301f33 Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Sat, 25 Jun 2022 14:06:31 -0400 Subject: [PATCH 37/38] Space out ViewArguments.swift properties --- Sources/TokamakCore/Fiber/ViewArguments.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/TokamakCore/Fiber/ViewArguments.swift b/Sources/TokamakCore/Fiber/ViewArguments.swift index d29c2dc19..332c37bdc 100644 --- a/Sources/TokamakCore/Fiber/ViewArguments.swift +++ b/Sources/TokamakCore/Fiber/ViewArguments.swift @@ -20,8 +20,10 @@ import Foundation /// Data passed to `_makeView` to create the `ViewOutputs` used in reconciling/rendering. public struct ViewInputs { public let content: V + @_spi(TokamakCore) public let environment: EnvironmentBox + public let traits: _ViewTraitStore? } @@ -30,7 +32,9 @@ public struct ViewOutputs { /// A container for the current `EnvironmentValues`. /// This is stored as a reference to avoid copying the environment when unnecessary. let environment: EnvironmentBox + let preferences: _PreferenceStore + let traits: _ViewTraitStore? } From 816af958c8accbc21f996859a5f2778baacfcfd8 Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Sun, 26 Jun 2022 22:27:17 -0400 Subject: [PATCH 38/38] Add backing storage to LayoutSubview to move out of ReconcilePass and slightly optimize stack usage --- Sources/TokamakCore/Fiber/Fiber.swift | 34 ++-- Sources/TokamakCore/Fiber/Layout/Layout.swift | 13 +- .../Fiber/Layout/LayoutSubviews.swift | 168 +++++++++++++++--- .../Fiber/Passes/FiberReconcilerPass.swift | 20 +-- .../TokamakCore/Fiber/Passes/LayoutPass.swift | 2 +- .../Fiber/Passes/ReconcilePass.swift | 81 ++------- 6 files changed, 198 insertions(+), 120 deletions(-) diff --git a/Sources/TokamakCore/Fiber/Fiber.swift b/Sources/TokamakCore/Fiber/Fiber.swift index 9edef115b..c9c0e50e7 100644 --- a/Sources/TokamakCore/Fiber/Fiber.swift +++ b/Sources/TokamakCore/Fiber/Fiber.swift @@ -59,7 +59,7 @@ public extension FiberReconciler { /// The erased `Layout` to use for this content. /// /// Stored as an IUO because it uses `bindProperties` to create the underlying instance. - var layout: AnyLayout! + var layout: AnyLayout? /// The identity of this `View` var id: Identity? @@ -151,8 +151,6 @@ public extension FiberReconciler { content = content(for: view) - layout = (view as? _AnyLayout)?._erased() ?? .init(DefaultLayout()) - if let element = element { self.element = element } else if Renderer.isPrimitive(view) { @@ -161,6 +159,10 @@ public extension FiberReconciler { ) } + if self.element != nil { + layout = (view as? _AnyLayout)?._erased() ?? DefaultLayout.shared + } + // Only specify an `elementIndex` if we have an element. if self.element != nil { self.elementIndex = elementIndex @@ -203,7 +205,7 @@ public extension FiberReconciler { init( bound view: V, state: [PropertyInfo: MutableStorage], - layout: AnyLayout, + layout: AnyLayout!, alternate: Fiber, outputs: ViewOutputs, typeInfo: TypeInfo?, @@ -222,7 +224,9 @@ public extension FiberReconciler { self.typeInfo = typeInfo self.outputs = outputs self.state = state - self.layout = layout + if element != nil { + self.layout = layout + } content = content(for: view) } @@ -281,7 +285,9 @@ public extension FiberReconciler { ) outputs = V._makeView(inputs) - layout = (view as? _AnyLayout)?._erased() ?? .init(DefaultLayout()) + if element != nil { + layout = (view as? _AnyLayout)?._erased() ?? DefaultLayout.shared + } if Renderer.isPrimitive(view) { return .init(from: view, useDynamicLayout: reconciler?.renderer.useDynamicLayout ?? false) @@ -335,7 +341,7 @@ public extension FiberReconciler { init( bound app: A, state: [PropertyInfo: MutableStorage], - layout: AnyLayout, + layout: AnyLayout?, alternate: Fiber, outputs: SceneOutputs, typeInfo: TypeInfo?, @@ -384,7 +390,9 @@ public extension FiberReconciler { content = content(for: scene) - layout = (scene as? _AnyLayout)?._erased() ?? .init(DefaultLayout()) + if element != nil { + layout = (scene as? _AnyLayout)?._erased() ?? DefaultLayout.shared + } let alternateScene = scene createAndBindAlternate = { [weak self] in @@ -423,7 +431,7 @@ public extension FiberReconciler { init( bound scene: S, state: [PropertyInfo: MutableStorage], - layout: AnyLayout, + layout: AnyLayout!, alternate: Fiber, outputs: SceneOutputs, typeInfo: TypeInfo?, @@ -442,7 +450,9 @@ public extension FiberReconciler { self.typeInfo = typeInfo self.outputs = outputs self.state = state - self.layout = layout + if element != nil { + self.layout = layout + } content = content(for: scene) } @@ -460,7 +470,9 @@ public extension FiberReconciler { traits: .init() )) - layout = (scene as? _AnyLayout)?._erased() ?? .init(DefaultLayout()) + if element != nil { + layout = (scene as? _AnyLayout)?._erased() ?? DefaultLayout.shared + } return nil } diff --git a/Sources/TokamakCore/Fiber/Layout/Layout.swift b/Sources/TokamakCore/Fiber/Layout/Layout.swift index cec49e5ae..dc7a7bff4 100644 --- a/Sources/TokamakCore/Fiber/Layout/Layout.swift +++ b/Sources/TokamakCore/Fiber/Layout/Layout.swift @@ -218,6 +218,9 @@ public struct LayoutView: View, Layout { /// A default `Layout` that fits to the first subview and places its children at its origin. struct DefaultLayout: Layout { + /// An erased `DefaultLayout` that is shared between all views. + static let shared: AnyLayout = .init(Self()) + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { let size = subviews.first?.sizeThatFits(proposal) ?? .zero return size @@ -239,7 +242,7 @@ struct DefaultLayout: Layout { /// /// Matches the `Layout` protocol with `Cache` erased to `Any`. @usableFromInline -protocol AnyLayoutBox { +protocol AnyLayoutBox: AnyObject { var layoutProperties: LayoutProperties { get } typealias Subviews = LayoutSubviews @@ -283,9 +286,13 @@ protocol AnyLayoutBox { var animatableData: _AnyAnimatableData { get set } } -struct ConcreteLayoutBox: AnyLayoutBox { +final class ConcreteLayoutBox: AnyLayoutBox { var base: L + init(_ base: L) { + self.base = base + } + var layoutProperties: LayoutProperties { L.layoutProperties } func makeCache(subviews: Subviews) -> Cache { @@ -387,7 +394,7 @@ public struct AnyLayout: Layout { var storage: AnyLayoutBox public init(_ layout: L) where L: Layout { - storage = ConcreteLayoutBox(base: layout) + storage = ConcreteLayoutBox(layout) } public struct Cache { diff --git a/Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift b/Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift index 12ee1ebcc..b71ce34e7 100644 --- a/Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift +++ b/Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift @@ -75,34 +75,141 @@ public struct LayoutSubviews: Equatable, RandomAccessCollection { /// If `place(at:anchor:proposal:)` is not called, the center will be used as its position. public struct LayoutSubview: Equatable { private let id: ObjectIdentifier - public static func == (lhs: LayoutSubview, rhs: LayoutSubview) -> Bool { - lhs.id == rhs.id - } - - private let traits: _ViewTraitStore - private let sizeThatFits: (ProposedViewSize) -> CGSize - private let dimensions: (CGSize) -> ViewDimensions - private let place: (ProposedViewSize, ViewDimensions, CGPoint, UnitPoint) -> () - private let computeSpacing: () -> ViewSpacing - - init( + private let storage: AnyStorage + + /// A protocol used to erase `Storage`. + private class AnyStorage { + let traits: _ViewTraitStore? + + init(traits: _ViewTraitStore?) { + self.traits = traits + } + + func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize { + fatalError("Implement \(#function) in subclass") + } + + func dimensions(_ sizeThatFits: CGSize) -> ViewDimensions { + fatalError("Implement \(#function) in subclass") + } + + func place( + _ proposal: ProposedViewSize, + _ dimensions: ViewDimensions, + _ position: CGPoint, + _ anchor: UnitPoint + ) { + fatalError("Implement \(#function) in subclass") + } + + func spacing() -> ViewSpacing { + fatalError("Implement \(#function) in subclass") + } + } + + /// The backing storage for a `LayoutSubview`. This contains the underlying implementations for + /// methods accessing the `fiber`, `element`, and `cache` this subview represents. + private final class Storage: AnyStorage { + weak var fiber: FiberReconciler.Fiber? + weak var element: R.ElementType? + unowned var caches: FiberReconciler.Caches + + init( + traits: _ViewTraitStore?, + fiber: FiberReconciler.Fiber?, + element: R.ElementType?, + caches: FiberReconciler.Caches + ) { + self.fiber = fiber + self.element = element + self.caches = caches + super.init(traits: traits) + } + + override func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize { + guard let fiber = fiber else { return .zero } + let request = FiberReconciler.Caches.LayoutCache.SizeThatFitsRequest(proposal) + return caches.updateLayoutCache(for: fiber) { cache in + guard let layout = fiber.layout else { return .zero } + if let size = cache.sizeThatFits[request] { + return size + } else { + let size = layout.sizeThatFits( + proposal: proposal, + subviews: caches.layoutSubviews(for: fiber), + cache: &cache.cache + ) + cache.sizeThatFits[request] = size + if let alternate = fiber.alternate { + caches.updateLayoutCache(for: alternate) { cache in + cache.sizeThatFits[request] = size + } + } + return size + } + } ?? .zero + } + + override func dimensions(_ sizeThatFits: CGSize) -> ViewDimensions { + // TODO: Add `alignmentGuide` modifier and pass into `ViewDimensions` + ViewDimensions(size: sizeThatFits, alignmentGuides: [:]) + } + + override func place( + _ proposal: ProposedViewSize, + _ dimensions: ViewDimensions, + _ position: CGPoint, + _ anchor: UnitPoint + ) { + guard let fiber = fiber, let element = element else { return } + let geometry = ViewGeometry( + // Shift to the anchor point in the parent's coordinate space. + origin: .init(origin: .init( + x: position.x - (dimensions.width * anchor.x), + y: position.y - (dimensions.height * anchor.y) + )), + dimensions: dimensions, + proposal: proposal + ) + // Push a layout mutation if needed. + if geometry != fiber.alternate?.geometry { + caches.mutations.append(.layout(element: element, geometry: geometry)) + } + // Update ours and our alternate's geometry + fiber.geometry = geometry + fiber.alternate?.geometry = geometry + } + + override func spacing() -> ViewSpacing { + guard let fiber = fiber else { return .init() } + + return caches.updateLayoutCache(for: fiber) { cache in + fiber.layout?.spacing( + subviews: caches.layoutSubviews(for: fiber), + cache: &cache.cache + ) ?? .zero + } ?? .zero + } + } + + init( id: ObjectIdentifier, - traits: _ViewTraitStore, - sizeThatFits: @escaping (ProposedViewSize) -> CGSize, - dimensions: @escaping (CGSize) -> ViewDimensions, - place: @escaping (ProposedViewSize, ViewDimensions, CGPoint, UnitPoint) -> (), - spacing: @escaping () -> ViewSpacing + traits: _ViewTraitStore?, + fiber: FiberReconciler.Fiber, + element: R.ElementType, + caches: FiberReconciler.Caches ) { self.id = id - self.traits = traits - self.sizeThatFits = sizeThatFits - self.dimensions = dimensions - self.place = place - computeSpacing = spacing + storage = Storage( + traits: traits, + fiber: fiber, + element: element, + caches: caches + ) } public func _trait(key: K.Type) -> K.Value where K: _ViewTraitKey { - traits.value(forKey: key) + storage.traits?.value(forKey: key) ?? K.defaultValue } public subscript(key: K.Type) -> K.Value where K: LayoutValueKey { @@ -114,15 +221,15 @@ public struct LayoutSubview: Equatable { } public func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize { - sizeThatFits(proposal) + storage.sizeThatFits(proposal) } public func dimensions(in proposal: ProposedViewSize) -> ViewDimensions { - dimensions(sizeThatFits(proposal)) + storage.dimensions(sizeThatFits(proposal)) } public var spacing: ViewSpacing { - computeSpacing() + storage.spacing() } public func place( @@ -130,6 +237,15 @@ public struct LayoutSubview: Equatable { anchor: UnitPoint = .topLeading, proposal: ProposedViewSize ) { - place(proposal, dimensions(in: proposal), position, anchor) + storage.place( + proposal, + dimensions(in: proposal), + position, + anchor + ) + } + + public static func == (lhs: LayoutSubview, rhs: LayoutSubview) -> Bool { + lhs.id == rhs.id } } diff --git a/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift b/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift index a5254b523..9111d7340 100644 --- a/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift +++ b/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift @@ -22,7 +22,6 @@ extension FiberReconciler { var elementIndices = [ObjectIdentifier: Int]() var layoutCaches = [ObjectIdentifier: LayoutCache]() var layoutSubviews = [ObjectIdentifier: LayoutSubviews]() - var elementChildren = [ObjectIdentifier: [Fiber]]() var mutations = [Mutation]() struct LayoutCache { @@ -64,15 +63,15 @@ extension FiberReconciler { func clear() { elementIndices = [:] layoutSubviews = [:] - elementChildren = [:] mutations = [] } - func layoutCache(for fiber: Fiber) -> LayoutCache { - layoutCaches[ + func layoutCache(for fiber: Fiber) -> LayoutCache? { + guard let layout = fiber.layout else { return nil } + return layoutCaches[ ObjectIdentifier(fiber), default: .init( - cache: fiber.layout.makeCache(subviews: layoutSubviews(for: fiber)), + cache: layout.makeCache(subviews: layoutSubviews(for: fiber)), sizeThatFits: [:], dimensions: [:], isDirty: false @@ -80,13 +79,14 @@ extension FiberReconciler { ] } - func updateLayoutCache(for fiber: Fiber, _ action: (inout LayoutCache) -> R) -> R { + func updateLayoutCache(for fiber: Fiber, _ action: (inout LayoutCache) -> R) -> R? { + guard let layout = fiber.layout else { return nil } let subviews = layoutSubviews(for: fiber) let key = ObjectIdentifier(fiber) var cache = layoutCaches[ key, default: .init( - cache: fiber.layout.makeCache(subviews: subviews), + cache: layout.makeCache(subviews: subviews), sizeThatFits: [:], dimensions: [:], isDirty: false @@ -94,7 +94,7 @@ extension FiberReconciler { ] // If the cache is dirty, update it before calling `action`. if cache.isDirty { - fiber.layout.updateCache(&cache.cache, subviews: subviews) + layout.updateCache(&cache.cache, subviews: subviews) cache.isDirty = false } defer { layoutCaches[key] = cache } @@ -113,10 +113,6 @@ extension FiberReconciler { } return result } - - func appendChild(parent: Fiber, child: Fiber) { - elementChildren[ObjectIdentifier(parent), default: []].append(child) - } } } diff --git a/Sources/TokamakCore/Fiber/Passes/LayoutPass.swift b/Sources/TokamakCore/Fiber/Passes/LayoutPass.swift index b3ad0934e..7ef73d31a 100644 --- a/Sources/TokamakCore/Fiber/Passes/LayoutPass.swift +++ b/Sources/TokamakCore/Fiber/Passes/LayoutPass.swift @@ -32,7 +32,7 @@ struct LayoutPass: FiberReconcilerPass { // Place subviews for each element fiber as we walk the tree. if fiber.element != nil { caches.updateLayoutCache(for: fiber) { cache in - fiber.layout.placeSubviews( + fiber.layout?.placeSubviews( in: .init( origin: .zero, size: fiber.geometry?.dimensions.size ?? reconciler.renderer.sceneSize diff --git a/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift b/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift index f403f083e..cde3bbf0d 100644 --- a/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift +++ b/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift @@ -121,68 +121,13 @@ struct ReconcilePass: FiberReconcilerPass { if let element = fiber.element, let elementParent = fiber.elementParent { - caches.appendChild(parent: elementParent, child: fiber) let parentKey = ObjectIdentifier(elementParent) let subview = LayoutSubview( - id: ObjectIdentifier(node), - traits: node.fiber?.outputs.traits ?? .init(), - sizeThatFits: { [weak fiber, unowned caches] proposal in - guard let fiber = fiber else { return .zero } - let request = FiberReconciler.Caches.LayoutCache - .SizeThatFitsRequest(proposal) - return caches.updateLayoutCache(for: fiber) { cache in - if let size = cache.sizeThatFits[request] { - return size - } else { - let size = fiber.layout.sizeThatFits( - proposal: proposal, - subviews: caches.layoutSubviews(for: fiber), - cache: &cache.cache - ) - cache.sizeThatFits[request] = size - if let alternate = fiber.alternate { - caches.updateLayoutCache(for: alternate) { cache in - cache.sizeThatFits[request] = size - } - } - return size - } - } - }, - dimensions: { sizeThatFits in - // TODO: Add `alignmentGuide` modifier and pass into `ViewDimensions` - ViewDimensions(size: sizeThatFits, alignmentGuides: [:]) - }, - place: { [weak fiber, weak element, unowned caches] - proposal, dimensions, position, anchor in - guard let fiber = fiber, let element = element else { return } - let geometry = ViewGeometry( - // Shift to the anchor point in the parent's coordinate space. - origin: .init(origin: .init( - x: position.x - (dimensions.width * anchor.x), - y: position.y - (dimensions.height * anchor.y) - )), - dimensions: dimensions, - proposal: proposal - ) - // Push a layout mutation if needed. - if geometry != fiber.alternate?.geometry { - caches.mutations.append(.layout(element: element, geometry: geometry)) - } - // Update ours and our alternate's geometry - fiber.geometry = geometry - fiber.alternate?.geometry = geometry - }, - spacing: { [weak fiber, unowned caches] in - guard let fiber = fiber else { return .init() } - - return caches.updateLayoutCache(for: fiber) { cache in - fiber.layout.spacing( - subviews: caches.layoutSubviews(for: fiber), - cache: &cache.cache - ) - } - } + id: ObjectIdentifier(fiber), + traits: node.fiber?.outputs.traits, + fiber: fiber, + element: element, + caches: caches ) caches.layoutSubviews[parentKey, default: .init(elementParent)].storage.append(subview) } @@ -199,7 +144,7 @@ struct ReconcilePass: FiberReconcilerPass { continue } else if let alternateChild = node.fiber?.alternate?.child { // The alternate has a child that no longer exists. - if let parent = node.fiber { + if let parent = node.fiber?.element != nil ? node.fiber : node.fiber?.elementParent { invalidateCache(for: parent, in: reconciler, caches: caches) } walk(alternateChild) { node in @@ -226,14 +171,16 @@ struct ReconcilePass: FiberReconcilerPass { // Now walk back up the tree until we find a sibling. while node.sibling == nil { - if let fiber = node.fiber { + if let fiber = node.fiber, + fiber.element != nil + { propagateCacheInvalidation(for: fiber, in: reconciler, caches: caches) } var alternateSibling = node.fiber?.alternate?.sibling // The alternate had siblings that no longer exist. while alternateSibling != nil { - if let fiber = alternateSibling?.parent { + if let fiber = alternateSibling?.elementParent { invalidateCache(for: fiber, in: reconciler, caches: caches) } if let element = alternateSibling?.element, @@ -271,14 +218,14 @@ struct ReconcilePass: FiberReconcilerPass { let parent = node.fiber?.elementParent?.element { if node.fiber?.alternate == nil { // This didn't exist before (no alternate) - if let fiber = node.fiber?.parent { + if let fiber = node.fiber?.elementParent { invalidateCache(for: fiber, in: reconciler, caches: caches) } return .insert(element: element, parent: parent, index: index) } else if node.fiber?.typeInfo?.type != node.fiber?.alternate?.typeInfo?.type, let previous = node.fiber?.alternate?.element { - if let fiber = node.fiber?.parent { + if let fiber = node.fiber?.elementParent { invalidateCache(for: fiber, in: reconciler, caches: caches) } // This is a completely different type of view. @@ -286,7 +233,7 @@ struct ReconcilePass: FiberReconcilerPass { } else if let newContent = node.newContent, newContent != element.content { - if let fiber = node.fiber?.parent { + if let fiber = node.fiber?.elementParent { invalidateCache(for: fiber, in: reconciler, caches: caches) } // This is the same type of view, but its backing data has changed. @@ -327,7 +274,7 @@ struct ReconcilePass: FiberReconcilerPass { in reconciler: FiberReconciler, caches: FiberReconciler.Caches ) { - guard caches.layoutCache(for: fiber).isDirty, + guard caches.layoutCache(for: fiber)?.isDirty ?? false, let elementParent = fiber.elementParent else { return } invalidateCache(for: elementParent, in: reconciler, caches: caches)