Skip to content

Commit

Permalink
Merge pull request #440 from square/watt/caffeinated-layout/1
Browse files Browse the repository at this point in the history
Introduce mode control and debugging types
  • Loading branch information
watt authored Mar 15, 2023
2 parents b8a2959 + a91c453 commit c5a3faa
Show file tree
Hide file tree
Showing 13 changed files with 247 additions and 30 deletions.
73 changes: 64 additions & 9 deletions BlueprintUI/Sources/BlueprintView/BlueprintView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ public final class BlueprintView: UIView {

private let rootController: NativeViewController

private var layoutResult: LayoutResultNode?

private var sizesThatFit: [SizeConstraint: CGSize] = [:]

/// A base environment used when laying out and rendering the element tree.
Expand Down Expand Up @@ -100,6 +102,16 @@ public final class BlueprintView: UIView {
}
}

/// An optional explicit layout mode for this view. If `nil`, this view will inherit the layout
/// mode of its nearest ancestor, or use ``LayoutMode/default``.
public var layoutMode: LayoutMode? {
didSet {
if layoutMode != oldValue {
setNeedsViewHierarchyUpdate()
}
}
}

/// An optional name to help identify this view
public var name: String?

Expand Down Expand Up @@ -211,11 +223,18 @@ public final class BlueprintView: UIView {
)
defer { Logger.logSizeThatFitsEnd(view: self) }

let measurement = element.content.measure(
in: constraint,
environment: makeEnvironment(),
cache: CacheFactory.makeCache(name: cacheName)
)
let environment = makeEnvironment()
let layoutMode = environment.layoutMode
let renderContext = RenderContext(layoutMode: layoutMode)

let measurement = renderContext.perform {
element.content.measure(
in: constraint,
environment: environment,
cache: CacheFactory.makeCache(name: cacheName),
layoutMode: layoutMode
)
}

sizesThatFit[constraint] = measurement

Expand Down Expand Up @@ -339,10 +358,21 @@ public final class BlueprintView: UIView {
size: bounds.size + rootCorrection.size
)

/// Grab view descriptions
let viewNodes = element?
.layout(layoutAttributes: LayoutAttributes(frame: rootFrame), environment: environment)
.resolve() ?? []
let layoutMode = environment.layoutMode
let renderContext = RenderContext(layoutMode: layoutMode)

// Perform layout
let layoutResult = renderContext.perform {
element?.layout(
frame: rootFrame,
environment: environment,
layoutMode: layoutMode
)
}
self.layoutResult = layoutResult

// Flatten into tree of view descriptions
let viewNodes = layoutResult?.resolve() ?? []

let layoutEndTime = CACurrentMediaTime()
Logger.logLayoutEnd(view: self)
Expand Down Expand Up @@ -401,6 +431,27 @@ public final class BlueprintView: UIView {
return rootController.children
}

/// Forces a synchronous layout, for testing purposes.
@_spi(BlueprintDebugging)
public func forceSynchronousLayout() {
setNeedsViewHierarchyUpdate()
updateViewHierarchyIfNeeded()
}

/// Dumps the result of the most recent layout, by recursing through the layout tree and calling
/// the provided visitor on each node. By default, this prints to `stdout`.
@_spi(BlueprintDebugging)
public func dumpLayoutResult(
visit: ((_ depth: Int, _ identifier: String, _ frame: CGRect) -> Void) = { depth, identifier, frame in
let origin = "x \(frame.origin.x), y \(frame.origin.y)"
let size = "\(frame.size.width) × \(frame.size.height)"
let indent = String(repeating: " ", count: depth)
print("\(indent)\(identifier) \(origin), \(size)")
}
) {
layoutResult?.dump(visit: visit)
}

