From e5e3185f485715a3da6bf6bbc0c990975032585a Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Mon, 22 Apr 2024 18:53:52 -0700 Subject: [PATCH] Create ViewDistributionBuilder (#117) --- Paralayout/UIView+Distribution.swift | 207 +++++++++++++++++ Paralayout/ViewDistributionBuilder.swift | 89 ++++++++ .../ViewDistributionBuilderTests.swift | 210 ++++++++++++++++++ 3 files changed, 506 insertions(+) create mode 100644 Paralayout/ViewDistributionBuilder.swift create mode 100644 ParalayoutTests/ViewDistributionBuilderTests.swift diff --git a/Paralayout/UIView+Distribution.swift b/Paralayout/UIView+Distribution.swift index 9cc9086..824ec52 100644 --- a/Paralayout/UIView+Distribution.swift +++ b/Paralayout/UIView+Distribution.swift @@ -150,6 +150,109 @@ extension UIView { } } +#if swift(>=5.4) + /// Arranges subviews along the vertical axis according to a distribution with fixed and/or flexible spacers. + /// + /// * If there are no flexible elements, this will treat the distribution as vertically centered (i.e. with two + /// flexible elements of equal weight at the top and bottom, respectively). + /// * If there are no spacers (fixed or flexible), this will treat the distribution as equal flexible spacing + /// at the top, bottom, and between each view. + /// + /// **Examples:** + /// + /// To stack two elements with a 10 pt margin between them: + /// ``` + /// // This is effectively the same as [ 1.flexible, icon, 10.fixed, label, 1.flexible ]. + /// applyVerticalSubviewDistribution { + /// icon + /// 10.fixed + /// label + /// } + /// ``` + /// + /// To evenly spread out items: + /// ``` + /// // This is effectively the same as [ 1.flexible, button1, 1.flexible, button2, 1.flexible, button3 ]. + /// applyVerticalSubviewDistribution { + /// button1 + /// button2 + /// button3 + /// } + /// ``` + /// + /// To stack two elements with 50% more space below than above: + /// ``` + /// applyVerticalSubviewDistribution { + /// 2.flexible + /// label + /// 12.fixed + /// textField + /// 3.flexible + /// } + /// ``` + /// + /// To arrange a pair of label on the top and bottom edges of a view, with another label centered between them: + /// ``` + /// applyVerticalSubviewDistribution { + /// 8.fixed + /// headerLabel + /// 1.flexible + /// bodyLabel + /// 1.flexible + /// footerLabel + /// 8.fixed + /// } + /// ``` + /// + /// To arrange UI in a view with an interior margin: + /// ``` + /// applyVerticalSubviewDistribution { + /// icon + /// 10.fixed + /// label + /// }, inRect: bounds.insetBy(dx: 20, dy: 40)) + /// ``` + /// + /// To arrange UI vertically aligned by their leading edge 10 pt in from the leading edge of their superview: + /// ``` + /// applyVerticalSubviewDistribution { + /// icon + /// 1.flexible + /// button + /// }, orthogonalOffset: .leading(inset: 10)) + /// ``` + /// + /// To arrange UI vertically without simultaneously centering it horizontally (the `icon` would need independent + /// horizontal positioning): + /// ``` + /// applyVerticalSubviewDistribution { + /// 1.flexible + /// icon + /// 2.flexible + /// }, orthogonalOffset: nil) + /// ``` + /// + /// - precondition: All views in the `distribution` must be subviews of the receiver. + /// - precondition: The `distribution` must not include any given view more than once. + /// + /// - parameter distribution: An array of distribution specifiers, ordered from the top edge to the bottom edge. + /// - parameter layoutBounds: The region in the receiver in which to distribute the view in the receiver's + /// coordinate space. Specify `nil` to use the receiver's bounds. Defaults to `nil`. + /// - parameter orthogonalAlignment: The horizontal alignment to apply to the views. If `nil`, views are left in + /// their horizontal position prior to the distribution. Defaults to centered with no offset. + public func applyVerticalSubviewDistribution( + @ViewDistributionBuilder _ distribution: () -> [ViewDistributionSpecifying], + inRect layoutBounds: CGRect? = nil, + orthogonalAlignment: HorizontalDistributionAlignment? = .centered(offset: 0) + ) { + applyVerticalSubviewDistribution( + distribution(), + inRect: layoutBounds, + orthogonalAlignment: orthogonalAlignment + ) + } +#endif + /// Arranges subviews along the horizontal axis according to a distribution with fixed and/or flexible spacers. /// /// * If there are no flexible elements, this will treat the distribution as horizontally centered (i.e. with two @@ -229,6 +332,110 @@ extension UIView { } } +#if swift(>=5.4) + /// Arranges subviews along the horizontal axis according to a distribution with fixed and/or flexible spacers. + /// + /// * If there are no flexible elements, this will treat the distribution as horizontally centered (i.e. with two + /// flexible elements of equal weight at the leading and trailing edges, respectively). + /// * If there are no spacers (fixed or flexible), this will treat the distribution as equal flexible spacing + /// at the leading edge, trailing edge, and between each view. + /// + /// **Examples:** + /// + /// To stack two elements with a 10 pt margin between them: + /// ``` + /// // This is effectively the same as [ 1.flexible, icon, 10.fixed, label, 1.flexible ]. + /// applyHorizontalSubviewDistribution { + /// icon + /// 10.fixed + /// label + /// } + /// ``` + /// + /// To evenly spread out items: + /// ``` + /// // This is effectively the same as [ 1.flexible, button1, 1.flexible, button2, 1.flexible, button3 ]. + /// applyHorizontalSubviewDistribution { + /// button1 + /// button2 + /// button3 + /// } + /// ``` + /// + /// To stack two elements with 50% more space after than before: + /// ``` + /// applyHorizontalSubviewDistribution { + /// 2.flexible + /// label + /// 12.fixed + /// textField + /// 3.flexible + /// } + /// ``` + /// + /// To arrange a pair of buttons on the left and right edges of a view, with a label centered between them: + /// ``` + /// applyHorizontalSubviewDistribution { + /// 8.fixed + /// backButton + /// 1.flexible + /// titleLabel + /// 1.flexible + /// nextButton + /// 8.fixed + /// } + /// ``` + /// + /// To arrange UI in a view with an interior margin: + /// ``` + /// applyHorizontalSubviewDistribution { + /// icon + /// 10.fixed + /// label + /// }, inRect: bounds.insetBy(dx: 20, dy: 40)) + /// ``` + /// + /// To arrange UI horizontally aligned by their top edge 10 pt in from the top edge of their superview: + /// ``` + /// applyHorizontalSubviewDistribution { + /// icon + /// 1.flexible + /// button + /// }, orthogonalOffset: .top(inset: 10)) + /// ``` + /// + /// To arrange UI horizontally without simultaneously centering it vertically (the `icon` would need independent + /// vertical positioning): + /// ``` + /// applyHorizontalSubviewDistribution { + /// 1.flexible + /// icon + /// 2.flexible + /// }, orthogonalOffset: nil) + /// ``` + /// + /// - precondition: All views in the `distribution` must be subviews of the receiver. + /// - precondition: The `distribution` must not include any given view more than once. + /// + /// - parameter distribution: An array of distribution specifiers, ordered from the leading edge to the trailing + /// edge. + /// - parameter layoutBounds: The region in the receiver in which to distribute the view in the receiver's + /// coordinate space. Specify `nil` to use the receiver's bounds. Defaults to `nil`. + /// - parameter orthogonalAlignment: The vertical alignment to apply to the views. If `nil`, views are left in + /// their vertical position prior to the distribution. Defaults to centered with no offset. + public func applyHorizontalSubviewDistribution( + @ViewDistributionBuilder _ distribution: () -> [ViewDistributionSpecifying], + inRect layoutBounds: CGRect? = nil, + orthogonalAlignment: VerticalDistributionAlignment? = .centered(offset: 0) + ) { + applyHorizontalSubviewDistribution( + distribution(), + inRect: layoutBounds, + orthogonalAlignment: orthogonalAlignment + ) + } +#endif + // MARK: - Private Methods private func applySubviewDistribution( diff --git a/Paralayout/ViewDistributionBuilder.swift b/Paralayout/ViewDistributionBuilder.swift new file mode 100644 index 0000000..41dfe37 --- /dev/null +++ b/Paralayout/ViewDistributionBuilder.swift @@ -0,0 +1,89 @@ +// +// Copyright © 2024 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. +// + +import UIKit + +#if swift(>=5.4) +@resultBuilder +public struct ViewDistributionBuilder { + + // Build expressions, which are turned into partial results. + + public static func buildExpression(_ component: ViewDistributionSpecifying) -> [ViewDistributionSpecifying] { + return [component] + } + public static func buildExpression(_ component: [ViewDistributionSpecifying?]) -> [ViewDistributionSpecifying] { + return component.compactMap { $0 } + } + public static func buildExpression(_ component: [ViewDistributionSpecifying]) -> [ViewDistributionSpecifying] { + return component + } + public static func buildExpression(_ component: ViewDistributionSpecifying?) -> [ViewDistributionSpecifying] { + return [component].compactMap { $0 } + } + + // Build partial results, which accumulate. + + public static func buildPartialBlock(first: ViewDistributionSpecifying) -> [ViewDistributionSpecifying] { + return [first] + } + public static func buildPartialBlock(first: [ViewDistributionSpecifying]) -> [ViewDistributionSpecifying] { + return first + } + public static func buildPartialBlock(accumulated: ViewDistributionSpecifying, next: ViewDistributionSpecifying) -> [ViewDistributionSpecifying] { + return [accumulated, next] + } + public static func buildPartialBlock(accumulated: ViewDistributionSpecifying, next: [ViewDistributionSpecifying]) -> [ViewDistributionSpecifying] { + return [accumulated] + next + } + public static func buildPartialBlock(accumulated: [ViewDistributionSpecifying], next: ViewDistributionSpecifying) -> [ViewDistributionSpecifying] { + return accumulated + [next] + } + public static func buildPartialBlock(accumulated: [ViewDistributionSpecifying], next: [ViewDistributionSpecifying]) -> [ViewDistributionSpecifying] { + return accumulated + next + } + + // Build if statements + + public static func buildOptional(_ component: [ViewDistributionSpecifying]?) -> [ViewDistributionSpecifying] { + return component ?? [] + } + public static func buildOptional(_ component: [ViewDistributionSpecifying]) -> [ViewDistributionSpecifying] { + return component + } + + // Build if-else and switch statements + + public static func buildEither(first component: [ViewDistributionSpecifying]) -> [ViewDistributionSpecifying] { + return component + } + public static func buildEither(second component: [ViewDistributionSpecifying]) -> [ViewDistributionSpecifying] { + return component + } + + // Build for-loop statements + + public static func buildArray(_ components: [[ViewDistributionSpecifying]]) -> [ViewDistributionSpecifying] { + return components.flatMap { $0 } + } + + // Build the blocks that turn into results. + + public static func buildBlock(_ components: [ViewDistributionSpecifying]...) -> [ViewDistributionSpecifying] { + return components.flatMap { $0 } + } +} +#endif diff --git a/ParalayoutTests/ViewDistributionBuilderTests.swift b/ParalayoutTests/ViewDistributionBuilderTests.swift new file mode 100644 index 0000000..ba2288d --- /dev/null +++ b/ParalayoutTests/ViewDistributionBuilderTests.swift @@ -0,0 +1,210 @@ +// +// Copyright © 2024 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. +// + +import Paralayout +import XCTest + +#if swift(>=5.4) +final class ViewDistributionBuilderTests: XCTestCase { + + // MARK: - Tests + + func testSimpleResultBuilder() throws { + let view = UIView() + XCTAssertEqual( + viewDistribution({ + ViewDistributionItem.fixed(8) + ViewDistributionItem.view(view, .zero) + ViewDistributionItem.flexible(1) + }).map { $0.distributionItem }, + [ + ViewDistributionItem.fixed(8), + ViewDistributionItem.view(view, .zero), + ViewDistributionItem.flexible(1), + ].map { $0.distributionItem } + ) + } + + func testIfTrueResultBuilder() throws { + let view = UIView() + let condition = true + XCTAssertEqual( + viewDistribution({ + ViewDistributionItem.fixed(8) + if condition { + ViewDistributionItem.view(view, .zero) + } + ViewDistributionItem.flexible(1) + }).map { $0.distributionItem }, + [ + ViewDistributionItem.fixed(8), + ViewDistributionItem.view(view, .zero), + ViewDistributionItem.flexible(1), + ].map { $0.distributionItem } + ) + } + + func testIfFalseResultBuilder() throws { + let view = UIView() + let condition = false + XCTAssertEqual( + viewDistribution({ + ViewDistributionItem.fixed(8) + if condition { + ViewDistributionItem.view(view, .zero) + } + ViewDistributionItem.flexible(1) + }).map { $0.distributionItem }, + [ + ViewDistributionItem.fixed(8), + ViewDistributionItem.flexible(1), + ].map { $0.distributionItem } + ) + } + + func testIfElseFirstBranchResultBuilder() throws { + let view = UIView() + view.tag = 1 + let otherView = UIView() + otherView.tag = 2 + let condition = true + XCTAssertEqual( + viewDistribution({ + ViewDistributionItem.fixed(8) + if condition { + ViewDistributionItem.view(view, .zero) + } else { + ViewDistributionItem.view(otherView, .zero) + } + ViewDistributionItem.flexible(1) + }).map { $0.distributionItem }, + [ + ViewDistributionItem.fixed(8), + ViewDistributionItem.view(view, .zero), + ViewDistributionItem.flexible(1), + ].map { $0.distributionItem } + ) + } + + func testIfElseSecondBranchResultBuilder() throws { + let view = UIView() + view.tag = 1 + let otherView = UIView() + otherView.tag = 2 + let condition = false + XCTAssertEqual( + viewDistribution({ + ViewDistributionItem.fixed(8) + if condition { + ViewDistributionItem.view(view, .zero) + } else { + ViewDistributionItem.view(otherView, .zero) + } + ViewDistributionItem.flexible(1) + }).map { $0.distributionItem }, + [ + ViewDistributionItem.fixed(8), + ViewDistributionItem.view(otherView, .zero), + ViewDistributionItem.flexible(1), + ].map { $0.distributionItem } + ) + } + + func testSwitchCaseResultBuilder() throws { + let view = UIView() + let value = 1 + XCTAssertEqual( + viewDistribution({ + ViewDistributionItem.fixed(8) + switch value { + case 1: + view + default: + nil + } + ViewDistributionItem.flexible(1) + }).map { $0.distributionItem }, + [ + ViewDistributionItem.fixed(8), + ViewDistributionItem.view(view, .zero), + ViewDistributionItem.flexible(1), + ].map { $0.distributionItem } + ) + } + + func testSwitchDefaultResultBuilder() throws { + let view = UIView() + let value = 2 + XCTAssertEqual( + viewDistribution({ + ViewDistributionItem.fixed(8) + switch value { + case 1: + view + default: + nil + } + ViewDistributionItem.flexible(1) + }).map { $0.distributionItem }, + [ + ViewDistributionItem.fixed(8), + ViewDistributionItem.flexible(1), + ].map { $0.distributionItem } + ) + } + + func testForLoopResultBuilder() throws { + XCTAssertEqual( + viewDistribution({ + for fixed in 1...5 { + ViewDistributionItem.fixed(CGFloat(fixed)) + } + }).map { $0.distributionItem }, + [ + ViewDistributionItem.fixed(1), + ViewDistributionItem.fixed(2), + ViewDistributionItem.fixed(3), + ViewDistributionItem.fixed(4), + ViewDistributionItem.fixed(5), + ].map { $0.distributionItem } + ) + } + + // MARK: - Private Methods + + private func viewDistribution(@ViewDistributionBuilder _ builder: () -> [ViewDistributionSpecifying]) -> [ViewDistributionSpecifying] { + builder() + } +} +#endif + +extension ViewDistributionItem: Equatable { + public static func == (lhs: ViewDistributionItem, rhs: ViewDistributionItem) -> Bool { + switch (lhs, rhs) { + case let (.view(lhsView, lhsEdgeInsets), .view(rhsView, rhsEdgeInsets)): + return lhsView === rhsView + && lhsEdgeInsets == rhsEdgeInsets + case let (.fixed(lhsFixed), .fixed(rhsFixed)): + return lhsFixed == rhsFixed + case let (.flexible(lhsFlexible), .flexible(rhsFlexible)): + return lhsFlexible == rhsFlexible + case (.view, _), + (.fixed, _), + (.flexible, _): + return false + } + } +}