Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce Decorate element #178

Merged
merged 7 commits into from
Mar 12, 2021
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
kyleve marked this conversation as resolved.
Show resolved Hide resolved
/// 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 {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks really close to an implementation of #49, with just a few API tweaks!


/// 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,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if it makes sense to take a closure here. Most Blueprint & SwiftUI containers that take a closure do it to support builders that accumulate multiple children (like stacks) or that have parameters (like GeometryReader). SwiftUI secondary views don't use builder closures either.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the closure since it gives you an immediate scope to set things up, but I'll remove for consistency for now.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'll keep it on the Element extension though since it makes the code read a bit more nicely, eg:

        .thingy()
        .decorate(layering: .below, position: .inset(5)) {
            Box(backgroundColor: .init(white: 0.0, alpha: 0.1), cornerStyle: .rounded(radius: 17))
        }

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 {
kyleve marked this conversation as resolved.
Show resolved Hide resolved

/// 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 {
kyleve marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of Corner & Position, can you add an Alignment type? I culled it from the alignment guides stuff because it was unused, but it looks like this:

public struct Alignment: Equatable {
    static let center = Alignment(horizontal: .center, vertical: .center)
    static let leading = Alignment(horizontal: .leading, vertical: .center)
    static let trailing = Alignment(horizontal: .trailing, vertical: .center)

    static let top = Alignment(horizontal: .center, vertical: .top)
    static let bottom = Alignment(horizontal: .center, vertical: .bottom)

    static let topLeading = Alignment(horizontal: .leading, vertical: .top)
    static let topTrailing = Alignment(horizontal: .trailing, vertical: .top)

    static let bottomLeading = Alignment(horizontal: .leading, vertical: .bottom)
    static let bottomTrailing = Alignment(horizontal: .trailing, vertical: .bottom)

    var horizontal: HorizontalAlignment
    var vertical: VerticalAlignment
}

You'll lose the custom frame case, but I think that can be done with a size constraint.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this is worth it to lose the custom frame case – providing an "escape hatch" for people still seems valuable so we're not on the hook for implementing every needed case within this type.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about replacing Corner with Alignment; seems about the same but added functionality for centering on a given side?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I think to do this "right" it's gonna take unifying some types between alignment guides, the aligned type, and this. Let's go ahead and merge this as is, and then I can follow up with that in another PR

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, this is really the main sticking point for me in this PR. If we use Alignment it's almost exactly the same as SwiftUI's secondary views, but if we invent new types for this it moves us further away.

I guess I would be OK merging as-is as long as we follow up before we do another release? Alternatively, I could make a PR onto this branch with suggested changes, seeing as how I've worked with alignments already, I have a pretty good idea of the changes involved.

What about replacing Corner with Alignment; seems about the same but added functionality for centering on a given side?

It's not. The static cases have similar names, but alignments are opaque — resolving them gives you an x/y position in the coordinate space of the parent.

providing an "escape hatch" for people still seems valuable

I think the custom frame escape hatch is still achievable using Alignment if you wrap your content in .constrainedSize().

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it – this makes sense. I'll do another PR into this one to separate the changes. I plan on keeping the outer positioning though; and will replace corner with alignment.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So something I don't quite understand looking at the existing alignments defined in Alignments.swift – is each of them provide a different edge – eg leading provides 0, center provides the center, and trailing provides the width – but in the case of eg the Aligned element, I need to ensure the contained wrapped element just buts up against the current edge – but this edge depends on the Alignment – but since the alignments are now just types, I can't switch over them easily... I think I'd run into the same issue here... Maybe this would be better to discuss tomorrow in 1:1? I feel like we're missing something to make this work everywhere, or I'm missing something obvious

case topLeft
case topRight
case bottomRight
case bottomLeft
}

/// How to position the decoration element relative to the content element.
public enum Position {
kyleve marked this conversation as resolved.
Show resolved Hide resolved
kyleve marked this conversation as resolved.
Show resolved Hide resolved

/// 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(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we expose this with background and overlay modifiers instead (maybe renaming the latter to disambiguate from the Overlay element)?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went with this because of potential confusion – eg background sounds like I'm giving a background to my current element, and overlay is confused with Overlay as you mention.

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 @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- [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)
}
}
}