diff --git a/BlueprintUI/Sources/Layout/Decorate.swift b/BlueprintUI/Sources/Layout/Decorate.swift new file mode 100644 index 000000000..e58276124 --- /dev/null +++ b/BlueprintUI/Sources/Layout/Decorate.swift @@ -0,0 +1,234 @@ +// +// Decorate.swift +// BlueprintUI +// +// Created by Kyle Van Essen on 11/4/20. +// + +import UIKit + + +/// +/// Places a decoration element behind or in front of the given `wrapped` element, +/// and positions it according to the `position` parameter. +/// +/// The size and position of the element is determined only by the `wrapped` +/// element, the `decoration` element does not affect the layout at all. +/// +/// Example +/// ------- +/// The arrows represent the measured size of the element for layout purposes. +/// ``` +/// ┌───────────────────┐ ┌──────┐ +/// │ Decoration │ │ │ +/// │ ┏━━━━━━━━━━━━━━━┓ │ ▲ │ ┣━━━━━━━━━━┓ ▲ +/// │ ┃ ┃ │ │ └─┳────┘ ┃ │ +/// │ ┃ Wrapped ┃ │ │ ┃ Wrapped ┃ │ +/// │ ┃ ┃ │ │ ┃ ┃ │ +/// │ ┗━━━━━━━━━━━━━━━┛ │ ▼ ┗━━━━━━━━━━━━━━━┛ ▼ +/// └───────────────────┘ +/// ◀───────────────▶ ◀───────────────▶ +/// ``` +public struct Decorate : ProxyElement { + + /// The element which provides the sizing and measurement. + /// The sizing and position of the `Decorate` element is determined + /// by this element. + public var wrapped : Element + + /// The element which is used to draw the decoration. + /// It does not affect sizing or positioning. + public var decoration : Element + + /// Where the decoration should be positioned in the z-axis: Above or below the wrapped element. + public var layering : Layering + + /// How the `decoration` should be positioned in respect to the `wrapped` element. + public var position : Position + + /// Creates a new instance with the provided overflow, background, and wrapped element. + public init( + layering : Layering, + position: Position, + wrapping: () -> Element, + decoration: () -> Element + ) { + self.layering = layering + + self.wrapped = wrapping() + + self.position = position + self.decoration = decoration() + } + + // MARK: ProxyElement + + public var elementRepresentation: Element { + LayoutWriter { context, layout in + + let contentSize = self.wrapped.content.measure( + in: context.size, + environment: context.environment + ) + + let contentFrame = CGRect(origin: .zero, size: contentSize) + + let decorationFrame = self.position.frame( + with: contentFrame, + decoration: self.decoration, + environment: context.environment + ) + + layout.sizing = .fixed(contentSize) + + switch self.layering { + case .above: + layout.add(with: contentFrame, child: self.wrapped) + layout.add(with: decorationFrame, child: self.decoration) + + case .below: + layout.add(with: decorationFrame, child: self.decoration) + layout.add(with: contentFrame, child: self.wrapped) + } + } + } +} + + +extension Decorate { + + /// If the decoration should be positioned above or below the content element. + public enum Layering : Equatable { + + /// The decoration is displayed above the content element. + case above + + /// The decoration is displayed below the content element. + case below + } + + /// What corner the decoration element should be positioned in. + public enum Corner : Equatable { + case topLeft + case topRight + case bottomRight + case bottomLeft + } + + /// How to position the decoration element relative to the content element. + public enum Position { + + /// Insets the decoration element on each edge by the amount specified by + /// the `UIEdgeInsets` property. + /// + /// A positive value for an edge expands the decoration outside of that edge, + /// whereas a negative inset pushes the the decoration inside that edge. + case inset(UIEdgeInsets) + + /// Provides a `.inset` position where the decoration is inset by the + /// same amount on each side. + public static func inset(_ amount : CGFloat) -> Self { + .inset(UIEdgeInsets(top: amount, left: amount, bottom: amount, right: amount)) + } + + /// Provides a `.inset` position where the decoration is inset by the + /// `horizontal` amount on the left and right, and the `vertical` amount on the top and bottom. + public static func inset(horizontal : CGFloat = 0.0, vertical : CGFloat = 0.0) -> Self { + .inset(UIEdgeInsets(top: vertical, left: horizontal, bottom: vertical, right: horizontal)) + } + + /// The decoration element is positioned in the given corner of the + /// content element, optionally offset by the provided amount. + case corner(Corner, UIOffset = .init()) + + /// Allows you to provide custom positioning for the decoration, based on the passed context. + case custom((CustomContext) -> CGRect) + + /// Information provided to the `.custom` positioning type. + public struct CustomContext { + + /// The frame of the content element within the `Decorate` element. + public var contentFrame : CGRect + + /// The environment the element is being rendered in. + public var environment : Environment + } + + func frame( + with contentFrame : CGRect, + decoration : Element, + environment : Environment + ) -> CGRect { + + switch self { + case .inset(let inset): + return contentFrame.inset(by: inset.negated) + + case .corner(let corner, let offset): + + let size = decoration.content.measure(in: .init(contentFrame.size), environment: environment) + + let center : CGPoint = { + switch corner { + case .topLeft: + return .zero + case .topRight: + return CGPoint(x: contentFrame.maxX, y: 0) + case .bottomRight: + return CGPoint(x: contentFrame.maxX, y: contentFrame.maxY) + case .bottomLeft: + return CGPoint(x: 0, y: contentFrame.maxY) + } + }() + + return CGRect( + origin: CGPoint( + x: center.x - (size.width / 2.0), + y: center.y - (size.height / 2.0) + ), + size: size + ).offset( + by: CGPoint(x: offset.horizontal, y: offset.vertical) + ) + + case .custom(let provider): + return provider(.init(contentFrame: contentFrame, environment: environment)) + } + } + } +} + + +extension Element { + + /// Places a decoration element behind or in front of the given `wrapped` element, + /// and positions it according to the `position` parameter. + /// + /// See the `Decorate` element for more. + /// + public func decorate( + layering : Decorate.Layering, + position : Decorate.Position, + decoration : () -> Element + ) -> Element { + + Decorate( + layering: layering, + position: position, + wrapping: { self }, + decoration: decoration + ) + } +} + + +extension UIEdgeInsets { + fileprivate var negated : UIEdgeInsets { + UIEdgeInsets( + top: -self.top, + left: -self.left, + bottom: -self.bottom, + right: -self.right + ) + } +} diff --git a/BlueprintUI/Sources/Layout/DecorateBackground.swift b/BlueprintUI/Sources/Layout/DecorateBackground.swift deleted file mode 100644 index cfec806b5..000000000 --- a/BlueprintUI/Sources/Layout/DecorateBackground.swift +++ /dev/null @@ -1,185 +0,0 @@ -// -// DecorateBackground.swift -// BlueprintUI -// -// Created by Kyle Van Essen on 11/4/20. -// - -import UIKit - - -/// -/// Places a background behind the given `wrapped` element, -/// and overflows it by the amount specified in `overflow`. -/// -/// The size of the element is determined only by the `wrapped` element – -/// that is, the `background` element will overflow the bounds of the element, but not -/// affect its layout or size. This is similar to how UIKit handles shadows: They live outside of the layout -/// rect of the view: -/// -/// The arrows represent the measured size of the view for layout purposes. -/// ``` -/// ┌───────────────────┐ -/// │ Background │ -/// │ ┏━━━━━━━━━━━━━━━┓ │ ▲ -/// │ ┃ ┃ │ │ -/// │ ┃ Wrapped ┃ │ │ -/// │ ┃ ┃ │ │ -/// │ ┗━━━━━━━━━━━━━━━┛ │ ▼ -/// └───────────────────┘ -/// ◀───────────────▶ -/// ``` -/// -/// This is useful to render on-touch or selected states of elements, where you -/// want to provide a padded background that does otherwise affect the layout of the element, -/// which is likely controlled by its parent element. -/// -public struct DecorateBackground : Element { - - /// The element which provides the sizing and measurement. - public var wrapped : Element - - /// The element which is used to render the background. - /// It is stretched to fit the `wrapped` content, plus the `overflow` padding. - /// - /// If you have a 100w x 50h element, and an overflow of (10, 10, 10, 10), - /// the measured sized will be 100w x 50h, and the background will be - /// sized to be 120w x 70h. - /// ``` - /// ┌───────────────────┐ - /// │ ┏━━━━━━━━━━━━━━━┓ │ ▲ - /// │ ┃ ┃ │ │ - /// │ ┃ Wrapped ┃ │ │ - /// │ ┃ ┃ │ │ - /// │ ┗━━━━━━━━━━━━━━━┛ │ ▼ - /// └───────────────────┘ - /// ◀───────────────▶ - /// ``` - public var background : Element - - /// How much the background should overflow the measured bounds of the - /// element. Positive values overflow outside of the bounds, and negative - /// values underflow to inside the bounds. - public var overflow : UIEdgeInsets - - /// Creates a new instance with the provided overflow, background, and wrapped element. - public init( - overflow: UIEdgeInsets, - background: () -> Element, - wrapping: () -> Element - ) { - self.wrapped = wrapping() - self.background = background() - self.overflow = overflow - } - - /// Creates a new instance with the provided uniform overflow, background, and wrapped element. - public init( - uniform: CGFloat, - background: () -> Element, - wrapping: () -> Element - ) { - self.init( - overflow: UIEdgeInsets(top: uniform, left: uniform, bottom: uniform, right: uniform), - background: background, - wrapping: wrapping - ) - } - - /// Creates a new instance with the provided horizontal and vertical overflow, background, and wrapped element. - public init( - horizontal: CGFloat? = nil, - vertical : CGFloat? = nil, - background: () -> Element, - wrapping: () -> Element - ) { - self.init( - overflow: UIEdgeInsets(top: vertical ?? 0, left: horizontal ?? 0, bottom: vertical ?? 0, right: horizontal ?? 0), - background: background, - wrapping: wrapping - ) - } - - // MARK: Element - - public var content: ElementContent { - let layout = Layout(overflow: self.overflow) - - return ElementContent(layout: layout) { builder in - builder.add(element: self.background) - builder.add(element: self.wrapped) - } - } - - public func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { - nil - } -} - - -extension Element { - - /// Decorates the element with the provided background for the provided overflow. - public func decorateBackground( - with overflow : UIEdgeInsets, - background : () -> Element - ) -> Element { - DecorateBackground(overflow: overflow, background: background, wrapping: { self }) - } - - /// Decorates the element with the provided background for the provided uniform overflow. - public func decorateBackground( - with uniform : CGFloat, - background : () -> Element - ) -> Element { - DecorateBackground(uniform: uniform, background: background, wrapping: { self }) - } - - /// Decorates the element with the provided background for the provided horizontal and vertical overflow. - public func decorateBackground( - horizontal: CGFloat? = nil, - vertical : CGFloat? = nil, - background : () -> Element - ) -> Element { - DecorateBackground(horizontal: horizontal, vertical: vertical, background: background, wrapping: { self }) - } -} - - -extension DecorateBackground { - fileprivate struct Layout : BlueprintUI.Layout { - - var overflow : UIEdgeInsets - - func measure(in constraint: SizeConstraint, items: [(traits: (), content: Measurable)]) -> CGSize { - - precondition(items.count == 2) - - let wrapped = items[1] - - return wrapped.content.measure(in: constraint) - } - - func layout(size: CGSize, items: [(traits: (), content: Measurable)]) -> [LayoutAttributes] { - - precondition(items.count == 2) - - return [ - LayoutAttributes(frame: CGRect(origin: .zero, size: size).inset(by: self.overflow.inverted)), - LayoutAttributes(size: size) - ] - } - } -} - - -extension UIEdgeInsets { - fileprivate var inverted : UIEdgeInsets { - UIEdgeInsets( - top: -self.top, - left: -self.left, - bottom: -self.bottom, - right: -self.right - ) - } -} diff --git a/BlueprintUI/Tests/DecorateTests.swift b/BlueprintUI/Tests/DecorateTests.swift new file mode 100644 index 000000000..467d93112 --- /dev/null +++ b/BlueprintUI/Tests/DecorateTests.swift @@ -0,0 +1,80 @@ +// +// DecorateTests.swift +// BlueprintUI-Unit-Tests +// +// Created by Kyle Van Essen on 12/10/20. +// + +import XCTest +@testable import BlueprintUI + + +class DecorateTests : XCTestCase { + +} + + +class Decorate_Position_Tests : XCTestCase { + + func test_frame() { + + let contentFrame = CGRect(x: 10, y: 10, width: 50, height: 30) + + // .inset + + let inset = Decorate.Position.inset(UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)) + XCTAssertEqual( + inset.frame(with: contentFrame, decoration: DecorationElement(), environment: .empty), + CGRect(x: 0, y: 0, width: 70, height: 50) + ) + + // .corner + + let topLeft = Decorate.Position.corner(.topLeft, .init(horizontal: 1, vertical: 2)) + XCTAssertEqual( + topLeft.frame(with: contentFrame, decoration: DecorationElement(), environment: .empty), + CGRect(x: -4, y: -5.5, width: 10, height: 15) + ) + + let topRight = Decorate.Position.corner(.topRight, .init(horizontal: 1, vertical: 2)) + XCTAssertEqual( + topRight.frame(with: contentFrame, decoration: DecorationElement(), environment: .empty), + CGRect(x: 56, y: -5.5, width: 10, height: 15) + ) + + let bottomRight = Decorate.Position.corner(.bottomRight, .init(horizontal: 1, vertical: 2)) + XCTAssertEqual( + bottomRight.frame(with: contentFrame, decoration: DecorationElement(), environment: .empty), + CGRect(x: 56, y: 34.5, width: 10, height: 15) + ) + + let bottomLeft = Decorate.Position.corner(.bottomLeft, .init(horizontal: 1, vertical: 2)) + XCTAssertEqual( + bottomLeft.frame(with: contentFrame, decoration: DecorationElement(), environment: .empty), + CGRect(x: -4, y: 34.5, width: 10, height: 15) + ) + + // .custom + + let custom = Decorate.Position.custom({ context in + CGRect(x: 10, y: 15, width: 20, height: 30) + }) + + XCTAssertEqual( + custom.frame(with: contentFrame, decoration: DecorationElement(), environment: .empty), + CGRect(x: 10, y: 15, width: 20, height: 30) + ) + } +} + + +fileprivate struct DecorationElement : Element { + + var content: ElementContent { + ElementContent { _ in CGSize(width: 10, height: 15) } + } + + func backingViewDescription(bounds: CGRect, subtreeExtent: CGRect?) -> ViewDescription? { + UIView.describe { _ in } + } +} diff --git a/SampleApp/Sources/RootViewController.swift b/SampleApp/Sources/RootViewController.swift index dca35d1dc..c449edbc7 100644 --- a/SampleApp/Sources/RootViewController.swift +++ b/SampleApp/Sources/RootViewController.swift @@ -18,7 +18,7 @@ final class RootViewController : UIViewController { fileprivate var demos : [DemoItem] { [ - DemoItem(title: "Post List", onTap: { [weak self] in + DemoItem(title: "Post List", badgeText: "3", onTap: { [weak self] in self?.push(PostsViewController()) }), DemoItem(title: "Keyboard Scrolling", onTap: { [weak self] in @@ -64,6 +64,8 @@ final class RootViewController : UIViewController fileprivate struct DemoItem : ProxyElement { var title : String + var badgeText : String? + var onTap : () -> () var elementRepresentation: Element { @@ -85,8 +87,20 @@ fileprivate struct DemoItem : ProxyElement .tappable { self.onTap() } - .decorateBackground(with: 5.0) { + .decorate(layering: .below, position: .inset(5)) { Box(backgroundColor: .init(white: 0.0, alpha: 0.1), cornerStyle: .rounded(radius: 17)) } + .decorate(layering: .above, position: .corner(.topLeft)) { + guard let badge = self.badgeText else { + return Empty() + } + + return Label(text: badge) { + $0.font = .systemFont(ofSize: 22.0, weight: .bold) + $0.color = .white + } + .inset(uniform: 7.0) + .box(background: .systemRed, corners: .capsule) + } } }