Skip to content

Commit

Permalink
Initial Commit
Browse files Browse the repository at this point in the history
  • Loading branch information
timdonnelly committed Mar 29, 2019
0 parents commit 0644699
Show file tree
Hide file tree
Showing 153 changed files with 9,731 additions and 0 deletions.
13 changes: 13 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# OS
.DS_Store

# cocoapods-generate
gen/

# Xcode
xcuserdata/

# Cocoapods

Pods/
*.xcworkspace
21 changes: 21 additions & 0 deletions BlueprintUI.podspec
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
Pod::Spec.new do |s|
s.name = 'BlueprintUI'
s.version = '0.1.0'
s.summary = 'Swift library for declarative UI construction'
s.homepage = 'https://www.github.com/square/blueprint'
s.license = 'Apache License, Version 2.0'
s.author = 'Square'
s.source = { :git => 'https://github.com/square/blueprint.git', :tag => s.version }

s.swift_version = '4.2'

s.ios.deployment_target = '9.3'

s.source_files = 'BlueprintUI/Sources/**/*.swift'

s.test_spec 'Tests' do |test_spec|
test_spec.source_files = 'BlueprintUI/Tests/**/*.swift'
test_spec.framework = 'XCTest'
end

end
4 changes: 4 additions & 0 deletions BlueprintUI/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Blueprint
=====

Declarative UI in Swift.
246 changes: 246 additions & 0 deletions BlueprintUI/Sources/Blueprint View/BlueprintView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
import UIKit

/// A view that is responsible for displaying an `Element` hierarchy.
///
/// A view controller that renders content via Blueprint might look something
/// like this:
///
/// ```
/// final class HelloWorldViewController: UIViewController {
///
/// private var blueprintView = BlueprintView(element: nil)
///
/// override func viewDidLoad() {
/// super.viewDidLoad()
///
/// let rootElement = Label(text: "Hello, world!")
/// blueprintView.element = rootElement
/// view.addSubview(blueprintView)
/// }
///
/// override func viewDidLayoutSubviews() {
/// super.viewDidLayoutSubviews()
/// blueprintView.frame = view.bounds
/// }
///
/// }
/// ```
public final class BlueprintView: UIView {

private var needsViewHierarchyUpdate: Bool = true
private var hasUpdatedViewHierarchy: Bool = false
private var lastViewHierarchyUpdateBounds: CGRect = .zero

/// Used to detect reentrant updates
private var isInsideUpdate: Bool = false

private let rootController: NativeViewController

/// The root element that is displayed within the view.
public var element: Element? {
didSet {
setNeedsViewHierarchyUpdate()
}
}

/// Instantiates a view with the given element
///
/// - parameter element: The root element that will be displayed in the view.
public required init(element: Element?) {

self.element = element

rootController = NativeViewController(
node: NativeViewNode(
content: UIView.describe() { _ in },
layoutAttributes: LayoutAttributes(),
children: []))

super.init(frame: CGRect.zero)

self.backgroundColor = .white
addSubview(rootController.view)
}

public override convenience init(frame: CGRect) {
self.init(element: nil)
self.frame = frame
}

@available(*, unavailable)
required public init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

/// Forwarded to the `measure(in:)` implementation of the root element.
override public func sizeThatFits(_ size: CGSize) -> CGSize {
guard let element = element else { return .zero }
let constraint: SizeConstraint
if size == .zero {
constraint = SizeConstraint(width: .unconstrained, height: .unconstrained)
} else {
constraint = SizeConstraint(size)
}
return element.content.measure(in: constraint)
}

override public func layoutSubviews() {
super.layoutSubviews()
performUpdate()
}

private func performUpdate() {
updateViewHierarchyIfNeeded()
}

private func setNeedsViewHierarchyUpdate() {
guard !needsViewHierarchyUpdate else { return }
needsViewHierarchyUpdate = true

/// We currently rely on CA's layout pass to actually perform a hierarchy update.
setNeedsLayout()
}

private func updateViewHierarchyIfNeeded() {
guard needsViewHierarchyUpdate || bounds != lastViewHierarchyUpdateBounds else { return }

assert(!isInsideUpdate, "Reentrant updates are not supported in BlueprintView. Ensure that view events from within the hierarchy are not synchronously triggering additional updates.")
isInsideUpdate = true

needsViewHierarchyUpdate = false
lastViewHierarchyUpdateBounds = bounds

/// Grab view descriptions
let viewNodes = element?
.layout(frame: bounds)
.resolve() ?? []

rootController.view.frame = bounds

let rootNode = NativeViewNode(
content: UIView.describe() { _ in },
layoutAttributes: LayoutAttributes(frame: bounds),
children: viewNodes)

rootController.update(node: rootNode, appearanceTransitionsEnabled: hasUpdatedViewHierarchy)
hasUpdatedViewHierarchy = true

isInsideUpdate = false
}

var currentNativeViewControllers: [(path: ElementPath, node: NativeViewController)] {

/// Perform an update if needed so that the node hierarchy is fully populated.
updateViewHierarchyIfNeeded()

/// rootViewNode always contains a simple UIView – its children represent the
/// views that are actually generated by the root element.
return rootController.children
}

}

