diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..094b85c21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# OS +.DS_Store + +# cocoapods-generate +gen/ + +# Xcode +xcuserdata/ + +# Cocoapods + +Pods/ +*.xcworkspace \ No newline at end of file diff --git a/BlueprintUI.podspec b/BlueprintUI.podspec new file mode 100644 index 000000000..0092f79b2 --- /dev/null +++ b/BlueprintUI.podspec @@ -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 diff --git a/BlueprintUI/README.md b/BlueprintUI/README.md new file mode 100644 index 000000000..eac9a8264 --- /dev/null +++ b/BlueprintUI/README.md @@ -0,0 +1,4 @@ +Blueprint +===== + +Declarative UI in Swift. \ No newline at end of file diff --git a/BlueprintUI/Sources/Blueprint View/BlueprintView.swift b/BlueprintUI/Sources/Blueprint View/BlueprintView.swift new file mode 100755 index 000000000..6d45fe8c0 --- /dev/null +++ b/BlueprintUI/Sources/Blueprint View/BlueprintView.swift @@ -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 = [] + 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 + } + + } + +} diff --git a/BlueprintUI/Sources/Element/Element.swift b/BlueprintUI/Sources/Element/Element.swift new file mode 100644 index 000000000..042ffa8db --- /dev/null +++ b/BlueprintUI/Sources/Element/Element.swift @@ -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? + +} diff --git a/BlueprintUI/Sources/Element/ElementContent.swift b/BlueprintUI/Sources/Element/ElementContent.swift new file mode 100644 index 000000000..0a0c66210 --- /dev/null +++ b/BlueprintUI/Sources/Element/ElementContent.swift @@ -0,0 +1,230 @@ +/// Represents the content of an element. +public struct ElementContent: Measurable { + + private let storage: ContentStorage + + /// Initializes a new `ElementContent` with the given layout and children. + /// + /// - parameter layout: The layout to use. + /// - parameter configure: A closure that configures the layout and adds children to the container. + public init(layout: LayoutType, configure: (inout Builder) -> Void = { _ in }) { + var builder = Builder(layout: layout) + configure(&builder) + self.storage = builder + } + + public func measure(in constraint: SizeConstraint) -> CGSize { + return storage.measure(in: constraint) + } + + public var childCount: Int { + return storage.childCount + } + + func performLayout(attributes: LayoutAttributes) -> [(identifier: ElementIdentifier, node: LayoutResultNode)] { + return storage.performLayout(attributes: attributes) + } + +} + +extension ElementContent { + + /// Initializes a new `ElementContent` with the given element and layout. + /// + /// - parameter element: The single child element. + /// - parameter layout: The layout that will be used. + public init(child: Element, layout: SingleChildLayout) { + self = ElementContent(layout: SingleChildLayoutHost(wrapping: layout)) { + $0.add(element: child) + } + } + + /// Initializes a new `ElementContent` with the given element. + /// + /// The given element will be used for measuring, and it will always fill the extent of the parent element. + /// + /// - parameter element: The single child element. + public init(child: Element) { + self = ElementContent(child: child, layout: PassthroughLayout()) + } + + /// Initializes a new `ElementContent` with no children that delegates to the provided `Measurable`. + public init(measurable: Measurable) { + self = ElementContent( + layout: MeasurableLayout(measurable: measurable), + configure: { _ in }) + } + + /// Initializes a new `ElementContent` with no children that delegates to the provided measure function. + public init(measureFunction: @escaping (SizeConstraint) -> CGSize) { + struct Measurer: Measurable { + var _measure: (SizeConstraint) -> CGSize + func measure(in constraint: SizeConstraint) -> CGSize { + return _measure(constraint) + } + } + self = ElementContent(measurable: Measurer(_measure: measureFunction)) + } + + /// Initializes a new `ElementContent` with no children that uses the provided intrinsic size for measuring. + public init(intrinsicSize: CGSize) { + self = ElementContent(measureFunction: { _ in intrinsicSize }) + } + +} + + +fileprivate protocol ContentStorage: Measurable { + var childCount: Int { get } + func performLayout(attributes: LayoutAttributes) -> [(identifier: ElementIdentifier, node: LayoutResultNode)] +} + + + + +extension ElementContent { + + public struct Builder { + + /// The layout object that is ultimately responsible for measuring + /// and layout tasks. + public var layout: LayoutType + + /// Child elements. + fileprivate var children: [Child] = [] + + init(layout: LayoutType) { + self.layout = layout + } + + } + + +} + +extension ElementContent.Builder { + + /// Adds the given child element. + public mutating func add(traits: LayoutType.Traits = LayoutType.defaultTraits, key: String? = nil, element: Element) { + let child = Child( + traits: traits, + key: key, + content: element.content, + element: element) + children.append(child) + } + +} + +extension ElementContent.Builder: ContentStorage { + + var childCount: Int { + return children.count + } + + public func measure(in constraint: SizeConstraint) -> CGSize { + return layout.measure(in: constraint, items: layoutItems) + } + + func performLayout(attributes: LayoutAttributes) -> [(identifier: ElementIdentifier, node: LayoutResultNode)] { + + let childAttributes = layout.layout(size: attributes.bounds.size, items: layoutItems) + + var result: [(identifier: ElementIdentifier, node: LayoutResultNode)] = [] + result.reserveCapacity(children.count) + + for index in 0.. CGSize { + return content.measure(in: constraint) + } + + } + +} + +// All layout is ultimately performed by the `Layout` protocol – this implementations delegates to a wrapped +// `SingleChildLayout` implementation for use in elements with a single child. +fileprivate struct SingleChildLayoutHost: Layout { + + private var wrapped: SingleChildLayout + + init(wrapping layout: SingleChildLayout) { + self.wrapped = layout + } + + func measure(in constraint: SizeConstraint, items: [(traits: (), content: Measurable)]) -> CGSize { + precondition(items.count == 1) + return wrapped.measure(in: constraint, child: items.map { $0.content }.first!) + } + + func layout(size: CGSize, items: [(traits: (), content: Measurable)]) -> [LayoutAttributes] { + precondition(items.count == 1) + return [ + wrapped.layout(size: size, child: items.map { $0.content }.first!) + ] + } +} + +// Used for elements with a single child that requires no custom layout +fileprivate struct PassthroughLayout: SingleChildLayout { + + func measure(in constraint: SizeConstraint, child: Measurable) -> CGSize { + return child.measure(in: constraint) + } + + func layout(size: CGSize, child: Measurable) -> LayoutAttributes { + return LayoutAttributes(size: size) + } + +} + +// Used for empty elements with an intrinsic size +fileprivate struct MeasurableLayout: Layout { + + var measurable: Measurable + + func measure(in constraint: SizeConstraint, items: [(traits: (), content: Measurable)]) -> CGSize { + precondition(items.isEmpty) + return measurable.measure(in: constraint) + } + + func layout(size: CGSize, items: [(traits: (), content: Measurable)]) -> [LayoutAttributes] { + precondition(items.isEmpty) + return [] + } + +} diff --git a/BlueprintUI/Sources/Element/ProxyElement.swift b/BlueprintUI/Sources/Element/ProxyElement.swift new file mode 100644 index 000000000..80c74253b --- /dev/null +++ b/BlueprintUI/Sources/Element/ProxyElement.swift @@ -0,0 +1,23 @@ +/// Custom elements commonly use another element to actually display content. For example, a profile element might +/// display an image and a few labels inside a `Column` element. The ProxyElement protocol is provided to make that +/// task easier. +/// +/// Conforming types only need to implement `elementRepresentation` in order to generate an element that will be +/// displayed. +public protocol ProxyElement: Element { + + /// Returns an element that represents the entire content of this element. + var elementRepresentation: Element { get } +} + +extension ProxyElement { + + public var content: ElementContent { + return ElementContent(child: elementRepresentation) + } + + public func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + return nil + } + +} diff --git a/BlueprintUI/Sources/Internal/ElementIdentifier.swift b/BlueprintUI/Sources/Internal/ElementIdentifier.swift new file mode 100644 index 000000000..f5e9bb9f9 --- /dev/null +++ b/BlueprintUI/Sources/Internal/ElementIdentifier.swift @@ -0,0 +1,43 @@ +/// Used to identify elements during an update pass. If no key identifier is present, the fallback behavior is to +/// identify the element by its index. +enum ElementIdentifier: Hashable { + + /// The element represented by this component was assigned a specific key. + case key(String) + + /// The element represented by this component was not assigned a specific key, so its index in the parent's array + /// of children is used instead. + case index(Int) + + internal init(key: String?, index: Int) { + if let key = key { + self = .key(key) + } else { + self = .index(index) + } + } + + /// Returns the reuse identifier of this component (if provided). + var key: String? { + switch self { + case .key(let key): + return key + default: + return nil + } + } + +} + +extension ElementIdentifier: CustomStringConvertible { + + var description: String { + switch self { + case let .key(string): + return string + case let .index(index): + return "(\(index))" + } + } + +} diff --git a/BlueprintUI/Sources/Internal/ElementPath.swift b/BlueprintUI/Sources/Internal/ElementPath.swift new file mode 100644 index 000000000..0da6524db --- /dev/null +++ b/BlueprintUI/Sources/Internal/ElementPath.swift @@ -0,0 +1,116 @@ +/// Represents a path into an element hierarchy. +/// Used for disambiguation during diff operations. +struct ElementPath: Hashable { + + private var storage: Storage + + init() { + storage = Storage(components: []) + } + + private mutating func storageForWriting() -> Storage { + if !isKnownUniquelyReferenced(&storage) { + storage = Storage(components: storage.components) + } + return storage + } + + var components: [Component] { + return storage.components + } + + mutating func prepend(component: Component) { + storageForWriting().prepend(component: component) + } + + mutating func append(component: Component) { + storageForWriting().append(component: component) + } + + func prepending(component: Component) -> ElementPath { + var result = self + result.prepend(component: component) + return result + } + + func appending(component: Component) -> ElementPath { + var result = self + result.append(component: component) + return result + } + + static var empty: ElementPath { + return ElementPath() + } + +} + +extension ElementPath { + + /// Represents an element in a hierarchy. + struct Component: Hashable { + + /// The type of element represented by this component. + var elementType: Element.Type + + /// The identifier of this component. + var identifier: ElementIdentifier + + init(elementType: Element.Type, identifier: ElementIdentifier) { + self.elementType = elementType + self.identifier = identifier + } + + static func ==(lhs: Component, rhs: Component) -> Bool { + return lhs.elementType == rhs.elementType + && lhs.identifier == rhs.identifier + } + + func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(elementType)) + hasher.combine(identifier) + } + + } + +} + +extension ElementPath { + + fileprivate final class Storage: Hashable { + + private var _hash: Int? = nil + + private (set) var components: [ElementPath.Component] { + didSet { + _hash = nil + } + } + + init(components: [ElementPath.Component]) { + self.components = components + } + + func append(component: ElementPath.Component) { + components.append(component) + } + + func prepend(component: ElementPath.Component) { + components.insert(component, at: 0) + } + + func hash(into hasher: inout Hasher) { + if _hash == nil { + _hash = components.hashValue + } + hasher.combine(_hash) + } + + static func ==(lhs: Storage, rhs: Storage) -> Bool { + return lhs.components == rhs.components + } + + } + +} + diff --git a/BlueprintUI/Sources/Internal/Extensions/CATransform3D.swift b/BlueprintUI/Sources/Internal/Extensions/CATransform3D.swift new file mode 100755 index 000000000..3c294c797 --- /dev/null +++ b/BlueprintUI/Sources/Internal/Extensions/CATransform3D.swift @@ -0,0 +1,43 @@ +import QuartzCore +import simd + +extension CATransform3D { + + init(_ double4x4Value: double4x4) { + self.init() + m11 = CGFloat(double4x4Value[0][0]) + m12 = CGFloat(double4x4Value[1][0]) + m13 = CGFloat(double4x4Value[2][0]) + m14 = CGFloat(double4x4Value[3][0]) + m21 = CGFloat(double4x4Value[0][1]) + m22 = CGFloat(double4x4Value[1][1]) + m23 = CGFloat(double4x4Value[2][1]) + m24 = CGFloat(double4x4Value[3][1]) + m31 = CGFloat(double4x4Value[0][2]) + m32 = CGFloat(double4x4Value[1][2]) + m33 = CGFloat(double4x4Value[2][2]) + m34 = CGFloat(double4x4Value[3][2]) + m41 = CGFloat(double4x4Value[0][3]) + m42 = CGFloat(double4x4Value[1][3]) + m43 = CGFloat(double4x4Value[2][3]) + m44 = CGFloat(double4x4Value[3][3]) + } + + var double4x4Value: double4x4 { + return double4x4(rows: [ + double4(Double(m11),Double(m12),Double(m13),Double(m14)), + double4(Double(m21),Double(m22),Double(m23),Double(m24)), + double4(Double(m31),Double(m32),Double(m33),Double(m34)), + double4(Double(m41),Double(m42),Double(m43),Double(m44)), + ]) + } + + var untranslated: CATransform3D { + var result = self + result.m41 = 0.0 + result.m42 = 0.0 + result.m43 = 0.0 + return result + } + +} diff --git a/BlueprintUI/Sources/Internal/Extensions/CGPoint.swift b/BlueprintUI/Sources/Internal/Extensions/CGPoint.swift new file mode 100755 index 000000000..327b54f25 --- /dev/null +++ b/BlueprintUI/Sources/Internal/Extensions/CGPoint.swift @@ -0,0 +1,24 @@ +import QuartzCore +import simd + +extension CGPoint { + + init(_ vector: double4) { + self.init( + x: CGFloat(vector.x), + y: CGFloat(vector.y)) + } + + var double4Value: double4 { + return double4(Double(x), Double(y), 0.0, 1.0) + } + + mutating func apply(transform: CATransform3D) { + self = applying(transform) + } + + func applying(_ transform: CATransform3D) -> CGPoint { + return CGPoint(double4Value * transform.double4x4Value) + } + +} diff --git a/BlueprintUI/Sources/Internal/Extensions/UIView.swift b/BlueprintUI/Sources/Internal/Extensions/UIView.swift new file mode 100644 index 000000000..79bfe7808 --- /dev/null +++ b/BlueprintUI/Sources/Internal/Extensions/UIView.swift @@ -0,0 +1,9 @@ +import UIKit + +extension UIView { + + final class var isInAnimationBlock: Bool { + return self.inheritedAnimationDuration > 0 + } + +} diff --git a/BlueprintUI/Sources/Internal/Extensions/UIViewAnimationOptions.swift b/BlueprintUI/Sources/Internal/Extensions/UIViewAnimationOptions.swift new file mode 100755 index 000000000..248319472 --- /dev/null +++ b/BlueprintUI/Sources/Internal/Extensions/UIViewAnimationOptions.swift @@ -0,0 +1,9 @@ +import UIKit + +extension UIView.AnimationOptions { + + init(animationCurve: UIView.AnimationCurve) { + self = UIView.AnimationOptions(rawValue: UInt(animationCurve.rawValue) << 16) + } + +} diff --git a/BlueprintUI/Sources/Internal/LayoutResultNode.swift b/BlueprintUI/Sources/Internal/LayoutResultNode.swift new file mode 100644 index 000000000..c1cf0c2d3 --- /dev/null +++ b/BlueprintUI/Sources/Internal/LayoutResultNode.swift @@ -0,0 +1,119 @@ +extension Element { + + /// Build a fully laid out element tree with complete layout attributes + /// for each element. + /// + /// - Parameter layoutAttributes: The layout attributes to assign to the + /// root element. + /// + /// - Returns: A layout result + func layout(layoutAttributes: LayoutAttributes) -> LayoutResultNode { + return LayoutResultNode( + element: self, + layoutAttributes: layoutAttributes, + content: content) + } + + /// Build a fully laid out element tree with complete layout attributes + /// for each element. + /// + /// - Parameter frame: The frame to assign to the root element. + /// + /// - Returns: A layout result + func layout(frame: CGRect) -> LayoutResultNode { + return layout(layoutAttributes: LayoutAttributes(frame: frame)) + } + +} + +/// Represents a tree of elements with complete layout attributes +struct LayoutResultNode { + + /// The element that was laid out + var element: Element + + /// Diagnostic information about the layout process. + var diagnosticInfo: DiagnosticInfo + + /// The layout attributes for the element + var layoutAttributes: LayoutAttributes + + /// The element's children. + var children: [(identifier: ElementIdentifier, node: LayoutResultNode)] + + init(element: Element, layoutAttributes: LayoutAttributes, content: ElementContent) { + + self.element = element + self.layoutAttributes = layoutAttributes + + let layoutBeginTime = DispatchTime.now() + children = content.performLayout(attributes: layoutAttributes) + let layoutEndTime = DispatchTime.now() + let layoutDuration = layoutEndTime.uptimeNanoseconds - layoutBeginTime.uptimeNanoseconds + diagnosticInfo = LayoutResultNode.DiagnosticInfo(layoutDuration: layoutDuration) + + } + +} + +extension LayoutResultNode { + + struct DiagnosticInfo { + + var layoutDuration: UInt64 + + init(layoutDuration: UInt64) { + self.layoutDuration = layoutDuration + } + } + +} + +extension LayoutResultNode { + + /// Returns the flattened tree of view descriptions (any element that does not return + /// a view description will be skipped). + func resolve() -> [(path: ElementPath, node: NativeViewNode)] { + + let resolvedChildContent: [(path: ElementPath, node: NativeViewNode)] = children + .flatMap { identifier, layoutResultNode in + + return layoutResultNode + .resolve() + .map { path, viewDescriptionNode in + + let component = ElementPath.Component( + elementType: type(of: layoutResultNode.element), + identifier: identifier) + + return (path: path.prepending(component: component), node: viewDescriptionNode) + } + } + + let subtreeExtent: CGRect? = children + .map { $0.node } + .reduce(into: nil) { (rect, node) in + rect = rect?.union(node.layoutAttributes.frame) ?? node.layoutAttributes.frame + } + + let viewDescription = element.backingViewDescription( + bounds: layoutAttributes.bounds, + subtreeExtent: subtreeExtent) + + if let viewDescription = viewDescription { + let node = NativeViewNode( + content: viewDescription, + layoutAttributes: layoutAttributes, + children: resolvedChildContent) + return [(path: .empty, node: node)] + } else { + return resolvedChildContent.map { (path, node) -> (path: ElementPath, node: NativeViewNode) in + var transformedNode = node + transformedNode.layoutAttributes = transformedNode.layoutAttributes.within(layoutAttributes) + return (path, transformedNode) + } + } + + } + +} diff --git a/BlueprintUI/Sources/Internal/NativeViewNode.swift b/BlueprintUI/Sources/Internal/NativeViewNode.swift new file mode 100644 index 000000000..8c5738ae8 --- /dev/null +++ b/BlueprintUI/Sources/Internal/NativeViewNode.swift @@ -0,0 +1,43 @@ +import UIKit + +/// Represents a flattened hierarchy of view descriptions and accompanying layout attributes which are derived from an +/// element hierarchy. +/// +/// Each child contains the path to the element that provided it, relative to its parent. +/// +/// Example: for a given element hierarchy +/// +/// - "A" * +/// - "B" +/// - "C" +/// - "D" * +/// +/// in which content-providing elements are designated by asterisks: +/// +/// The resulting content nodes will be shaped like this. +/// +/// - (path: ["A"]) +/// - (path: ["B","C","D"]) +struct NativeViewNode { + + /// The view description returned by this node + var viewDescription: ViewDescription + + /// The layout attributes for this content (relative to the parent's layout + /// attributes). + var layoutAttributes: LayoutAttributes + + /// The children of this node. + var children: [(path: ElementPath, node: NativeViewNode)] + + init( + content: ViewDescription, + layoutAttributes: LayoutAttributes, + children: [(path: ElementPath, node: NativeViewNode)]) { + + self.viewDescription = content + self.layoutAttributes = layoutAttributes + self.children = children + } + +} diff --git a/BlueprintUI/Sources/Layout/Centered.swift b/BlueprintUI/Sources/Layout/Centered.swift new file mode 100644 index 000000000..2e3cc5a60 --- /dev/null +++ b/BlueprintUI/Sources/Layout/Centered.swift @@ -0,0 +1,42 @@ +/// Centers a content element within itself. +/// +/// The size of the content element is determined by calling `measure(in:)` on +/// the content element – even if that size is larger than the wrapping `Centered` +/// element. +/// +public struct Centered: Element { + + /// The content element to be centered. + public var wrappedElement: Element + + /// Initializes a `Centered` element with the given content element. + public init(_ wrappedElement: Element) { + self.wrappedElement = wrappedElement + } + + public var content: ElementContent { + return ElementContent(child: wrappedElement, layout: Layout()) + } + + public func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + return nil + } +} + +extension Centered { + fileprivate struct Layout: SingleChildLayout { + + func measure(in constraint: SizeConstraint, child: Measurable) -> CGSize { + return child.measure(in: constraint) + } + + func layout(size: CGSize, child: Measurable) -> LayoutAttributes { + var childAttributes = LayoutAttributes() + childAttributes.bounds.size = child.measure(in: SizeConstraint(size)) + childAttributes.center.x = size.width/2.0 + childAttributes.center.y = size.height/2.0 + return childAttributes + } + + } +} diff --git a/BlueprintUI/Sources/Layout/Column.swift b/BlueprintUI/Sources/Layout/Column.swift new file mode 100644 index 000000000..d4990a4d4 --- /dev/null +++ b/BlueprintUI/Sources/Layout/Column.swift @@ -0,0 +1,30 @@ +/// Displays a list of items in a linear vertical layout. +public struct Column: StackElement { + + public var children: [(element: Element, traits: StackLayout.Traits, key: String?)] = [] + + private (set) public var layout = StackLayout(axis: .vertical) + + public init() {} + + public var verticalUnderflow: StackLayout.UnderflowDistribution { + get { return layout.underflow } + set { layout.underflow = newValue } + } + + public var verticalOverflow: StackLayout.OverflowDistribution { + get { return layout.overflow } + set { layout.overflow = newValue } + } + + public var horizontalAlignment: StackLayout.Alignment { + get { return layout.alignment } + set { layout.alignment = newValue } + } + + public var minimumVerticalSpacing: CGFloat { + get { return layout.minimumSpacing } + set { layout.minimumSpacing = newValue } + } + +} diff --git a/BlueprintUI/Sources/Layout/ConstrainedSize.swift b/BlueprintUI/Sources/Layout/ConstrainedSize.swift new file mode 100644 index 000000000..9352fedc8 --- /dev/null +++ b/BlueprintUI/Sources/Layout/ConstrainedSize.swift @@ -0,0 +1,82 @@ +/// Constrains the measured size of the content element. +public struct ConstrainedSize: Element { + + public var wrappedElement: Element + + public var width: Constraint + public var height: Constraint + + public init(wrapping element: Element, width: Constraint = .unconstrained, height: Constraint = .unconstrained) { + self.wrappedElement = element + self.width = width + self.height = height + } + + public var content: ElementContent { + return ElementContent(child: wrappedElement, layout: Layout(width: width, height: height)) + } + + public func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + return nil + } + +} + +extension ConstrainedSize { + + public enum Constraint { + case unconstrained + case atMost(CGFloat) + case atLeast(CGFloat) + case within(ClosedRange) + case absolute(CGFloat) + + fileprivate func applied(to value: CGFloat) -> CGFloat { + switch self { + case .unconstrained: + return value + case let .atMost(max): + return min(max, value) + case let .atLeast(min): + return max(min, value) + case let .within(range): + return value.clamped(to: range) + case let .absolute(absoluteValue): + return absoluteValue + } + } + } + +} + +extension Comparable { + + fileprivate func clamped(to limits: ClosedRange) -> Self { + return min(max(self, limits.lowerBound), limits.upperBound) + } + +} + +extension ConstrainedSize { + + fileprivate struct Layout: SingleChildLayout { + + var width: Constraint + var height: Constraint + + func measure(in constraint: SizeConstraint, child: Measurable) -> CGSize { + var result = child.measure(in: constraint) + result.width = width.applied(to: result.width) + result.height = height.applied(to: result.height) + return result + } + + func layout(size: CGSize, child: Measurable) -> LayoutAttributes { + return LayoutAttributes(size: size) + } + + } + +} + + diff --git a/BlueprintUI/Sources/Layout/GridLayout.swift b/BlueprintUI/Sources/Layout/GridLayout.swift new file mode 100644 index 000000000..38ec1c41a --- /dev/null +++ b/BlueprintUI/Sources/Layout/GridLayout.swift @@ -0,0 +1,88 @@ +public struct GridLayout: Layout { + + public init() {} + + public enum Direction: Equatable { + case horizontal(rows: Int) + case vertical(columns: Int) + + fileprivate var primaryDimensionSize: Int { + switch self { + case .horizontal(let rows): + return rows + case .vertical(let cols): + return cols + } + } + + } + + public var direction: Direction = .vertical(columns: 4) + + public var gutter: CGFloat = 10.0 + + public var margin: CGFloat = 0.0 + + public func measure(in constraint: SizeConstraint, items: [(traits: (), content: Measurable)]) -> CGSize { + + let primarySize = direction.primaryDimensionSize + let secondarySize = Int(ceil(Double(items.count) / Double(primarySize))) + + let itemSize: CGFloat + switch direction { + case .horizontal(let rows): + itemSize = (constraint.maximum.height - (margin * 2.0) - (CGFloat(rows - 1) * gutter)) / CGFloat(rows) + return CGSize( + width: margin*2.0 + gutter*CGFloat(secondarySize-1) + itemSize*CGFloat(secondarySize), + height: constraint.maximum.height + ) + case .vertical(let cols): + itemSize = (constraint.maximum.width - (margin * 2.0) - (CGFloat(cols - 1) * gutter)) / CGFloat(cols) + return CGSize( + width: constraint.maximum.width, + height: margin*2.0 + gutter*CGFloat(secondarySize-1) + itemSize*CGFloat(secondarySize) + ) + } + } + + public func layout(size: CGSize, items: [(traits: (), content: Measurable)]) -> [LayoutAttributes] { + guard items.count > 0 else { return [] } + assert(direction.primaryDimensionSize > 0) + + + let itemSize: CGFloat + switch direction { + case .horizontal(let rows): + itemSize = (size.height - (margin * 2.0) - (CGFloat(rows - 1) * gutter)) / CGFloat(rows) + case .vertical(let cols): + itemSize = (size.width - (margin * 2.0) - (CGFloat(cols - 1) * gutter)) / CGFloat(cols) + } + + let primarySize = direction.primaryDimensionSize + + var result: [LayoutAttributes] = [] + + for (index, _) in items.enumerated() { + let primaryPosition = index % primarySize + let secondaryPosition = index / primarySize + + var frame = CGRect.zero + frame.size.width = itemSize + frame.size.height = itemSize + + switch direction { + case .horizontal(_): + frame.origin.x = margin + (itemSize + gutter) * CGFloat(secondaryPosition) + frame.origin.y = margin + (itemSize + gutter) * CGFloat(primaryPosition) + case .vertical(_): + frame.origin.x = margin + (itemSize + gutter) * CGFloat(primaryPosition) + frame.origin.y = margin + (itemSize + gutter) * CGFloat(secondaryPosition) + } + + result.append(LayoutAttributes(frame: frame)) + } + + return result + } + +} diff --git a/BlueprintUI/Sources/Layout/Inset.swift b/BlueprintUI/Sources/Layout/Inset.swift new file mode 100644 index 000000000..3552a7dbf --- /dev/null +++ b/BlueprintUI/Sources/Layout/Inset.swift @@ -0,0 +1,107 @@ +/// Insets a content element within a layout. +/// +/// Commonly used to add padding around another element when displayed within a +/// container. +public struct Inset: Element { + + /// The wrapped element to be inset. + public var wrappedElement: Element + + /// The amount to inset the content element. + public var top: CGFloat + public var bottom: CGFloat + public var left: CGFloat + public var right: CGFloat + + public init(wrapping element: Element, top: CGFloat = 0.0, bottom: CGFloat = 0.0, left: CGFloat = 0.0, right: CGFloat = 0.0) { + self.wrappedElement = element + self.top = top + self.bottom = bottom + self.left = left + self.right = right + } + + public init(wrapping element: Element, uniformInset: CGFloat) { + self.wrappedElement = element + self.top = uniformInset + self.bottom = uniformInset + self.left = uniformInset + self.right = uniformInset + } + + public init(wrapping element: Element, insets: UIEdgeInsets) { + self.wrappedElement = element + self.top = insets.top + self.bottom = insets.bottom + self.left = insets.left + self.right = insets.right + } + + public init(wrapping element: Element, sideInsets: CGFloat) { + self.init( + wrapping: element, + insets: UIEdgeInsets( + top: 0.0, + left: sideInsets, + bottom: 0.0, + right: sideInsets)) + } + + public init(wrapping element: Element, vertical: CGFloat) { + self.init( + wrapping: element, + top: vertical, + bottom: vertical) + } + + public var content: ElementContent { + return ElementContent( + child: wrappedElement, + layout: Layout( + top: top, + bottom: bottom, + left: left, + right: right)) + } + + public func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + return nil + } + +} + + +extension Inset { + + fileprivate struct Layout: SingleChildLayout { + + var top: CGFloat + var bottom: CGFloat + var left: CGFloat + var right: CGFloat + + func measure(in constraint: SizeConstraint, child: Measurable) -> CGSize { + let insetConstraint = constraint.inset( + width: left + right, + height: top + bottom) + + var size = child.measure(in: insetConstraint) + + size.width += left + right + size.height += top + bottom + + return size + } + + func layout(size: CGSize, child: Measurable) -> LayoutAttributes { + var frame = CGRect(origin: .zero, size: size) + frame.origin.x += left + frame.origin.y += top + frame.size.width -= left + right + frame.size.height -= top + bottom + return LayoutAttributes(frame: frame) + } + + } + +} diff --git a/BlueprintUI/Sources/Layout/Layout.swift b/BlueprintUI/Sources/Layout/Layout.swift new file mode 100644 index 000000000..255b444b5 --- /dev/null +++ b/BlueprintUI/Sources/Layout/Layout.swift @@ -0,0 +1,40 @@ +/// Conforming types can calculate layout attributes for an array of children. +public protocol Layout { + + /// Per-item metadata that is used during the measuring and layout pass. + associatedtype Traits = () + + /// Computes the size that this layout requires in a layout, given an array + /// of chidren and accompanying layout traits. + /// + /// - parameter constraint: The size constraint in which measuring should + /// occur. + /// - parameter items: An array of 'items', pairs consisting of a traits + /// object and a `Measurable` value. + /// + /// - returns: The measured size for the given array of items. + func measure(in constraint: SizeConstraint, items: [(traits: Self.Traits, content: Measurable)]) -> CGSize + + /// Generates layout attributes for the given items. + /// + /// - parameter size: The size that layout attributes should be generated + /// within. + /// + /// - parameter items: An array of 'items', pairs consisting of a traits + /// object and a `Measurable` value. + /// + /// - returns: Layout attributes for the given array of items. + func layout(size: CGSize, items: [(traits: Self.Traits, content: Measurable)]) -> [LayoutAttributes] + + /// Returns a default traits object. + static var defaultTraits: Self.Traits { get } + +} + +extension Layout where Traits == () { + + public static var defaultTraits: () { + return () + } + +} diff --git a/BlueprintUI/Sources/Layout/LayoutAttributes.swift b/BlueprintUI/Sources/Layout/LayoutAttributes.swift new file mode 100755 index 000000000..f3d34989d --- /dev/null +++ b/BlueprintUI/Sources/Layout/LayoutAttributes.swift @@ -0,0 +1,205 @@ +/// Contains layout-related metrics for an element. +public struct LayoutAttributes { + + /// Corresponds to `UIView.center`. + public var center: CGPoint { + didSet { validateCenter() } + } + + /// Corresponds to `UIView.bounds`. + public var bounds: CGRect { + didSet { validateBounds() } + } + + /// Corresponds to `UIView.layer.transform`. + public var transform: CATransform3D { + didSet { validateTransform() } + } + + /// Corresponds to `UIView.alpha`. + public var alpha: CGFloat { + didSet { validateAlpha() } + } + + public init() { + self.init(center: .zero, bounds: .zero) + } + + public init(frame: CGRect) { + self.init( + center: CGPoint(x: frame.midX, y: frame.midY), + bounds: CGRect(origin: .zero, size: frame.size)) + } + + public init(size: CGSize) { + self.init(frame: CGRect(origin: .zero, size: size)) + } + + public init(center: CGPoint, bounds: CGRect) { + self.center = center + self.bounds = bounds + self.transform = CATransform3DIdentity + self.alpha = 1.0 + + validateBounds() + validateCenter() + validateTransform() + validateAlpha() + } + + public var frame: CGRect { + get { + var f = CGRect.zero + f.size = bounds.size + f.origin.x = center.x - f.size.width/2.0 + f.origin.y = center.y - f.size.height/2.0 + return f + } + set { + bounds.size = newValue.size + center.x = newValue.midX + center.y = newValue.midY + } + } + + internal func apply(to view: UIView) { + view.bounds = bounds + view.center = center + view.layer.transform = transform + view.alpha = alpha + } + + + // Given nested layout attributes: + // + // β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + // β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + // β”‚ β”‚a β”‚ β”‚ + // β”‚ β”‚ β”‚ β”‚ + // β”‚ β”‚ β”‚ β”‚ + // β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ + // β”‚ β”‚ β”‚b β”‚ β”‚ β”‚ + // β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ + // β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ + // β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ + // β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ + // β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ + // β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + // β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + // + // `let c = b.within(layoutAttributes: a)` results in: + // + // β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + // β”‚ β”‚ + // β”‚ β”‚ + // β”‚ β”‚ + // β”‚ β”‚ + // β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + // β”‚ β”‚c β”‚ β”‚ + // β”‚ β”‚ β”‚ β”‚ + // β”‚ β”‚ β”‚ β”‚ + // β”‚ β”‚ β”‚ β”‚ + // β”‚ β”‚ β”‚ β”‚ + // β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + // β”‚ β”‚ + // β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + // + /// Concatonates layout attributes, moving the receiver from the local + /// coordinate space of `layoutAttributes` and into its parent coordinate + /// space. + /// + /// - parameter layoutAttributes: Another layout attributes object representing + /// a parent coordinate space. + /// + /// - returns: The resulting combined layout attributes object. + public func within(_ layoutAttributes: LayoutAttributes) -> LayoutAttributes { + + var t : CATransform3D = CATransform3DIdentity + t = CATransform3DTranslate(t, -layoutAttributes.bounds.midX, -layoutAttributes.bounds.midY, 0.0) + t = CATransform3DConcat( + t, + layoutAttributes.transform + ) + t = CATransform3DConcat( + t, + CATransform3DMakeTranslation(layoutAttributes.center.x, layoutAttributes.center.y, 0.0) + ) + + var result = LayoutAttributes( + center: center.applying(t), + bounds: bounds) + + result.transform = CATransform3DConcat(transform, t.untranslated) + result.alpha = alpha * layoutAttributes.alpha + + return result + } + + private func validateBounds() { + assert(bounds.isFinite, "LayoutAttributes.bounds must only contain finite values.") + } + + private func validateCenter() { + assert(center.isFinite, "LayoutAttributes.center must only contain finite values.") + } + + private func validateTransform() { + assert(transform.isFinite, "LayoutAttributes.transform only not contain finite values.") + } + + private func validateAlpha() { + assert(alpha.isFinite, "LayoutAttributes.alpha must only contain finite values.") + } + +} + +extension LayoutAttributes: Equatable { + + public static func ==(lhs: LayoutAttributes, rhs: LayoutAttributes) -> Bool { + return lhs.center == rhs.center + && lhs.bounds == rhs.bounds + && CATransform3DEqualToTransform(lhs.transform, rhs.transform) + && lhs.alpha == rhs.alpha + } + +} + +extension CGRect { + fileprivate var isFinite: Bool { + return origin.isFinite || size.isFinite + } +} + +extension CGPoint { + fileprivate var isFinite: Bool { + return x.isFinite || y.isFinite + } +} + +extension CGSize { + fileprivate var isFinite: Bool { + return width.isFinite || height.isFinite + } +} + +extension CATransform3D { + + fileprivate var isFinite: Bool { + return m11.isFinite + && m12.isFinite + && m13.isFinite + && m14.isFinite + && m21.isFinite + && m22.isFinite + && m23.isFinite + && m24.isFinite + && m31.isFinite + && m32.isFinite + && m33.isFinite + && m34.isFinite + && m41.isFinite + && m42.isFinite + && m43.isFinite + && m44.isFinite + } +} diff --git a/BlueprintUI/Sources/Layout/Overlay.swift b/BlueprintUI/Sources/Layout/Overlay.swift new file mode 100644 index 000000000..3a164d95e --- /dev/null +++ b/BlueprintUI/Sources/Layout/Overlay.swift @@ -0,0 +1,41 @@ +public struct Overlay: Element { + + public var elements: [Element] + + public init(elements: [Element]) { + self.elements = elements + } + + public var content: ElementContent { + return ElementContent(layout: OverlayLayout()) { + for element in elements { + $0.add(element: element) + } + } + } + + public func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + return nil + } + +} + +/// A layout implementation that places all children on top of each other with +/// the same frame (filling the container’s bounds). +fileprivate struct OverlayLayout: Layout { + + func measure(in constraint: SizeConstraint, items: [(traits: Void, content: Measurable)]) -> CGSize { + return items.reduce(into: CGSize.zero, { (result, item) in + let measuredSize = item.content.measure(in: constraint) + result.width = max(result.width, measuredSize.width) + result.height = max(result.height, measuredSize.height) + }) + } + + func layout(size: CGSize, items: [(traits: Void, content: Measurable)]) -> [LayoutAttributes] { + return Array( + repeating: LayoutAttributes(size: size), + count: items.count) + } + +} diff --git a/BlueprintUI/Sources/Layout/Row.swift b/BlueprintUI/Sources/Layout/Row.swift new file mode 100644 index 000000000..cb26134e6 --- /dev/null +++ b/BlueprintUI/Sources/Layout/Row.swift @@ -0,0 +1,30 @@ +/// Displays a list of items in a linear horizontal layout. +public struct Row: StackElement { + + public var children: [(element: Element, traits: StackLayout.Traits, key: String?)] = [] + + private (set) public var layout = StackLayout(axis: .horizontal) + + public init() {} + + public var horizontalUnderflow: StackLayout.UnderflowDistribution { + get { return layout.underflow } + set { layout.underflow = newValue } + } + + public var horizontalOverflow: StackLayout.OverflowDistribution { + get { return layout.overflow } + set { layout.overflow = newValue } + } + + public var verticalAlignment: StackLayout.Alignment { + get { return layout.alignment } + set { layout.alignment = newValue } + } + + public var minimumHorizontalSpacing: CGFloat { + get { return layout.minimumSpacing } + set { layout.minimumSpacing = newValue } + } + +} diff --git a/BlueprintUI/Sources/Layout/SingleChildLayout.swift b/BlueprintUI/Sources/Layout/SingleChildLayout.swift new file mode 100644 index 000000000..7b6eb89d6 --- /dev/null +++ b/BlueprintUI/Sources/Layout/SingleChildLayout.swift @@ -0,0 +1,21 @@ +/// Conforming types can calculate layout attributes for an array of children. +public protocol SingleChildLayout { + + /// Computes the size that this layout requires + /// + /// - parameter constraint: The size constraint in which measuring should occur. + /// - parameter child: A `Measurable` representing the single child of this layout. + /// + /// - returns: The measured size. + func measure(in constraint: SizeConstraint, child: Measurable) -> CGSize + + /// Generates layout attributes for the child. + /// + /// - parameter size: The size that layout attributes should be generated within. + /// + /// - parameter child: A `Measurable` representing the single child of this layout. + /// + /// - returns: Layout attributes for the child of this layout. + func layout(size: CGSize, child: Measurable) -> LayoutAttributes + +} diff --git a/BlueprintUI/Sources/Layout/Spacer.swift b/BlueprintUI/Sources/Layout/Spacer.swift new file mode 100644 index 000000000..c860d4d4d --- /dev/null +++ b/BlueprintUI/Sources/Layout/Spacer.swift @@ -0,0 +1,22 @@ +/// An element that does not display anything (it has neither children or a view). +/// +/// `Spacer` simply takes up a specified amount of space within a layout. +public struct Spacer: Element { + + /// The size that this spacer will take in a layout. + public var size: CGSize + + /// Initializes a new spacer with the given size. + public init(size: CGSize) { + self.size = size + } + + public var content: ElementContent { + return ElementContent(intrinsicSize: size) + } + + public func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + return nil + } + +} diff --git a/BlueprintUI/Sources/Layout/Stack.swift b/BlueprintUI/Sources/Layout/Stack.swift new file mode 100644 index 000000000..3c0473509 --- /dev/null +++ b/BlueprintUI/Sources/Layout/Stack.swift @@ -0,0 +1,442 @@ +/// Conforming types (Row and Column) act as StackLayout powered containers. +/// +/// This protocol should only be used by Row and Column elements (you should never add conformance to other custom +/// types). +public protocol StackElement: Element { + init() + var layout: StackLayout { get } + var children: [(element: Element, traits: StackLayout.Traits, key: String?)] { get set } +} + +extension StackElement { + + public var content: ElementContent { + return ElementContent(layout: layout) { + for child in self.children { + $0.add(traits: child.traits, key: child.key, element: child.element) + } + } + } + + public func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + return nil + } + +} + +extension StackElement { + + public init(_ configure: (inout Self) -> Void) { + self.init() + configure(&self) + } + + /// Adds a given child element to the stack. + /// + /// - parameters: + /// - growPriority: If the layout underflows (there is extra space to be distributed) and the layout's underflow distribution + /// is set to either `growProportionally` or `growUniformly`, additional space will be given to children + /// within the layout. `growPriority` is used to customize how much of that additional space should be given + /// to a particular child. + /// + /// The default value is 1.0 + /// + /// The algorithm for distributing space is functionally equal to the following: + /// + /// ``` + /// let totalGrowPriority: CGFloat = /// The sum of the grow priority from all children + /// let totalUnderflowSize: CGFloat = /// The extra space to be distributed + /// for child in children { + /// let extraSize = (child.growPriority / totalGrowPriority) * totalUnderflowSize + /// /// `extraSize` is then added to the original measured size + /// } + /// ``` + /// + /// - shrinkPriority: If the layout overflows (there is not enough space to fit all children as measured), each child will receive + /// a smaller size within the layout. `shrinkPriority` is used to customize how much each child should shrink. + /// + /// The default value is 1.0 + /// + /// The algorithm for removing space is functionally equal to the following: + /// + /// ``` + /// let totalShrinkPriority: CGFloat = /// The sum of the shrink priority from all children + /// let totalOverflowSize: CGFloat = /// The overflow space to be subtracted + /// for child in children { + /// let shrinkSize = (child.shrinkPriority / totalShrinkPriority) * totalOverflowSize + /// /// `extraSize` is then subtracted from the original measured size + /// } + /// ``` + /// + /// - key: A key used to disambiguate children between subsequent updates of the view hierarchy + /// + /// - child: The child element to add to this stack + /// + mutating public func add(growPriority: CGFloat = 1.0, shrinkPriority: CGFloat = 1.0, key: String? = nil, child: Element) { + children.append(( + element: child, + traits: StackLayout.Traits(growPriority: growPriority, shrinkPriority: shrinkPriority), + key: key + )) + } + +} + + +/// A layout implementation that linearly lays out an array of children along either the horizontal or vertial axis. +public struct StackLayout: Layout { + + /// The default traits for a child contained within a stack layout + public static var defaultTraits: Traits { + return Traits() + } + + public struct Traits { + + public var growPriority: CGFloat + + public var shrinkPriority: CGFloat + + public init(growPriority: CGFloat = 1.0, shrinkPriority: CGFloat = 1.0) { + self.growPriority = growPriority + self.shrinkPriority = shrinkPriority + } + + } + + public var axis: Axis + + public var underflow = UnderflowDistribution.spaceEvenly + public var overflow = OverflowDistribution.condenseProportionally + public var alignment = Alignment.leading + public var minimumSpacing: CGFloat = 0 + + + public init(axis: Axis) { + self.axis = axis + } + + public func measure(in constraint: SizeConstraint, items: [(traits: Traits, content: Measurable)]) -> CGSize { + let size = _measureIn(constraint: constraint, items: items) + return size + } + + public func layout(size: CGSize, items: [(traits: Traits, content: Measurable)]) -> [LayoutAttributes] { + return _layout(size: size, items: items) + } + +} + +extension StackLayout { + + /// The direction of the stack. + public enum Axis { + case horizontal + case vertical + } + + /// Determines the on-axis layout when there is extra free space available. + public enum UnderflowDistribution { + + /// Additional space will be evenly devided into the spacing between items. + case spaceEvenly + + /// Additional space will be divided proportionally by the measured size of each child. + case growProportionally + + /// Additional space will be distributed uniformly between children. + case growUniformly + } + + /// Determines the on-axis layout when there is not enough space to fit all children as measured. + public enum OverflowDistribution { + + /// Each child will shrink proportionally to its measured size. + case condenseProportionally + + /// Each child will shrink by the same amount. + case condenseUniformly + } + + /// Determines the cross-axis layout (height for a horizontal stack, width for a vertical stack). + public enum Alignment { + case fill + case leading + case center + case trailing + } + +} + +extension StackLayout { + + fileprivate func _layout(size: CGSize, items: [(traits: Traits, content: Measurable)]) -> [LayoutAttributes] { + + guard items.count > 0 else { return [] } + + let constraint = SizeConstraint(size) + + let layoutSize = size.stackVector(axis: axis) + + let basisSizes = _getBasisSizes(constraint: constraint, items: items.map { $0.content }) + + let totalMeasuredAxis: CGFloat = basisSizes.map({ $0.axis }).reduce(0.0, +) + let minimumTotalSpacing = CGFloat(items.count-1) * minimumSpacing + + let frames: [Frame] + + /// Determine if we are dealing with overflow or underflow + if totalMeasuredAxis + minimumTotalSpacing >= layoutSize.axis { + /// Overflow + frames = _layoutOverflow(basisSizes: basisSizes, traits: items.map { $0.traits }, layoutSize: layoutSize) + } else { + /// Underflow + frames = _layoutUnderflow(basisSizes: basisSizes, traits: items.map { $0.traits }, layoutSize: layoutSize) + } + + return frames.map({ (frame) -> LayoutAttributes in + let rect = frame.rect(axis: axis) + return LayoutAttributes(frame: rect) + }) + } + + fileprivate func _measureIn(constraint: SizeConstraint, items: [(traits: Traits, content: Measurable)]) -> CGSize { + + guard items.count > 0 else { + return .zero + } + + var result = Vector.zero + + for item in items { + let measuredSize = item.content.measure(in: constraint).stackVector(axis: axis) + result.axis += measuredSize.axis + result.cross = max(result.cross, measuredSize.cross) + } + + result.axis += minimumSpacing * CGFloat(items.count-1) + + return result.size(axis: axis) + } + + fileprivate func _getBasisSizes(constraint: SizeConstraint, items: [Measurable]) -> [Vector] { + return items.map { $0.measure(in: constraint).stackVector(axis: axis) } + } + + fileprivate func _layoutOverflow(basisSizes: [Vector], traits: [Traits], layoutSize: Vector) -> [Frame] { + assert(basisSizes.count > 0) + + let totalBasisSize: CGFloat = basisSizes.map({ $0.axis }).reduce(0.0, +) + let totalSpacing = minimumSpacing * CGFloat(basisSizes.count-1) + + /// The size that will be distributed among children (can be positive or negative) + let extraSize: CGFloat = layoutSize.axis - (totalBasisSize + totalSpacing) + + assert(extraSize <= 0.0) + + var shrinkPriorities: [CGFloat] = [] + + for index in 0.. [Frame] { + assert(basisSizes.count > 0) + + let totalBasisSize: CGFloat = basisSizes.map({ $0.axis }).reduce(0.0, +) + + let minimumTotalSpace = minimumSpacing * CGFloat(basisSizes.count-1) + let extraSize: CGFloat = layoutSize.axis - (totalBasisSize + minimumTotalSpace) + assert(extraSize >= 0.0) + + let space: CGFloat + + switch underflow { + case .growProportionally: + space = minimumSpacing + case .growUniformly: + space = minimumSpacing + case .spaceEvenly: + space = (layoutSize.axis - totalBasisSize) / CGFloat(basisSizes.count-1) + } + + var frames = _calculateCross(basisSizes: basisSizes, layoutSize: layoutSize) + + var axisOrigin: CGFloat = 0.0 + + var growPriorities: [CGFloat] = [] + + for index in 0.. [Frame] { + return basisSizes.map { (measuredSize) -> Frame in + var result = Frame.zero + switch alignment { + case .center: + result.origin.cross = (layoutSize.cross - measuredSize.cross) / 2.0 + result.size.cross = measuredSize.cross + case .fill: + result.origin.cross = 0.0 + result.size.cross = layoutSize.cross + case .leading: + result.origin.cross = 0.0 + result.size.cross = measuredSize.cross + case .trailing: + result.origin.cross = layoutSize.cross - measuredSize.cross + result.size.cross = measuredSize.cross + } + return result + } + } + + fileprivate struct Vector { + var axis: CGFloat + var cross: CGFloat + + static var zero: Vector { + return Vector(axis: 0.0, cross: 0.0) + } + + func size(axis: StackLayout.Axis) -> CGSize { + switch axis { + case .horizontal: + return CGSize(width: self.axis, height: self.cross) + case .vertical: + return CGSize(width: self.cross, height: self.axis) + } + } + + func point(axis: StackLayout.Axis) -> CGPoint { + switch axis { + case .horizontal: + return CGPoint(x: self.axis, y: self.cross) + case .vertical: + return CGPoint(x: self.cross, y: self.axis) + } + } + } + + fileprivate struct Frame { + + var origin: Vector + var size: Vector + + static var zero: Frame { + return Frame(origin: .zero, size: .zero) + } + + func rect(axis: StackLayout.Axis) -> CGRect { + return CGRect(origin: origin.point(axis: axis), size: size.size(axis: axis)) + } + + var maxAxis: CGFloat { + return origin.axis + size.axis + } + + var minAxis: CGFloat { + return origin.axis + } + } + +} + +extension CGSize { + + fileprivate func stackVector(axis: StackLayout.Axis) -> StackLayout.Vector { + switch axis { + case .horizontal: + return StackLayout.Vector(axis: width, cross: height) + case .vertical: + return StackLayout.Vector(axis: height, cross: width) + } + } + +} + +extension CGPoint { + + fileprivate func stackVector(axis: StackLayout.Axis) -> StackLayout.Vector { + switch axis { + case .horizontal: + return StackLayout.Vector(axis: x, cross: y) + case .vertical: + return StackLayout.Vector(axis: y, cross: x) + } + } + +} + +extension CGRect { + + fileprivate func stackFrame(axis: StackLayout.Axis) -> StackLayout.Frame { + return StackLayout.Frame(origin: origin.stackVector(axis: axis), size: size.stackVector(axis: axis)) + } + +} diff --git a/BlueprintUI/Sources/Measuring/Measurable.swift b/BlueprintUI/Sources/Measuring/Measurable.swift new file mode 100644 index 000000000..5ef24dad8 --- /dev/null +++ b/BlueprintUI/Sources/Measuring/Measurable.swift @@ -0,0 +1,10 @@ +/// Conforming types can calculate the size that they require within a layout. +public protocol Measurable { + + /// Measures the required size of the receiver. + /// + /// - parameter constraint: The size constraint. + /// + /// - returns: The layout size needed by the receiver. + func measure(in constraint: SizeConstraint) -> CGSize +} diff --git a/BlueprintUI/Sources/Measuring/SizeConstraint.swift b/BlueprintUI/Sources/Measuring/SizeConstraint.swift new file mode 100644 index 000000000..4a83150e6 --- /dev/null +++ b/BlueprintUI/Sources/Measuring/SizeConstraint.swift @@ -0,0 +1,97 @@ +/// Defines the maximum size for a measurement. +/// +/// Currently this constraint type can only handles layout where +/// the primary (breaking) axis is horizontal (row in CSS-speak). +public struct SizeConstraint: Hashable { + + /// The width constraint. + public var width: Axis + + /// The height constraint. + public var height: Axis + + public init(width: Axis, height: Axis) { + self.width = width + self.height = height + } + +} + +extension SizeConstraint { + + public static var unconstrained: SizeConstraint { + return SizeConstraint(width: .unconstrained, height: .unconstrained) + } + + public init(_ size: CGSize) { + width = .atMost(size.width) + height = .atMost(size.height) + } + + public init(width: CGFloat) { + self.init(width: .atMost(width), height: .unconstrained) + } + + public init(height: CGFloat) { + self.init(width: .unconstrained, height: .atMost(height)) + } + + public var minimum: CGSize { + return CGSize(width: width.minimum, height: height.minimum) + } + + public var maximum: CGSize { + return CGSize(width: width.maximum, height: height.maximum) + } + + public func inset(width: CGFloat, height: CGFloat) -> SizeConstraint { + return SizeConstraint( + width: self.width - width, + height: self.height - height) + } + +} + +extension SizeConstraint { + + /// Represents a size constraint for a single axis. + public enum Axis: Hashable { + + /// The measurement should treat the associated value as the largest + /// possible size in the given dimension. + case atMost(CGFloat) + + /// The measurement is unconstrained in the given dimension. + case unconstrained + + /// The maximum magnitude in the given dimension. + public var maximum: CGFloat { + switch self { + case .atMost(let value): + return value + case .unconstrained: + return .greatestFiniteMagnitude + } + } + + /// The minimum magnitude in the given dimension. + public var minimum: CGFloat { + switch self { + case .atMost(_): + return 0.0 + case .unconstrained: + return 0.0 + } + } + + public static func -(lhs: SizeConstraint.Axis, rhs: CGFloat) -> SizeConstraint.Axis { + switch lhs { + case .atMost(let limit): + return .atMost(limit - rhs) + case .unconstrained: + return .unconstrained + } + } + + } +} diff --git a/BlueprintUI/Sources/View Description/AnimationAttributes.swift b/BlueprintUI/Sources/View Description/AnimationAttributes.swift new file mode 100644 index 000000000..4fa3342e4 --- /dev/null +++ b/BlueprintUI/Sources/View Description/AnimationAttributes.swift @@ -0,0 +1,42 @@ +/// UIView animation configuration values. +public struct AnimationAttributes { + + /// The duration of the animation. + public var duration: TimeInterval + + /// The timing curve of the animation. + public var curve: UIView.AnimationCurve + + /// Whether the view supports user interaction during the animation. + public var allowUserInteraction: Bool + + public init(duration: TimeInterval = 0.2, curve: UIView.AnimationCurve = .easeInOut, allowUserInteraction: Bool = true) { + self.duration = duration + self.curve = curve + self.allowUserInteraction = allowUserInteraction + } + +} + + +extension AnimationAttributes { + + func perform(animations: @escaping () -> Void, completion: @escaping ()->Void) { + + var options: UIView.AnimationOptions = [UIView.AnimationOptions(animationCurve: curve), .beginFromCurrentState] + if allowUserInteraction { + options.insert(.allowUserInteraction) + } + + UIView.animate( + withDuration: duration, + delay: 0.0, + options: options, + animations: { + animations() + }) { _ in + completion() + } + + } +} diff --git a/BlueprintUI/Sources/View Description/LayoutTransition.swift b/BlueprintUI/Sources/View Description/LayoutTransition.swift new file mode 100644 index 000000000..22e585f0d --- /dev/null +++ b/BlueprintUI/Sources/View Description/LayoutTransition.swift @@ -0,0 +1,58 @@ +/// The transition used when layout attributes change for a view during an +/// update cycle. +/// +/// **'Inherited' transitions:** the 'inherited' transition is determined by searching up the tree (not literally, but +/// this is the resulting behavior). The nearest ancestor that defines an animation will be used, following this +/// logic: +/// - Ancestors with a layout transition of `none` will result in no inherited animation for their descendents. +/// - Ancestors in the tree with a layout transition of `inherited` will be skipped, and the search will continue +/// up the tree. +/// - Any ancestors in the tree with a layout transition of `inheritedWithFallback` will be used *if* they do not +/// themselves inherit a layout transition from one of their ancestors. +/// - Ancestors with a layout transition of `specific` will always be used for their descendents inherited +/// animation. +/// - If no ancestor is found that specifies a layout transition, but the containing `BlueprintView` has the `element` +/// property assigned from within a `UIKit` animation block, that animation will be used as the inherited animation. +public enum LayoutTransition { + + /// The view will never animate layout changes. + case none + + /// Layout changes will always animate with the given attributes. + case specific(AnimationAttributes) + + /// The view will only animate layout changes if an inherited transition exists. + case inherited + + /// The view will animate along with an inherited transition (if present) or the specified fallback attributes. + case inheritedWithFallback(AnimationAttributes) + +} + + +extension LayoutTransition { + + func perform(_ animations: @escaping ()->Void) { + + switch self { + case .inherited: + animations() + case .none: + UIView.performWithoutAnimation(animations) + case .inheritedWithFallback(let fallback): + if UIView.isInAnimationBlock { + animations() + } else { + fallback.perform( + animations: animations, + completion: {}) + } + case .specific(let attributes): + attributes.perform( + animations: animations, + completion: {}) + } + + } + +} diff --git a/BlueprintUI/Sources/View Description/ViewDescription.swift b/BlueprintUI/Sources/View Description/ViewDescription.swift new file mode 100644 index 000000000..ab12ab9ba --- /dev/null +++ b/BlueprintUI/Sources/View Description/ViewDescription.swift @@ -0,0 +1,258 @@ +import UIKit + +/// Marker protocol used by generic extensions to native views (e.g. `UIView`). +public protocol NativeView {} + +extension UIView: NativeView {} + +extension NativeView where Self: UIView { + + /// Generates a view description for the receiving class. + /// Example: + /// ``` + /// let viewDescription = UILabel.describe { config in + /// config.bind("Hello, world", to: \.text) + /// config.bind(UIColor.orange, to: \.textColor) + /// } + /// ``` + /// - parameter configuring: A closure that is responsible for populating a configuration object. + /// + /// - returns: The resulting view description. + public static func describe(_ configuring: (inout ViewDescription.Configuration) -> Void) -> ViewDescription { + return ViewDescription.init(Self.self, configuring: configuring) + } + +} + +/// Contains a _description_ of a UIView instance. A description includes +/// logic to handle all parts of a view lifecycle from instantiation onward. +/// +/// View descriptions include: +/// - The view's class. +/// - How an instance of the view should be instantiated. +/// - How to update a view instance by setting properties appropriately. +/// - Which subview of a view instance should be used as a contain for +/// additional subviews. +/// - How to animate transitions for appearance, layout changes, and +/// disappearance. +/// +/// A view description does **not** contain a concrete view instance. It simply +/// contains functionality for creating, updating, and animating view instances. +public struct ViewDescription { + + private let _viewType: UIView.Type + private let _build: () -> UIView + private let _apply: (UIView) -> Void + private let _contentView: (UIView) -> UIView + + private let _layoutTransition: LayoutTransition + private let _appearingTransition: VisibilityTransition? + private let _disappearingTransition: VisibilityTransition? + + /// Generates a view description for the given view class. + /// - parameter viewType: The class of the described view. + public init(_ viewType: View.Type) where View: UIView { + self.init(viewType, configuring: { _ in }) + } + + /// Generates a view description for the given view class. + /// - parameter viewType: The class of the described view. + /// - parameter configuring: A closure that is responsible for populating a configuration object. + public init(_ type: View.Type, configuring: (inout Configuration)->Void) { + var configuration = Configuration() + configuring(&configuration) + self.init(configuration: configuration) + } + + /// Generates a view description with the given configuration object. + /// - parameter configuration: The configuration object. + private init(configuration: Configuration) { + _viewType = View.self + + _build = configuration.builder + + _apply = { view in + let typedView = configuration.typeChecked(view: view) + for update in configuration.updates { + update(typedView) + } + for binding in configuration.bindings.values { + binding.apply(to: typedView) + } + } + + _contentView = { (view) in + let typedView = configuration.typeChecked(view: view) + return configuration.contentView(typedView) + } + + _layoutTransition = configuration.layoutTransition + _appearingTransition = configuration.appearingTransition + _disappearingTransition = configuration.disappearingTransition + } + + public var viewType: UIView.Type { + return _viewType + } + + public func build() -> UIView { + return _build() + } + + public func apply(to view: UIView) { + _apply(view) + } + + public func contentView(in view: UIView) -> UIView { + return _contentView(view) + } + + public var layoutTransition: LayoutTransition { + return _layoutTransition + } + + public var appearingTransition: VisibilityTransition? { + return _appearingTransition + } + + public var disappearingTransition: VisibilityTransition? { + return _disappearingTransition + } + +} + +extension ViewDescription { + + /// Represents the configuration of a specific UIView type. + public struct Configuration { + + fileprivate var bindings: [AnyHashable:AnyValueBinding] = [:] + + /// A closure that is applied to the native view instance during an update cycle. + /// - parameter view: The native view instance. + public typealias Update = (_ view: View) -> Void + + /// A closure that is responsible for instantiating an instance of the native view. + /// The default value instantiates the view using `init(frame:)`. + public var builder: () -> View + + /// An array of update closures. + public var updates: [Update] + + /// A closure that takes a native view instance as the single argument, and + /// returns a subview of that view into which child views should be added + /// and managed. + public var contentView: (View) -> UIView + + /// The transition to use during layout changes. + public var layoutTransition: LayoutTransition = .inherited + + /// The transition to use when this view appears. + public var appearingTransition: VisibilityTransition? = nil + + /// The transition to use when this view disappears. + public var disappearingTransition: VisibilityTransition? = nil + + /// Initializes a default configuration object. + public init() { + builder = { View.init(frame: .zero) } + updates = [] + contentView = { $0 } + } + + fileprivate func typeChecked(view: UIView) -> View { + guard let typedView = view as? View else { + fatalError("A view of type \(type(of: view)) was used with a ViewDescription instance that expects views of type \(View.self)") + } + return typedView + } + + } + +} + +extension ViewDescription.Configuration { + + /// Adds the given update closure to the `updates` array. + public mutating func apply(_ update: @escaping Update) { + updates.append(update) + } + + /// Subscript for values that are not optional. We must represent these values as optional so that we can + /// return nil from the subscript in the case where no value has been assigned for the given keypath. + /// + /// When getting a value for a keypath: + /// - If a value has previously been assigned, it will be returned. + /// - If no value has been assigned, nil will be returned. + /// + /// When assigning a value for a keypath: + /// - If a value is provided, it will be applied to the view. + /// - If `nil` is provided, no value will be applied to the view (any previous assignment will be cleared). + public subscript(keyPath: ReferenceWritableKeyPath) -> Value? { + get { + let key = AnyHashable(keyPath) + if let binding = bindings[key] as? ValueBinding { + return binding.value + } else { + return nil + } + } + set { + let key = AnyHashable(keyPath) + if let value = newValue { + bindings[key] = ValueBinding(keyPath: keyPath, value: value) + } else { + bindings[key] = nil + } + } + } + + /// Subscript for values that are optional. + /// + /// When getting a value for a keypath: + /// - If a value has previously been assigned (including `nil`), it will be returned. + /// - If no value has been assigned, nil will be returned. + /// + /// When assigning a value for a keypath: + /// - Any provided value will be applied to the view (including `nil`). **This means that there is a difference + /// between the initial state of a view description (where the view's property will not be touched), and the + /// state after `nil` is assigned.** After assigning `nil` to an optional keypath, `view.property = nil` will + /// be called on the next update. + public subscript(keyPath: ReferenceWritableKeyPath) -> Value? { + get { + let key = AnyHashable(keyPath) + if let binding = bindings[key] as? ValueBinding { + return binding.value + } else { + return nil + } + } + set { + let key = AnyHashable(keyPath) + bindings[key] = ValueBinding(keyPath: keyPath, value: newValue) + } + } + +} + +extension ViewDescription.Configuration { + + fileprivate class AnyValueBinding { + func apply(to view: View) { fatalError() } + } + + fileprivate final class ValueBinding: AnyValueBinding { + var value: Value + var keyPath: ReferenceWritableKeyPath + + init(keyPath: ReferenceWritableKeyPath, value: Value) { + self.value = value + self.keyPath = keyPath + } + + override func apply(to view: View) { + view[keyPath: keyPath] = value + } + } + +} diff --git a/BlueprintUI/Sources/View Description/VisibilityTransition.swift b/BlueprintUI/Sources/View Description/VisibilityTransition.swift new file mode 100644 index 000000000..c1e9d0d3a --- /dev/null +++ b/BlueprintUI/Sources/View Description/VisibilityTransition.swift @@ -0,0 +1,75 @@ +/// The transition used when a view is inserted or removed during an update cycle. +public struct VisibilityTransition { + + /// The alpha of the view in the hidden state (initial for appearing, final for disappearing). + public var alpha: CGFloat + + /// The transform of the view in the hidden state (initial for appearing, final for disappearing). + public var transform: CATransform3D + + /// The animation attributes that will be used to drive the transition. + public var attributes: AnimationAttributes + + public init(alpha: CGFloat, transform: CATransform3D, attributes: AnimationAttributes = AnimationAttributes()) { + self.alpha = alpha + self.transform = transform + self.attributes = attributes + } + + /// Returns a `VisibilityTransition` that scales in and out. + public static var scale: VisibilityTransition { + return VisibilityTransition( + alpha: 1.0, + transform: CATransform3DMakeScale(0.01, 0.01, 0.01)) + } + + /// Returns a `VisibilityTransition` that fades in and out. + public static var fade: VisibilityTransition { + return VisibilityTransition( + alpha: 0.0, + transform: CATransform3DIdentity) + } + + /// Returns a `VisibilityTransition` that simultaneously scales and fades in and out. + public static var scaleAndFade: VisibilityTransition { + return VisibilityTransition( + alpha: 0.0, + transform: CATransform3DMakeScale(0.01, 0.01, 0.01)) + } +} + + + + +extension VisibilityTransition { + + func performAppearing(view: UIView, layoutAttributes: LayoutAttributes, completion: @escaping ()->Void) { + + UIView.performWithoutAnimation { + self.getInvisibleAttributesFor(layoutAttributes: layoutAttributes).apply(to: view) + } + + attributes.perform( + animations: { layoutAttributes.apply(to: view) }, + completion: completion) + + + } + + func performDisappearing(view: UIView, layoutAttributes: LayoutAttributes, completion: @escaping ()->Void) { + + attributes.perform( + animations: { + self.getInvisibleAttributesFor(layoutAttributes: layoutAttributes).apply(to: view) + }, + completion: completion) + + } + + private func getInvisibleAttributesFor(layoutAttributes: LayoutAttributes) -> LayoutAttributes { + var attributes = layoutAttributes + attributes.transform = CATransform3DConcat(attributes.transform, transform) + attributes.alpha *= alpha + return attributes + } +} diff --git a/BlueprintUI/Tests/BlueprintViewTests.swift b/BlueprintUI/Tests/BlueprintViewTests.swift new file mode 100755 index 000000000..f7910e551 --- /dev/null +++ b/BlueprintUI/Tests/BlueprintViewTests.swift @@ -0,0 +1,45 @@ +import XCTest +@testable import BlueprintUI + +class BlueprintViewTests: XCTestCase { + + func test_displaysSimpleView() { + + let blueprintView = BlueprintView(element: SimpleViewElement(color: .red)) + + XCTAssert(UIView.self == type(of: blueprintView.currentNativeViewControllers[0].node.view)) + XCTAssertEqual(blueprintView.currentNativeViewControllers[0].node.view.frame, blueprintView.bounds) + } + + func test_updatesExistingViews() { + let blueprintView = BlueprintView(element: SimpleViewElement(color: .green)) + + let initialView = blueprintView.currentNativeViewControllers[0].node.view + XCTAssertEqual(initialView.backgroundColor, UIColor.green) + + blueprintView.element = SimpleViewElement(color: .blue) + + XCTAssert(initialView === blueprintView.currentNativeViewControllers[0].node.view) + XCTAssertEqual(initialView.backgroundColor, UIColor.blue) + } + + + +} + + +fileprivate struct SimpleViewElement: Element { + + var color: UIColor + + var content: ElementContent { + return ElementContent(intrinsicSize: CGSize(width: 100, height: 100)) + } + + func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + return UIView.describe { config in + config[\.backgroundColor] = color + } + } + +} diff --git a/BlueprintUI/Tests/CATransform3DExtensionTests.swift b/BlueprintUI/Tests/CATransform3DExtensionTests.swift new file mode 100644 index 000000000..fd92bb086 --- /dev/null +++ b/BlueprintUI/Tests/CATransform3DExtensionTests.swift @@ -0,0 +1,23 @@ +import XCTest +@testable import BlueprintUI + +class CATransform3DExtensionTests: XCTestCase { + + func testSimdRoundTrip() { + var transform = CATransform3DIdentity + transform = CATransform3DRotate(transform, 2.0, 1.0, 0.0, 0.0) + + let matrix = transform.double4x4Value + let recreatedTransform = CATransform3D(matrix) + + XCTAssertEqual(transform, recreatedTransform) + + } + +} + +extension CATransform3D: Equatable { + public static func ==(lhs: CATransform3D, rhs: CATransform3D) -> Bool { + return CATransform3DEqualToTransform(lhs, rhs) + } +} diff --git a/BlueprintUI/Tests/CGPointExtensionTests.swift b/BlueprintUI/Tests/CGPointExtensionTests.swift new file mode 100644 index 000000000..73873f5d3 --- /dev/null +++ b/BlueprintUI/Tests/CGPointExtensionTests.swift @@ -0,0 +1,23 @@ +import XCTest +@testable import BlueprintUI + +class CGPointExtensionTests: XCTestCase { + + func testTransformApplication() { + + let point = CGPoint(x: 100.0, y: 100.0) + + let scaleTransform = CATransform3DMakeScale(0.5, 0.5, 0.5) + let scaledPoint = point.applying(scaleTransform) + XCTAssertEqual(scaledPoint, CGPoint(x: 50.0, y: 50.0)) + + let rotateTransform = CATransform3DMakeRotation(.pi, 0.0, 0.0, 1.0) + let rotatedPoint = point.applying(rotateTransform) + XCTAssertEqual(rotatedPoint, CGPoint(x: -100.0, y: -100.0)) + + let translateTransform = CATransform3DMakeTranslation(33.0, 22.0, 0.0) + let translatedPoint = point.applying(translateTransform) + XCTAssertEqual(translatedPoint, CGPoint(x: 133.0, y: 122.0)) + } + +} diff --git a/BlueprintUI/Tests/CenteredTests.swift b/BlueprintUI/Tests/CenteredTests.swift new file mode 100644 index 000000000..a0a1da198 --- /dev/null +++ b/BlueprintUI/Tests/CenteredTests.swift @@ -0,0 +1,42 @@ +import XCTest +@testable import BlueprintUI + + +class CenteredTests: XCTestCase { + + func test_measuring() { + let constraint = SizeConstraint(width: .unconstrained, height: .unconstrained) + let element = TestElement() + let centered = Centered(element) + XCTAssertEqual(centered.content.measure(in: constraint), element.content.measure(in: constraint)) + } + + func test_layout() { + let element = TestElement() + let centered = Centered(element) + + let children = centered + .layout(frame: CGRect(x: 0, y: 0, width: 5000, height: 6000)) + .children + .map { $0.node } + + XCTAssertEqual(children.count, 1) + XCTAssertEqual(children[0].layoutAttributes.center, CGPoint(x: 2500, y: 3000)) + XCTAssertEqual(children[0].layoutAttributes.bounds, CGRect(x: 0, y: 0, width: 123, height: 456)) + XCTAssertTrue(children[0].element is TestElement) + } + +} + + +fileprivate struct TestElement: Element { + + var content: ElementContent { + return ElementContent(intrinsicSize: CGSize(width: 123, height: 456)) + } + + func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + return nil + } + +} diff --git a/BlueprintUI/Tests/ConstrainedSizeTests.swift b/BlueprintUI/Tests/ConstrainedSizeTests.swift new file mode 100644 index 000000000..ebe9f304a --- /dev/null +++ b/BlueprintUI/Tests/ConstrainedSizeTests.swift @@ -0,0 +1,89 @@ +import XCTest +import BlueprintUI + +class ConstrainedSizeTests: XCTestCase { + + func test_unconstrained() { + let constraint = SizeConstraint(width: .unconstrained, height: .unconstrained) + + XCTAssertEqual( + ConstrainedSize(wrapping: TestElement()).content.measure(in: constraint).width, + 100 + ) + + XCTAssertEqual( + ConstrainedSize(wrapping: TestElement()).content.measure(in: constraint).height, + 100 + ) + } + + func test_atMost() { + let constraint = SizeConstraint(width: .unconstrained, height: .unconstrained) + + XCTAssertEqual( + ConstrainedSize(wrapping: TestElement(), width: .atMost(75)).content.measure(in: constraint).width, + 75 + ) + + XCTAssertEqual( + ConstrainedSize(wrapping: TestElement(), height: .atMost(75)).content.measure(in: constraint).height, + 75 + ) + } + + func test_atLeast() { + let constraint = SizeConstraint(width: .unconstrained, height: .unconstrained) + + XCTAssertEqual( + ConstrainedSize(wrapping: TestElement(), width: .atLeast(175)).content.measure(in: constraint).width, + 175 + ) + + XCTAssertEqual( + ConstrainedSize(wrapping: TestElement(), height: .atLeast(175)).content.measure(in: constraint).height, + 175 + ) + } + + func test_withinRange() { + let constraint = SizeConstraint(width: .unconstrained, height: .unconstrained) + + XCTAssertEqual( + ConstrainedSize(wrapping: TestElement(), width: .within(0...13)).content.measure(in: constraint).width, + 13 + ) + + XCTAssertEqual( + ConstrainedSize(wrapping: TestElement(), height: .within(0...13)).content.measure(in: constraint).height, + 13 + ) + } + + func test_absolute() { + let constraint = SizeConstraint(width: .unconstrained, height: .unconstrained) + + XCTAssertEqual( + ConstrainedSize(wrapping: TestElement(), width: .absolute(49)).content.measure(in: constraint).width, + 49 + ) + + XCTAssertEqual( + ConstrainedSize(wrapping: TestElement(), height: .absolute(49)).content.measure(in: constraint).height, + 49 + ) + } + +} + + +fileprivate struct TestElement: Element { + + var content: ElementContent { + return ElementContent(intrinsicSize: CGSize(width: 100, height: 100)) + } + + func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + return nil + } + +} diff --git a/BlueprintUI/Tests/ElementContentTests.swift b/BlueprintUI/Tests/ElementContentTests.swift new file mode 100755 index 000000000..39141db94 --- /dev/null +++ b/BlueprintUI/Tests/ElementContentTests.swift @@ -0,0 +1,86 @@ +import XCTest +@testable import BlueprintUI + +class ElementContentTests: XCTestCase { + + func test_noChildren() { + let container = ElementContent(layout: FrameLayout()) + XCTAssertEqual(container.childCount, 0) + XCTAssertEqual(container.measure(in: SizeConstraint(CGSize(width: 100, height: 100))), CGSize.zero) + } + + func test_singleChild() { + let frame = CGRect(x: 0, y: 0, width: 20, height: 20) + + let container = ElementContent(layout: FrameLayout()) { + $0.add(traits: frame, element: SimpleElement()) + } + + let children = container + .performLayout(attributes: LayoutAttributes(frame: .zero)) + .map { $0.node } + + XCTAssertEqual(children.count, 1) + + XCTAssertEqual(children[0].layoutAttributes, LayoutAttributes(frame: frame)) + + XCTAssertEqual(container.measure(in: SizeConstraint(CGSize.zero)), CGSize(width: frame.maxX, height: frame.maxY)) + } + + func test_multipleChildren() { + + let frame1 = CGRect(x: 0, y: 0, width: 20, height: 20) + let frame2 = CGRect(x: 200, y: 300, width: 400, height: 500) + + let container = ElementContent(layout: FrameLayout()) { + $0.add(traits: frame1, element: SimpleElement()) + $0.add(traits: frame2, element: SimpleElement()) + } + + let children = container + .performLayout(attributes: LayoutAttributes(frame: .zero)) + .map { $0.node } + + XCTAssertEqual(children.count, 2) + + XCTAssertEqual(children[0].layoutAttributes, LayoutAttributes(frame: frame1)) + XCTAssertEqual(children[1].layoutAttributes, LayoutAttributes(frame: frame2)) + + XCTAssertEqual(container.measure(in: SizeConstraint(CGSize.zero)), CGSize(width: 600, height: 800)) + } + +} + +fileprivate struct SimpleElement: Element { + + var content: ElementContent { + return ElementContent(intrinsicSize: .zero) + } + + func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + return nil + } + +} + + +fileprivate struct FrameLayout: Layout { + + typealias Traits = CGRect + + func measure(in constraint: SizeConstraint, items: [(traits: CGRect, content: Measurable)]) -> CGSize { + return items.reduce(into: CGSize.zero, { (result, item) in + result.width = max(result.width, item.traits.maxX) + result.height = max(result.height, item.traits.maxY) + }) + } + + func layout(size: CGSize, items: [(traits: CGRect, content: Measurable)]) -> [LayoutAttributes] { + return items.map { LayoutAttributes(frame: $0.traits) } + } + + static var defaultTraits: CGRect { + return CGRect.zero + } + +} diff --git a/BlueprintUI/Tests/ElementIdentifierTests.swift b/BlueprintUI/Tests/ElementIdentifierTests.swift new file mode 100644 index 000000000..b4e1c2842 --- /dev/null +++ b/BlueprintUI/Tests/ElementIdentifierTests.swift @@ -0,0 +1,24 @@ +import XCTest +@testable import BlueprintUI + +class ElementIdentifierTests: XCTestCase { + + func test_equality() { + + XCTAssertEqual(ElementIdentifier.index(0), ElementIdentifier.index(0)) + XCTAssertNotEqual(ElementIdentifier.index(0), ElementIdentifier.index(1)) + + XCTAssertEqual(ElementIdentifier.key("asdf"), ElementIdentifier.key("asdf")) + XCTAssertNotEqual(ElementIdentifier.key("foo"), ElementIdentifier.key("bar")) + + XCTAssertNotEqual(ElementIdentifier.index(0), ElementIdentifier.key("0")) + + } + + func test_convenienceProperties() { + XCTAssertNil(ElementIdentifier.index(0).key) + XCTAssertEqual(ElementIdentifier.key("asdf").key, "asdf") + } + +} + diff --git a/BlueprintUI/Tests/ElementPathTests.swift b/BlueprintUI/Tests/ElementPathTests.swift new file mode 100644 index 000000000..f609c3a7f --- /dev/null +++ b/BlueprintUI/Tests/ElementPathTests.swift @@ -0,0 +1,57 @@ +import XCTest +@testable import BlueprintUI + +class ElementPathTests: XCTestCase { + + func test_equality() { + + XCTAssertEqual(ElementPath.empty, ElementPath.empty) + + let testPath = ElementPath.init().appending(component: ElementPath.Component(elementType: A.self, identifier: .index(0))) + + XCTAssertNotEqual(testPath, .empty) + + XCTAssertEqual(testPath, testPath) + + } + + func test_copyOnWrite() { + + let testPath = ElementPath.init().appending(component: ElementPath.Component(elementType: A.self, identifier: .index(0))) + + var otherPath = testPath + otherPath.prepend(component: ElementPath.Component(elementType: B.self, identifier: .key("asdf"))) + + XCTAssertNotEqual(testPath, otherPath) + } + + func test_empty() { + XCTAssertEqual(ElementPath.empty.components, []) + } + +} + + +fileprivate struct A: Element { + + var content: ElementContent { + return ElementContent(intrinsicSize: .zero) + } + + func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + return nil + } + +} + +fileprivate struct B: Element { + + var content: ElementContent { + return ElementContent(intrinsicSize: .zero) + } + + func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + return nil + } + +} diff --git a/BlueprintUI/Tests/GridLayoutTests.swift b/BlueprintUI/Tests/GridLayoutTests.swift new file mode 100644 index 000000000..e29fa0cbe --- /dev/null +++ b/BlueprintUI/Tests/GridLayoutTests.swift @@ -0,0 +1,83 @@ +import XCTest +@testable import BlueprintUI + +class GridLayoutTests: XCTestCase { + + func test_defaults() { + let layout = GridLayout() + XCTAssertEqual(layout.direction, GridLayout.Direction.vertical(columns: 4)) + XCTAssertEqual(layout.gutter, 10.0) + XCTAssertEqual(layout.margin, 0.0) + } + + func test_measuring() { + + let layout = GridLayout() + + let container = ElementContent(layout: layout) { + for _ in 0..<20 { + $0.add(element: TestElement()) + } + } + + + let constraint = SizeConstraint(width: 130) + let measuredSize = container.measure(in: constraint) + + /// Default grid has 4 columns (20/4 == 5) + let rowCount = 5 + + let cellSize = (130.0 - (layout.gutter * 3.0)) / 4.0 + + XCTAssertEqual(measuredSize.width, 130) + XCTAssertEqual(measuredSize.height, cellSize * CGFloat(rowCount) + layout.gutter * CGFloat(rowCount-1)) + + } + + func test_layout() { + + let container = ElementContent(layout: GridLayout()) { + $0.layout.direction = .vertical(columns: 2) + for _ in 0..<4 { + $0.add(element: TestElement()) + } + } + + + XCTAssertEqual( + container + .performLayout(attributes: LayoutAttributes(frame: CGRect(x: 0, y: 0, width: 110, height: 10000))) + .map { $0.node.layoutAttributes.frame }, + [ + CGRect(x: 0, y: 0, width: 50, height: 50), + CGRect(x: 60, y: 0, width: 50, height: 50), + CGRect(x: 0, y: 60, width: 50, height: 50), + CGRect(x: 60, y: 60, width: 50, height: 50), + + ] + ) + + } + + +} + + + +fileprivate struct TestElement: Element { + + var size: CGSize + + init(size: CGSize = CGSize(width: 100, height: 100)) { + self.size = size + } + + var content: ElementContent { + return ElementContent(intrinsicSize: size) + } + + func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + return nil + } + +} diff --git a/BlueprintUI/Tests/InsetTests.swift b/BlueprintUI/Tests/InsetTests.swift new file mode 100644 index 000000000..fee80527d --- /dev/null +++ b/BlueprintUI/Tests/InsetTests.swift @@ -0,0 +1,39 @@ +import XCTest +@testable import BlueprintUI + +class InsetTests: XCTestCase { + + func test_measuring() { + let element = TestElement() + let inset = Inset(wrapping: element, uniformInset: 20.0) + + let constraint = SizeConstraint(width: .unconstrained, height: .unconstrained) + + XCTAssertEqual(element.content.measure(in: constraint).width + 40, inset.content.measure(in: constraint).width) + XCTAssertEqual(element.content.measure(in: constraint).height + 40, inset.content.measure(in: constraint).height) + } + + func test_layout() { + let element = TestElement() + let inset = Inset(wrapping: element, uniformInset: 20.0) + + let children = inset.layout(frame: CGRect(x: 0, y: 0, width: 100, height: 100)).children.map { $0.node } + + XCTAssertEqual(children.count, 1) + XCTAssertEqual(children[0].layoutAttributes.frame, CGRect(x: 20, y: 20, width: 60, height: 60)) + } + +} + + +fileprivate struct TestElement: Element { + + var content: ElementContent { + return ElementContent(intrinsicSize: CGSize(width: 100, height: 100)) + } + + func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + return nil + } + +} diff --git a/BlueprintUI/Tests/LayoutAttributesTests.swift b/BlueprintUI/Tests/LayoutAttributesTests.swift new file mode 100644 index 000000000..82d409f08 --- /dev/null +++ b/BlueprintUI/Tests/LayoutAttributesTests.swift @@ -0,0 +1,119 @@ +import XCTest +import QuartzCore +@testable import BlueprintUI + +final class LayoutAttributesTests: XCTestCase { + + + + + func testEquality() { + + let attributes = LayoutAttributes(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + + do { + /// Unchanged + let other = attributes + XCTAssertEqual(attributes, other) + } + + do { + /// Center + var other = attributes + other.center = CGPoint(x: 20, y: 20) + XCTAssertNotEqual(attributes, other) + } + + do { + /// Bounds + var other = attributes + other.bounds = CGRect(x: 0.0, y: 20.0, width: 40.0, height: 80.0) + XCTAssertNotEqual(attributes, other) + } + + do { + /// Alpha + var other = attributes + other.alpha = 0.5 + XCTAssertNotEqual(attributes, other) + } + + do { + /// Transform + var other = attributes + other.transform = CATransform3DMakeScale(2.0, 3.0, 5.0) + XCTAssertNotEqual(attributes, other) + } + + } + + func testConcatAlpha() { + var a = LayoutAttributes(frame: .zero) + a.alpha = 0.5 + var b = LayoutAttributes(frame: .zero) + b.alpha = 0.5 + let combined = b.within(a) + XCTAssertEqual(combined.alpha, 0.25) + } + + func testConcatCenter() { + + do { + var a = LayoutAttributes() + a.center = CGPoint(x: 100, y: 0) + + var b = LayoutAttributes() + b.center = CGPoint(x: 25, y: 50) + + let combined = b.within(a) + + XCTAssertEqual(combined.center.x, 125) + XCTAssertEqual(combined.center.y, 50) + } + + do { + var a = LayoutAttributes() + a.center = .zero + a.bounds.size = CGSize(width: 100.0, height: 100.0) + + var b = LayoutAttributes() + b.center = CGPoint(x: 25, y: 10) + + let combined = b.within(a) + + XCTAssertEqual(combined.center.x, -25) + XCTAssertEqual(combined.center.y, -40) + } + + do { + var a = LayoutAttributes() + a.center = .zero + a.bounds.size = CGSize(width: 200.0, height: 200.0) + + var b = LayoutAttributes() + b.center = .zero + + let combined = b.within(a) + + XCTAssertEqual(combined.center.x, -100) + XCTAssertEqual(combined.center.y, -100) + } + + do { + var a = LayoutAttributes() + a.center = .zero + a.bounds.size = CGSize(width: 200.0, height: 200.0) + a.transform = CATransform3DMakeRotation(.pi, 0.0, 0.0, 1.0) + + var b = LayoutAttributes() + b.center = .zero + + let combined = b.within(a) + + XCTAssertEqual(combined.center.x, 100) + XCTAssertEqual(combined.center.y, 100) + } + + } + +} diff --git a/BlueprintUI/Tests/LayoutResultNodeTests.swift b/BlueprintUI/Tests/LayoutResultNodeTests.swift new file mode 100644 index 000000000..815364e41 --- /dev/null +++ b/BlueprintUI/Tests/LayoutResultNodeTests.swift @@ -0,0 +1,70 @@ +import XCTest +@testable import BlueprintUI + + +final class LayoutResultNodeTests: XCTestCase { + + func testResolveFlattensNonViewBackedElements() { + + /// Three levels of abstract containers (insetting by 10pt each) + let testHierarchy = AbstractElement( + AbstractElement( + AbstractElement( + ConcreteElement() + ) + ) + ) + + let layoutResult = testHierarchy.layout(frame: CGRect(x: 0, y: 0, width: 160, height: 160)) + let viewNodes = layoutResult.resolve() + + XCTAssertEqual(viewNodes.count, 1) + + let viewNode = viewNodes[0] + + XCTAssertEqual(viewNode.node.layoutAttributes.frame, CGRect(x: 30, y: 30, width: 100, height: 100)) + + } + +} + + +fileprivate struct AbstractElement: Element { + + var wrappedElement: Element + + init(_ wrappedElement: Element) { + self.wrappedElement = wrappedElement + } + + func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + return nil + } + + var content: ElementContent { + return ElementContent(child: wrappedElement, layout: Layout()) + } + + private struct Layout: SingleChildLayout { + func measure(in constraint: SizeConstraint, child: Measurable) -> CGSize { + return .zero + } + func layout(size: CGSize, child: Measurable) -> LayoutAttributes { + return LayoutAttributes(frame: CGRect(origin: .zero, size: size).insetBy(dx: 10, dy: 10)) + } + } + +} + + +fileprivate struct ConcreteElement: Element { + + func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + return UIView.describe { _ in } + } + + var content: ElementContent { + return ElementContent(intrinsicSize: .zero) + } + +} diff --git a/BlueprintUI/Tests/OverlayLayoutTests.swift b/BlueprintUI/Tests/OverlayLayoutTests.swift new file mode 100644 index 000000000..a0b856e98 --- /dev/null +++ b/BlueprintUI/Tests/OverlayLayoutTests.swift @@ -0,0 +1,49 @@ +import XCTest +@testable import BlueprintUI + +class OverlayTests: XCTestCase { + + func test_measuring() { + let overlay = Overlay(elements: [ + TestElement(size: CGSize(width: 200, height: 200)), + TestElement(size: CGSize(width: 100, height: 100)), + TestElement(size: CGSize(width: 50, height: 50)) + ]) + XCTAssertEqual(overlay.content.measure(in: .unconstrained), CGSize(width: 200, height: 200)) + } + + func test_layout() { + let overlay = Overlay(elements: [ + TestElement(size: CGSize(width: 200, height: 200)), + TestElement(size: CGSize(width: 100, height: 100)), + TestElement(size: CGSize(width: 50, height: 50)) + ]) + XCTAssertEqual( + overlay + .layout(frame: CGRect(x: 0, y: 0, width: 456, height: 789)) + .children + .map { $0.node.layoutAttributes.frame }, + Array(repeating: CGRect(x: 0, y: 0, width: 456, height: 789), count: 3) + ) + } + +} + + +fileprivate struct TestElement: Element { + + var size: CGSize + + init(size: CGSize = CGSize(width: 100, height: 100)) { + self.size = size + } + + var content: ElementContent { + return ElementContent(intrinsicSize: size) + } + + func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + return nil + } + +} diff --git a/BlueprintUI/Tests/StackTests.swift b/BlueprintUI/Tests/StackTests.swift new file mode 100644 index 000000000..3f87deb6c --- /dev/null +++ b/BlueprintUI/Tests/StackTests.swift @@ -0,0 +1,489 @@ +import XCTest +@testable import BlueprintUI + +class StackTests: XCTestCase { + + func test_defaults() { + let column = Column() + XCTAssertEqual(column.minimumVerticalSpacing, 0.0) + XCTAssertEqual(column.horizontalAlignment, .leading) + XCTAssertEqual(column.verticalOverflow, .condenseProportionally) + XCTAssertEqual(column.verticalUnderflow, .spaceEvenly) + } + + func test_vertical() { + var column = Column() + column.add(child: TestElement()) + column.add(child: TestElement()) + + XCTAssertEqual(column.content.measure(in: .unconstrained).width, 100) + XCTAssertEqual(column.content.measure(in: .unconstrained).height, 200) + + let children = column + .layout(frame: CGRect(x: 0, y: 0, width: 100, height: 200)) + .children + .map { $0.node } + + XCTAssertEqual(children.count, 2) + + XCTAssertEqual(children[0].layoutAttributes.frame, CGRect(x: 0, y: 0, width: 100, height: 100)) + XCTAssertEqual(children[1].layoutAttributes.frame, CGRect(x: 0, y: 100, width: 100, height: 100)) + } + + func test_horizontal() { + var row = Row() + row.add(child: TestElement()) + row.add(child: TestElement()) + + XCTAssertEqual(row.content.measure(in: .unconstrained).width, 200) + XCTAssertEqual(row.content.measure(in: .unconstrained).height, 100) + + let children = row + .layout(frame: CGRect(x: 0, y: 0, width: 200, height: 100)) + .children + .map { $0.node } + + XCTAssertEqual(children.count, 2) + + XCTAssertEqual(children[0].layoutAttributes.frame, CGRect(x: 0, y: 0, width: 100, height: 100)) + XCTAssertEqual(children[1].layoutAttributes.frame, CGRect(x: 100, y: 0, width: 100, height: 100)) + } + + func test_minimumSpacing() { + var row = Row() + row.add(child: TestElement()) + row.add(child: TestElement()) + row.minimumHorizontalSpacing = 10.0 + + XCTAssertEqual(row.content.measure(in: .unconstrained).width, 210) + XCTAssertEqual(row.content.measure(in: .unconstrained).height, 100) + + let children = row + .layout(frame: CGRect(x: 0, y: 0, width: 210, height: 100)) + .children + .map { $0.node } + + XCTAssertEqual(children[0].layoutAttributes.frame, CGRect(x: 0, y: 0, width: 100, height: 100)) + XCTAssertEqual(children[1].layoutAttributes.frame, CGRect(x: 110, y: 0, width: 100, height: 100)) + } + + func test_alignment() { + + + func test( + alignment: StackLayout.Alignment, + layoutCrossSize: CGFloat, + elementCrossSize: CGFloat, + expectedOrigin: CGFloat, + expectedSize: CGFloat, + file: StaticString = #file, + line: UInt = #line) { + + do { + var column = Column() + column.add(child: TestElement(size: CGSize(width: elementCrossSize, height: 100))) + column.horizontalAlignment = alignment + + XCTAssertEqual( + column + .layout(frame: CGRect(x: 0, y: 0, width: layoutCrossSize, height: 100)) + .children[0] + .node + .layoutAttributes + .frame, + CGRect(x: expectedOrigin, y: 0.0, width: expectedSize, height: 100), + "Vertical", + file: file, + line: line + ) + } + + do { + var row = Row() + row.add(child: TestElement(size: CGSize(width: 100, height: elementCrossSize))) + row.verticalAlignment = alignment + + XCTAssertEqual( + row + .layout(frame: CGRect(x: 0, y: 0, width: 100, height: layoutCrossSize)) + .children[0] + .node + .layoutAttributes + .frame, + CGRect(x: 0.0, y: expectedOrigin, width: 100, height: expectedSize), + "Horizontal", + file: file, + line: line + ) + } + + + + } + + test(alignment: .leading, layoutCrossSize: 200, elementCrossSize: 100, expectedOrigin: 0, expectedSize: 100) + test(alignment: .trailing, layoutCrossSize: 200, elementCrossSize: 100, expectedOrigin: 100, expectedSize: 100) + test(alignment: .fill, layoutCrossSize: 200, elementCrossSize: 100, expectedOrigin: 0, expectedSize: 200) + test(alignment: .center, layoutCrossSize: 200, elementCrossSize: 100, expectedOrigin: 50, expectedSize: 100) + + } + + func test_underflow() { + + func test( + underflow: StackLayout.UnderflowDistribution, + layoutLength: CGFloat, + items: [(measuredLength: CGFloat, growPriority: CGFloat)], + expectedRanges: [ClosedRange], + file: StaticString = #file, + line: UInt = #line) { + + do { + var row = Row() + for item in items { + row.add( + growPriority: item.growPriority, + shrinkPriority: 1.0, + child: TestElement(size: CGSize(width: item.measuredLength, height: 100))) + } + row.horizontalUnderflow = underflow + + let childRanges = row + .layout(frame: CGRect(x: 0, y: 0, width: layoutLength, height: 100)) + .children + .map { + ClosedRange(uncheckedBounds: ($0.node.layoutAttributes.frame.minX, $0.node.layoutAttributes.frame.maxX)) + } + + XCTAssertEqual(childRanges, expectedRanges, "Horizontal", file: file, line: line) + } + + do { + var column = Column() + for item in items { + column.add( + growPriority: item.growPriority, + shrinkPriority: 1.0, + child: TestElement(size: CGSize(width: 100, height: item.measuredLength))) + } + column.verticalUnderflow = underflow + + let childRanges = column + .layout(frame: CGRect(x: 0, y: 0, width: 100, height: layoutLength)) + .children + .map { + ClosedRange(uncheckedBounds: ($0.node.layoutAttributes.frame.minY, $0.node.layoutAttributes.frame.maxY)) + } + + XCTAssertEqual(childRanges, expectedRanges, "Vertical", file: file, line: line) + } + + } + + + // Test single child for different underflow distributions, including different grow priorities. + do { + test( + underflow: .spaceEvenly, + layoutLength: 200, + items: [ + (measuredLength: 100, growPriority: 1.0) + ], + expectedRanges: [ + 0...100 + ]) + + test( + underflow: .spaceEvenly, + layoutLength: 200, + items: [ + (measuredLength: 100, growPriority: 0.0) + ], + expectedRanges: [ + 0...100 + ]) + + test( + underflow: .growProportionally, + layoutLength: 200, + items: [ + (measuredLength: 100, growPriority: 1.0) + ], + expectedRanges: [ + 0...200 + ]) + + test( + underflow: .growProportionally, + layoutLength: 200, + items: [ + (measuredLength: 100, growPriority: 0.0) + ], + expectedRanges: [ + 0...100 + ]) + + test( + underflow: .growUniformly, + layoutLength: 200, + items: [ + (measuredLength: 100, growPriority: 1.0) + ], + expectedRanges: [ + 0...200 + ]) + + test( + underflow: .growUniformly, + layoutLength: 200, + items: [ + (measuredLength: 100, growPriority: 0.0) + ], + expectedRanges: [ + 0...100 + ]) + } + + // Test with default grow priorities + do { + + test( + underflow: .spaceEvenly, + layoutLength: 400, items: [ + (measuredLength: 100, growPriority: 1.0), + (measuredLength: 100, growPriority: 1.0) + ], expectedRanges: [ + 0...100, + 300...400 + ]) + + test( + underflow: .growUniformly, + layoutLength: 400, items: [ + (measuredLength: 200, growPriority: 1.0), + (measuredLength: 100, growPriority: 1.0) + ], expectedRanges: [ + 0...250, + 250...400 + ]) + + test( + underflow: .growProportionally, + layoutLength: 600, items: [ + (measuredLength: 200, growPriority: 1.0), + (measuredLength: 100, growPriority: 1.0) + ], expectedRanges: [ + 0...400, + 400...600 + ]) + + } + + // Test with custom grow priorities + do { + + test( + underflow: .spaceEvenly, + layoutLength: 400, + items: [ + (measuredLength: 100, growPriority: 3.0), + (measuredLength: 100, growPriority: 1.0) + ], expectedRanges: [ + 0...100, + 300...400 + ]) + + test( + underflow: .growUniformly, + layoutLength: 600, + items: [ + (measuredLength: 100, growPriority: 3.0), + (measuredLength: 100, growPriority: 1.0) + ], expectedRanges: [ + 0...400, + 400...600 + ]) + + test( + underflow: .growProportionally, + layoutLength: 400, + items: [ + (measuredLength: 200, growPriority: 1.0), + (measuredLength: 100, growPriority: 2.0) + ], expectedRanges: [ + 0...250, + 250...400 + ]) + + } + + + } + + func test_overflow() { + + func test( + overflow: StackLayout.OverflowDistribution, + layoutLength: CGFloat, + items: [(measuredLength: CGFloat, shrinkPriority: CGFloat)], + expectedRanges: [ClosedRange], + file: StaticString = #file, + line: UInt = #line) { + + do { + var row = Row() + for item in items { + row.add( + growPriority: 1.0, + shrinkPriority: item.shrinkPriority, + child: TestElement(size: CGSize(width: item.measuredLength, height: 100))) + } + row.horizontalOverflow = overflow + + let childRanges = row + .layout(frame: CGRect(x: 0, y: 0, width: layoutLength, height: 100)) + .children + .map { ClosedRange(uncheckedBounds: ($0.node.layoutAttributes.frame.minX, $0.node.layoutAttributes.frame.maxX)) + } + + XCTAssertEqual(childRanges, expectedRanges, "Horizontal", file: file, line: line) + } + + do { + var column = Column() + for item in items { + column.add( + growPriority: 1.0, + shrinkPriority: item.shrinkPriority, + child: TestElement(size: CGSize(width: 100, height: item.measuredLength))) + } + column.verticalOverflow = overflow + + let childRanges = column + .layout(frame: CGRect(x: 0, y: 0, width: 100, height: layoutLength)) + .children + .map { + ClosedRange(uncheckedBounds: ($0.node.layoutAttributes.frame.minY, $0.node.layoutAttributes.frame.maxY)) + } + + XCTAssertEqual(childRanges, expectedRanges, "Vertical", file: file, line: line) + } + + } + + + // Test single child for different overflow distributions, including different shrink priorities. + do { + test( + overflow: .condenseUniformly, + layoutLength: 100, + items: [ + (measuredLength: 200, shrinkPriority: 1.0) + ], expectedRanges: [ + 0...100 + ]) + + test( + overflow: .condenseUniformly, + layoutLength: 100, + items: [ + (measuredLength: 200, shrinkPriority: 0.0) + ], expectedRanges: [ + 0...200 + ]) + + test( + overflow: .condenseProportionally, + layoutLength: 100, + items: [ + (measuredLength: 200, shrinkPriority: 1.0) + ], expectedRanges: [ + 0...100 + ]) + + test( + overflow: .condenseProportionally, + layoutLength: 100, + items: [ + (measuredLength: 200, shrinkPriority: 0.0) + ], expectedRanges: [ + 0...200 + ]) + } + + // Test with default shrink priorities + do { + + test( + overflow: .condenseProportionally, + layoutLength: 200, + items: [ + (measuredLength: 300, shrinkPriority: 1.0), + (measuredLength: 100, shrinkPriority: 1.0) + ], expectedRanges: [ + 0...150, + 150...200 + ]) + + test( + overflow: .condenseUniformly, + layoutLength: 300, + items: [ + (measuredLength: 300, shrinkPriority: 1.0), + (measuredLength: 100, shrinkPriority: 1.0) + ], expectedRanges: [ + 0...250, + 250...300 + ]) + + } + + // Test with custom shrink priorities + do { + test( + overflow: .condenseProportionally, + layoutLength: 200, + items: [ + (measuredLength: 200, shrinkPriority: 2.0), + (measuredLength: 100, shrinkPriority: 1.0) + ], expectedRanges: [ + 0...120, + 120...200 + ]) + + test( + overflow: .condenseUniformly, + layoutLength: 300, + items: [ + (measuredLength: 300, shrinkPriority: 1.0), + (measuredLength: 100, shrinkPriority: 4.0) + ], expectedRanges: [ + 0...280, + 280...300 + ]) + } + + + } + + +} + + +fileprivate struct TestElement: Element { + + var size: CGSize + + init(size: CGSize = CGSize(width: 100, height: 100)) { + self.size = size + } + + var content: ElementContent { + return ElementContent(intrinsicSize: size) + } + + func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + return nil + } + +} diff --git a/BlueprintUI/Tests/ViewDescriptionTests.swift b/BlueprintUI/Tests/ViewDescriptionTests.swift new file mode 100644 index 000000000..ee6a7304b --- /dev/null +++ b/BlueprintUI/Tests/ViewDescriptionTests.swift @@ -0,0 +1,73 @@ +import XCTest +@testable import BlueprintUI + +class ViewDescriptionTests: XCTestCase { + + func test_build() { + let description = TestView.describe { config in + config.builder = { TestView(initializationProperty: "hello") } + } + + let view = description.build() as! TestView + + XCTAssertEqual(view.initializationProperty, "hello") + } + + func test_apply() { + let description = TestView.describe { config in + config.apply { + $0.mutableProperty = "testing" + } + } + + let view = description.build() as! TestView + description.apply(to: view) + XCTAssertEqual(view.mutableProperty, "testing") + + let secondDescription = TestView.describe { config in + config.apply { $0.mutableProperty = "123" } + } + secondDescription.apply(to: view) + XCTAssertEqual(view.mutableProperty, "123") + } + + func test_bind() { + let description = TestView.describe { config in + config[\.mutableProperty] = "testing" + } + + let view = description.build() as! TestView + description.apply(to: view) + XCTAssertEqual(view.mutableProperty, "testing") + + let secondDescription = TestView.describe { config in + config[\.mutableProperty] = "123" + } + secondDescription.apply(to: view) + XCTAssertEqual(view.mutableProperty, "123") + } + +} + + +final class TestView: UIView { + + let initializationProperty: String + + var mutableProperty: String = "" + + init(initializationProperty: String) { + self.initializationProperty = initializationProperty + super.init(frame: .zero) + } + + override init(frame: CGRect) { + self.initializationProperty = "" + super.init(frame: frame) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} diff --git a/BlueprintUICommonControls.podspec b/BlueprintUICommonControls.podspec new file mode 100644 index 000000000..7e26f6f36 --- /dev/null +++ b/BlueprintUICommonControls.podspec @@ -0,0 +1,28 @@ +Pod::Spec.new do |s| + s.name = 'BlueprintUICommonControls' + s.version = '0.1.0' + s.summary = 'UIKit-backed elements for Blueprint' + 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 = 'BlueprintUICommonControls/Sources/**/*.swift' + + s.dependency 'BlueprintUI' + + s.test_spec 'SnapshotTests' do |test_spec| + + test_spec.ios.deployment_target = '10.0' + + test_spec.source_files = 'BlueprintUICommonControls/Tests/Sources/*.swift' + test_spec.resources = 'BlueprintUICommonControls/Tests/Resources/**/*' + test_spec.framework = 'XCTest' + + test_spec.dependency 'SnapshotTesting', '~> 1.3' + end +end diff --git a/BlueprintUICommonControls/README.md b/BlueprintUICommonControls/README.md new file mode 100644 index 000000000..46ba1559c --- /dev/null +++ b/BlueprintUICommonControls/README.md @@ -0,0 +1,4 @@ +BlueprintUICommonControls +===== + +Blueprint elements wrapping commonly used UIKit primitives. \ No newline at end of file diff --git a/BlueprintUICommonControls/Sources/AccessibilityBlocker.swift b/BlueprintUICommonControls/Sources/AccessibilityBlocker.swift new file mode 100644 index 000000000..780490c79 --- /dev/null +++ b/BlueprintUICommonControls/Sources/AccessibilityBlocker.swift @@ -0,0 +1,23 @@ +import BlueprintUI +import UIKit + +public struct AccessibilityBlocker: Element { + + public var wrappedElement: Element + + public init(wrapping element: Element) { + self.wrappedElement = element + } + + public var content: ElementContent { + return ElementContent(child: wrappedElement) + } + + public func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + return UIView.describe { config in + config[\.isAccessibilityElement] = false + config[\.accessibilityElementsHidden] = true + } + } + +} diff --git a/BlueprintUICommonControls/Sources/AccessibilityElement.swift b/BlueprintUICommonControls/Sources/AccessibilityElement.swift new file mode 100644 index 000000000..abaca63ad --- /dev/null +++ b/BlueprintUICommonControls/Sources/AccessibilityElement.swift @@ -0,0 +1,109 @@ +import BlueprintUI +import UIKit + +public struct AccessibilityElement: Element { + + public enum Trait: Hashable { + case button + case link + case header + case searchField + case image + case selected + case playsSound + case keyboardKey + case staticText + case summaryElement + case notEnabled + case updatesFrequently + case startsMediaSession + case adjustable + case allowsDirectInteraction + case causesPageTurn + + @available(iOS 10.0, *) + case tabBar + } + + public var label: String? + public var value: String? + public var hint: String? + public var traits: Set + public var wrappedElement: Element + + public init( + label: String? = nil, + value: String? = nil, + hint: String? = nil, + traits: Set = [], + wrapping element: Element) + { + self.label = label + self.value = value + self.hint = hint + self.traits = traits + self.wrappedElement = element + } + + private var accessibilityTraits: UIAccessibilityTraits { + var traits: UIAccessibilityTraits = .none + + for trait in self.traits { + switch trait { + case .button: + traits.formUnion(.button) + case .link: + traits.formUnion(.link) + case .header: + traits.formUnion(.header) + case .searchField: + traits.formUnion(.searchField) + case .image: + traits.formUnion(.image) + case .selected: + traits.formUnion(.selected) + case .playsSound: + traits.formUnion(.playsSound) + case .keyboardKey: + traits.formUnion(.keyboardKey) + case .staticText: + traits.formUnion(.staticText) + case .summaryElement: + traits.formUnion(.summaryElement) + case .notEnabled: + traits.formUnion(.notEnabled) + case .updatesFrequently: + traits.formUnion(.updatesFrequently) + case .startsMediaSession: + traits.formUnion(.startsMediaSession) + case .adjustable: + traits.formUnion(.adjustable) + case .allowsDirectInteraction: + traits.formUnion(.allowsDirectInteraction) + case .causesPageTurn: + traits.formUnion(.causesPageTurn) + case .tabBar: + if #available(iOS 10.0, *) { + traits.formUnion(.tabBar) + } + } + } + + return traits + } + + public var content: ElementContent { + return ElementContent(child: wrappedElement) + } + + public func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + return UIView.describe { config in + config[\.accessibilityLabel] = label + config[\.accessibilityValue] = value + config[\.accessibilityHint] = hint + config[\.accessibilityTraits] = accessibilityTraits + config[\.isAccessibilityElement] = true + } + } + +} diff --git a/BlueprintUICommonControls/Sources/AttributedLabel.swift b/BlueprintUICommonControls/Sources/AttributedLabel.swift new file mode 100644 index 000000000..2d25eeb88 --- /dev/null +++ b/BlueprintUICommonControls/Sources/AttributedLabel.swift @@ -0,0 +1,41 @@ +import BlueprintUI +import UIKit + +public struct AttributedLabel: Element { + + public var attributedText: NSAttributedString + public var numberOfLines: Int = 0 + + public init(attributedText: NSAttributedString) { + self.attributedText = attributedText + } + + public var content: ElementContent { + struct Measurer: Measurable { + + var attributedText: NSAttributedString + + func measure(in constraint: SizeConstraint) -> CGSize { + var size = attributedText.boundingRect( + with: constraint.maximum, + options: [.usesLineFragmentOrigin], + context: nil) + .size + size.width = ceil(size.width) + size.height = ceil(size.height) + + return size + } + } + + return ElementContent(measurable: Measurer(attributedText: attributedText)) + } + + public func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + return UILabel.describe { (config) in + config[\.attributedText] = attributedText + config[\.numberOfLines] = numberOfLines + } + } + +} diff --git a/BlueprintUICommonControls/Sources/Box.swift b/BlueprintUICommonControls/Sources/Box.swift new file mode 100644 index 000000000..150a62aa6 --- /dev/null +++ b/BlueprintUICommonControls/Sources/Box.swift @@ -0,0 +1,203 @@ +import BlueprintUI + + +/// A simple element that wraps a child element and adds visual styling including +/// background color. +public struct Box: Element { + + public var backgroundColor: UIColor = .clear + public var cornerStyle: CornerStyle = .square + public var borderStyle: BorderStyle = .none + public var shadowStyle: ShadowStyle = .none + public var clipsContent: Bool = false + + public var wrappedElement: Element? + + public init(backgroundColor: UIColor = .clear, cornerStyle: CornerStyle = .square, wrapping element: Element? = nil) { + self.backgroundColor = backgroundColor + self.cornerStyle = cornerStyle + self.wrappedElement = element + } + + public var content: ElementContent { + if let wrappedElement = wrappedElement { + return ElementContent(child: wrappedElement) + } else { + return ElementContent(intrinsicSize: .zero) + } + } + + public func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + return BoxView.describe { config in + + config.apply({ (view) in + + if self.backgroundColor != view.backgroundColor { + view.backgroundColor = self.backgroundColor + } + + if self.cornerStyle.radius != view.layer.cornerRadius { + view.layer.cornerRadius = self.cornerStyle.radius + } + + if self.borderStyle.color?.cgColor != view.layer.borderColor { + view.layer.borderColor = self.borderStyle.color?.cgColor + } + + if self.borderStyle.width != view.layer.borderWidth { + view.layer.borderWidth = self.borderStyle.width + } + + if self.shadowStyle.radius != view.layer.shadowRadius { + view.layer.shadowRadius = self.shadowStyle.radius + } + + if self.shadowStyle.offset != view.layer.shadowOffset { + view.layer.shadowOffset = self.shadowStyle.offset + } + + if self.shadowStyle.color?.cgColor != view.layer.shadowColor { + view.layer.shadowColor = self.shadowStyle.color?.cgColor + } + + if self.shadowStyle.opacity != CGFloat(view.layer.shadowOpacity) { + view.layer.shadowOpacity = Float(self.shadowStyle.opacity) + } + + /// `.contentView` is used for clipping, make sure the corner radius + /// matches. + + if self.clipsContent != view.contentView.clipsToBounds { + view.contentView.clipsToBounds = self.clipsContent + } + + if self.cornerStyle.radius != view.contentView.layer.cornerRadius { + view.contentView.layer.cornerRadius = self.cornerStyle.radius + } + + }) + + + config.contentView = { view in + return view.contentView + } + + } + } +} + +extension Box { + + public enum CornerStyle { + case square + case rounded(radius: CGFloat) + } + + public enum BorderStyle { + case none + case solid(color: UIColor, width: CGFloat) + } + + public enum ShadowStyle { + case none + case simple(radius: CGFloat, opacity: CGFloat, offset: CGSize, color: UIColor) + } + +} + +extension Box.CornerStyle { + + fileprivate var radius: CGFloat { + switch self { + case .square: + return 0 + case let .rounded(radius: radius): + return radius + } + } + +} + +extension Box.BorderStyle { + + fileprivate var width: CGFloat { + switch self { + case .none: + return 0.0 + case let .solid(_, width): + return width + } + } + + fileprivate var color: UIColor? { + switch self { + case .none: + return nil + case let .solid(color, _): + return color + } + } + +} + +extension Box.ShadowStyle { + + fileprivate var radius: CGFloat { + switch self { + case .none: + return 0.0 + case let .simple(radius, _, _, _): + return radius + } + } + + fileprivate var opacity: CGFloat { + switch self { + case .none: + return 0.0 + case let .simple(_, opacity, _, _): + return opacity + } + } + + fileprivate var offset: CGSize { + switch self { + case .none: + return .zero + case let .simple(_, _, offset, _): + return offset + } + } + + fileprivate var color: UIColor? { + switch self { + case .none: + return nil + case let .simple(_, _, _, color): + return color + } + } + + +} + +fileprivate final class BoxView: UIView { + + let contentView = UIView() + + override init(frame: CGRect) { + super.init(frame: frame) + contentView.frame = bounds + addSubview(contentView) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + contentView.frame = bounds + } + +} diff --git a/BlueprintUICommonControls/Sources/Button.swift b/BlueprintUICommonControls/Sources/Button.swift new file mode 100644 index 000000000..c0783fe8f --- /dev/null +++ b/BlueprintUICommonControls/Sources/Button.swift @@ -0,0 +1,119 @@ +import BlueprintUI +import UIKit + + +/// An element that wraps a child element in a button that mimics a UIButton with the .system style. That is, when +/// highlighted (or disabled), it fades its contents to partial alpha. +public struct Button: Element { + + public var wrappedElement: Element + public var isEnabled: Bool + public var onTap: () -> Void + public var minumumTappableSize: CGSize = CGSize(width: 44, height: 44) + + public init(wrapping element: Element, isEnabled: Bool = true, onTap: @escaping () -> Void = {}) { + self.wrappedElement = element + self.isEnabled = isEnabled + self.onTap = onTap + } + + public var content: ElementContent { + return ElementContent(child: wrappedElement) + } + + public func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + return Button.NativeButton.describe { config in + config.contentView = { $0.contentView } + config[\.isEnabled] = isEnabled + config[\.onTap] = onTap + config[\.minumumTappableSize] = minumumTappableSize + } + } + +} + +extension Button { + + fileprivate final class NativeButton: UIControl { + internal let contentView = UIView() + internal var onTap: (() -> Void)? = nil + internal var minumumTappableSize: CGSize = CGSize(width: 44, height: 44) + + + override init(frame: CGRect) { + super.init(frame: frame) + contentView.frame = bounds + contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + contentView.isUserInteractionEnabled = false + addSubview(contentView) + + addTarget(self, action: #selector(handleTap), for: .touchUpInside) + } + + required public init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private var tappableRect: CGRect { + return bounds + .insetBy( + dx: min(0, bounds.width - minumumTappableSize.width), + dy: min(0, bounds.height - minumumTappableSize.height)) + + } + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + return tappableRect.contains(point) + } + + override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + contentView.alpha = 0.2 + return super.beginTracking(touch, with: event) + } + + override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + // Mimic UIButtonStyle.system + if tappableRect.insetBy(dx: -70, dy: -70).contains(touch.location(in: self)) { + animateAlpha(to: 0.2) + } else { + animateAlpha(to: 1) + } + + return super.continueTracking(touch, with: event) + } + + override func endTracking(_ touch: UITouch?, with event: UIEvent?) { + super.endTracking(touch, with: event) + animateAlpha(to: 1, forcedStartAlpha: 0.2) + } + + override func cancelTracking(with event: UIEvent?) { + super.cancelTracking(with: event) + contentView.alpha = 1 + } + + override var isEnabled: Bool { + didSet { + guard oldValue != isEnabled else { return } + contentView.layer.removeAnimation(forKey: "opacity") + contentView.alpha = isEnabled ? 1 : 0.2 + } + } + + private func animateAlpha(to alpha: CGFloat, forcedStartAlpha: CGFloat? = nil) { + if abs(contentView.alpha - alpha) > 0.0001 { + let animation = CABasicAnimation(keyPath: "opacity") + animation.fromValue = forcedStartAlpha ?? contentView.layer.presentation()?.opacity + animation.toValue = alpha + animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.default) + animation.duration = 0.47 + contentView.alpha = alpha + contentView.layer.add(animation, forKey: "opacity") + } + } + + @objc private func handleTap() { + onTap?() + } + } +} diff --git a/BlueprintUICommonControls/Sources/Image.swift b/BlueprintUICommonControls/Sources/Image.swift new file mode 100644 index 000000000..6c3ced6c6 --- /dev/null +++ b/BlueprintUICommonControls/Sources/Image.swift @@ -0,0 +1,146 @@ +import BlueprintUI +import UIKit + + +/// Displays an image within an element hierarchy. +public struct Image: Element { + + /// The image to be displayed + public var image: UIImage? + + /// The tint color. + public var tintColor: UIColor? = nil + + /// The content mode determines the layout of the image when its size does + /// not precisely match the size that the element is assigned. + public var contentMode: ContentMode = .aspectFill + + /// Initializes an image element with the given `UIImage` instance. + public init(image: UIImage?) { + self.image = image + } + + public var content: ElementContent { + let measurer = Measurer(contentMode: contentMode, imageSize: image?.size) + return ElementContent(measurable: measurer) + } + + public func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + return UIImageView.describe { config in + config[\.image] = image + config[\.contentMode] = contentMode.uiViewContentMode + config[\.layer.minificationFilter] = .trilinear + config[\.tintColor] = tintColor + } + } + +} + +extension Image { + + /// The content mode determines the layout of the image when its size does + /// not precisely match the size that the element is assigned. + public enum ContentMode { + + /// The image is not scaled, and is simply centered within the `Image` + /// element. + case center + + /// The image is stretched to fill the `Image` element, causing the image + /// to become distorted if its aspect ratio is different than that of the + /// containing element. + case stretch + + /// The image is scaled to touch the edges of the `Image` element while + /// maintaining the image's aspect ratio. If the aspect ratio of the + /// image is different than that of the element, the image will be + /// letterboxed or pillarboxed as needed to ensure that the entire + /// image is visible within the element. + case aspectFit + + /// The image is scaled to fill the entire `Image` element. If the aspect + /// ratio of the image is different than that of the element, the image + /// will be cropped to match the element's aspect ratio. + case aspectFill + + fileprivate var uiViewContentMode: UIView.ContentMode { + switch self { + case .center: return .center + case .stretch: return .scaleToFill + case .aspectFit: return .scaleAspectFit + case .aspectFill: return .scaleAspectFill + } + } + } + +} + + +extension CGSize { + + fileprivate var aspectRatio: CGFloat { + if height > 0.0 { + return width/height + } else { + return 0.0 + } + } + +} + +extension Image { + + fileprivate struct Measurer: Measurable { + + var contentMode: ContentMode + var imageSize: CGSize? + + func measure(in constraint: SizeConstraint) -> CGSize { + guard let imageSize = imageSize else { return .zero } + + enum Mode { + case fitWidth(CGFloat) + case fitHeight(CGFloat) + case useImageSize + } + + let mode: Mode + + switch contentMode { + case .center, .stretch: + mode = .useImageSize + case .aspectFit, .aspectFill: + if case .atMost(let width) = constraint.width, case .atMost(let height) = constraint.height { + if CGSize(width: width, height: height).aspectRatio > imageSize.aspectRatio { + mode = .fitWidth(width) + } else { + mode = .fitHeight(height) + } + } else if case .atMost(let width) = constraint.width { + mode = .fitWidth(width) + } else if case .atMost(let height) = constraint.height { + mode = .fitHeight(height) + } else { + mode = .useImageSize + } + } + + switch mode { + case .fitWidth(let width): + return CGSize( + width: width, + height: width / imageSize.aspectRatio) + case .fitHeight(let height): + return CGSize( + width: height * imageSize.aspectRatio, + height: height) + case .useImageSize: + return imageSize + } + + + } + + } + +} diff --git a/BlueprintUICommonControls/Sources/Label.swift b/BlueprintUICommonControls/Sources/Label.swift new file mode 100644 index 000000000..bb726c49d --- /dev/null +++ b/BlueprintUICommonControls/Sources/Label.swift @@ -0,0 +1,44 @@ +import BlueprintUI +import UIKit + + +/// Displays text content. +public struct Label: Element { + + /// The text to be displayed. + public var text: String + public var font: UIFont = UIFont.systemFont(ofSize: UIFont.systemFontSize) + public var color: UIColor = .black + public var alignment: NSTextAlignment = .left + public var numberOfLines: Int = 0 + public var lineBreakMode: NSLineBreakMode = .byWordWrapping + + public init(text: String, configure: (inout Label) -> Void = { _ in }) { + self.text = text + configure(&self) + } + + private var attributedText: NSAttributedString { + let paragraphStyle = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle + paragraphStyle.alignment = alignment + paragraphStyle.lineBreakMode = lineBreakMode + return NSAttributedString( + string: text, + attributes: [ + NSAttributedString.Key.font: font, + NSAttributedString.Key.foregroundColor: color, + NSAttributedString.Key.paragraphStyle: paragraphStyle + ]) + } + + public var content: ElementContent { + var element = AttributedLabel(attributedText: attributedText) + element.numberOfLines = numberOfLines + return ElementContent(child: element) + } + + public func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + return nil + } + +} diff --git a/BlueprintUICommonControls/Sources/ScrollView.swift b/BlueprintUICommonControls/Sources/ScrollView.swift new file mode 100644 index 000000000..3f7d5c1cd --- /dev/null +++ b/BlueprintUICommonControls/Sources/ScrollView.swift @@ -0,0 +1,269 @@ +import BlueprintUI +import UIKit + + +/// Wraps a content element and makes it scrollable. +public struct ScrollView: Element { + + /// The content to be scrolled. + public var wrappedElement: Element + + /// Determines the sizing behavior of the content within the scroll view. + public var contentSize: ContentSize = .fittingHeight + public var alwaysBounceVertical = false + public var alwaysBounceHorizontal = false + public var contentInset: UIEdgeInsets = .zero + public var centersUnderflow: Bool = false + public var showsHorizontalScrollIndicator: Bool = true + public var showsVerticalScrollIndicator: Bool = true + public var pullToRefreshBehavior: PullToRefreshBehavior = .disabled + + public init(wrapping element: Element) { + self.wrappedElement = element + } + + public var content: ElementContent { + return ElementContent(child: wrappedElement, layout: layout) + } + + public func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + return ScrollerWrapperView.describe { config in + config.contentView = { $0.scrollView } + config.apply({ (view) in + view.apply(scrollView: self, contentFrame: subtreeExtent ?? .zero) + }) + } + } + + private var layout: Layout { + return Layout( + contentInset: contentInset, + contentSize: contentSize, + centersUnderflow: centersUnderflow) + } + + + +} + +extension ScrollView { + + fileprivate struct Layout: SingleChildLayout { + + var contentInset: UIEdgeInsets + var contentSize: ContentSize + var centersUnderflow: Bool + + func measure(in constraint: SizeConstraint, child: Measurable) -> CGSize { + let adjustedConstraint = constraint.inset( + width: contentInset.left + contentInset.right, + height: contentInset.top + contentInset.bottom) + + var result = child.measure(in: adjustedConstraint) + result.width += contentInset.left + contentInset.right + result.height += contentInset.top + contentInset.bottom + return result + } + + func layout(size: CGSize, child: Measurable) -> LayoutAttributes { + + var insetSize = size + insetSize.width -= contentInset.left + contentInset.right + insetSize.height -= contentInset.top + contentInset.bottom + var itemSize = child.measure(in: SizeConstraint(insetSize)) + if self.contentSize == .fittingHeight { + itemSize.width = insetSize.width + } else if self.contentSize == .fittingWidth { + itemSize.height = insetSize.height + } + + var contentAttributes = LayoutAttributes(frame: CGRect(origin: .zero, size: itemSize)) + + if centersUnderflow { + if contentAttributes.bounds.width < size.width { + contentAttributes.center.x = size.width / 2.0 + } + + if contentAttributes.bounds.height < size.height { + contentAttributes.center.y = size.height / 2.0 + } + } + return contentAttributes + } + + } + +} + +extension ScrollView { + + public enum ContentSize : Equatable { + + /// The content will fill the height of the scroller, width will be dynamic + case fittingWidth + + /// The content will fill the width of the scroller, height will be dynamic + case fittingHeight + + /// The content size will be the minimum required to fit the content. + case fittingContent + + /// Manually provided content size. + case custom(CGSize) + + } + + public enum PullToRefreshBehavior { + + case disabled + case enabled(action: () -> Void) + case refreshing + + var needsRefreshControl: Bool { + switch self { + case .disabled: + return false + case .enabled, .refreshing: + return true + } + } + + var isRefreshing: Bool { + switch self { + case .refreshing: + return true + case .disabled, .enabled: + return false + } + } + + } + +} + +fileprivate final class ScrollerWrapperView: UIView { + + let scrollView = UIScrollView() + + private var refreshControl: UIRefreshControl? = nil { + + didSet { + if #available(iOS 10.0, *) { + scrollView.refreshControl = refreshControl + } else { + oldValue?.removeFromSuperview() + if let refreshControl = refreshControl { + scrollView.addSubview(refreshControl) + } + } + } + + } + + private var refreshAction: () -> Void = { } + + override init(frame: CGRect) { + super.init(frame: frame) + addSubview(scrollView) + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + scrollView.frame = bounds + } + + @objc private func didPullToRefresh() { + refreshAction() + } + + func apply(scrollView: ScrollView, contentFrame: CGRect) { + + switch scrollView.pullToRefreshBehavior { + case .disabled, .refreshing: + refreshAction = { } + case .enabled(let action): + refreshAction = action + } + + switch scrollView.pullToRefreshBehavior { + case .disabled: + refreshControl = nil + case .enabled, .refreshing: + if refreshControl == nil { + let control = UIRefreshControl() + control.addTarget(self, action: #selector(didPullToRefresh), for: .valueChanged) + refreshControl = control + } + } + + if refreshControl?.isRefreshing != scrollView.pullToRefreshBehavior.isRefreshing { + if scrollView.pullToRefreshBehavior.isRefreshing { + refreshControl?.beginRefreshing() + } else { + refreshControl?.endRefreshing() + } + } + + let contentSize: CGSize + + switch scrollView.contentSize { + case .fittingWidth, .fittingHeight, .fittingContent: + contentSize = CGSize(width: contentFrame.maxX, height: contentFrame.maxY) + case .custom(let customSize): + contentSize = customSize + } + + if self.scrollView.alwaysBounceHorizontal != scrollView.alwaysBounceHorizontal { + self.scrollView.alwaysBounceHorizontal = scrollView.alwaysBounceHorizontal + } + + if self.scrollView.alwaysBounceVertical != scrollView.alwaysBounceVertical { + self.scrollView.alwaysBounceVertical = scrollView.alwaysBounceVertical + } + + if self.scrollView.contentSize != contentSize { + self.scrollView.contentSize = contentSize + } + + if self.scrollView.showsVerticalScrollIndicator != scrollView.showsVerticalScrollIndicator { + self.scrollView.showsVerticalScrollIndicator = scrollView.showsVerticalScrollIndicator + } + + if self.scrollView.showsHorizontalScrollIndicator != scrollView.showsHorizontalScrollIndicator { + self.scrollView.showsHorizontalScrollIndicator = scrollView.showsHorizontalScrollIndicator + } + + var contentInset = scrollView.contentInset + + if case .refreshing = scrollView.pullToRefreshBehavior, let refreshControl = refreshControl { + // The refresh control lives above the content and adjusts the + // content inset for itself when visible. Do the same adjustment to + // our expected content inset. + contentInset.top += refreshControl.bounds.height + } + + if self.scrollView.contentInset != contentInset { + + let wasScrolledToTop = self.scrollView.contentOffset.y == -self.scrollView.contentInset.top + let wasScrolledToLeft = self.scrollView.contentOffset.x == -self.scrollView.contentInset.left + + self.scrollView.contentInset = contentInset + + if wasScrolledToTop { + self.scrollView.contentOffset.y = -contentInset.top + } + + if wasScrolledToLeft { + self.scrollView.contentOffset.x = -contentInset.left + } + } + + + } + +} diff --git a/BlueprintUICommonControls/Sources/SegmentedControl.swift b/BlueprintUICommonControls/Sources/SegmentedControl.swift new file mode 100644 index 000000000..e978e165f --- /dev/null +++ b/BlueprintUICommonControls/Sources/SegmentedControl.swift @@ -0,0 +1,183 @@ +import BlueprintUI + + +/// Allows users to pick from an array of options. +public struct SegmentedControl: Element, Measurable { + + public var items: [Item] + + public var selection: Selection = .none + + public var font: UIFont = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.body) + + public init(items: [Item] = []) { + self.items = items + } + + public mutating func appendItem(title: String, width: Item.Width = .automatic, onSelect: @escaping ()->Void) { + items.append(Item(title: title, width: width, onSelect: onSelect)) + } + + public var content: ElementContent { + return ElementContent(measurable: self) + } + + public func measure(in constraint: SizeConstraint) -> CGSize { + return items.reduce(CGSize.zero, { (current, item) -> CGSize in + let itemSize = item.measure(font: font, in: constraint) + return CGSize( + width: itemSize.width + current.width, + height: max(itemSize.height, current.height)) + }) + } + + public func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + return SegmentedControlView.describe { config in + config[\.element] = self + } + } + + fileprivate var titleTextAttributes: [NSAttributedString.Key:Any] { + return [NSAttributedString.Key.font: font] + } + +} + +extension SegmentedControl { + + public enum Selection { + case none + case index(Int) + + fileprivate var resolvedIndex: Int { + switch self { + case .none: + return UISegmentedControl.noSegment + case let .index(index): + return index + } + } + } + + public struct Item { + + public var title: String + + public var width: Width = .automatic + + public var onSelect: () -> Void + + internal func measure(font: UIFont, in constraint: SizeConstraint) -> CGSize { + return CGSize( + width: width.requiredWidth(for: title, font: font, in: constraint), + height: 36.0) + } + + } + +} + +extension SegmentedControl.Item { + + public enum Width { + case automatic + case specific(CGFloat) + + fileprivate var resolvedWidth: CGFloat { + switch self { + case .automatic: + return 0.0 + case let .specific(width): + return width + } + } + + fileprivate func requiredWidth(for title: String, font: UIFont, in constraint: SizeConstraint) -> CGFloat { + switch self { + case .automatic: + let width = (title as NSString) + .boundingRect( + with: constraint.maximum, + options: [.usesLineFragmentOrigin], + attributes: [.font: font], + context: nil) + .size + .width + + return ceil(width) + 8 // 4pt padding on each side + case let .specific(width): + return width + } + } + } + +} + + +fileprivate final class SegmentedControlView: UIView { + + fileprivate var element: SegmentedControl = SegmentedControl() { + didSet { + reload() + } + } + + private let segmentedControl = UISegmentedControl() + + override init(frame: CGRect) { + super.init(frame: frame) + segmentedControl.apportionsSegmentWidthsByContent = true + addSubview(segmentedControl) + + segmentedControl.addTarget(self, action: #selector(selectionChanged), for: UIControl.Event.valueChanged) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + segmentedControl.frame = bounds + } + + private func reload() { + + for (offset, item) in element.items.enumerated() { + + if segmentedControl.numberOfSegments <= offset { + segmentedControl.insertSegment( + withTitle: item.title, + at: offset, + animated: false) + } else { + if item.title != segmentedControl.titleForSegment(at: offset) { + segmentedControl.setTitle(item.title, forSegmentAt: offset) + } + } + + if segmentedControl.widthForSegment(at: offset) != item.width.resolvedWidth { + segmentedControl.setWidth( + item.width.resolvedWidth, + forSegmentAt: offset) + } + + } + + while segmentedControl.numberOfSegments > element.items.count { + segmentedControl.removeSegment(at: segmentedControl.numberOfSegments-1, animated: false) + } + + if segmentedControl.selectedSegmentIndex != element.selection.resolvedIndex { + segmentedControl.selectedSegmentIndex = element.selection.resolvedIndex + } + + segmentedControl.setTitleTextAttributes(element.titleTextAttributes, for: .normal) + } + + @objc private func selectionChanged() { + let item = element.items[segmentedControl.selectedSegmentIndex] + item.onSelect() + } + +} diff --git a/BlueprintUICommonControls/Sources/Tappable.swift b/BlueprintUICommonControls/Sources/Tappable.swift new file mode 100644 index 000000000..c1cd75829 --- /dev/null +++ b/BlueprintUICommonControls/Sources/Tappable.swift @@ -0,0 +1,48 @@ +import BlueprintUI +import UIKit + + +/// Wraps a content element and calls the provided closure when tapped. +public struct Tappable: Element { + + public var wrappedElement: Element + public var onTap: ()->Void + + public init(wrapping element: Element, onTap: @escaping ()->Void) { + self.wrappedElement = element + self.onTap = onTap + } + + public var content: ElementContent { + return ElementContent(child: wrappedElement) + } + + public func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + return TappableView.describe { config in + config[\.onTap] = onTap + } + } + +} + + +fileprivate final class TappableView: UIView { + + var onTap: (()->Void)? = nil + + override init(frame: CGRect) { + super.init(frame: frame) + + let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(tapped)) + addGestureRecognizer(tapRecognizer) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func tapped() { + onTap?() + } + +} diff --git a/BlueprintUICommonControls/Sources/TextField.swift b/BlueprintUICommonControls/Sources/TextField.swift new file mode 100644 index 000000000..b205aab59 --- /dev/null +++ b/BlueprintUICommonControls/Sources/TextField.swift @@ -0,0 +1,134 @@ +import BlueprintUI +import UIKit + + +/// Displays a text field. +public struct TextField: Element { + + public var text: String + public var placeholder: String = "" + public var onChange: ((String) -> Void)? = nil + public var secure: Bool = false + public var isEnabled: Bool = true + + public var clearButtonMode: UITextField.ViewMode = .never + + public var keyboardType: UIKeyboardType = .default + public var keyboardAppearance: UIKeyboardAppearance = .default + + public var autocapitalizationType: UITextAutocapitalizationType = .sentences + public var autocorrectionType: UITextAutocorrectionType = .default + public var spellCheckingType: UITextSpellCheckingType = .default + public var textContentType: UITextContentType? = nil + + public var onReturn: (() -> Void)? + public var returnKeyType: UIReturnKeyType = .default + public var enablesReturnKeyAutomatically: Bool = false + + public var becomeActiveTrigger: Trigger? + public var resignActiveTrigger: Trigger? + + public init(text: String) { + self.text = text + } + + public func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + return CallbackTextField.describe({ (configuration) in + configuration[\.backgroundColor] = .clear + + configuration[\.text] = text + configuration[\.placeholder] = placeholder + configuration[\.onChange] = onChange + configuration[\.isSecureTextEntry] = secure + configuration[\.isEnabled] = isEnabled + + configuration[\.clearButtonMode] = clearButtonMode + + configuration[\.keyboardType] = keyboardType + configuration[\.keyboardAppearance] = keyboardAppearance + + configuration[\.autocapitalizationType] = autocapitalizationType + configuration[\.autocorrectionType] = autocorrectionType + configuration[\.spellCheckingType] = spellCheckingType + if #available(iOS 10.0, *) { + configuration[\.textContentType] = textContentType + } + + configuration[\.onReturn] = onReturn + configuration[\.returnKeyType] = returnKeyType + configuration[\.enablesReturnKeyAutomatically] = enablesReturnKeyAutomatically + + configuration[\.becomeActiveTrigger] = becomeActiveTrigger + configuration[\.resignActiveTrigger] = resignActiveTrigger + }) + } + + public var content: ElementContent { + return ElementContent { constraint in + return CGSize( + width: max(constraint.maximum.width, 44), + height: 44.0) + } + } + +} + +extension TextField { + + final public class Trigger { + + var action: () -> Void + + public init() { + action = { } + } + + public func fire() { + action() + } + + } + +} + + +fileprivate final class CallbackTextField: UITextField, UITextFieldDelegate { + + var onChange: ((String) -> Void)? = nil + var onReturn: (() -> Void)? = nil + + var becomeActiveTrigger: TextField.Trigger? { + didSet { + oldValue?.action = { } + becomeActiveTrigger?.action = { [weak self] in self?.becomeFirstResponder() } + } + } + + var resignActiveTrigger: TextField.Trigger? { + didSet { + oldValue?.action = { } + resignActiveTrigger?.action = { [weak self] in self?.resignFirstResponder() } + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + delegate = self + addTarget(self, action: #selector(textDidChange), for: .editingChanged) + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func textDidChange() { + onChange?(text ?? "") + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + onReturn?() + return true + } + +} diff --git a/BlueprintUICommonControls/Sources/TransitionContainer.swift b/BlueprintUICommonControls/Sources/TransitionContainer.swift new file mode 100644 index 000000000..285d87d9e --- /dev/null +++ b/BlueprintUICommonControls/Sources/TransitionContainer.swift @@ -0,0 +1,31 @@ +import BlueprintUI +import UIKit + + +/// Wraps a content element and adds transitions when the element appears, +/// disappears, or changes layout. +public struct TransitionContainer: Element { + + public var appearingTransition: VisibilityTransition = .fade + public var disappearingTransition: VisibilityTransition = .fade + public var layoutTransition: LayoutTransition = .specific(AnimationAttributes()) + + public var wrappedElement: Element + + public init(wrapping element: Element) { + self.wrappedElement = element + } + + public var content: ElementContent { + return ElementContent(child: wrappedElement) + } + + public func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + return UIView.describe { config in + config.appearingTransition = appearingTransition + config.disappearingTransition = disappearingTransition + config.layoutTransition = layoutTransition + } + } + +} diff --git a/BlueprintUICommonControls/Tests/Resources/test-image.jpg b/BlueprintUICommonControls/Tests/Resources/test-image.jpg new file mode 100644 index 000000000..cca2cfd43 Binary files /dev/null and b/BlueprintUICommonControls/Tests/Resources/test-image.jpg differ diff --git a/BlueprintUICommonControls/Tests/Sources/AttributedLabelTests.swift b/BlueprintUICommonControls/Tests/Sources/AttributedLabelTests.swift new file mode 100644 index 000000000..432415881 --- /dev/null +++ b/BlueprintUICommonControls/Tests/Sources/AttributedLabelTests.swift @@ -0,0 +1,91 @@ +import XCTest +import BlueprintUI +@testable import BlueprintUICommonControls + + +class AttributedLabelTests: XCTestCase { + + func test_displaysText() { + let string = NSAttributedString() + .appending(string: "H", font: .boldSystemFont(ofSize: 24.0), color: .red) + .appending(string: "e", font: .systemFont(ofSize: 14.0), color: .blue) + .appending(string: "llo, ", font: .italicSystemFont(ofSize: 13.0), color: .magenta) + .appending(string: "World!", font: .monospacedDigitSystemFont(ofSize: 32.0, weight: .black), color: .yellow) + + let element = AttributedLabel(attributedText: string) + + compareSnapshot(of: element) + + } + + func test_numberOfLines() { + + let string = NSAttributedString(string: "Hello, world. This is some long text that runs onto several lines.") + var element = AttributedLabel(attributedText: string) + + element.numberOfLines = 0 + compareSnapshot( + of: element, + size: CGSize(width: 100, height: 800), + identifier: "zero") + + element.numberOfLines = 1 + compareSnapshot( + of: element, + size: CGSize(width: 100, height: 800), + identifier: "one") + + element.numberOfLines = 2 + compareSnapshot( + of: element, + size: CGSize(width: 100, height: 800), + identifier: "two") + } + + func test_measuring() { + + func test(in size: CGSize, file: StaticString = #file, line: UInt = #line) { + + let string = NSAttributedString() + .appending(string: "H", font: .boldSystemFont(ofSize: 24.0), color: .red) + .appending(string: "e", font: .systemFont(ofSize: 14.0), color: .blue) + .appending(string: "llo, ", font: .italicSystemFont(ofSize: 13.0), color: .magenta) + .appending(string: "World!", font: .monospacedDigitSystemFont(ofSize: 32.0, weight: .black), color: .yellow) + + let element = AttributedLabel(attributedText: string) + + var measuredSize = string.boundingRect(with: size, options: .usesLineFragmentOrigin, context: nil).size + measuredSize.width = ceil(measuredSize.width) + measuredSize.height = ceil(measuredSize.height) + + let elementSize = element.content.measure(in: SizeConstraint(size)) + + XCTAssertEqual(measuredSize, elementSize, file: file, line: line) + } + + test(in: CGSize(width: 30, height: 20)) + test(in: CGSize(width: 100, height: 300)) + test(in: CGSize(width: 120, height: 300)) + test(in: CGSize(width: 8000, height: 4000)) + + } + + +} + + +extension NSAttributedString { + + func appending(string: String, font: UIFont, color: UIColor) -> NSAttributedString { + let mutableResult = NSMutableAttributedString(attributedString: self) + let stringToAppend = NSAttributedString( + string: string, + attributes: [ + NSAttributedString.Key.font: font, + NSAttributedString.Key.foregroundColor: color + ]) + mutableResult.append(stringToAppend) + return NSAttributedString(attributedString: mutableResult) + } + +} diff --git a/BlueprintUICommonControls/Tests/Sources/BoxTests.swift b/BlueprintUICommonControls/Tests/Sources/BoxTests.swift new file mode 100644 index 000000000..6844736ec --- /dev/null +++ b/BlueprintUICommonControls/Tests/Sources/BoxTests.swift @@ -0,0 +1,98 @@ +import XCTest +import BlueprintUI +@testable import BlueprintUICommonControls + + +class BoxTests: XCTestCase { + + func test_backgroundColor() { + let box = Box(backgroundColor: .red) + compareSnapshot( + of: box, + size: CGSize(width: 100, height: 100), + identifier: "red") + + compareSnapshot( + of: Box(backgroundColor: .clear), + size: CGSize(width: 100, height: 100), + identifier: "clear") + } + + func test_cornerRadius() { + var box = Box() + box.backgroundColor = .blue + box.cornerStyle = .rounded(radius: 10.0) + compareSnapshot( + of: box, + size: CGSize(width: 100, height: 100)) + } + + func test_shadow() { + var element = InsettingElement() + element.box.backgroundColor = UIColor.green + element.box.cornerStyle = .rounded(radius: 10.0) + element.box.shadowStyle = .simple(radius: 8.0, opacity: 1.0, offset: .zero, color: .magenta) + + compareSnapshot( + of: element, + size: CGSize(width: 100, height: 100)) + } + + func test_borders() { + + do { + var box = Box() + box.backgroundColor = .blue + box.borderStyle = .solid(color: .orange, width: 1.0) + compareSnapshot( + of: box, + size: CGSize(width: 100, height: 100), + identifier: "square") + } + + do { + var box = Box() + box.backgroundColor = .blue + box.cornerStyle = .rounded(radius: 10.0) + box.borderStyle = .solid(color: .orange, width: 1.0) + compareSnapshot( + of: box, + size: CGSize(width: 100, height: 100), + identifier: "rounded") + } + } + + func test_displaysContent() { + var element = InsettingElement() + element.box.wrappedElement = Label(text: "Hello, world") + compareSnapshot( + of: element, + size: CGSize(width: 100, height: 100)) + } + +} + + + +private struct InsettingElement: Element { + + var box: Box = Box() + + func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + return nil + } + + var content: ElementContent { + return ElementContent(child: box, layout: Layout()) + } + + private struct Layout: SingleChildLayout { + func measure(in constraint: SizeConstraint, child: Measurable) -> CGSize { + return .zero + } + func layout(size: CGSize, child: Measurable) -> LayoutAttributes { + return LayoutAttributes(frame: CGRect(origin: .zero, size: size).insetBy(dx: 20, dy: 20)) + } + } + +} diff --git a/BlueprintUICommonControls/Tests/Sources/ButtonTests.swift b/BlueprintUICommonControls/Tests/Sources/ButtonTests.swift new file mode 100644 index 000000000..6d5254018 --- /dev/null +++ b/BlueprintUICommonControls/Tests/Sources/ButtonTests.swift @@ -0,0 +1,24 @@ +import XCTest +import BlueprintUI +@testable import BlueprintUICommonControls + + +class ButtonTests: XCTestCase { + + func test_snapshots() { + + do { + let button = Button(wrapping: Label(text: "Hello, world")) + compareSnapshot(of: button, identifier: "simple") + } + + do { + var button = Button(wrapping: Label(text: "Hello, world")) + button.isEnabled = false + compareSnapshot(of: button, identifier: "disabled") + } + + } + +} + diff --git a/BlueprintUICommonControls/Tests/Sources/ImageTests.swift b/BlueprintUICommonControls/Tests/Sources/ImageTests.swift new file mode 100644 index 000000000..12d067cfe --- /dev/null +++ b/BlueprintUICommonControls/Tests/Sources/ImageTests.swift @@ -0,0 +1,54 @@ +import XCTest +import BlueprintUI +@testable import BlueprintUICommonControls + + +class ImageTests: XCTestCase { + + private let image: UIImage = { + let bundle = Bundle(for: ImageTests.self) + let imageURL = bundle.url(forResource: "test-image", withExtension: "jpg")! + return UIImage(contentsOfFile: imageURL.path)! + }() + + func test_defaults() { + let element = Image(image: image) + XCTAssertEqual(element.contentMode, .aspectFill) + XCTAssertNil(element.tintColor) + } + + func test_measuring() { + let element = Image(image: image) + XCTAssertEqual( + element.content.measure(in: SizeConstraint(width: .unconstrained, height: .unconstrained)), + image.size + ) + + } + + func test_aspectFill() { + var element = Image(image: image) + element.contentMode = .aspectFill + compareSnapshot(of: element, size: CGSize(width: 100, height: 100)) + } + + func test_aspectFit() { + var element = Image(image: image) + element.contentMode = .aspectFit + compareSnapshot(of: element, size: CGSize(width: 100, height: 100)) + } + + func test_center() { + var element = Image(image: image) + element.contentMode = .center + compareSnapshot(of: element, size: CGSize(width: 100, height: 100)) + } + + func test_stretch() { + var element = Image(image: image) + element.contentMode = .stretch + compareSnapshot(of: element, size: CGSize(width: 100, height: 100)) + } + +} + diff --git a/BlueprintUICommonControls/Tests/Sources/LabelTests.swift b/BlueprintUICommonControls/Tests/Sources/LabelTests.swift new file mode 100644 index 000000000..6fc3ae530 --- /dev/null +++ b/BlueprintUICommonControls/Tests/Sources/LabelTests.swift @@ -0,0 +1,79 @@ +import XCTest +import BlueprintUI +@testable import BlueprintUICommonControls + + +class LabelTests: XCTestCase { + + func test_snapshots() { + + compareSnapshot(identifier: "left") { label in + label.alignment = .left + } + + compareSnapshot(identifier: "center") { label in + label.alignment = .center + } + + compareSnapshot(identifier: "right") { label in + label.alignment = .center + } + + compareSnapshot(identifier: "justify") { label in + label.alignment = .justified + } + + compareSnapshot(identifier: "color") { label in + label.color = .green + } + + compareSnapshot(identifier: "font") { label in + label.font = .boldSystemFont(ofSize: 32.0) + } + + compareSnapshot(identifier: "two-lines") { label in + label.numberOfLines = 2 + } + + compareSnapshot(identifier: "char-wrapping") { label in + label.numberOfLines = 2 + label.lineBreakMode = .byCharWrapping + } + + compareSnapshot(identifier: "clipping") { label in + label.numberOfLines = 2 + label.lineBreakMode = .byClipping + } + + compareSnapshot(identifier: "truncating-head") { label in + label.numberOfLines = 2 + label.lineBreakMode = .byTruncatingHead + } + + compareSnapshot(identifier: "truncating-middle") { label in + label.numberOfLines = 2 + label.lineBreakMode = .byTruncatingMiddle + } + + compareSnapshot(identifier: "truncating-tail") { label in + label.numberOfLines = 2 + label.lineBreakMode = .byTruncatingTail + } + + compareSnapshot(identifier: "word-wrapping") { label in + label.numberOfLines = 2 + label.lineBreakMode = .byWordWrapping + } + + + + } + + fileprivate func compareSnapshot(identifier: String? = nil, file: StaticString = #file, testName: String = #function, line: UInt = #line, configuration: (inout Label) -> Void) { + var label = Label(text: "Hello, world. This is a long run of text that should wrap at some point. Someone should improve this test by adding a joke or something. Alright, it's been fun!") + configuration(&label) + compareSnapshot(of: label, size: CGSize(width: 300, height: 300), identifier: identifier, file: file, testName: testName, line: line) + } + +} + diff --git a/BlueprintUICommonControls/Tests/Sources/TextFieldTests.swift b/BlueprintUICommonControls/Tests/Sources/TextFieldTests.swift new file mode 100644 index 000000000..cde4e5474 --- /dev/null +++ b/BlueprintUICommonControls/Tests/Sources/TextFieldTests.swift @@ -0,0 +1,39 @@ +import XCTest +import BlueprintUI +@testable import BlueprintUICommonControls + + +class TextFieldTests: XCTestCase { + + func test_snapshots() { + + do { + let field = TextField(text: "Hello, world") + compareSnapshot( + of: field, + size: CGSize(width: 200, height: 44), + identifier: "simple") + } + + do { + var field = TextField(text: "") + field.placeholder = "Type something..." + compareSnapshot( + of: field, + size: CGSize(width: 200, height: 44), + identifier: "placeholder") + } + + do { + var field = TextField(text: "Disabled") + field.isEnabled = false + compareSnapshot( + of: field, + size: CGSize(width: 200, height: 44), + identifier: "disabled") + } + + } + +} + diff --git a/BlueprintUICommonControls/Tests/Sources/XCTestCaseExtensions.swift b/BlueprintUICommonControls/Tests/Sources/XCTestCaseExtensions.swift new file mode 100644 index 000000000..37adcc78d --- /dev/null +++ b/BlueprintUICommonControls/Tests/Sources/XCTestCaseExtensions.swift @@ -0,0 +1,47 @@ +import XCTest +import BlueprintUI +import SnapshotTesting + + +extension XCTestCase { + + func compareSnapshot(of image: UIImage, identifier: String? = nil, file: StaticString = #file, testName: String = #function, line: UInt = #line) { + assertSnapshot(matching: image, as: .image, named: identifier, file: file, testName: testName, line: line) + } + + func compareSnapshot(of view: UIView, identifier: String? = nil, file: StaticString = #file, testName: String = #function, line: UInt = #line) { + + view.layoutIfNeeded() + + UIGraphicsBeginImageContextWithOptions(view.bounds.size, false, 0) + + guard let context = UIGraphicsGetCurrentContext() else { + XCTFail("Failed to get graphics context", file: file, line: line) + return + } + + view.layer.render(in: context) + + guard let image = UIGraphicsGetImageFromCurrentImageContext() else { + XCTFail("Failed to get snapshot image from view", file: file, line: line) + return + } + + UIGraphicsEndImageContext() + + compareSnapshot(of: image, identifier: identifier, file: file, testName: testName, line: line) + } + + func compareSnapshot(of element: Element, size: CGSize? = nil, identifier: String? = nil, file: StaticString = #file, testName: String = #function, line: UInt = #line) { + let view = BlueprintView(element: element) + + if let size = size { + view.frame = CGRect(origin: .zero, size: size) + } else { + view.sizeToFit() + } + + compareSnapshot(of: view, identifier: identifier, file: file, testName: testName, line: line) + } + +} diff --git a/BlueprintUICommonControls/Tests/Sources/__Snapshots__/AttributedLabelTests/test_displaysText.1.png b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/AttributedLabelTests/test_displaysText.1.png new file mode 100644 index 000000000..bac067184 Binary files /dev/null and b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/AttributedLabelTests/test_displaysText.1.png differ diff --git a/BlueprintUICommonControls/Tests/Sources/__Snapshots__/AttributedLabelTests/test_numberOfLines.one.png b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/AttributedLabelTests/test_numberOfLines.one.png new file mode 100644 index 000000000..8f7e8bff4 Binary files /dev/null and b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/AttributedLabelTests/test_numberOfLines.one.png differ diff --git a/BlueprintUICommonControls/Tests/Sources/__Snapshots__/AttributedLabelTests/test_numberOfLines.two.png b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/AttributedLabelTests/test_numberOfLines.two.png new file mode 100644 index 000000000..ac85d7372 Binary files /dev/null and b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/AttributedLabelTests/test_numberOfLines.two.png differ diff --git a/BlueprintUICommonControls/Tests/Sources/__Snapshots__/AttributedLabelTests/test_numberOfLines.zero.png b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/AttributedLabelTests/test_numberOfLines.zero.png new file mode 100644 index 000000000..f3bdfcd8a Binary files /dev/null and b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/AttributedLabelTests/test_numberOfLines.zero.png differ diff --git a/BlueprintUICommonControls/Tests/Sources/__Snapshots__/BoxTests/test_backgroundColor.clear.png b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/BoxTests/test_backgroundColor.clear.png new file mode 100644 index 000000000..6747d04d6 Binary files /dev/null and b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/BoxTests/test_backgroundColor.clear.png differ diff --git a/BlueprintUICommonControls/Tests/Sources/__Snapshots__/BoxTests/test_backgroundColor.red.png b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/BoxTests/test_backgroundColor.red.png new file mode 100644 index 000000000..ed1a1f4bd Binary files /dev/null and b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/BoxTests/test_backgroundColor.red.png differ diff --git a/BlueprintUICommonControls/Tests/Sources/__Snapshots__/BoxTests/test_borders.rounded.png b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/BoxTests/test_borders.rounded.png new file mode 100644 index 000000000..b359120ae Binary files /dev/null and b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/BoxTests/test_borders.rounded.png differ diff --git a/BlueprintUICommonControls/Tests/Sources/__Snapshots__/BoxTests/test_borders.square.png b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/BoxTests/test_borders.square.png new file mode 100644 index 000000000..69db3c6cd Binary files /dev/null and b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/BoxTests/test_borders.square.png differ diff --git a/BlueprintUICommonControls/Tests/Sources/__Snapshots__/BoxTests/test_cornerRadius.1.png b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/BoxTests/test_cornerRadius.1.png new file mode 100644 index 000000000..5daed7b4e Binary files /dev/null and b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/BoxTests/test_cornerRadius.1.png differ diff --git a/BlueprintUICommonControls/Tests/Sources/__Snapshots__/BoxTests/test_displaysContent.1.png b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/BoxTests/test_displaysContent.1.png new file mode 100644 index 000000000..390b71fd3 Binary files /dev/null and b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/BoxTests/test_displaysContent.1.png differ diff --git a/BlueprintUICommonControls/Tests/Sources/__Snapshots__/BoxTests/test_shadow.1.png b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/BoxTests/test_shadow.1.png new file mode 100644 index 000000000..0f2fbda59 Binary files /dev/null and b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/BoxTests/test_shadow.1.png differ diff --git a/BlueprintUICommonControls/Tests/Sources/__Snapshots__/ButtonTests/test_snapshots.disabled.png b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/ButtonTests/test_snapshots.disabled.png new file mode 100644 index 000000000..4d3fa21b9 Binary files /dev/null and b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/ButtonTests/test_snapshots.disabled.png differ diff --git a/BlueprintUICommonControls/Tests/Sources/__Snapshots__/ButtonTests/test_snapshots.simple.png b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/ButtonTests/test_snapshots.simple.png new file mode 100644 index 000000000..b3f254bce Binary files /dev/null and b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/ButtonTests/test_snapshots.simple.png differ diff --git a/BlueprintUICommonControls/Tests/Sources/__Snapshots__/ImageTests/test_aspectFill.1.png b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/ImageTests/test_aspectFill.1.png new file mode 100644 index 000000000..47b8eec02 Binary files /dev/null and b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/ImageTests/test_aspectFill.1.png differ diff --git a/BlueprintUICommonControls/Tests/Sources/__Snapshots__/ImageTests/test_aspectFit.1.png b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/ImageTests/test_aspectFit.1.png new file mode 100644 index 000000000..dea9feb5d Binary files /dev/null and b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/ImageTests/test_aspectFit.1.png differ diff --git a/BlueprintUICommonControls/Tests/Sources/__Snapshots__/ImageTests/test_center.1.png b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/ImageTests/test_center.1.png new file mode 100644 index 000000000..0ea473ec1 Binary files /dev/null and b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/ImageTests/test_center.1.png differ diff --git a/BlueprintUICommonControls/Tests/Sources/__Snapshots__/ImageTests/test_stretch.1.png b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/ImageTests/test_stretch.1.png new file mode 100644 index 000000000..4688ab73d Binary files /dev/null and b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/ImageTests/test_stretch.1.png differ diff --git a/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.center.png b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.center.png new file mode 100644 index 000000000..958367ba5 Binary files /dev/null and b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.center.png differ diff --git a/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.char-wrapping.png b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.char-wrapping.png new file mode 100644 index 000000000..ec64da75d Binary files /dev/null and b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.char-wrapping.png differ diff --git a/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.clipping.png b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.clipping.png new file mode 100644 index 000000000..44d33415c Binary files /dev/null and b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.clipping.png differ diff --git a/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.color.png b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.color.png new file mode 100644 index 000000000..128fa6a3a Binary files /dev/null and b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.color.png differ diff --git a/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.font.png b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.font.png new file mode 100644 index 000000000..77ef1f770 Binary files /dev/null and b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.font.png differ diff --git a/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.justify.png b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.justify.png new file mode 100644 index 000000000..3a7f507bd Binary files /dev/null and b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.justify.png differ diff --git a/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.left.png b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.left.png new file mode 100644 index 000000000..d193520c2 Binary files /dev/null and b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.left.png differ diff --git a/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.right.png b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.right.png new file mode 100644 index 000000000..958367ba5 Binary files /dev/null and b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.right.png differ diff --git a/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.truncating-head.png b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.truncating-head.png new file mode 100644 index 000000000..5ef9cc343 Binary files /dev/null and b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.truncating-head.png differ diff --git a/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.truncating-middle.png b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.truncating-middle.png new file mode 100644 index 000000000..e46675fae Binary files /dev/null and b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.truncating-middle.png differ diff --git a/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.truncating-tail.png b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.truncating-tail.png new file mode 100644 index 000000000..32b3619c3 Binary files /dev/null and b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.truncating-tail.png differ diff --git a/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.two-lines.png b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.two-lines.png new file mode 100644 index 000000000..b61d78adb Binary files /dev/null and b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.two-lines.png differ diff --git a/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.word-wrapping.png b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.word-wrapping.png new file mode 100644 index 000000000..b61d78adb Binary files /dev/null and b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/LabelTests/test_snapshots.word-wrapping.png differ diff --git a/BlueprintUICommonControls/Tests/Sources/__Snapshots__/TextFieldTests/test_snapshots.disabled.png b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/TextFieldTests/test_snapshots.disabled.png new file mode 100644 index 000000000..b6d9de998 Binary files /dev/null and b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/TextFieldTests/test_snapshots.disabled.png differ diff --git a/BlueprintUICommonControls/Tests/Sources/__Snapshots__/TextFieldTests/test_snapshots.placeholder.png b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/TextFieldTests/test_snapshots.placeholder.png new file mode 100644 index 000000000..476fe4a36 Binary files /dev/null and b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/TextFieldTests/test_snapshots.placeholder.png differ diff --git a/BlueprintUICommonControls/Tests/Sources/__Snapshots__/TextFieldTests/test_snapshots.simple.png b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/TextFieldTests/test_snapshots.simple.png new file mode 100644 index 000000000..b58bbdd11 Binary files /dev/null and b/BlueprintUICommonControls/Tests/Sources/__Snapshots__/TextFieldTests/test_snapshots.simple.png differ diff --git a/Documentation/GettingStarted/1_element_hierarchy.svg b/Documentation/GettingStarted/1_element_hierarchy.svg new file mode 100644 index 000000000..7162cd182 --- /dev/null +++ b/Documentation/GettingStarted/1_element_hierarchy.svg @@ -0,0 +1,19 @@ + + + + element_hierarchy + Created with Sketch. + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Documentation/GettingStarted/2_element_hierarchy_view_backed.svg b/Documentation/GettingStarted/2_element_hierarchy_view_backed.svg new file mode 100644 index 000000000..5354d620c --- /dev/null +++ b/Documentation/GettingStarted/2_element_hierarchy_view_backed.svg @@ -0,0 +1,19 @@ + + + + element_hierarchy_view_backed + Created with Sketch. + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Documentation/GettingStarted/3_element_hierarchy_views.svg b/Documentation/GettingStarted/3_element_hierarchy_views.svg new file mode 100644 index 000000000..30b070c8e --- /dev/null +++ b/Documentation/GettingStarted/3_element_hierarchy_views.svg @@ -0,0 +1,16 @@ + + + + element_hierarchy_views + Created with Sketch. + + + + + + + + + + + \ No newline at end of file diff --git a/Documentation/GettingStarted/CustomElements.md b/Documentation/GettingStarted/CustomElements.md new file mode 100644 index 000000000..13d7f77be --- /dev/null +++ b/Documentation/GettingStarted/CustomElements.md @@ -0,0 +1,132 @@ +# Building Custom Elements + + + + +## `ProxyElement` (easy mode) + +A common motivation for defining a custom element is to create an API boundary; the custom implementation can contain its own initializers, properties, etc. + +When it comes to actually displaying content, however, many custom elements simply compose other existing elements together (they don't do custom layout or provide a custom view backing). + +`ProxyElement` is a tool to make this common case easier. + +```swift +public protocol ProxyElement: Element { + var elementRepresentation: Element { get } +} + +/// `ProxyElement` provides default implementations of the `Element` API that delegate to the element returned by `elementRepresentation`. +extension ProxyElement { + public var content: ElementContent { get } + public func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? +} +``` + +For example, let's define a custom element that displays a title and subtitle: + +```swift +struct TitleSubtitleElement: ProxyElement { + var title: String + var subtitle: String + + var elementRepresentation: Element { + return Column { col in + col.horizontalAlignment = .leading + col.minimumVerticalSpacing = 8.0 + + var titleLabel = Label(text: title) + titleLabel.font = .boldSystemFont(ofSize: 18.0) + col.add(child: titleLabel) + + var subtitleLabel = Label(text: title) + subtitleLabel.font = .systemFont(ofSize: 14.0) + subtitleLabel.color = .darkGray + col.add(child: subtitleLabel) + } + } +} +``` + +--- + +## `Element` (hard mode) + +For more information, please see [`Element` reference](../Reference/Element.md). + +```swift +public protocol Element { + var content: ElementContent { get } + func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? +} +``` + +To implement the `Element` protocol, your custom type must implement two methods: + +#### `var content: ElementContent` + +`ElementContent` represents (surprise!) the content of an element. Elements generally fall into one of two types: containers and leaves. +- Containers, or elements that have children. +- Leaves: elements that have no children, but often have some intrinsic size (a label is a good example of this). + +#### `func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription?` + +If a [`ViewDescription`](../Reference/ViewDescription.md) is returned, the element will be view-backed. + +--- + +## Bonus: do you need a custom element? + +You may want to define a custom element so that you can reuse it throughout your codebase (great!). In other cases, however, you may simply want to generate an element for display. + +For example, let's say we want to show some centered text over an image. This is only used in one place: + +```swift +final class ImageViewController { + + private let blueprintView = BlueprintView() + + var image: UIImage { + didSet { updateDisplay() } + } + + var text: String { + didSet { updateDisplay() } + } + + private func updateDisplay() { + let newElement = // We need an element! + blueprintView.element = newElement + } + +} +``` + +You might simply define a function to turn input (in this case an image and some text) into an element: + +```swift +private func makeElement(image: UIImage, text: String) -> Element { + + return Overlay(elements: [ + Image(image: image), + Centered( + Label(text: text) + ) + ]) + +} +``` + +Your `updateDisplay()` method can then call this method as needed: + +```swift +final class ImageViewController { + + // ... + + private func updateDisplay() { + blueprintView.element = makeElement(image: image, text: text) + } + +} +``` \ No newline at end of file diff --git a/Documentation/GettingStarted/ElementHierarchy.md b/Documentation/GettingStarted/ElementHierarchy.md new file mode 100644 index 000000000..0670ced3b --- /dev/null +++ b/Documentation/GettingStarted/ElementHierarchy.md @@ -0,0 +1,56 @@ +# The Element Hierarchy + +In Blueprint, Elements are the building blocks that are assembled together to create UI. + +If you have experience building iOS apps, we can compare elements to `UIView`s: + +**Elements are similar to views:** +- Elements and views both cover some area of the screen +- Elements and views can both display visual information and respond to user input +- Elements and views can both contain children, where the parent is responsible for laying out those children. + +**Elements are different from views:** +- Views are long lived (classes), whereas elements are Swift value types + + +Any useful UIKit app uses more than one view. Every bit of content, every bar, and every button within that bar – they are all implemented as views. + +Likewise, building UI with Blueprint usually involves working with multiple elements. + +`BlueprintView` is a view that you can use to display Blueprint elements on screen. It is initialized with a single element: + +```swift +public final class BlueprintView: UIView { + public init(element: Element?) +} +``` + +The element displayed by `BlueprintView` is the "root" element of the hierarchy. Its child elements, and all of their descendents, form the entirety of the element hierarchy. + +### How the element hierarchy is displayed + +#### 1. Layout + +The first step is to calculate layout attributes for the entire element hierarchy. + +![Element Hierarchy](1_element_hierarchy.svg) + + +#### 2. View-backed elements + +Elements can optionally provide a [`ViewDescription`](../Reference/ViewDescription.md). If so, that element is considered "view-backed", and it will be displayed with a concrete system view. Elements shaded in blue below are view backed. + +![Element Hierarchy with view descriptions](2_element_hierarchy_view_backed.svg) + + +#### 3. View hierarchy update + +After the hierarchy has a fully computed layout, and some elements have chosen to be view backed, the hierarchy is *flattened*. + +This means that every element that is *not view backed* is removed – its layout attributes are applied to its children so that they still appear in the same location on-screen. + +Now that we have a flattened tree that only contains views to be displayed, we traverse the view hierarchy (inside of `BlueprintView`) and update all views to match the new view descriptions. + +The flattening step allows element hierarchies to be as deep as necessary without compromising performance. It becomes very cheap to introduce extra layers in the hierarchy for layout purposes, knowing that this will not complicate the view hierarchy that is ultimately displayed. + +![Views from the Element Hierarchy](3_element_hierarchy_views.svg) diff --git a/Documentation/GettingStarted/HelloWorld.md b/Documentation/GettingStarted/HelloWorld.md new file mode 100644 index 000000000..ac34b6318 --- /dev/null +++ b/Documentation/GettingStarted/HelloWorld.md @@ -0,0 +1,26 @@ +# Hello, World + +`BlueprintView` is a `UIView` subclass that can display an element hierarchy. + +```swift +import UIKit +import BlueprintUI + + +private func makeHelloWorldElement() -> Element { + var label = Label(text: "Hello, world") + label.font = .boldSystemFont(ofSize: 18.0) + return Centered(label) +} + +final class ViewController: UIViewController { + + private let blueprintView = BlueprintView(element: makeHelloWorldElement()) + + override func loadView() { + self.view = blueprintView + } + +} + +``` \ No newline at end of file diff --git a/Documentation/GettingStarted/Layout.md b/Documentation/GettingStarted/Layout.md new file mode 100644 index 000000000..8ee7a54ce --- /dev/null +++ b/Documentation/GettingStarted/Layout.md @@ -0,0 +1,112 @@ +# Layout + +Unlike some declarative UI architectures (like the web), Blueprint does not have a single, complex layout model (like Flexbox). + +Instead, Blueprint allows each element in the tree to use a different layout implementation that is appropriate for its use. + +For example, consider the following element hierarchy + +```swift +let element = Box( + backgroundColor: .blue, + wrapping: Label(text: "Hello, world")) +``` + +The label will be stretched to fill the box. + +If we want to center the label within the box, we do *not* change the box or the label at all. Instead, we add another level to the tree: + +```swift +let element = Box( + backgroundColor: .blue, + wrapping: Centered( + Label(text: "Hello, world") + )) +``` + +By adding a `Centered` element into the hierarchy, the label will now be centered within the outer box. + +## Layout Elements + +Blueprint includes a set of elements that make common layout tasks easier. + +### `Centered` + +Centers a wrapped element within its parent. + +A `Centered` element always wraps a single child. During a layout pass, the layout always delegates measuring to the wrapped element. + +After `Centered` has been assigned a size during a layout pass, it always sizes the wrapped element to its measured size, then centers it within the layout area. + +```swift +let centered = Centered(wrapping: Label(text: "Hello, world")) +``` + +### `Spacer` + +Takes up space within a layout, but does not show any visual content. + +```swift +let spacer = Spacer(size: CGSize(width: 100.0, height: 100.0)) +``` + +### `Overlay` + +Stretches all of its child elements to fill the layout area, stacked on top of each other. + +During a layout pass, measurent is calculated as the max size (in both x and y dimensions) produced by measuring all of the child elements. + +```swift +let overlay = Overlay(elements: [ + Box(backgroundColor: .lightGray), + Label(text: "Hello, world") +]) +``` + +### `Inset` + +Wraps a single element, insetting it by the given amount. + +```swift +let inset = Inset(wrapping: Label(text: "Hello", uniformInset: 20.0)) +``` + +### Stack Elements: `Row` and `Column` + +These elements are used to layout stacks of content in either the x (row) or y (column) dimension. + +```swift +let row = Row { row in + row.verticalAlignment = .center + row.minimumHorizontalPadding = 8.0 + + row.add(child: Label(text: "Lorem")) + row.add(child: Label(text: "Ipsum")) +} +``` + +```swift +let column = Column { col in + col.horizontalAlignment = .center + col.minimumVerticalPadding = 8.0 + + col.add(child: Label(text: "Lorem")) + col.add(child: Label(text: "Ipsum")) +} +``` + +--- + +## Layout implementations + +All elements are responsible for producing a `ElementContent` instance that contains both its children and a layout implementation. + +There are two types of layouts in Blueprint, both defined as protocols. + +### `Layout` + +Defines a layout that supports an arbitrary number of children. + +### `SingleChildLayout` + +Defines a layout that supports exactly one child element. \ No newline at end of file diff --git a/Documentation/Reference/BlueprintView.md b/Documentation/Reference/BlueprintView.md new file mode 100644 index 000000000..fe52475a0 --- /dev/null +++ b/Documentation/Reference/BlueprintView.md @@ -0,0 +1,45 @@ +# BlueprintView + +`BlueprintView` is a `UIView` subclass that displays a Blueprint element hierarchy. + + +### Creating a `BlueprintView` instance + +`init(element:)` instantiates a new `BlueprintView` with the given element: + +```swift + +let rootElement = Center( + Column { column in + + column.layout.horizontalAlignment = .center + column.layout.minimumVerticalSpacing = 12.0 + + column.add(child: Label(text: "Hello, world!")) + column.add(child: Label(text: "This is a label")) + } +} + +let blueprintView = BlueprintView(element: rootElement) +``` + + +### Updating the element hierarchy + +A `BlueprintView` instance can be updated after initialization by assigning the `.element` property: + +```swift +blueprintView.element = Label(text: "This is a new element") +``` + +Updates can be animated within an animation block: + +```swift + +UIView.animate(withDuration) { + blueprintView.element = Label(text: "This is a new element") +} + +``` + +See the documentation for `ViewDescription` for more information about transitions. \ No newline at end of file diff --git a/Documentation/Reference/Element.md b/Documentation/Reference/Element.md new file mode 100644 index 000000000..b602d4214 --- /dev/null +++ b/Documentation/Reference/Element.md @@ -0,0 +1,152 @@ +# The `Element` protocol + +```swift +public protocol Element { + var content: ElementContent { get } + func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? +} +``` + +--- + +## Example Elements + +#### A view-backed element that displays a `UISwitch` +```swift +struct RedSquare: Element { + + var content: ElementContent { + return ElementContent(intrinsicSize: CGSize(width: 90.0, height: 90.0)) + } + + func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + return UIView.describe { config in + config[\.backgroundColor] = .blue + } + } + +} + +``` + +--- + +## `backingViewDescription(bounds:subtreeExtent:)` + +If the element is be view-backed, it should return a view description from this method. + +This method is called after layout is complete, and the passed in parameters provide information about the layout: + +*`bounds`* +Contains the extent of the element after the layout is calculated *in the element's local coordinate space*. + +*`subtreeExtent`* +A rectangle, given within the element's local coordinate space, that completely contains all of the element's children. `nil` will be provided if the element has no children. + +Most view-backed elements will not need to care about the bounds or subtree extent, but they are provided for the rare cases when they are needed. + +```swift +struct MyElement: Element { + + // ... + + func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + return UIImageView.describe { config in + config[\.image] = UIImage(named: "cat") + config[\.contentMode] = .scaleAspectFill + } + } + +} +``` + +[`ViewDescription` reference](ViewDescription.md) + +--- + +## `content` + +`ElementContent` represents the content *within* an element. + +Elements can contain multiple children with a complex layout, a single child, or simply an intrinsic size that allows the element to participate in a layout. + +```swift +public struct ElementContent : Measurable { + + public func measure(in constraint: SizeConstraint) -> CGSize + + public var childCount: Int { get } + +} + +extension ElementContent { + + public static func container(layout: LayoutType, configure: (inout Builder) -> Void = { _ in }) -> ElementContent where LayoutType : Layout + + public static func container(element: Element, layout: SingleChildLayout) -> ElementContent + + public static func container(element: Element) -> ElementContent + + public static func leaf(measurable: Measurable) -> ElementContent + + public static func leaf(measureFunction: @escaping (SizeConstraint) -> CGSize) -> ElementContent + + public static func leaf(intrinsicSize: CGSize) -> ElementContent + +} +``` + +### `content` Examples + +#### An element with no children and an intrinsic size + +```swift +var content: ElementContent { + return ElementContent(intrinsicSize: CGSize(width: 100, height: 100)) +} +``` + +#### An element with no children and a measurable intrinsic size + +```swift +var content: ElementContent { + return ElementContent(measurable: CustomMeasurer()) +} +``` + +#### An element with no children and a custom measurable intrinsic size + +```swift +var content: ElementContent { + return ElementContent { constraint in + return CGSize(width: constraint.max.width, height: 44.0) + } +} +``` + +#### An element with a single child that performs no custom layout + +```swift +var content: ElementContent { + return ElementContent(child: WrappedElement()) +} +``` + +#### An element with a single child that uses a custom layout + +```swift +var content: ElementContent { + return ElementContent(child: WrappedElement(), layout: MyCustomLayout()) +} +``` + +#### An element with multiple children + +```swift +var content: ElementContent { + return ElementContent(layout: MyCustomLayout()) { builder in + builder.add(child: WrappedElementA()) + builder.add(child: WrappedElementB()) + builder.add(child: WrappedElementC()) + } +} \ No newline at end of file diff --git a/Documentation/Reference/Transitions.md b/Documentation/Reference/Transitions.md new file mode 100644 index 000000000..822785b3d --- /dev/null +++ b/Documentation/Reference/Transitions.md @@ -0,0 +1,50 @@ +# Transitions + +There are two types of transitions in Blueprint: + +- Layout transitions, in which the layout attributes of an existing view change, and an animation is used when applying the new attributes. +- Visibility transitions, in which the appearance or disappearance of a view is animated. + +## `LayoutTransition` + +```swift +public enum LayoutTransition { + case none + case specific(AnimationAttributes) + case inherited + case inheritedWithFallback(AnimationAttributes) +} +``` + +**'Inherited' transitions:** the 'inherited' transition is determined by searching up the tree (not literally, but this is the resulting behavior). The nearest ancestor that defines an animation will be used, following this logic: +- Ancestors with a layout transition of `none` will result in no inherited animation for their descendents. +- Ancestors in the tree with a layout transition of `inherited` will be skipped, and the search will continue up the tree. +- Any ancestors in the tree with a layout transition of `inheritedWithFallback` will be used *if* they do not themselves inherit a layout transition from one of their ancestors. +- Ancestors with a layout transition of `specific` will always be used for their descendents inherited animation. +- If no ancestor is found that specifies a layout transition, but the containing `BlueprintView` has the `element` property assigned from within a `UIKit` animation block, that animation will be used as the inherited animation. + + +## `VisibilityTransition` + +```swift +public struct VisibilityTransition { + + /// The alpha of the view in the hidden state (initial for appearing, final for disappearing). + public var alpha: CGFloat + + /// The transform of the view in the hidden state (initial for appearing, final for disappearing). + public var transform: CATransform3D + + /// The animation attributes that will be used to drive the transition. + public var attributes: AnimationAttributes + + /// Returns a `VisibilityTransition` that scales in and out. + public static var scale: VisibilityTransition { get } + + /// Returns a `VisibilityTransition` that fades in and out. + public static var fade: VisibilityTransition { get } + + /// Returns a `VisibilityTransition` that simultaneously scales and fades in and out. + public static var scaleAndFade: VisibilityTransition { get } +} +``` \ No newline at end of file diff --git a/Documentation/Reference/ViewDescription.md b/Documentation/Reference/ViewDescription.md new file mode 100644 index 000000000..e5a637ffb --- /dev/null +++ b/Documentation/Reference/ViewDescription.md @@ -0,0 +1,72 @@ +# `ViewDescription` + +View Descriptions contain all the information needed to create an instance of a native UIView. **Importantly, view desctiptions do not contain *instances* of a view**. They only contain the data necessary to instantiate or update a view. + +## Creating a view description + +```swift +let description = UILabel.describe { config in + config[\.text] = "Hello, world" + config[\.textColor] = .orange +} + +// Or... + +let description = ViewDescription(UILabel.self) { config in + config[\.text] = "Hello, world" + config[\.textColor] = .orange +} +``` + +In both cases, the last argument is a closure responsible for configuring the view type. The argument passed into the closure is a value of the type `ViewDescription.Configuration` + +```swift +extension ViewDescription { + public struct Configuration {} +} +``` + +### Specifying how the view should be instantiated + +```swift +let description = UITableView.describe { config in + config.builder = { UITableView(frame: .zero, style: .plain) } +} +``` + +### Assigning values to properties + +```swift +let description = UIView.describe { config in + config[\.backgroundColor] = .magenta +} +``` + +### Applying arbitrary update logic + +```swift +let description = UIView.describe { config in + config.apply { view in + view.layer.masksToBounds = true + } +} +``` + +### Specifying a subview that should contain any view-backed child elements + +```swift +let description = MyCustomView.describe { config in + config.contentView = { $0.childContainerView } +} +``` + +### Specifying transitions + +```swift +let description = UIView.describe { config in + config.layoutTransition = .specific(AnimationAttributes()) + config.appearingTransition = .scale + config.disappearingTransition = .fade +} +``` + diff --git a/Documentation/Tutorials/Setup.md b/Documentation/Tutorials/Setup.md new file mode 100644 index 000000000..139e8574c --- /dev/null +++ b/Documentation/Tutorials/Setup.md @@ -0,0 +1,46 @@ +# Tutorial setup instructions + +The easiest way to complete the Blueprint tutorials is to use the sample app included with the project. + +1. Clone the Blueprint repo + +```bash +git clone git@github.com:square/Blueprint.git +``` + +```bash +cd Blueprint +``` + +2. CocoaPods + +The sample app uses CocoaPods to integrate dependencies. First, we'll make sure CocoaPods and all of its dependencies are installed. + +```bash +bundle install +``` + +Next, we'll use CocoaPods to integrate the workspace that we will use for completing the tutorials. + +```bash +bundle exec pod install +``` + +Finally... + +```bash +open SampleApp.xcworkspace +``` + +3. Code! + +The SampleApp project contains multiple app targets. `SampleApp` is a standalone demonstration of Blueprint. The project also contains targets for each tutorial (along with a target showing the tutorial in its completed state). + +``` +SampleApp +Tutorial 1 +Tutorial 1 (Completed) +/// etc... +``` + +To follow along with [Tutorial 1](./Tutorial1.md), navigate to `Tutorials` > `Tutorial 1` in the project navigator to see the source dode. Also be sure to select the `Tutorial 1` target before building so you can see the tutorial running in the simulator. \ No newline at end of file diff --git a/Documentation/Tutorials/Tutorial1.md b/Documentation/Tutorials/Tutorial1.md new file mode 100644 index 000000000..a2f7d6e64 --- /dev/null +++ b/Documentation/Tutorials/Tutorial1.md @@ -0,0 +1,185 @@ +# Tutorial 1 + +## Using Blueprint in a View Controller + +This tutorial will walk you through the basic setup required to create UI with Blueprint. This will also serve as the foundation for subsequent tutorials. + +[Tutorial setup instructions](./Setup.md) + +#### Goals + +- Show a Blueprint Element hierarchy within a view controller +- Style a label to display a message on-screen + +![Screenshot of finished tutorial](tutorial_1_complete.png) + +--- + +### Existing code + +This tutorial starts off with a single source file: `AppDelegate.swift`. The app delegate is ready to go apart from one small detail: it has no view controller to show. + +```swift +// window?.rootViewController = +``` + +### Create a view controller + +1. In Xcode, select `File > New > File...`. +2. Choose `Swift File` +3. Name the new file `ViewController.swift` +4. Place the file in `Tutorials/Tutorial 1` +5. Make sure the new source file is *only* added to the `Tutorial 1` target. + +Add an empty view controller implementation for now: + +```swift +// ViewController.swift + +import UIKit + +final class ViewController: UIViewController { + +} + +``` + +### Show the view controller + +1. Open `Tutorial 1 > AppDelegate.swift` +2. Un-comment the line `// window?.rootViewController =` in the method `application(:didFinishLaunchingWithOptions:)`. +3. Assign a new instance of your view controller as the root view controller: + +```swift +@UIApplicationMain +final class AppDelegate: UIResponder, UIApplicationDelegate { + + // ... + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + + window = UIWindow(frame: UIScreen.main.bounds) + window?.rootViewController = ViewController() + window?.makeKeyAndVisible() + + return true + } + +} +``` + +### Import Blueprint + +Go back to `ViewController.swift`. In order to use Blueprint, we need to import it. We'll also import a companion library (`BlueprintUICommonControls`) that provides some primitive elements such as labels that we will use to build our UI. + +```swift +// ViewController.swift + +import UIKit +import BlueprintUI +import BlueprintUICommonControls +``` + +### Define a root element + +We'll build fancier UI later, but for now we just want to make sure that our application works. `Box` is an element provided by `BlueprintUICommonControls` that supports background colors, borders, and rounded corners. We'll make one with a red background for now. + +```swift +// ViewController.swift + +let rootElement = Box(backgroundColor: .red) + +``` + +### Add `BlueprintView` to `ViewController` + +Blueprint element hierarchies are displayed within instances of `BlueprintView`. We'll add one to our view controller. The initializer takes an element to display as the only parameter, so we can pass in the root element that we created. + +```swift +final class ViewController: UIViewController { + + private let blueprintView = BlueprintView(element: rootElement) + +} +``` + +Now that we have a `BlueprintView` instance, we will use it as the view for this view controller. Override `loadView` and assign the blueprint view to `self.view`. + +```swift +final class ViewController: UIViewController { + + private let blueprintView = BlueprintView(element: rootElement) + + override func loadView() { + self.view = blueprintView + } + +} +``` + +Make sure `Tutorial 1` is selected as the active scheme, then run the app in Xcode. If everything worked, you should see a red screen. + +![Simulator with red screen](tutorial_1_red.png) + +### Adding "Hello, World" + +Our app shows a red screen, which proves that Blueprint is on-screen and working. It's not a particularly nice UI, however, so we'll replace it with a message. + +We previously defined the root element as a simple `Box`. We'll be doing a bit more customization this time, so we will define a *custom element*. + +Custom elements have a few nice qualities: +- They are a unique type, with a unique name +- They can define their own initializers and properties + +It is rare, however, for custom elements to do their own layout or view customization – they are typically assembled from other existing elements. + +The `ProxyElement` protocol formalizes this pattern: conforming elements simply generate another element that will actually be displayed. + +Let's see this in action: + +Define a new element called `HelloWorldElement`: + +```swift +// ViewController.swift + +struct HelloWorldElement: ProxyElement { + + // this computed property returns another element that will be displayed. + var elementRepresentation: Element { + var label = Label(text: "Hello, world") + label.font = .boldSystemFont(ofSize: 24.0) + label.color = .darkGray + + return Centered(label) + } + +} +``` + +The element hierarchy that we have just defined is: + +``` +- Box + - Centered + - Label +``` + +Now update `ViewController` to use the new element: + +```swift +final class ViewController: UIViewController { + + private let blueprintView = BlueprintView(element: HelloWorldElement()) + + // ... + +} +``` + +The app now displays "Hello, world" in bold white text, centered over a dark gray background. + +Try rotating the simulator. Notice that the text is properly centered in both orientations, and it animates properly between orientations. + +--- + +#### [Continue to Tutorial 2](./Tutorial2.md) \ No newline at end of file diff --git a/Documentation/Tutorials/Tutorial2.md b/Documentation/Tutorials/Tutorial2.md new file mode 100644 index 000000000..13e609b41 --- /dev/null +++ b/Documentation/Tutorials/Tutorial2.md @@ -0,0 +1,587 @@ +# Tutorial 2 + +## Building a receipt layout with Blueprint + +This tutorial will build a receipt for a quick service restaurant. + +[Tutorial setup instructions](./Setup.md) + +#### Goals + +- Show receipt information in a complex layout +- Use a few of the build-in element types from Blueprint + +![Screenshot of finished tutorial](tutorial_2_complete.png) + +--- + +### Picking up from Tutorial 1 + +In Tutorial 1 we added Blueprint to a view controller, and created a new `HelloWorldElement` to display in it. + +```swift +struct HelloWorldElement: ProxyElement { + + var elementRepresentation: Element { + var label = Label(text: "Hello, world") + label.font = .boldSystemFont(ofSize: 24.0) + label.color = .darkGray + + return Centered(label) + } + +} +``` + +We'll be building a receipt layout in this tutorial. You can repurpose `HelloWorldElement` for this: just rename it to `ReceiptElement` and we'll continue from there. + +### Making it scroll + +Receipts can get pretty long, so we'll give this receipt the ability to scroll right off the bat. + +Instead of returning `Centered(label)`, we'll make a `ScrollView` to display the label: + +```swift +var scrollView = ScrollView(wrapping: label) +``` + +Next, we'll configure the `ScrollView and return it. + +```swift +struct ReceiptElement: ProxyElement { + + var elementRepresentation: Element { + var label = Label(text: "Hello, world") + label.font = .boldSystemFont(ofSize: 24.0) + label.color = .darkGray + + var scrollView = ScrollView(wrapping: label) + scrollView.contentSize = .fittingHeight // Stretches content to fill width, but sizes height to fit + scrollView.alwaysBounceVertical = true + return scrollView + } + +} +``` + +If you run `Tutorial 2` in the simulator, you should be able to scroll vertically (though with a single label there is nothing to scroll just yet) + +![Scrolling label](tutorial_2_scroll.png) + +### Insetting content from the edge of the screen + +So we now have a scrolling label, but it's jammed right up against the edge of the screen. We'll inset everything inside the `ScrollView` to make sure we have consistent padding. + +We do this with the `Inset` element. We'll wrap the label in an `Inset` before we place it into the `ScrollView`. + +```swift +struct ReceiptElement: ProxyElement { + + var elementRepresentation: Element { + var label = Label(text: "Hello, world") + label.font = .boldSystemFont(ofSize: 24.0) + label.color = .darkGray + + let inset = Inset( + wrapping: label, + uniformInset: 24.0) + + var scrollView = ScrollView(wrapping: inset) + scrollView.contentSize = .fittingHeight // Stretches content to fill width, but sizes height to fit + scrollView.alwaysBounceVertical = true + return scrollView + } + +} +``` + +If we run `Tutorial 2` again, we can see that the label is now inset from the edge of the screen. + +![Scrolling inset label](tutorial_2_inset.png) + + +### Receipt data + +We're about ready to start building the contents of the receipt, so we'll go ahead and give ourselves some data. + +We've defined a `Purchase` model below (complete with sample data). Don't feel compelled to type this, you can copy and paste this one. + +Create a new file called `Purchase.swift` (make sure it's part of `Tutorial 2`) and copy the following code into it: + +```swift +struct Purchase { + + var items: [Item] + + var subtotal: Double { + return items + .map { $0.price } + .reduce(0.0, +) + } + + var tax: Double { + return subtotal * 0.085 + } + + var total: Double { + return subtotal + tax + } + + struct Item { + var name: String + var price: Double + } + + static var sample: Purchase { + return Purchase(items: [ + Item(name: "Burger", price: 7.99), + Item(name: "Fries", price: 2.49), + Item(name: "Soda", price: 1.49) + ]) + } + +} +``` + +Next, add a property to `ReceiptElement` to hold a purchase to be displayed: + +```swift +struct ReceiptElement: ProxyElement { + + let purchase = Purchase.sample + + // ... +``` + +### Show the total on the receipt + +Receipts are typically arranged in a vertical stack of line items. We'll start simple and build a single row first, in which we will display the purchase total. + +We'll do this with a new element: + +```swift +struct LineItemElement: ProxyElement { + + var elementRepresentation: Element { + // TODO + } + +} +``` + +Line items on a receipt show text on one side, and a price on the other side. Let's add a couple of properties to `LineItemElement` to make sure we have the values that we will be displaying. + +```swift +struct LineItemElement: ProxyElement { + + var title: String + var price: Double + + var elementRepresentation: Element { + // TODO + } + +} +``` + +Now that we have content to show, we need to configure the elements that will actually show that content. We know that we want the title to appear on the left and the price to appear on the right. Both pieces of information should be aligned vertically. + +This is a great time to use one of the most common Blueprint elements: `Row`. + +`Row` arranges (stacks) its children along the horizontal axis. + +```swift +struct LineItemElement: ProxyElement { + + var title: String + var price: Double + + var elementRepresentation: Element { + return Row { row in + + row.horizontalUnderflow = .spaceEvenly + + var titleLabel = Label(text: title) + row.add(child: titleLabel) + + let formatter = NumberFormatter() + formatter.numberStyle = .currency + let formattedPrice = formatter.string(from: NSNumber(value: price)) ?? "" + + var priceLabel = Label(text: formattedPrice) + row.add(child: priceLabel) + + } + } + +} +``` + +Notice how we create a row: its initializer takes a single closure parameter, which is used to configure the row. This is useful because rows can have multiple children, so it would be very awkward to try to pass everything in to the initializer at once. + + +Let's go back to `ReceiptElement`. + +We'll now replace the "Hello, World" label with a `LineItemElement`: + +```swift +struct ReceiptElement: ProxyElement { + + var elementRepresentation: Element { + let totalItem = LineItemElement( + title: "Total", + price: purchase.total) + + var scrollView = ScrollView(wrapping: totalItem) + scrollView.contentSize = .fittingHeight // Stretches content to fill width, but sizes height to fit + scrollView.alwaysBounceVertical = true + return scrollView + } + +} +``` + +Run `Tutorial 2`, and you should see the receipt total! + +![Receipt with total](./tutorial_2_line_item.png) + +### Show items on the receipt + +We can now show a single line item at a time. To show all of the items on the receipt, however, we'll need to vertically stack multiple line items. + +While the `Row` element that we used to implement `LineItemElement` stacks elements horizontally, `Column` is an element that stacks them vertically. + +We will use a `Column` to contain all of the line items that we ultimately want to show. + +Update `ReceiptElement` to wrap the line item in a `Column`: + +```swift +struct ReceiptElement: ProxyElement { + + let purchase = Purchase.sample + + var elementRepresentation: Element { + let column = Column { col in + + col.minimumVerticalSpacing = 16.0 + col.horizontalAlignment = .fill + + col.add( + child: LineItemElement( + title: "Total", + price: purchase.total)) + } + + let inset = Inset( + wrapping: column, + uniformInset: 24.0) + + var scrollView = ScrollView(wrapping: inset) + scrollView.contentSize = .fittingHeight + scrollView.alwaysBounceVertical = true + return scrollView + } + +} +``` + +If you were to run the app at this point, it wouldn't look any different from last time. We're now ready to add more line items, however. Let's add subtotal and tax: + + +```swift +struct ReceiptElement: ProxyElement { + + let purchase = Purchase.sample + + var elementRepresentation: Element { + let column = Column { col in + + col.minimumVerticalSpacing = 16.0 + col.horizontalAlignment = .fill + + col.add( + child: LineItemElement( + title: "Subtotal", + price: purchase.subtotal)) + + col.add( + child: LineItemElement( + title: "Tax", + price: purchase.tax)) + + col.add( + child: LineItemElement( + title: "Total", + price: purchase.total)) + } + + let inset = Inset( + wrapping: column, + uniformInset: 24.0) + + var scrollView = ScrollView(wrapping: inset) + scrollView.contentSize = .fittingHeight + scrollView.alwaysBounceVertical = true + return scrollView + } + +} +``` + +Run `Tutorial 2`, and you should see three separate line items, stacked vertically. + +![Receipt with total, subtotal, and tax](./tutorial_2_total_subtotal_tax.png) + + +### Showing items + +We now have everything that we need to show all of the items from the purchase as well. + +In `ReceiptElement`, we'll iterate over all of the items in `purchase.item` and add a line item for each: + +```swift +struct ReceiptElement: ProxyElement { + + let purchase = Purchase.sample + + var elementRepresentation: Element { + let column = Column { col in + + col.minimumVerticalSpacing = 16.0 + col.horizontalAlignment = .fill + + for item in purchase.items { + col.add( + child: LineItemElement( + title: item.name, + price: item.price)) + } + + col.add( + child: LineItemElement( + title: "Subtotal", + price: purchase.subtotal)) + + col.add( + child: LineItemElement( + title: "Tax", + price: purchase.tax)) + + col.add( + child: LineItemElement( + title: "Total", + price: purchase.total)) + } + + let inset = Inset( + wrapping: column, + uniformInset: 24.0) + + var scrollView = ScrollView(wrapping: inset) + scrollView.contentSize = .fittingHeight + scrollView.alwaysBounceVertical = true + return scrollView + } + +} +``` + +### Adding a horizontal rule + +If you were to run the app right now, you might notice that it's hard to tell where the items stop and the subtotal/tax/total start. We can improve legibility by adding a horizontal rule. + +We'll define `RuleElement` like this: + +```swift +import BlueprintUI +import BlueprintUICommonControls + +struct RuleElement: ProxyElement { + var elementRepresentation: Element { + return ConstrainedSize( + wrapping: Box(backgroundColor: .black), + height: .absolute(1.0)) + } +} +``` + +We use a `Box` so that we can specify a background color. + +We then wrap the box in a `ConstrainedSize`. We leave the width untouched, but we constrain the height to always require exactly 1 point. + +We can then add the rule in between the items and the extra info inside `ReceiptElement`: + +```swift +// ... + +for item in purchase.items { + col.add( + child: LineItemElement( + title: item.name, + price: item.price)) +} + +col.add(child: RuleElement()) + +col.add( + child: LineItemElement( + title: "Subtotal", + price: purchase.subtotal)) + +// ... +``` + +### Styling + +Let's add some text styles to introduce visual contrast to our line items. + +We want the total line to appear heavier than the rest of the items, so we can add an `enum` to model the different styles that `LineItemElement` should support: + +```swift +extension LineItemElement { + enum Style { + case regular + case bold + } +} +``` + +We'll extend that `Style` enum to provide fonts and colors for the title and price labels: + +```swift +extension LineItemElement { + + enum Style { + case regular + case bold + + fileprivate var titleFont: UIFont { + switch self { + case .regular: return .systemFont(ofSize: 18.0) + case .bold: return .boldSystemFont(ofSize: 18.0) + } + } + + fileprivate var titleColor: UIColor { + switch self { + case .regular: return .gray + case .bold: return .black + } + } + + fileprivate var priceFont: UIFont { + switch self { + case .regular: return .systemFont(ofSize: 18.0) + case .bold: return .boldSystemFont(ofSize: 18.0) + } + } + + fileprivate var priceColor: UIColor { + switch self { + case .regular: return .black + case .bold: return .black + } + } + + } + +} +``` + +Add a `style` property to `LineItemElement`, then update its implementation to use the style: + +```swift +struct LineItemElement: ProxyElement { + + var style: Style + var title: String + var price: Double + + var elementRepresentation: Element { + return Row { row in + + row.horizontalUnderflow = .spaceEvenly + + var titleLabel = Label(text: title) + titleLabel.font = style.titleFont + titleLabel.color = style.titleColor + row.add(child: titleLabel) + + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.locale = .current + let formattedPrice = formatter.string(from: NSNumber(value: price))! + + var priceLabel = Label(text: formattedPrice) + priceLabel.font = style.priceFont + priceLabel.color = style.priceColor + row.add(child: priceLabel) + + } + } + +} +``` + +Finally, update `ReceiptElement` to pass in the correct style for each line item: `.regular` for everything except the total, whould should receive `.bold`. + +```swift +struct ReceiptElement: ProxyElement { + + let purchase = Purchase.sample + + var elementRepresentation: Element { + let column = Column { col in + col.minimumVerticalSpacing = 16.0 + col.horizontalAlignment = .fill + + for item in purchase.items { + col.add( + child: LineItemElement( + style: .regular, + title: item.name, + price: item.price)) + } + + // Add a rule below all of the line items + col.add(child: RuleElement()) + + // Totals + col.add( + child: LineItemElement( + style: .regular, + title: "Subtotal", + price: purchase.subtotal)) + + + col.add( + child: LineItemElement( + style: .regular, + title: "Tax", + price: purchase.tax)) + + col.add( + child: LineItemElement( + style: .bold, + title: "Total", + price: purchase.total)) + } + + let inset = Inset( + wrapping: column, + uniformInset: 24.0) + + var scrollView = ScrollView(wrapping: inset) + scrollView.contentSize = .fittingHeight + scrollView.alwaysBounceVertical = true + return scrollView + } + +} +``` + +Run `Tutorial 2` in the simulator. + +![Tutorial 2 complete](./tutorial_2_complete.png) + +The receipt is now complete! Experiment with adding more items, modifying the scroll view, or changing any of the other elements that we created along the way. \ No newline at end of file diff --git a/Documentation/Tutorials/tutorial_1_complete.png b/Documentation/Tutorials/tutorial_1_complete.png new file mode 100644 index 000000000..69f66e261 Binary files /dev/null and b/Documentation/Tutorials/tutorial_1_complete.png differ diff --git a/Documentation/Tutorials/tutorial_1_red.png b/Documentation/Tutorials/tutorial_1_red.png new file mode 100644 index 000000000..1f302e648 Binary files /dev/null and b/Documentation/Tutorials/tutorial_1_red.png differ diff --git a/Documentation/Tutorials/tutorial_2_complete.png b/Documentation/Tutorials/tutorial_2_complete.png new file mode 100644 index 000000000..bcd243cb0 Binary files /dev/null and b/Documentation/Tutorials/tutorial_2_complete.png differ diff --git a/Documentation/Tutorials/tutorial_2_inset.png b/Documentation/Tutorials/tutorial_2_inset.png new file mode 100644 index 000000000..a86cec5c8 Binary files /dev/null and b/Documentation/Tutorials/tutorial_2_inset.png differ diff --git a/Documentation/Tutorials/tutorial_2_line_item.png b/Documentation/Tutorials/tutorial_2_line_item.png new file mode 100644 index 000000000..a252497a5 Binary files /dev/null and b/Documentation/Tutorials/tutorial_2_line_item.png differ diff --git a/Documentation/Tutorials/tutorial_2_scroll.png b/Documentation/Tutorials/tutorial_2_scroll.png new file mode 100644 index 000000000..fce94f6fa Binary files /dev/null and b/Documentation/Tutorials/tutorial_2_scroll.png differ diff --git a/Documentation/Tutorials/tutorial_2_total_subtotal_tax.png b/Documentation/Tutorials/tutorial_2_total_subtotal_tax.png new file mode 100644 index 000000000..c60ab6008 Binary files /dev/null and b/Documentation/Tutorials/tutorial_2_total_subtotal_tax.png differ diff --git a/Gemfile b/Gemfile new file mode 100644 index 000000000..ef23b5e3f --- /dev/null +++ b/Gemfile @@ -0,0 +1,4 @@ +source 'https://rubygems.org' + +gem 'cocoapods', '1.7.0.beta.2' +gem 'cocoapods-generate', '~> 1.0' diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 000000000..fbe80d9f2 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,78 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.0) + activesupport (4.2.11.1) + i18n (~> 0.7) + minitest (~> 5.1) + thread_safe (~> 0.3, >= 0.3.4) + tzinfo (~> 1.1) + atomos (0.1.3) + claide (1.0.2) + cocoapods (1.7.0.beta.2) + activesupport (>= 4.0.2, < 5) + claide (>= 1.0.2, < 2.0) + cocoapods-core (= 1.7.0.beta.2) + cocoapods-deintegrate (>= 1.0.3, < 2.0) + cocoapods-downloader (>= 1.2.2, < 2.0) + cocoapods-plugins (>= 1.0.0, < 2.0) + cocoapods-search (>= 1.0.0, < 2.0) + cocoapods-stats (>= 1.0.0, < 2.0) + cocoapods-trunk (>= 1.3.1, < 2.0) + cocoapods-try (>= 1.1.0, < 2.0) + colored2 (~> 3.1) + escape (~> 0.0.4) + fourflusher (>= 2.2.0, < 3.0) + gh_inspector (~> 1.0) + molinillo (~> 0.6.6) + nap (~> 1.0) + ruby-macho (~> 1.4) + xcodeproj (>= 1.8.1, < 2.0) + cocoapods-core (1.7.0.beta.2) + activesupport (>= 4.0.2, < 6) + fuzzy_match (~> 2.0.4) + nap (~> 1.0) + cocoapods-deintegrate (1.0.3) + cocoapods-downloader (1.2.2) + cocoapods-generate (1.4.0) + cocoapods-plugins (1.0.0) + nap + cocoapods-search (1.0.0) + cocoapods-stats (1.1.0) + cocoapods-trunk (1.3.1) + nap (>= 0.8, < 2.0) + netrc (~> 0.11) + cocoapods-try (1.1.0) + colored2 (3.1.2) + concurrent-ruby (1.1.5) + escape (0.0.4) + fourflusher (2.2.0) + fuzzy_match (2.0.4) + gh_inspector (1.1.3) + i18n (0.9.5) + concurrent-ruby (~> 1.0) + minitest (5.11.3) + molinillo (0.6.6) + nanaimo (0.2.6) + nap (1.1.0) + netrc (0.11.0) + ruby-macho (1.4.0) + thread_safe (0.3.6) + tzinfo (1.2.5) + thread_safe (~> 0.1) + xcodeproj (1.8.1) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.2.6) + +PLATFORMS + ruby + +DEPENDENCIES + cocoapods (= 1.7.0.beta.2) + cocoapods-generate (~> 1.0) + +BUNDLED WITH + 2.0.1 diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/Podfile b/Podfile new file mode 100644 index 000000000..d081d638d --- /dev/null +++ b/Podfile @@ -0,0 +1,29 @@ +platform :ios, '12.0' +inhibit_all_warnings! + +project 'SampleApp/SampleApp.xcodeproj' + +def blueprint_pods + pod 'BlueprintUI', :path => './BlueprintUI.podspec', :testspecs => ['Tests'] + pod 'BlueprintUICommonControls', :path => './BlueprintUICommonControls.podspec', :testspecs => ['SnapshotTests'] +end + +target 'SampleApp' do + blueprint_pods +end + +target 'Tutorial 1' do + blueprint_pods +end + +target 'Tutorial 1 (Completed)' do + blueprint_pods +end + +target 'Tutorial 2' do + blueprint_pods +end + +target 'Tutorial 2 (Completed)' do + blueprint_pods +end diff --git a/Podfile.lock b/Podfile.lock new file mode 100644 index 000000000..9466bcfd7 --- /dev/null +++ b/Podfile.lock @@ -0,0 +1,34 @@ +PODS: + - BlueprintUI (0.1.0) + - BlueprintUI/Tests (0.1.0) + - BlueprintUICommonControls (0.1.0): + - BlueprintUI + - BlueprintUICommonControls/SnapshotTests (0.1.0): + - BlueprintUI + - SnapshotTesting (~> 1.3) + - SnapshotTesting (1.3.0) + +DEPENDENCIES: + - BlueprintUI (from `./BlueprintUI.podspec`) + - BlueprintUI/Tests (from `./BlueprintUI.podspec`) + - BlueprintUICommonControls (from `./BlueprintUICommonControls.podspec`) + - BlueprintUICommonControls/SnapshotTests (from `./BlueprintUICommonControls.podspec`) + +SPEC REPOS: + https://github.com/cocoapods/specs.git: + - SnapshotTesting + +EXTERNAL SOURCES: + BlueprintUI: + :path: "./BlueprintUI.podspec" + BlueprintUICommonControls: + :path: "./BlueprintUICommonControls.podspec" + +SPEC CHECKSUMS: + BlueprintUI: f4bfb888ae3da230b7499fc615e0f9cf5077354f + BlueprintUICommonControls: 0469ba2b1f1aa51656170637a85026acb235f825 + SnapshotTesting: d4bd40afcf104af96bce7b84f98084fdd817396a + +PODFILE CHECKSUM: ac9879b66f47ce48b091deb12779b38eee8d3618 + +COCOAPODS: 1.7.0.beta.2 diff --git a/README.md b/README.md new file mode 100644 index 000000000..8dd9aee88 --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# Blueprint + +### Declarative UI construction for iOS, written in Swift + +Blueprint greatly simplifies the task of building and updating views as application state changes. + +*We still consider Blueprint experimental (and subject to major breaking API changes), but it has been used within Square's production iOS apps.* + +```swift +let rootElement = Label(text: "Hello from Blueprint!") +let view = BlueprintView(element: rootElement) +``` + +--- + +##### What does this library do? + +Blueprint provides an architecture that allows you to: +- Declaratively define a UI hierarchy as pure values (Swift structs and enums). +- Display that hierarchy within your application. +- Update that hierarchy as application state changes (including animated transitions). + +##### When should I use it? + +Use Blueprint any time you want to display a view hierarchy, but don't want to manage view lifecycle (hint: managing view lifecycle is a large portion of most conventional UIKit code). There are times when you *want* to manage view lifecycle (complex animations and transitions are a good example), and for these cases you may want to stick with a conventional approach. + +##### How does it interact with `UIKit`? + +Blueprint is not a replacement for UIKit! From the beginning, Blueprint has been designed as a compliment to all of the powerful tools that come with the platform. You can use Blueprint to manage the display of a single view controller, or of a single view representing a small part of the screen. Likewise, it's straightforward to host standard views and controls *within* a blueprint hierarchy, always leaving you with an escape hatch. + +--- + +## Documentation + +#### Getting Started + +1. **[Hello, World](Documentation/GettingStarted/HelloWorld.md)** + +1. **[The Element Hierarchy](Documentation/GettingStarted/ElementHierarchy.md)** + +1. **[Building Custom Elements](Documentation/GettingStarted/CustomElements.md)** + +1. **[Layout](Documentation/GettingStarted/Layout.md)** + + +#### Reference + +1. **[`Element`](Documentation/Reference/Element.md)** + +1. **[`BlueprintView`](Documentation/Reference/BlueprintView.md)** + +1. **[`ViewDescription`](Documentation/Reference/ViewDescription.md)** + +1. **[Transitions](Documentation/Reference/Transitions.md)** + + +#### Tutorials + +[Tutorial setup instructions](Documentation/Tutorials/Setup.md) + +1. **[Using Blueprint in a View Controller](Documentation/Tutorials/Tutorial1.md)** + +1. **[Building a receipt layout with Blueprint](Documentation/Tutorials/Tutorial2.md)** + +--- + +## Adding Blueprint to an existing project + +Two modules are provided: +- **`Blueprint`** contains the core architecture and layout types. +- **`BlueprintUICommonControls`** includes elements representing some common `UIKit` views and controls. + +Blueprint is available via CocoaPods. Add it to your `Podfile` to integrate: + +```ruby +target MyTarget do + pod 'BlueprintUI' + pod 'BlueprintUICommonControls' +end +``` + +--- + +[Release instructions](./RELEASING.md) + +--- + +Copyright 2019 Square, Inc. + +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. diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 000000000..ce1cded52 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,22 @@ +# Releasing a new version + +1. Make sure you're on the `master` branch + +1. Make sure that the library version in both `BlueprintUI.podspec` and `BlueprintUICommonControls.podspec` is correct (it should match the version number that you are about to release). + +1. Create a commit and tag the commit with the version number + ```bash + git commit -am "Releasing 0.1.0." + git tag 0.1.0 + ``` + +1. Push your changes + ```bash + git push origin master && git push origin 0.1.0 + ``` + +1. Publish to CocoaPods + ```bash + bundle exec pod trunk push BlueprintUI.podspec + bundle exec pod trunk push BlueprintUICommonControls.podspec + ``` \ No newline at end of file diff --git a/SampleApp/Podfile.lock b/SampleApp/Podfile.lock new file mode 100644 index 000000000..e7f847c59 --- /dev/null +++ b/SampleApp/Podfile.lock @@ -0,0 +1,28 @@ +PODS: + - Blueprint (0.1.0) + - BlueprintUICommonControls (0.1.0): + - Blueprint + - BlueprintLayout (0.1.0): + - Blueprint + +DEPENDENCIES: + - Blueprint (from `../Blueprint.podspec`) + - BlueprintUICommonControls (from `../BlueprintUICommonControls.podspec`) + - BlueprintLayout (from `../BlueprintLayout.podspec`) + +EXTERNAL SOURCES: + Blueprint: + :path: "../Blueprint.podspec" + BlueprintUICommonControls: + :path: "../BlueprintUICommonControls.podspec" + BlueprintLayout: + :path: "../BlueprintLayout.podspec" + +SPEC CHECKSUMS: + Blueprint: 1005095cbaa8f592a9102292ba22932b49c91579 + BlueprintUICommonControls: daf324024a1bda0b798f584b24778d8278e9b849 + BlueprintLayout: e8f5c09041c642913fa88795a4ac2889d96e6629 + +PODFILE CHECKSUM: ceace49132d366eec8755b3cce4d8204b21e6415 + +COCOAPODS: 1.7.0.beta.2 diff --git a/SampleApp/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/SampleApp/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..d8db8d65f --- /dev/null +++ b/SampleApp/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "3x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "83.5x83.5", + "scale" : "2x" + }, + { + "idiom" : "ios-marketing", + "size" : "1024x1024", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SampleApp/Resources/Assets.xcassets/Contents.json b/SampleApp/Resources/Assets.xcassets/Contents.json new file mode 100644 index 000000000..da4a164c9 --- /dev/null +++ b/SampleApp/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SampleApp/Resources/Base.lproj/LaunchScreen.storyboard b/SampleApp/Resources/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 000000000..bfa361294 --- /dev/null +++ b/SampleApp/Resources/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SampleApp/Resources/Info.plist b/SampleApp/Resources/Info.plist new file mode 100644 index 000000000..4222ac2dd --- /dev/null +++ b/SampleApp/Resources/Info.plist @@ -0,0 +1,43 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/SampleApp/SampleApp.xcodeproj/project.pbxproj b/SampleApp/SampleApp.xcodeproj/project.pbxproj new file mode 100644 index 000000000..b0c32478d --- /dev/null +++ b/SampleApp/SampleApp.xcodeproj/project.pbxproj @@ -0,0 +1,964 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 50; + objects = { + +/* Begin PBXBuildFile section */ + 0BF0F09E709B907FEBB9151A /* libPods-Tutorial 2.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 677CAC0F5C43DEB48D008314 /* libPods-Tutorial 2.a */; }; + 38404DA60F4D86EDB143A804 /* libPods-SampleApp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 657BACD5084F5F777389E373 /* libPods-SampleApp.a */; }; + 41DF9D1A7FCDBD28E6A76331 /* libPods-Tutorial 1 (Completed).a in Frameworks */ = {isa = PBXBuildFile; fileRef = 2A9C0EC0A18BC6CE78F8E747 /* libPods-Tutorial 1 (Completed).a */; }; + 68E4B3FC6122A5CDB8793F63 /* (null) in Frameworks */ = {isa = PBXBuildFile; }; + 77E0E6A12C3CFC84CAB148C8 /* libPods-Tutorial 1.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 1632153721990210BC2F08E1 /* libPods-Tutorial 1.a */; }; + 7B135EA8D745E6E92DE5CD50 /* libPods-SampleApp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 657BACD5084F5F777389E373 /* libPods-SampleApp.a */; }; + 97545519223C12E9003E353F /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97545510223C12E9003E353F /* ViewController.swift */; }; + 9754551A223C12E9003E353F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97545511223C12E9003E353F /* AppDelegate.swift */; }; + 9754551B223C12E9003E353F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97545513223C12E9003E353F /* Assets.xcassets */; }; + 9754551C223C12E9003E353F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97545514223C12E9003E353F /* LaunchScreen.storyboard */; }; + 9796EC42224D1D2000E729F3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97545513223C12E9003E353F /* Assets.xcassets */; }; + 9796EC43224D1D2000E729F3 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97545514223C12E9003E353F /* LaunchScreen.storyboard */; }; + 9796EC4A224D1D5B00E729F3 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9796EC49224D1D5A00E729F3 /* ViewController.swift */; }; + 9796EC4B224D1D6000E729F3 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9796EC39224D1CFE00E729F3 /* AppDelegate.swift */; }; + 9796EC5A224DB3BF00E729F3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97545513223C12E9003E353F /* Assets.xcassets */; }; + 9796EC5B224DB3BF00E729F3 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97545514223C12E9003E353F /* LaunchScreen.storyboard */; }; + 9796EC69224DB3C500E729F3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97545513223C12E9003E353F /* Assets.xcassets */; }; + 9796EC6A224DB3C500E729F3 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97545514223C12E9003E353F /* LaunchScreen.storyboard */; }; + 9796EC70224DB3DF00E729F3 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9796EC50224DB3B500E729F3 /* ViewController.swift */; }; + 9796EC71224DB3DF00E729F3 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9796EC51224DB3B500E729F3 /* AppDelegate.swift */; }; + 9796EC72224DB3E300E729F3 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9796EC4D224DB3B500E729F3 /* ViewController.swift */; }; + 9796EC73224DB3E300E729F3 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9796EC4E224DB3B500E729F3 /* AppDelegate.swift */; }; + 9796EC75224DB41500E729F3 /* ReceiptElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9796EC74224DB41500E729F3 /* ReceiptElement.swift */; }; + 9796EC7A224DB90B00E729F3 /* LineItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9796EC79224DB90B00E729F3 /* LineItemElement.swift */; }; + 9796EC7C224DBE0000E729F3 /* RuleElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9796EC7B224DBE0000E729F3 /* RuleElement.swift */; }; + 9796EC80224DD67900E729F3 /* Purchase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9796EC7F224DD67900E729F3 /* Purchase.swift */; }; + 979F49ED224D1BD300A3C5D4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97545513223C12E9003E353F /* Assets.xcassets */; }; + 979F49EE224D1BD300A3C5D4 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97545514223C12E9003E353F /* LaunchScreen.storyboard */; }; + 979F49F4224D1BF200A3C5D4 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 979F49E4224D1B6C00A3C5D4 /* AppDelegate.swift */; }; + ED702DDED74A980DC2F69551 /* libPods-Tutorial 2 (Completed).a in Frameworks */ = {isa = PBXBuildFile; fileRef = AA71354E6E995DD148F595D1 /* libPods-Tutorial 2 (Completed).a */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 0DA29F056002872418F7D2C9 /* Pods-SampleApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SampleApp.debug.xcconfig"; path = "../../Pods/Target Support Files/Pods-SampleApp/Pods-SampleApp.debug.xcconfig"; sourceTree = ""; }; + 1632153721990210BC2F08E1 /* libPods-Tutorial 1.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Tutorial 1.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 1A147478D522E763BFC19F37 /* Pods-Tutorial 2 (Completed).debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Tutorial 2 (Completed).debug.xcconfig"; path = "../../Pods/Target Support Files/Pods-Tutorial 2 (Completed)/Pods-Tutorial 2 (Completed).debug.xcconfig"; sourceTree = ""; }; + 2A9C0EC0A18BC6CE78F8E747 /* libPods-Tutorial 1 (Completed).a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Tutorial 1 (Completed).a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 2C24AF26BD9F959834BD0A25 /* Pods-Tutorial1.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Tutorial1.debug.xcconfig"; path = "../../Pods/Target Support Files/Pods-Tutorial1/Pods-Tutorial1.debug.xcconfig"; sourceTree = ""; }; + 2F2123B124F06446978E629D /* Pods-SampleApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SampleApp.release.xcconfig"; path = "../../Pods/Target Support Files/Pods-SampleApp/Pods-SampleApp.release.xcconfig"; sourceTree = ""; }; + 657BACD5084F5F777389E373 /* libPods-SampleApp.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-SampleApp.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 677CAC0F5C43DEB48D008314 /* libPods-Tutorial 2.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Tutorial 2.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 6D524EC9EF929FCE2F54EC85 /* Pods-Tutorial1.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Tutorial1.release.xcconfig"; path = "../../Pods/Target Support Files/Pods-Tutorial1/Pods-Tutorial1.release.xcconfig"; sourceTree = ""; }; + 70E2DBB7A375C527D66B2643 /* Pods-Tutorial 1.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Tutorial 1.debug.xcconfig"; path = "../../Pods/Target Support Files/Pods-Tutorial 1/Pods-Tutorial 1.debug.xcconfig"; sourceTree = ""; }; + 7A5624391C38E617246C4356 /* Pods-Tutorial 2.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Tutorial 2.debug.xcconfig"; path = "../../Pods/Target Support Files/Pods-Tutorial 2/Pods-Tutorial 2.debug.xcconfig"; sourceTree = ""; }; + 7EA5EE31421ECA2CA9457450 /* Pods-Tutorial 2.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Tutorial 2.release.xcconfig"; path = "../../Pods/Target Support Files/Pods-Tutorial 2/Pods-Tutorial 2.release.xcconfig"; sourceTree = ""; }; + 8501C9E99E00354D89175E65 /* Pods-Tutorial 1.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Tutorial 1.release.xcconfig"; path = "../../Pods/Target Support Files/Pods-Tutorial 1/Pods-Tutorial 1.release.xcconfig"; sourceTree = ""; }; + 975454FA223C1289003E353F /* SampleApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SampleApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97545510223C12E9003E353F /* ViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; + 97545511223C12E9003E353F /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 97545513223C12E9003E353F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97545515223C12E9003E353F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97545518223C12E9003E353F /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 9796EC39224D1CFE00E729F3 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 9796EC47224D1D2000E729F3 /* Tutorial 1 (Completed).app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Tutorial 1 (Completed).app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 9796EC49224D1D5A00E729F3 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; + 9796EC4D224DB3B500E729F3 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; + 9796EC4E224DB3B500E729F3 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 9796EC50224DB3B500E729F3 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; + 9796EC51224DB3B500E729F3 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 9796EC5F224DB3BF00E729F3 /* Tutorial 2.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Tutorial 2.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 9796EC6E224DB3C500E729F3 /* Tutorial 2 (Completed).app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Tutorial 2 (Completed).app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 9796EC74224DB41500E729F3 /* ReceiptElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptElement.swift; sourceTree = ""; }; + 9796EC79224DB90B00E729F3 /* LineItemElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineItemElement.swift; sourceTree = ""; }; + 9796EC7B224DBE0000E729F3 /* RuleElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuleElement.swift; sourceTree = ""; }; + 9796EC7F224DD67900E729F3 /* Purchase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Purchase.swift; sourceTree = ""; }; + 979F49E4224D1B6C00A3C5D4 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 979F49F2224D1BD300A3C5D4 /* Tutorial 1.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Tutorial 1.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 9D59D92E955A10C9F7EF0730 /* Pods-Tutorial 2 (Completed).release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Tutorial 2 (Completed).release.xcconfig"; path = "../../Pods/Target Support Files/Pods-Tutorial 2 (Completed)/Pods-Tutorial 2 (Completed).release.xcconfig"; sourceTree = ""; }; + AA71354E6E995DD148F595D1 /* libPods-Tutorial 2 (Completed).a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Tutorial 2 (Completed).a"; sourceTree = BUILT_PRODUCTS_DIR; }; + CC5FC85BE57034BE39916388 /* Pods-Tutorial 1 (Completed).debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Tutorial 1 (Completed).debug.xcconfig"; path = "../../Pods/Target Support Files/Pods-Tutorial 1 (Completed)/Pods-Tutorial 1 (Completed).debug.xcconfig"; sourceTree = ""; }; + F211AAFE0FF4DC614FC65630 /* Pods-Tutorial 1 (Completed).release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Tutorial 1 (Completed).release.xcconfig"; path = "../../Pods/Target Support Files/Pods-Tutorial 1 (Completed)/Pods-Tutorial 1 (Completed).release.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 975454F7223C1289003E353F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 38404DA60F4D86EDB143A804 /* libPods-SampleApp.a in Frameworks */, + 7B135EA8D745E6E92DE5CD50 /* libPods-SampleApp.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 9796EC3F224D1D2000E729F3 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 68E4B3FC6122A5CDB8793F63 /* (null) in Frameworks */, + 41DF9D1A7FCDBD28E6A76331 /* libPods-Tutorial 1 (Completed).a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 9796EC57224DB3BF00E729F3 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 0BF0F09E709B907FEBB9151A /* libPods-Tutorial 2.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 9796EC66224DB3C500E729F3 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ED702DDED74A980DC2F69551 /* libPods-Tutorial 2 (Completed).a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 979F49EA224D1BD300A3C5D4 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 77E0E6A12C3CFC84CAB148C8 /* libPods-Tutorial 1.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 69943C07C9B6B3DCFC875540 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 657BACD5084F5F777389E373 /* libPods-SampleApp.a */, + 1632153721990210BC2F08E1 /* libPods-Tutorial 1.a */, + 677CAC0F5C43DEB48D008314 /* libPods-Tutorial 2.a */, + AA71354E6E995DD148F595D1 /* libPods-Tutorial 2 (Completed).a */, + 2A9C0EC0A18BC6CE78F8E747 /* libPods-Tutorial 1 (Completed).a */, + ); + name = Frameworks; + sourceTree = ""; + }; + 975454F1223C1289003E353F = { + isa = PBXGroup; + children = ( + 97545512223C12E9003E353F /* Resources */, + 9754550F223C12E9003E353F /* Sources */, + 979F49E1224D1B4500A3C5D4 /* Tutorials */, + 975454FB223C1289003E353F /* Products */, + BC66766C3ED64DAEF49CBABD /* Pods */, + 69943C07C9B6B3DCFC875540 /* Frameworks */, + ); + sourceTree = ""; + }; + 975454FB223C1289003E353F /* Products */ = { + isa = PBXGroup; + children = ( + 975454FA223C1289003E353F /* SampleApp.app */, + 979F49F2224D1BD300A3C5D4 /* Tutorial 1.app */, + 9796EC47224D1D2000E729F3 /* Tutorial 1 (Completed).app */, + 9796EC5F224DB3BF00E729F3 /* Tutorial 2.app */, + 9796EC6E224DB3C500E729F3 /* Tutorial 2 (Completed).app */, + ); + name = Products; + sourceTree = ""; + }; + 9754550F223C12E9003E353F /* Sources */ = { + isa = PBXGroup; + children = ( + 97545510223C12E9003E353F /* ViewController.swift */, + 97545511223C12E9003E353F /* AppDelegate.swift */, + ); + path = Sources; + sourceTree = ""; + }; + 97545512223C12E9003E353F /* Resources */ = { + isa = PBXGroup; + children = ( + 97545513223C12E9003E353F /* Assets.xcassets */, + 97545514223C12E9003E353F /* LaunchScreen.storyboard */, + 97545518223C12E9003E353F /* Info.plist */, + ); + path = Resources; + sourceTree = ""; + }; + 9796EC38224D1CFD00E729F3 /* Tutorial 1 (Completed) */ = { + isa = PBXGroup; + children = ( + 9796EC39224D1CFE00E729F3 /* AppDelegate.swift */, + 9796EC49224D1D5A00E729F3 /* ViewController.swift */, + ); + path = "Tutorial 1 (Completed)"; + sourceTree = ""; + }; + 9796EC4C224DB3B500E729F3 /* Tutorial 2 (Completed) */ = { + isa = PBXGroup; + children = ( + 9796EC7F224DD67900E729F3 /* Purchase.swift */, + 9796EC4D224DB3B500E729F3 /* ViewController.swift */, + 9796EC4E224DB3B500E729F3 /* AppDelegate.swift */, + 9796EC74224DB41500E729F3 /* ReceiptElement.swift */, + 9796EC79224DB90B00E729F3 /* LineItemElement.swift */, + 9796EC7B224DBE0000E729F3 /* RuleElement.swift */, + ); + path = "Tutorial 2 (Completed)"; + sourceTree = ""; + }; + 9796EC4F224DB3B500E729F3 /* Tutorial 2 */ = { + isa = PBXGroup; + children = ( + 9796EC50224DB3B500E729F3 /* ViewController.swift */, + 9796EC51224DB3B500E729F3 /* AppDelegate.swift */, + ); + path = "Tutorial 2"; + sourceTree = ""; + }; + 979F49E1224D1B4500A3C5D4 /* Tutorials */ = { + isa = PBXGroup; + children = ( + 979F49E2224D1B4F00A3C5D4 /* Tutorial1 */, + 9796EC38224D1CFD00E729F3 /* Tutorial 1 (Completed) */, + 9796EC4F224DB3B500E729F3 /* Tutorial 2 */, + 9796EC4C224DB3B500E729F3 /* Tutorial 2 (Completed) */, + ); + path = Tutorials; + sourceTree = ""; + }; + 979F49E2224D1B4F00A3C5D4 /* Tutorial1 */ = { + isa = PBXGroup; + children = ( + 979F49E4224D1B6C00A3C5D4 /* AppDelegate.swift */, + ); + name = Tutorial1; + path = "Tutorial 1"; + sourceTree = ""; + }; + BC66766C3ED64DAEF49CBABD /* Pods */ = { + isa = PBXGroup; + children = ( + 0DA29F056002872418F7D2C9 /* Pods-SampleApp.debug.xcconfig */, + 2F2123B124F06446978E629D /* Pods-SampleApp.release.xcconfig */, + 2C24AF26BD9F959834BD0A25 /* Pods-Tutorial1.debug.xcconfig */, + 6D524EC9EF929FCE2F54EC85 /* Pods-Tutorial1.release.xcconfig */, + 70E2DBB7A375C527D66B2643 /* Pods-Tutorial 1.debug.xcconfig */, + 8501C9E99E00354D89175E65 /* Pods-Tutorial 1.release.xcconfig */, + CC5FC85BE57034BE39916388 /* Pods-Tutorial 1 (Completed).debug.xcconfig */, + F211AAFE0FF4DC614FC65630 /* Pods-Tutorial 1 (Completed).release.xcconfig */, + 7A5624391C38E617246C4356 /* Pods-Tutorial 2.debug.xcconfig */, + 7EA5EE31421ECA2CA9457450 /* Pods-Tutorial 2.release.xcconfig */, + 1A147478D522E763BFC19F37 /* Pods-Tutorial 2 (Completed).debug.xcconfig */, + 9D59D92E955A10C9F7EF0730 /* Pods-Tutorial 2 (Completed).release.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 975454F9223C1289003E353F /* SampleApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 9754550C223C128B003E353F /* Build configuration list for PBXNativeTarget "SampleApp" */; + buildPhases = ( + 6DD21A891D7570215C38D645 /* [CP] Check Pods Manifest.lock */, + 975454F6223C1289003E353F /* Sources */, + 975454F7223C1289003E353F /* Frameworks */, + 975454F8223C1289003E353F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = SampleApp; + productName = SampleApp; + productReference = 975454FA223C1289003E353F /* SampleApp.app */; + productType = "com.apple.product-type.application"; + }; + 9796EC3B224D1D2000E729F3 /* Tutorial 1 (Completed) */ = { + isa = PBXNativeTarget; + buildConfigurationList = 9796EC44224D1D2000E729F3 /* Build configuration list for PBXNativeTarget "Tutorial 1 (Completed)" */; + buildPhases = ( + 9796EC3C224D1D2000E729F3 /* [CP] Check Pods Manifest.lock */, + 9796EC3D224D1D2000E729F3 /* Sources */, + 9796EC3F224D1D2000E729F3 /* Frameworks */, + 9796EC41224D1D2000E729F3 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "Tutorial 1 (Completed)"; + productName = SampleApp; + productReference = 9796EC47224D1D2000E729F3 /* Tutorial 1 (Completed).app */; + productType = "com.apple.product-type.application"; + }; + 9796EC52224DB3BF00E729F3 /* Tutorial 2 */ = { + isa = PBXNativeTarget; + buildConfigurationList = 9796EC5C224DB3BF00E729F3 /* Build configuration list for PBXNativeTarget "Tutorial 2" */; + buildPhases = ( + 9796EC53224DB3BF00E729F3 /* [CP] Check Pods Manifest.lock */, + 9796EC54224DB3BF00E729F3 /* Sources */, + 9796EC57224DB3BF00E729F3 /* Frameworks */, + 9796EC59224DB3BF00E729F3 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "Tutorial 2"; + productName = SampleApp; + productReference = 9796EC5F224DB3BF00E729F3 /* Tutorial 2.app */; + productType = "com.apple.product-type.application"; + }; + 9796EC61224DB3C500E729F3 /* Tutorial 2 (Completed) */ = { + isa = PBXNativeTarget; + buildConfigurationList = 9796EC6B224DB3C500E729F3 /* Build configuration list for PBXNativeTarget "Tutorial 2 (Completed)" */; + buildPhases = ( + 9796EC62224DB3C500E729F3 /* [CP] Check Pods Manifest.lock */, + 9796EC63224DB3C500E729F3 /* Sources */, + 9796EC66224DB3C500E729F3 /* Frameworks */, + 9796EC68224DB3C500E729F3 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "Tutorial 2 (Completed)"; + productName = SampleApp; + productReference = 9796EC6E224DB3C500E729F3 /* Tutorial 2 (Completed).app */; + productType = "com.apple.product-type.application"; + }; + 979F49E5224D1BD300A3C5D4 /* Tutorial 1 */ = { + isa = PBXNativeTarget; + buildConfigurationList = 979F49EF224D1BD300A3C5D4 /* Build configuration list for PBXNativeTarget "Tutorial 1" */; + buildPhases = ( + 5C44B0027B3C66DF83F44D0A /* [CP] Check Pods Manifest.lock */, + 979F49E7224D1BD300A3C5D4 /* Sources */, + 979F49EA224D1BD300A3C5D4 /* Frameworks */, + 979F49EC224D1BD300A3C5D4 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "Tutorial 1"; + productName = SampleApp; + productReference = 979F49F2224D1BD300A3C5D4 /* Tutorial 1.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 975454F2223C1289003E353F /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1010; + LastUpgradeCheck = 1010; + ORGANIZATIONNAME = Square; + TargetAttributes = { + 975454F9223C1289003E353F = { + CreatedOnToolsVersion = 10.1; + }; + }; + }; + buildConfigurationList = 975454F5223C1289003E353F /* Build configuration list for PBXProject "SampleApp" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 975454F1223C1289003E353F; + productRefGroup = 975454FB223C1289003E353F /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 975454F9223C1289003E353F /* SampleApp */, + 979F49E5224D1BD300A3C5D4 /* Tutorial 1 */, + 9796EC3B224D1D2000E729F3 /* Tutorial 1 (Completed) */, + 9796EC52224DB3BF00E729F3 /* Tutorial 2 */, + 9796EC61224DB3C500E729F3 /* Tutorial 2 (Completed) */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 975454F8223C1289003E353F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 9754551B223C12E9003E353F /* Assets.xcassets in Resources */, + 9754551C223C12E9003E353F /* LaunchScreen.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 9796EC41224D1D2000E729F3 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 9796EC42224D1D2000E729F3 /* Assets.xcassets in Resources */, + 9796EC43224D1D2000E729F3 /* LaunchScreen.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 9796EC59224DB3BF00E729F3 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 9796EC5A224DB3BF00E729F3 /* Assets.xcassets in Resources */, + 9796EC5B224DB3BF00E729F3 /* LaunchScreen.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 9796EC68224DB3C500E729F3 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 9796EC69224DB3C500E729F3 /* Assets.xcassets in Resources */, + 9796EC6A224DB3C500E729F3 /* LaunchScreen.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 979F49EC224D1BD300A3C5D4 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 979F49ED224D1BD300A3C5D4 /* Assets.xcassets in Resources */, + 979F49EE224D1BD300A3C5D4 /* LaunchScreen.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 5C44B0027B3C66DF83F44D0A /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Tutorial 1-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 6DD21A891D7570215C38D645 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-SampleApp-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 9796EC3C224D1D2000E729F3 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Tutorial 1 (Completed)-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 9796EC53224DB3BF00E729F3 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Tutorial 2-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 9796EC62224DB3C500E729F3 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Tutorial 2 (Completed)-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 975454F6223C1289003E353F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 9754551A223C12E9003E353F /* AppDelegate.swift in Sources */, + 97545519223C12E9003E353F /* ViewController.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 9796EC3D224D1D2000E729F3 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 9796EC4B224D1D6000E729F3 /* AppDelegate.swift in Sources */, + 9796EC4A224D1D5B00E729F3 /* ViewController.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 9796EC54224DB3BF00E729F3 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 9796EC71224DB3DF00E729F3 /* AppDelegate.swift in Sources */, + 9796EC70224DB3DF00E729F3 /* ViewController.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 9796EC63224DB3C500E729F3 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 9796EC7A224DB90B00E729F3 /* LineItemElement.swift in Sources */, + 9796EC73224DB3E300E729F3 /* AppDelegate.swift in Sources */, + 9796EC7C224DBE0000E729F3 /* RuleElement.swift in Sources */, + 9796EC72224DB3E300E729F3 /* ViewController.swift in Sources */, + 9796EC75224DB41500E729F3 /* ReceiptElement.swift in Sources */, + 9796EC80224DD67900E729F3 /* Purchase.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 979F49E7224D1BD300A3C5D4 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 979F49F4224D1BF200A3C5D4 /* AppDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 97545514223C12E9003E353F /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97545515223C12E9003E353F /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 9754550A223C128B003E353F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.1; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 9754550B223C128B003E353F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.1; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 9754550D223C128B003E353F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 0DA29F056002872418F7D2C9 /* Pods-SampleApp.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = "$(SRCROOT)/Resources/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.squareup.blueprint-sample-app.SampleApp"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 4.2; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 9754550E223C128B003E353F /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 2F2123B124F06446978E629D /* Pods-SampleApp.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = "$(SRCROOT)/Resources/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.squareup.blueprint-sample-app.SampleApp"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 4.2; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 9796EC45224D1D2000E729F3 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = CC5FC85BE57034BE39916388 /* Pods-Tutorial 1 (Completed).debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = "$(SRCROOT)/Resources/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.squareup.blueprint-sample-app.Tutorial1Completed"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 4.2; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 9796EC46224D1D2000E729F3 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F211AAFE0FF4DC614FC65630 /* Pods-Tutorial 1 (Completed).release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = "$(SRCROOT)/Resources/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.squareup.blueprint-sample-app.Tutorial1Completed"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 4.2; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 9796EC5D224DB3BF00E729F3 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7A5624391C38E617246C4356 /* Pods-Tutorial 2.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = "$(SRCROOT)/Resources/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.squareup.blueprint-sample-app.Tutorial2"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 4.2; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 9796EC5E224DB3BF00E729F3 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7EA5EE31421ECA2CA9457450 /* Pods-Tutorial 2.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = "$(SRCROOT)/Resources/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.squareup.blueprint-sample-app.Tutorial2"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 4.2; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 9796EC6C224DB3C500E729F3 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 1A147478D522E763BFC19F37 /* Pods-Tutorial 2 (Completed).debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = "$(SRCROOT)/Resources/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.squareup.blueprint-sample-app.Tutorial2Completed"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 4.2; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 9796EC6D224DB3C500E729F3 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9D59D92E955A10C9F7EF0730 /* Pods-Tutorial 2 (Completed).release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = "$(SRCROOT)/Resources/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.squareup.blueprint-sample-app.Tutorial2Completed"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 4.2; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 979F49F0224D1BD300A3C5D4 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 70E2DBB7A375C527D66B2643 /* Pods-Tutorial 1.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = "$(SRCROOT)/Resources/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.squareup.blueprint-sample-app.Tutorial1"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 4.2; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 979F49F1224D1BD300A3C5D4 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 8501C9E99E00354D89175E65 /* Pods-Tutorial 1.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = "$(SRCROOT)/Resources/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.squareup.blueprint-sample-app.Tutorial1"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 4.2; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 975454F5223C1289003E353F /* Build configuration list for PBXProject "SampleApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9754550A223C128B003E353F /* Debug */, + 9754550B223C128B003E353F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 9754550C223C128B003E353F /* Build configuration list for PBXNativeTarget "SampleApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9754550D223C128B003E353F /* Debug */, + 9754550E223C128B003E353F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 9796EC44224D1D2000E729F3 /* Build configuration list for PBXNativeTarget "Tutorial 1 (Completed)" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9796EC45224D1D2000E729F3 /* Debug */, + 9796EC46224D1D2000E729F3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 9796EC5C224DB3BF00E729F3 /* Build configuration list for PBXNativeTarget "Tutorial 2" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9796EC5D224DB3BF00E729F3 /* Debug */, + 9796EC5E224DB3BF00E729F3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 9796EC6B224DB3C500E729F3 /* Build configuration list for PBXNativeTarget "Tutorial 2 (Completed)" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9796EC6C224DB3C500E729F3 /* Debug */, + 9796EC6D224DB3C500E729F3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 979F49EF224D1BD300A3C5D4 /* Build configuration list for PBXNativeTarget "Tutorial 1" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 979F49F0224D1BD300A3C5D4 /* Debug */, + 979F49F1224D1BD300A3C5D4 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 975454F2223C1289003E353F /* Project object */; +} diff --git a/SampleApp/SampleApp.xcodeproj/xcshareddata/xcschemes/SampleApp.xcscheme b/SampleApp/SampleApp.xcodeproj/xcshareddata/xcschemes/SampleApp.xcscheme new file mode 100644 index 000000000..065daa54c --- /dev/null +++ b/SampleApp/SampleApp.xcodeproj/xcshareddata/xcschemes/SampleApp.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SampleApp/SampleApp.xcodeproj/xcshareddata/xcschemes/Tutorial 1 (Completed).xcscheme b/SampleApp/SampleApp.xcodeproj/xcshareddata/xcschemes/Tutorial 1 (Completed).xcscheme new file mode 100644 index 000000000..6ad555980 --- /dev/null +++ b/SampleApp/SampleApp.xcodeproj/xcshareddata/xcschemes/Tutorial 1 (Completed).xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SampleApp/SampleApp.xcodeproj/xcshareddata/xcschemes/Tutorial 1.xcscheme b/SampleApp/SampleApp.xcodeproj/xcshareddata/xcschemes/Tutorial 1.xcscheme new file mode 100644 index 000000000..73691b466 --- /dev/null +++ b/SampleApp/SampleApp.xcodeproj/xcshareddata/xcschemes/Tutorial 1.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SampleApp/Sources/AppDelegate.swift b/SampleApp/Sources/AppDelegate.swift new file mode 100644 index 000000000..b86166126 --- /dev/null +++ b/SampleApp/Sources/AppDelegate.swift @@ -0,0 +1,18 @@ +import UIKit + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + + var window: UIWindow? + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + + window = UIWindow(frame: UIScreen.main.bounds) + window?.rootViewController = ViewController() + window?.makeKeyAndVisible() + + return true + } + +} + diff --git a/SampleApp/Sources/ViewController.swift b/SampleApp/Sources/ViewController.swift new file mode 100644 index 000000000..0fdba3f61 --- /dev/null +++ b/SampleApp/Sources/ViewController.swift @@ -0,0 +1,144 @@ +import UIKit +import BlueprintUI +import BlueprintUICommonControls + + +struct Post { + var authorName: String + var timeAgo: String + var body: String +} + +let posts = [ + Post( + authorName: "Tim", + timeAgo: "1 hour ago", + body: "Lorem Ipsum"), + Post( + authorName: "Jane", + timeAgo: "2 days ago", + body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."), + Post( + authorName: "John", + timeAgo: "2 days ago", + body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit!") + +] + + +final class ViewController: UIViewController { + + private let blueprintView = BlueprintView(element: List(posts: posts)) + + override func loadView() { + self.view = blueprintView + } + +} + + +fileprivate struct List: ProxyElement { + + var posts: [Post] + + var elementRepresentation: Element { + let col = Column { col in + col.horizontalAlignment = .fill + col.minimumVerticalSpacing = 8.0 + + for post in posts { + col.add(child: FeedItem(post: post)) + } + } + + var scroll = ScrollView(wrapping: col) + scroll.contentSize = .fittingHeight + scroll.alwaysBounceVertical = true + + let background = Box( + backgroundColor: UIColor(white: 0.95, alpha: 1.0), + wrapping: scroll) + + return background + } + +} + + +fileprivate struct FeedItem: ProxyElement { + + var post: Post + + var elementRepresentation: Element { + let element = Row { row in + row.verticalAlignment = .leading + row.minimumHorizontalSpacing = 16.0 + row.horizontalUnderflow = .growUniformly + + let avatar = ConstrainedSize( + wrapping: Box( + backgroundColor: .lightGray, + cornerStyle: .rounded(radius: 32.0), + wrapping: nil), + width: .absolute(64), + height: .absolute(64)) + + row.add( + growPriority: 0.0, + shrinkPriority: 0.0, + child: avatar) + + row.add( + growPriority: 1.0, + shrinkPriority: 1.0, + child: FeedItemBody(post: post)) + } + + let box = Box( + backgroundColor: .white, + wrapping: Inset( + wrapping: element, + uniformInset: 16.0)) + + + return box + } + +} + +fileprivate struct FeedItemBody: ProxyElement { + + var post: Post + + var elementRepresentation: Element { + let column = Column { col in + + col.horizontalAlignment = .leading + col.minimumVerticalSpacing = 8.0 + + let header = Row { row in + row.minimumHorizontalSpacing = 8.0 + row.verticalAlignment = .center + + var name = Label(text: post.authorName) + name.font = UIFont.boldSystemFont(ofSize: 14.0) + row.add(child: name) + + var timeAgo = Label(text: post.timeAgo) + timeAgo.font = UIFont.systemFont(ofSize: 14.0) + timeAgo.color = .lightGray + row.add(child: timeAgo) + } + + col.add(child: header) + + var body = Label(text: post.body) + body.font = UIFont.systemFont(ofSize: 13.0) + + col.add(child: body) + } + + return column + } + +} diff --git a/SampleApp/Tutorials/Tutorial 1 (Completed)/AppDelegate.swift b/SampleApp/Tutorials/Tutorial 1 (Completed)/AppDelegate.swift new file mode 100644 index 000000000..9fa31763f --- /dev/null +++ b/SampleApp/Tutorials/Tutorial 1 (Completed)/AppDelegate.swift @@ -0,0 +1,19 @@ +import UIKit + +@UIApplicationMain +final class AppDelegate: UIResponder, UIApplicationDelegate { + + var window: UIWindow? + + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + + window = UIWindow(frame: UIScreen.main.bounds) + window?.rootViewController = ViewController() + window?.makeKeyAndVisible() + + return true + } + +} + diff --git a/SampleApp/Tutorials/Tutorial 1 (Completed)/ViewController.swift b/SampleApp/Tutorials/Tutorial 1 (Completed)/ViewController.swift new file mode 100644 index 000000000..d01c99bfc --- /dev/null +++ b/SampleApp/Tutorials/Tutorial 1 (Completed)/ViewController.swift @@ -0,0 +1,31 @@ +import UIKit +import BlueprintUI +import BlueprintUICommonControls + + +struct HelloWorldElement: ProxyElement { + + var elementRepresentation: Element { + var label = Label(text: "Hello, world") + label.font = .boldSystemFont(ofSize: 24.0) + label.color = .darkGray + + return Centered(label) + } + +} + + +final class ViewController: UIViewController { + + private let blueprintView = BlueprintView(element: HelloWorldElement()) + + override func loadView() { + self.view = blueprintView + } + + override var prefersStatusBarHidden: Bool { + return true + } + +} diff --git a/SampleApp/Tutorials/Tutorial 1/AppDelegate.swift b/SampleApp/Tutorials/Tutorial 1/AppDelegate.swift new file mode 100644 index 000000000..41336b7bd --- /dev/null +++ b/SampleApp/Tutorials/Tutorial 1/AppDelegate.swift @@ -0,0 +1,19 @@ +import UIKit + +@UIApplicationMain +final class AppDelegate: UIResponder, UIApplicationDelegate { + + var window: UIWindow? + + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + + window = UIWindow(frame: UIScreen.main.bounds) + // window?.rootViewController = + window?.makeKeyAndVisible() + + return true + } + +} + diff --git a/SampleApp/Tutorials/Tutorial 2 (Completed)/AppDelegate.swift b/SampleApp/Tutorials/Tutorial 2 (Completed)/AppDelegate.swift new file mode 100644 index 000000000..9fa31763f --- /dev/null +++ b/SampleApp/Tutorials/Tutorial 2 (Completed)/AppDelegate.swift @@ -0,0 +1,19 @@ +import UIKit + +@UIApplicationMain +final class AppDelegate: UIResponder, UIApplicationDelegate { + + var window: UIWindow? + + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + + window = UIWindow(frame: UIScreen.main.bounds) + window?.rootViewController = ViewController() + window?.makeKeyAndVisible() + + return true + } + +} + diff --git a/SampleApp/Tutorials/Tutorial 2 (Completed)/LineItemElement.swift b/SampleApp/Tutorials/Tutorial 2 (Completed)/LineItemElement.swift new file mode 100644 index 000000000..f0f5bb465 --- /dev/null +++ b/SampleApp/Tutorials/Tutorial 2 (Completed)/LineItemElement.swift @@ -0,0 +1,70 @@ +import BlueprintUI +import BlueprintUICommonControls + +struct LineItemElement: ProxyElement { + + var style: Style + var title: String + var price: Double + + var elementRepresentation: Element { + return Row { row in + + row.horizontalUnderflow = .spaceEvenly + + var titleLabel = Label(text: title) + titleLabel.font = style.titleFont + titleLabel.color = style.titleColor + row.add(child: titleLabel) + + let formatter = NumberFormatter() + formatter.numberStyle = .currency + let formattedPrice = formatter.string(from: NSNumber(value: price)) ?? "" + + var priceLabel = Label(text: formattedPrice) + priceLabel.font = style.priceFont + priceLabel.color = style.priceColor + row.add(child: priceLabel) + + } + } + +} + +extension LineItemElement { + + enum Style { + case regular + case bold + + fileprivate var titleFont: UIFont { + switch self { + case .regular: return .systemFont(ofSize: 18.0) + case .bold: return .boldSystemFont(ofSize: 18.0) + } + } + + fileprivate var titleColor: UIColor { + switch self { + case .regular: return .gray + case .bold: return .black + } + } + + fileprivate var priceFont: UIFont { + switch self { + case .regular: return .systemFont(ofSize: 18.0) + case .bold: return .boldSystemFont(ofSize: 18.0) + } + } + + fileprivate var priceColor: UIColor { + switch self { + case .regular: return .black + case .bold: return .black + } + } + + } + +} diff --git a/SampleApp/Tutorials/Tutorial 2 (Completed)/Purchase.swift b/SampleApp/Tutorials/Tutorial 2 (Completed)/Purchase.swift new file mode 100644 index 000000000..173365a47 --- /dev/null +++ b/SampleApp/Tutorials/Tutorial 2 (Completed)/Purchase.swift @@ -0,0 +1,32 @@ +struct Purchase { + + var items: [Item] + + var subtotal: Double { + return items + .map { $0.price } + .reduce(0.0, +) + } + + var tax: Double { + return subtotal * 0.085 + } + + var total: Double { + return subtotal + tax + } + + struct Item { + var name: String + var price: Double + } + + static var sample: Purchase { + return Purchase(items: [ + Item(name: "Burger", price: 7.99), + Item(name: "Fries", price: 2.49), + Item(name: "Soda", price: 1.49) + ]) + } + +} diff --git a/SampleApp/Tutorials/Tutorial 2 (Completed)/ReceiptElement.swift b/SampleApp/Tutorials/Tutorial 2 (Completed)/ReceiptElement.swift new file mode 100644 index 000000000..ce3239b49 --- /dev/null +++ b/SampleApp/Tutorials/Tutorial 2 (Completed)/ReceiptElement.swift @@ -0,0 +1,56 @@ +import BlueprintUI +import BlueprintUICommonControls + + +struct ReceiptElement: ProxyElement { + + let purchase = Purchase.sample + + var elementRepresentation: Element { + let column = Column { col in + col.minimumVerticalSpacing = 16.0 + col.horizontalAlignment = .fill + + for item in purchase.items { + col.add( + child: LineItemElement( + style: .regular, + title: item.name, + price: item.price)) + } + + // Add a rule below all of the line items + col.add(child: RuleElement()) + + // Totals + col.add( + child: LineItemElement( + style: .regular, + title: "Subtotal", + price: purchase.subtotal)) + + + col.add( + child: LineItemElement( + style: .regular, + title: "Tax", + price: purchase.tax)) + + col.add( + child: LineItemElement( + style: .bold, + title: "Total", + price: purchase.total)) + } + + let inset = Inset( + wrapping: column, + uniformInset: 24.0) + + var scrollView = ScrollView(wrapping: inset) + scrollView.contentSize = .fittingHeight + scrollView.alwaysBounceVertical = true + return scrollView + } + +} diff --git a/SampleApp/Tutorials/Tutorial 2 (Completed)/RuleElement.swift b/SampleApp/Tutorials/Tutorial 2 (Completed)/RuleElement.swift new file mode 100644 index 000000000..c80345185 --- /dev/null +++ b/SampleApp/Tutorials/Tutorial 2 (Completed)/RuleElement.swift @@ -0,0 +1,11 @@ +import BlueprintUI +import BlueprintUICommonControls + + +struct RuleElement: ProxyElement { + var elementRepresentation: Element { + return ConstrainedSize( + wrapping: Box(backgroundColor: .black), + height: .absolute(1.0)) + } +} diff --git a/SampleApp/Tutorials/Tutorial 2 (Completed)/ViewController.swift b/SampleApp/Tutorials/Tutorial 2 (Completed)/ViewController.swift new file mode 100644 index 000000000..219b39fce --- /dev/null +++ b/SampleApp/Tutorials/Tutorial 2 (Completed)/ViewController.swift @@ -0,0 +1,18 @@ +import UIKit +import BlueprintUI +import BlueprintUICommonControls + + +final class ViewController: UIViewController { + + private let blueprintView = BlueprintView(element: ReceiptElement()) + + override func loadView() { + self.view = blueprintView + } + + override var prefersStatusBarHidden: Bool { + return true + } + +} diff --git a/SampleApp/Tutorials/Tutorial 2/AppDelegate.swift b/SampleApp/Tutorials/Tutorial 2/AppDelegate.swift new file mode 100644 index 000000000..9fa31763f --- /dev/null +++ b/SampleApp/Tutorials/Tutorial 2/AppDelegate.swift @@ -0,0 +1,19 @@ +import UIKit + +@UIApplicationMain +final class AppDelegate: UIResponder, UIApplicationDelegate { + + var window: UIWindow? + + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + + window = UIWindow(frame: UIScreen.main.bounds) + window?.rootViewController = ViewController() + window?.makeKeyAndVisible() + + return true + } + +} + diff --git a/SampleApp/Tutorials/Tutorial 2/ViewController.swift b/SampleApp/Tutorials/Tutorial 2/ViewController.swift new file mode 100644 index 000000000..d01c99bfc --- /dev/null +++ b/SampleApp/Tutorials/Tutorial 2/ViewController.swift @@ -0,0 +1,31 @@ +import UIKit +import BlueprintUI +import BlueprintUICommonControls + + +struct HelloWorldElement: ProxyElement { + + var elementRepresentation: Element { + var label = Label(text: "Hello, world") + label.font = .boldSystemFont(ofSize: 24.0) + label.color = .darkGray + + return Centered(label) + } + +} + + +final class ViewController: UIViewController { + + private let blueprintView = BlueprintView(element: HelloWorldElement()) + + override func loadView() { + self.view = blueprintView + } + + override var prefersStatusBarHidden: Bool { + return true + } + +}