Skip to content

Commit

Permalink
Merge pull request #178 from square/kve/decorate-background
Browse files Browse the repository at this point in the history
Introduce Decorate element
  • Loading branch information
kyleve committed Mar 12, 2021
2 parents 352f557 + 3d002f3 commit 750e6fe
Show file tree
Hide file tree
Showing 4 changed files with 337 additions and 6 deletions.
236 changes: 236 additions & 0 deletions BlueprintUI/Sources/Layout/Decorate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
//
// 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 {
EnvironmentReader { environment in
LayoutWriter { context, layout in

let contentSize = self.wrapped.content.measure(
in: context.size,
environment: environment
)

let contentFrame = CGRect(origin: .zero, size: contentSize)

let decorationFrame = self.position.frame(
with: contentFrame,
decoration: self.decoration,
environment: 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,
with 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
)
}
}
75 changes: 75 additions & 0 deletions BlueprintUI/Tests/DecorateTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
//
// DecorateTests.swift
// BlueprintUI-Unit-Tests
//
// Created by Kyle Van Essen on 12/10/20.
//

import XCTest
@testable import BlueprintUI


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 }
}
}
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- [Introduce conditionals on `Element`](https://github.com/square/Blueprint/pull/198) to allow you to perform inline checks like `if`, `if let`, and `map` when building `Element` trees.

- [Introduce `Decorate`](https://github.com/square/Blueprint/pull/178) to allow placing a decoration element in front or behind of an `Element`, without affecting its layout. This is useful for rendering tap or selection states which should overflow the natural bounds of the `Element`, similar to a shadow, or useful for adding a badge to an `Element`.

### Removed

### Changed
Expand Down
30 changes: 24 additions & 6 deletions SampleApp/Sources/RootViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -64,25 +64,43 @@ final class RootViewController : UIViewController
fileprivate struct DemoItem : ProxyElement
{
var title : String
var badgeText : String?

var onTap : () -> ()

var elementRepresentation: Element {
Label(text: self.title) { label in

Label(text: self.title) { label in
label.font = .systemFont(ofSize: 18.0, weight: .semibold)
}
.inset(uniform: 20.0)
.box(
background: .white,
corners: .rounded(radius: 20.0),
corners: .rounded(radius: 15.0),
shadow: .simple(
radius: 6.0,
opacity: 0.2,
offset: .init(width: 0, height: 3.0),
radius: 5.0,
opacity: 0.3,
offset: .init(width: 0, height: 2.0),
color: .black
)
)
.tappable {
self.onTap()
}
.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)
}
}
}

0 comments on commit 750e6fe

Please sign in to comment.