extension BlueprintView {

final class NativeViewController {

private var viewDescription: ViewDescription

private var layoutAttributes: LayoutAttributes

private (set) var children: [(ElementPath, NativeViewController)]

let view: UIView

init(node: NativeViewNode) {
self.viewDescription = node.viewDescription
self.layoutAttributes = node.layoutAttributes
self.children = []
self.view = node.viewDescription.build()
update(node: node, appearanceTransitionsEnabled: false)
}

fileprivate func canUpdateFrom(node: NativeViewNode) -> Bool {
return node.viewDescription.viewType == type(of: view)
}

fileprivate func update(node: NativeViewNode, appearanceTransitionsEnabled: Bool) {

assert(node.viewDescription.viewType == type(of: view))

viewDescription = node.viewDescription
layoutAttributes = node.layoutAttributes

viewDescription.apply(to: view)

var oldChildren: [ElementPath: NativeViewController] = [:]
oldChildren.reserveCapacity(children.count)

for (path, childController) in children {
oldChildren[path] = childController
}

var newChildren: [(path: ElementPath, node: NativeViewController)] = []
newChildren.reserveCapacity(node.children.count)

var usedKeys: Set<ElementPath> = []
usedKeys.reserveCapacity(node.children.count)

for (path, child) in node.children {

guard usedKeys.contains(path) == false else {
fatalError("Duplicate view identifier")
}
usedKeys.insert(path)

if let controller = oldChildren[path], controller.canUpdateFrom(node: child) {

oldChildren.removeValue(forKey: path)
newChildren.append((path: path, node: controller))

let layoutTransition: LayoutTransition

if child.layoutAttributes != controller.layoutAttributes {
layoutTransition = child.viewDescription.layoutTransition
} else {
layoutTransition = .inherited
}
layoutTransition.perform {
child.layoutAttributes.apply(to: controller.view)
controller.update(node: child, appearanceTransitionsEnabled: true)
}
} else {
let controller = NativeViewController(node: child)
newChildren.append((path: path, node: controller))

UIView.performWithoutAnimation {
child.layoutAttributes.apply(to: controller.view)
}

let contentView = node.viewDescription.contentView(in: view)
contentView.addSubview(controller.view)

controller.update(node: child, appearanceTransitionsEnabled: false)

if appearanceTransitionsEnabled {
child.viewDescription.appearingTransition?.performAppearing(view: controller.view, layoutAttributes: child.layoutAttributes, completion: {})
}
}
}

for controller in oldChildren.values {
if let transition = controller.viewDescription.disappearingTransition {
transition.performDisappearing(view: controller.view, layoutAttributes: controller.layoutAttributes, completion: {
controller.view.removeFromSuperview()
})
} else {
controller.view.removeFromSuperview()
}
}

children = newChildren
}

}

}
64 changes: 64 additions & 0 deletions BlueprintUI/Sources/Element/Element.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/// Conforming types represent a rectangular content area in a two-dimensional
/// layout space.
///
/// ***
///
/// The ultimate purpose of an element is to provide visual content. This can be
/// done in two ways:
///
/// - By providing a view description (`ViewDescription`).
///
/// - By providing child elements that will be displayed recursively within
/// the local coordinate space.
///
/// ***
///
/// A custom element might look something like this:
///
/// ```
/// struct MyElement: Element {
///
/// var backgroundColor: UIColor = .red
///
/// // Returns a single child element.
/// var content: ElementContent {
/// return ElementContent(child: Label(text: "😂"))
/// }
///
/// // Providing a view description means that this element will be
/// // backed by a UIView instance when displayed in a `BlueprintView`.
/// func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? {
/// return UIView.describe { config in
/// config.bind(backgroundColor, to: \.backgrouncColor)
/// }
/// }
///
/// }
/// ```
///
public protocol Element {

/// Returns the content of this element.
///
/// Elements generally fall into two types:
/// - Leaf elements, or elements that have no children. These elements commonly have an intrinsic size, or some
/// content that can be measured. Leaf elements typically instantiate their content with
/// `ElementContent(measurable:)` or similar.
/// - Container elements: these element have one or more children, which are arranged by a layout implementation.
/// Container elements typically use methods like `ElementContent(layout:configure:)` to instantiate
/// their content.
var content: ElementContent { get }

/// Returns an (optional) description of the view that should back this element.
///
/// In Blueprint, elements that are displayed using a live `UIView` instance are referred to as "view-backed".
/// Elements become view-backed by returning a `ViewDescription` value from this method.
///
/// - Parameter bounds: The bounds of this element after layout is complete.
/// - Parameter subtreeExtent: A rectangle in the local coordinate space that contains any children.
/// `subtreeExtent` will be nil if there are no children.
///
/// - Returns: An optional `ViewDescription`.
func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription?

}
Loading

0 comments on commit 0644699

Please sign in to comment.