private func makeEnvironment() -> Environment {

let inherited: Environment = {
Expand Down Expand Up @@ -429,6 +480,10 @@ public final class BlueprintView: UIView {
environment.windowSize = window.bounds.size
}

if let layoutMode = layoutMode ?? RenderContext.current?.layoutMode {
environment.layoutMode = layoutMode
}

return environment
}

Expand Down
15 changes: 13 additions & 2 deletions BlueprintUI/Sources/Element/ElementContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,22 @@ public struct ElementContent {
measure(
in: constraint,
environment: environment,
cache: CacheFactory.makeCache(name: "ElementContent")
cache: CacheFactory.makeCache(name: "ElementContent"),
layoutMode: RenderContext.current?.layoutMode ?? environment.layoutMode
)
}

func measure(in constraint: SizeConstraint, environment: Environment, cache: CacheTree) -> CGSize {
func measure(
in constraint: SizeConstraint,
environment: Environment,
cache: CacheTree,
layoutMode: LayoutMode
) -> CGSize {
// TODO: switch on layoutMode
storage.measure(in: constraint, environment: environment, cache: cache)
}

fileprivate func measure(in constraint: SizeConstraint, environment: Environment, cache: CacheTree) -> CGSize {
storage.measure(in: constraint, environment: environment, cache: cache)
}

Expand Down
10 changes: 5 additions & 5 deletions BlueprintUI/Sources/Internal/ElementIdentifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,24 +41,24 @@

You will note that the identifiers remain stable, which ultimately ensures that views are reused.
*/
struct ElementIdentifier: Hashable, CustomDebugStringConvertible {
struct ElementIdentifier: Hashable, CustomStringConvertible {

let elementType: ObjectIdentifier
let elementType: Metatype
let key: AnyHashable?

let count: Int

init(elementType: Element.Type, key: AnyHashable?, count: Int) {

self.elementType = ObjectIdentifier(elementType)
self.elementType = Metatype(elementType)
self.key = key

self.count = count
}

var debugDescription: String {
var description: String {
if let key = key {
return "\(elementType).\(String(describing: key)).\(count)"
return "\(elementType).\(key).\(count)"
} else {
return "\(elementType).\(count)"
}
Expand Down
10 changes: 4 additions & 6 deletions BlueprintUI/Sources/Internal/ElementPath.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// Represents a path into an element hierarchy.
/// Used for disambiguation during diff operations.
struct ElementPath: Hashable, CustomDebugStringConvertible {
struct ElementPath: Hashable, CustomStringConvertible {

private var identifiersHash: Int? = nil

Expand Down Expand Up @@ -44,11 +44,9 @@ struct ElementPath: Hashable, CustomDebugStringConvertible {
hasher.combine(identifiersHash)
}

// MARK: CustomDebugStringConvertible
// MARK: CustomStringConvertible

var debugDescription: String {
identifiers.map { $0.debugDescription }.joined()
var description: String {
identifiers.map(\.description).joined(separator: "/")
}
}


14 changes: 14 additions & 0 deletions BlueprintUI/Sources/Internal/LayoutModeKey.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Foundation

enum LayoutModeKey: EnvironmentKey {
static let defaultValue: LayoutMode = .default
}

extension Environment {
/// This mode will be inherited by descendant BlueprintViews that do not have an explicit
/// mode set.
var layoutMode: LayoutMode {
get { self[LayoutModeKey.self] }
set { self[LayoutModeKey.self] = newValue }
}
}
24 changes: 24 additions & 0 deletions BlueprintUI/Sources/Internal/LayoutResultNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ extension Element {
)
}

func layout(frame: CGRect, environment: Environment, layoutMode: LayoutMode) -> LayoutResultNode {
// TODO: switch on layoutMode
layout(layoutAttributes: LayoutAttributes(frame: frame), environment: environment)
}
}

/// Represents a tree of elements with complete layout attributes
Expand Down Expand Up @@ -126,4 +130,24 @@ extension LayoutResultNode {

}

/// Recursively dump layout tree, for debugging. By default, prints to stdout.
@_spi(BlueprintDebugging)
public func dump(
depth: Int = 0,
visit: ((_ depth: Int, _ identifier: String, _ frame: CGRect) -> Void) = { depth, identifier, frame in
let origin = "x \(frame.origin.x), y \(frame.origin.y)"
let size = "\(frame.size.width) × \(frame.size.height)"
let indent = String(repeating: " ", count: depth)
print("\(indent)\(identifier) \(origin), \(size)")
}
) {
for child in children {
let attributes = child.node.layoutAttributes

visit(depth, "\(child.identifier)", attributes.frame)

child.node.dump(depth: depth + 1, visit: visit)
}
}

}
23 changes: 23 additions & 0 deletions BlueprintUI/Sources/Internal/Metatype.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import Foundation

/// A wrapper to make metatypes easier to work with, providing Equatable, Hashable, and
/// CustomStringConvertible.
struct Metatype: Hashable, CustomStringConvertible {
var type: Any.Type

init(_ type: Any.Type) {
self.type = type
}

var description: String {
"\(type)"
}

func hash(into hasher: inout Hasher) {
hasher.combine(ObjectIdentifier(type))
}

static func == (lhs: Metatype, rhs: Metatype) -> Bool {
lhs.type == rhs.type
}
}
25 changes: 25 additions & 0 deletions BlueprintUI/Sources/Internal/RenderContext.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Foundation

/// Stores info about the currently running render pass, if there is one.
///
/// The render context is available statically, which allows "out of band" operations like
/// calls to ``ElementContent/measure(in:environment:)`` to get some context without having it
/// passed in explicitly. This depends entirely on the render pass running exclusively on the main
/// thread.
struct RenderContext {
/// The current render context, if there is one.
private(set) static var current: Self?

var layoutMode: LayoutMode

/// Perform the given block with this as the current render context, restoring the previous
/// context before returning.
func perform<Result>(block: () throws -> Result) rethrows -> Result {
let previous = Self.current
defer { Self.current = previous }

Self.current = self

return try block()
}
}
12 changes: 10 additions & 2 deletions BlueprintUI/Sources/Layout/Alignment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public protocol AlignmentID {
}

/// An alignment position along the horizontal axis.
public struct HorizontalAlignment: Equatable {
public struct HorizontalAlignment: Equatable, CustomStringConvertible {

var id: AlignmentID.Type

Expand All @@ -65,10 +65,14 @@ public struct HorizontalAlignment: Equatable {
public static func == (lhs: HorizontalAlignment, rhs: HorizontalAlignment) -> Bool {
lhs.id == rhs.id
}

public var description: String {
"\(id)"
}
}

/// An alignment position along the vertical axis.
public struct VerticalAlignment: Equatable {
public struct VerticalAlignment: Equatable, CustomStringConvertible {

var id: AlignmentID.Type

Expand All @@ -82,6 +86,10 @@ public struct VerticalAlignment: Equatable {
public static func == (lhs: VerticalAlignment, rhs: VerticalAlignment) -> Bool {
lhs.id == rhs.id
}

public var description: String {
"\(id)"
}
}

extension HorizontalAlignment {
Expand Down
20 changes: 20 additions & 0 deletions BlueprintUI/Sources/Layout/LayoutMode.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import Foundation

/// Controls the layout system that Blueprint uses to lay out elements.
///
/// Blueprint supports multiple layout systems. Each is expected to produce the same result, but
/// some may have different performance profiles or special requirements.
///
/// You can change the layout system used by setting the ``BlueprintView/layoutMode`` property, but
/// generally you should use the ``default`` option.
///
public enum LayoutMode: Equatable {
public static let `default`: Self = .legacy

/// The "standard" layout system.
case legacy

/// A newer layout system with some optimizations made possible by ensuring elements adhere
/// certain contract for behavior.
case caffeinated
}
10 changes: 5 additions & 5 deletions BlueprintUI/Sources/Measuring/SizeConstraint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import UIKit
///
/// Currently this constraint type can only handles layout where
/// the primary (breaking) axis is horizontal (row in CSS-speak).
public struct SizeConstraint: Hashable, CustomDebugStringConvertible {
public struct SizeConstraint: Hashable, CustomStringConvertible {

/// The width constraint.
@UnconstrainedInfiniteAxis public var width: Axis
Expand All @@ -19,8 +19,8 @@ public struct SizeConstraint: Hashable, CustomDebugStringConvertible {

// MARK: CustomDebugStringConvertible

public var debugDescription: String {
"<SizeConstraint: \(width.debugDescription) x \(height.debugDescription)>"
public var description: String {
"\(width) × \(height)"
}
}

Expand Down Expand Up @@ -62,7 +62,7 @@ extension SizeConstraint {
extension SizeConstraint {

/// Represents a size constraint for a single axis.
public enum Axis: Hashable, CustomDebugStringConvertible {
public enum Axis: Hashable, CustomStringConvertible {

/// The measurement should treat the associated value as the largest
/// possible size in the given dimension.
Expand Down Expand Up @@ -192,7 +192,7 @@ extension SizeConstraint {

// MARK: CustomDebugStringConvertible

public var debugDescription: String {
public var description: String {
switch self {
case .atMost(let max):
return "atMost(\(max))"
Expand Down
3 changes: 2 additions & 1 deletion BlueprintUI/Tests/ElementContentTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@ class ElementContentTests: XCTestCase {
_ = container.measure(
in: SizeConstraint(containerSize),
environment: .empty,
cache: cache
cache: cache,
layoutMode: .legacy
)

return (cache, counts)
Expand Down
Loading

0 comments on commit c5a3faa

Please sign in to comment.