Skip to content

Commit

Permalink
Merge branch 'master' into kve/api-ergo-improvements
Browse files Browse the repository at this point in the history
  • Loading branch information
kyleve committed Apr 30, 2020
2 parents 5da4863 + 62a2d93 commit ecb5dae
Show file tree
Hide file tree
Showing 22 changed files with 902 additions and 137 deletions.
2 changes: 1 addition & 1 deletion BlueprintUI.podspec
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'BlueprintUI'
s.version = '0.9.1'
s.version = '0.10.0'
s.summary = 'Swift library for declarative UI construction'
s.homepage = 'https://www.github.com/square/blueprint'
s.license = 'Apache License, Version 2.0'
Expand Down
10 changes: 9 additions & 1 deletion BlueprintUI/Sources/Blueprint View/BlueprintView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ public final class BlueprintView: UIView {
invalidateIntrinsicContentSize()
performUpdate()
}

public override func didMoveToWindow() {
super.didMoveToWindow()
setNeedsViewHierarchyUpdate()
}

private func performUpdate() {
updateViewHierarchyIfNeeded()
Expand Down Expand Up @@ -141,11 +146,14 @@ public final class BlueprintView: UIView {

rootController.view.frame = bounds

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

let scale = window?.screen.scale ?? UIScreen.main.scale
rootNode.round(from: .zero, correction: .zero, scale: scale)

rootController.update(node: rootNode, appearanceTransitionsEnabled: hasUpdatedViewHierarchy)
hasUpdatedViewHierarchy = true
Expand Down
51 changes: 33 additions & 18 deletions BlueprintUI/Sources/Blueprint View/ElementPreview.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// Created by Kyle Van Essen on 4/14/20.
//

#if DEBUG && canImport(SwiftUI) && !arch(i386)
#if DEBUG && canImport(SwiftUI) && !arch(i386) && !arch(arm)

import UIKit
import SwiftUI
Expand All @@ -26,7 +26,7 @@ import SwiftUI
///
/// // Add this at the bottom of your element's source file.
///
/// #if DEBUG && canImport(SwiftUI) && !arch(i386)
/// #if DEBUG && canImport(SwiftUI) && !arch(i386) && !arch(arm)
///
/// import SwiftUI
///
Expand Down Expand Up @@ -74,7 +74,7 @@ public struct ElementPreview : View {
public typealias ElementProvider = () -> Element

private let name : String

/// The types of previews to include in the Xcode preview.
private let previewTypes : [PreviewType]

Expand Down Expand Up @@ -141,30 +141,31 @@ public struct ElementPreview : View {
ForEach(self.previewTypes, id: \.identifier) { previewType in
previewType.preview(
with: self.name,
for: ElementView(
element: self.provider()
)
for: self.provider()
)
}
}
}


@available(iOS 13.0, *)
extension ElementPreview {

private struct ElementView : UIViewRepresentable {
fileprivate struct ElementView : UIViewRepresentable {

var element : Element

func makeUIView(context: Context) -> BlueprintView {
return BlueprintView(element: self.element)
let view = BlueprintView()
view.element = self.element

return view
}

func updateUIView(_ view: BlueprintView, context: Context) {
view.element = self.element
}
}
}


@available(iOS 13.0, *)
extension ElementPreview {

/// The preview type to use to display an element in an Xcode preview.
///
Expand All @@ -191,9 +192,9 @@ extension ElementPreview {
}
}

fileprivate func preview<ViewType:View>(
fileprivate func preview(
with name : String,
for view : ViewType
for element : Element
) -> AnyView {

let formattedName : String = {
Expand All @@ -207,27 +208,41 @@ extension ElementPreview {
switch self {
case .device(let device):
return AnyView(
view
self.constrained(element: element)
.previewDevice(.init(rawValue: device.rawValue))
.previewDisplayName(device.rawValue + formattedName)
)

case .fixed(let width, let height):
return AnyView(
view
self.constrained(element: element)
.previewLayout(.fixed(width: width, height: height))
.previewDisplayName("Fixed Size: (\(width), \(height)" + formattedName)
)

case .thatFits(let padding):
return AnyView(
view
self.constrained(element: element)
.previewLayout(.sizeThatFits)
.previewDisplayName("Size That Fits" + formattedName)
.padding(.all, padding)
)
}
}

private func constrained(
element : Element
) -> some View {
GeometryReader { info in
return ElementView(
element: ConstrainedSize(
width: .atMost(info.size.width),
height: .atMost(info.size.height),
wrapping: element
)
)
}
}
}
}

Expand Down
13 changes: 12 additions & 1 deletion BlueprintUI/Sources/Internal/Extensions/CGPoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,16 @@ extension CGPoint {
func applying(_ transform: CATransform3D) -> CGPoint {
return CGPoint(double4Value * transform.double4x4Value)
}


static func + (lhs: CGPoint, rhs: CGPoint) -> CGPoint {
return CGPoint(x: lhs.x + rhs.x, y: lhs.y + rhs.y)
}

static func - (lhs: CGPoint, rhs: CGPoint) -> CGPoint {
return CGPoint(x: lhs.x - rhs.x, y: lhs.y - rhs.y)
}

static prefix func - (point: CGPoint) -> CGPoint {
return CGPoint(x: -point.x, y: -point.y)
}
}
28 changes: 28 additions & 0 deletions BlueprintUI/Sources/Internal/Extensions/CGRect.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import CoreGraphics

extension CGRect {
init(minX: CGFloat, minY: CGFloat, maxX: CGFloat, maxY: CGFloat) {
self.init(
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY)
}

/// Creates a new rectangle by rounding each of the min and max X and Y values of this rect individually.
/// - Parameters:
/// - rule: The rounding rule.
/// - scale: The rounding scale.
/// - Returns: A rectangle with the rounded values.
func rounded(_ rule: FloatingPointRoundingRule, by scale: CGFloat) -> CGRect {
return CGRect(
minX: minX.rounded(rule, by: scale),
minY: minY.rounded(rule, by: scale),
maxX: maxX.rounded(rule, by: scale),
maxY: maxY.rounded(rule, by: scale))
}

func offset(by point: CGPoint) -> CGRect {
return self.offsetBy(dx: point.x, dy: point.y)
}
}
30 changes: 29 additions & 1 deletion BlueprintUI/Sources/Internal/NativeViewNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,33 @@ struct NativeViewNode {
self.layoutAttributes = layoutAttributes
self.children = children
}


/// Recursively rounds this node's layout frame and all its children to snap to pixel boundaries.
///
/// - Parameters:
/// - origin: The global origin to offset the frame by before rounding. This offset is used to ensure that
/// positive and negative frame coordinates both round away from zero.
/// - correction: The amount of rounding correction to apply to the origin before rounding, to account for the
/// rounding applied to this node's parent.
/// - scale: The screen scale to use when rounding.
mutating func round(from origin: CGPoint, correction: CGPoint, scale: CGFloat) {
// Per the docs for UIView.frame:
// > If the transform property is not the identity transform, the value of this property is undefined
// > and therefore should be ignored.
// So we do not attempt to snap the frame to pixel bounds in this case.
guard CATransform3DIsIdentity(layoutAttributes.transform) else {
return
}

let childCorrection = layoutAttributes.round(
from: origin,
correction: correction,
scale: scale)

let childOrigin = origin + layoutAttributes.frame.origin

for i in children.indices {
children[i].node.round(from: childOrigin, correction: childCorrection, scale: scale)
}
}
}
66 changes: 57 additions & 9 deletions BlueprintUI/Sources/Layout/ConstrainedSize.swift
Original file line number Diff line number Diff line change
@@ -1,13 +1,29 @@
import UIKit

/// Constrains the measured size of the content element.
/// Constrains the measured size of the contained element in the ranges specified by the `width` and `height` properties.
///
/// There are several constraint types available for each axis. See `ConstrainedSize.Constraint` for a full list and in-depth
/// descriptions of each.
///
/// Notes
/// --------
/// An important note is that the constraints of `ConstrainedSize` are authoritative during measurement. For example,
/// if your `ConstrainedSize` specifies `.atLeast(300)` for `width`, and the `ConstrainedSize` is asked to measure within
/// a `SizeConstraint` that is at most 100 points wide, the returned measurement will still be 300 points. The same goes for the
/// height of the `ConstrainedSize`.
///
public struct ConstrainedSize: Element {

/// The element whose measurement will be constrained by the `ConstrainedSize`.
public var wrapped: Element

/// The constraint to place on the width of the element.
public var width: Constraint

/// The constraint to place on the height of the element.
public var height: Constraint

/// Creates a new `ConstrainedSize` with the provided constraint options.
public init(
width: Constraint = .unconstrained,
height: Constraint = .unconstrained,
Expand All @@ -33,11 +49,23 @@ public struct ConstrainedSize: Element {

extension ConstrainedSize {

/// The available ways to constrain the measurement of a given axis within a `ConstrainedSize` element.
public enum Constraint {
/// There is no constraint for this axis – the natural size of the element will be used.
case unconstrained

/// The measured size for this axis will be **no greater** than the value provided.
case atMost(CGFloat)

/// The measured size for this axis will be **no less** than the value provided.
case atLeast(CGFloat)

/// The measured size for this axis will be **within** the range provided.
/// If the measured value is below the bottom of the range, the lower value will be used.
/// If the measured value is above the top of the range, the lower value will be used.
case within(ClosedRange<CGFloat>)

/// The measured size for this axis will be **exactly** the value provided.
case absolute(CGFloat)

fileprivate func applied(to value: CGFloat) -> CGFloat {
Expand All @@ -55,9 +83,9 @@ extension ConstrainedSize {
}
}
}

}


public extension Element {

/// Constrains the measured size of the content element.
Expand All @@ -70,14 +98,15 @@ public extension Element {
}
}

extension Comparable {

extension Comparable {

fileprivate func clamped(to limits: ClosedRange<Self>) -> Self {
return min(max(self, limits.lowerBound), limits.upperBound)
}

}


extension ConstrainedSize {

fileprivate struct Layout: SingleChildLayout {
Expand All @@ -86,16 +115,35 @@ extension ConstrainedSize {
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

/// 1) Measure how big the element should be by constraining the passed in
/// `SizeConstraint` to not be larger than our maximum size. This ensures
/// the real maximum possible width is passed to the child, not an unconstrained width.
///
/// This is important because some elements heights are affected by their width (eg, a text label),
/// or any other elements type which reflows its content.

let maximumConstraint = SizeConstraint(
width: .init(self.width.applied(to: constraint.width.maximum)),
height: .init(self.height.applied(to: constraint.height.maximum))
)

let measurement = child.measure(in: maximumConstraint)

/// 2) If our returned size needs to be larger than the measured size,
/// eg: the element did not take up all the space during measurement,
/// and we have a minimum size in either axis. In that case, adjust the
/// measured size to that minimum size before returning.

return CGSize(
width: width.applied(to: measurement.width),
height: height.applied(to: measurement.height)
)
}

func layout(size: CGSize, child: Measurable) -> LayoutAttributes {
return LayoutAttributes(size: size)
}

}

}
28 changes: 28 additions & 0 deletions BlueprintUI/Sources/Layout/FloatingPoint+ScaleRounding.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import Foundation

public extension FloatingPoint {
/// Rounds this value to the specified scale, where the scale is the number of rounding stops per integer.
/// - Parameters:
/// - rule: the rounding rule
/// - scale: the rounding scale
///
/// A rounding scale of 1.0 is standard integer rounding.
/// A rounding scale of 2.0 rounds to halves (0, 0.5, 1.0, 1.5, 2.0, 2.5., ...).
/// A rounding scale of 3.0 rounds to thirds (0, 1/3, 2/3, 1.0, 4/3, 5/3, 2.0, ...).
mutating func round(_ rule: FloatingPointRoundingRule, by scale: Self) {
self = self.rounded(rule, by: scale)
}

/// Returns this value rounded to the specified scale, where the scale is the number of rounding stops per integer.
/// - Parameters:
/// - rule: the rounding rule
/// - scale: the rounding scale
/// - Returns: The rounded value.
///
/// A rounding scale of 1.0 is standard integer rounding.
/// A rounding scale of 2.0 rounds to halves (0, 0.5, 1.0, 1.5, 2.0, 2.5., ...).
/// A rounding scale of 3.0 rounds to thirds (0, 1/3, 2/3, 1.0, 4/3, 5/3, 2.0, ...).
func rounded(_ rule: FloatingPointRoundingRule, by scale: Self) -> Self {
return (self * scale).rounded(rule) / scale
}
}
Loading

0 comments on commit ecb5dae

Please sign in to comment.