├── .swift-version
├── images
└── layout.png
├── .swiftpm
└── xcode
│ ├── package.xcworkspace
│ └── contents.xcworkspacedata
│ └── xcshareddata
│ ├── xcbaselines
│ └── LayoutTests.xcbaseline
│ │ ├── 363F3548-14BA-4E40-9781-9B1D6C460DC9.plist
│ │ └── Info.plist
│ └── xcschemes
│ └── Layout.xcscheme
├── Sources
└── Layout
│ ├── Extensions
│ ├── NSDirectionalRectEdge+Extensions.swift
│ ├── NSDirectionalEdgeInsets+Extensions.swift
│ ├── Array+Extensions.swift
│ ├── ArrayBuilder.swift
│ ├── Spacers.swift
│ ├── ViewGuideBuilder.swift
│ ├── UIView+Extensions.swift
│ └── StackBuilder.swift
│ ├── Constraints
│ ├── ConstraintGenerators
│ │ ├── ConstraintGenerator.swift
│ │ ├── MultipleConstaints.swift
│ │ ├── ConstraintGenerator+Operators.swift
│ │ └── SingleConstraint.swift
│ ├── TypeSafeAttributes.swift
│ ├── Generators
│ │ ├── Center.swift
│ │ ├── Size.swift
│ │ └── Edge.swift
│ └── ConstrainableItem.swift
│ ├── Utils
│ └── FatalError.swift
│ └── DynamicLayout
│ ├── DynamicLayoutTraitEnvironmentProtocol.swift
│ ├── DynamicLayoutConfiguration.swift
│ ├── DynamicLayoutPredicate.swift
│ └── DynamicLayout.swift
├── Playground.playground
├── contents.xcplayground
└── Contents.swift
├── Package.swift
├── Tests
└── LayoutTests
│ ├── TestConstraintAutoIdentifiers.swift
│ ├── ConstraintGeneratorTests.swift
│ ├── SpacerTests.swift
│ ├── Utils
│ └── Utils.swift
│ ├── ConvenienceTests.swift
│ ├── PredicateExtensionTests.swift
│ ├── CenterTests.swift
│ ├── SizeTests.swift
│ ├── BuilderTests.swift
│ ├── EdgeTests.swift
│ └── DynamicLayoutTests.swift
├── .swiftformat
├── LICENSE
├── .swiftlint.yml
├── .gitignore
└── README.md
/.swift-version:
--------------------------------------------------------------------------------
1 | 5.4
2 |
--------------------------------------------------------------------------------
/images/layout.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noremac/Layout/HEAD/images/layout.png
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Sources/Layout/Extensions/NSDirectionalRectEdge+Extensions.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | public extension NSDirectionalRectEdge {
4 | static let horizontal: Self = [.leading, .trailing]
5 |
6 | static let vertical: Self = [.top, .bottom]
7 | }
8 |
--------------------------------------------------------------------------------
/Playground.playground/contents.xcplayground:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/Sources/Layout/Extensions/NSDirectionalEdgeInsets+Extensions.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | extension NSDirectionalEdgeInsets: ExpressibleByIntegerLiteral, ExpressibleByFloatLiteral {
4 | public init(integerLiteral value: Int) {
5 | let float = CGFloat(value)
6 | self.init(top: float, leading: float, bottom: float, trailing: float)
7 | }
8 |
9 | public init(floatLiteral value: Double) {
10 | let float = CGFloat(value)
11 | self.init(top: float, leading: float, bottom: float, trailing: float)
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.4
2 |
3 | import PackageDescription
4 |
5 | let package = Package(
6 | name: "Layout",
7 | platforms: [
8 | .iOS(.v13),
9 | .tvOS(.v13)
10 | ],
11 | products: [
12 | .library(
13 | name: "Layout",
14 | targets: ["Layout"]
15 | )
16 | ],
17 | targets: [
18 | .target(
19 | name: "Layout",
20 | dependencies: []
21 | ),
22 | .testTarget(
23 | name: "LayoutTests",
24 | dependencies: ["Layout"]
25 | )
26 | ]
27 | )
28 |
--------------------------------------------------------------------------------
/Tests/LayoutTests/TestConstraintAutoIdentifiers.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 | import XCTest
3 |
4 | import Layout
5 |
6 | class TestIdentifiers: XCTestCase {
7 | func testConstraintAutoIdentifiers() {
8 | let view = UIView()
9 | let constraints = view.makeConstraints {
10 | Width(1)
11 | }
12 | XCTAssertEqual(constraints[0].identifier, "TestConstraintAutoIdentifiers.swift:10")
13 | }
14 |
15 | func testIdentifierOperator() {
16 | let view = UIView()
17 | let constraints = view.makeConstraints {
18 | Width(1) <- "hello, world!"
19 | }
20 | XCTAssertEqual(constraints[0].identifier, "hello, world!")
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/Sources/Layout/Extensions/Array+Extensions.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | public extension Array where Element == NSLayoutConstraint {
4 | /// Sets the constant of each element of the receiver to the desired
5 | /// constant.
6 | ///
7 | /// - Parameter constant: The new constant.
8 | func setConstant(_ constant: CGFloat) {
9 | forEach {
10 | $0.constant = constant
11 | }
12 | }
13 |
14 | /// Activates each constraint of the receiver.
15 | func activate() {
16 | NSLayoutConstraint.activate(self)
17 | }
18 |
19 | /// Deactivates each constraint of the receiver.
20 | func deactivate() {
21 | NSLayoutConstraint.deactivate(self)
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcbaselines/LayoutTests.xcbaseline/363F3548-14BA-4E40-9781-9B1D6C460DC9.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | classNames
6 |
7 | DynamicLayoutTests
8 |
9 | testUpdatePerformance()
10 |
11 | com.apple.XCTPerformanceMetric_WallClockTime
12 |
13 | baselineAverage
14 | 0.211
15 | baselineIntegrationDisplayName
16 | Local Baseline
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/Sources/Layout/Constraints/ConstraintGenerators/ConstraintGenerator.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | public protocol MultipleConstraintGenerator {
4 | var priority: UILayoutPriority { get set }
5 |
6 | var identifier: String? { get set }
7 |
8 | func insertConstraints(withItem item: ConstrainableItem, into constraints: inout [NSLayoutConstraint])
9 | }
10 |
11 | public protocol SingleConstraintGenerator: MultipleConstraintGenerator {
12 | func constraint(withItem item: ConstrainableItem) -> NSLayoutConstraint
13 | }
14 |
15 | public extension SingleConstraintGenerator {
16 | func insertConstraints(withItem item: ConstrainableItem, into constraints: inout [NSLayoutConstraint]) {
17 | constraints.append(constraint(withItem: item))
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/.swiftformat:
--------------------------------------------------------------------------------
1 | # format options
2 |
3 | --allman false
4 | --binarygrouping 4,8
5 | --commas inline
6 | --comments indent
7 | --elseposition same-line
8 | --empty void
9 | --exponentcase lowercase
10 | --exponentgrouping disabled
11 | --fractiongrouping disabled
12 | --header ignore
13 | --hexgrouping 4,8
14 | --hexliteralcase uppercase
15 | --ifdef indent
16 | --importgrouping testable-bottom
17 | --indent 4
18 | --indentcase false
19 | --linebreaks lf
20 | --nospaceoperators ...,..<
21 | --octalgrouping 4,8
22 | --operatorfunc spaced
23 | --self init-only
24 | --semicolons inline
25 | --stripunusedargs unnamed-only
26 | --trimwhitespace always
27 | --wraparguments preserve
28 | --wrapcollections preserve
29 |
30 | # rules
31 |
32 | --enable blankLinesBetweenScopes
33 | --disable emptyBraces
34 | --disable trailingClosures
35 | --disable hoistPatternLet
36 |
--------------------------------------------------------------------------------
/Tests/LayoutTests/ConstraintGeneratorTests.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 | import XCTest
3 |
4 | @testable import Layout
5 |
6 | class ConstraintGeneratorTests: XCTestCase {
7 | var parentView: UIView!
8 | var view1: UIView!
9 | var view2: UIView!
10 | var layoutGuide: UILayoutGuide!
11 |
12 | override func setUp() {
13 | super.setUp()
14 | parentView = .init()
15 | view1 = .init()
16 | view2 = .init()
17 | layoutGuide = .init()
18 | parentView.addSubview(view1)
19 | parentView.addSubview(view2)
20 | parentView.addLayoutGuide(layoutGuide)
21 | }
22 |
23 | func testNoParentViewCrash() {
24 | let crashed = FatalError.withTestFatalError {
25 | UIView().applyConstraints {
26 | Leading()
27 | }
28 | }
29 | XCTAssertTrue(crashed)
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/Sources/Layout/Utils/FatalError.swift:
--------------------------------------------------------------------------------
1 | @usableFromInline
2 | enum FatalError {
3 | /// Crash. Runs `fatalError` unless overriden.
4 | private static var _crash: (@autoclosure () -> String, StaticString, UInt) -> Void = { message, file, line in
5 | fatalError(message(), file: file, line: line)
6 | }
7 |
8 | @usableFromInline
9 | static func crash(_ message: @autoclosure () -> String, file: StaticString = #file, line: UInt = #line) {
10 | _crash(message(), file, line)
11 | }
12 |
13 | #if DEBUG
14 | static func withTestFatalError(_ f: () -> Void) -> Bool {
15 | let originalCrash = _crash
16 | defer { _crash = originalCrash }
17 | var crashed = false
18 | _crash = { message, _, _ in
19 | print("would have crashed with message:", message())
20 | crashed = true
21 | }
22 | f()
23 | return crashed
24 | }
25 | #endif
26 | }
27 |
--------------------------------------------------------------------------------
/Sources/Layout/Constraints/ConstraintGenerators/MultipleConstaints.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | struct MultipleConstraints: MultipleConstraintGenerator {
4 | var constraintGenerators: [MultipleConstraintGenerator]
5 | var priority: UILayoutPriority = .required
6 | var identifier: String?
7 |
8 | init(@ArrayBuilder _ constraintGenerators: () -> [MultipleConstraintGenerator]) {
9 | self.constraintGenerators = constraintGenerators()
10 | }
11 |
12 | func insertConstraints(withItem item: ConstrainableItem, into constraints: inout [NSLayoutConstraint]) {
13 | let initialCount = constraints.count
14 | constraintGenerators.forEach { generator in
15 | generator.insertConstraints(withItem: item, into: &constraints)
16 | }
17 | for constraint in constraints[initialCount...] {
18 | constraint.priority = priority
19 |
20 | if let identifier = identifier {
21 | constraint.identifier = identifier
22 | }
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2021 Cameron Pulsford
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcbaselines/LayoutTests.xcbaseline/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | runDestinationsByUUID
6 |
7 | 363F3548-14BA-4E40-9781-9B1D6C460DC9
8 |
9 | localComputer
10 |
11 | busSpeedInMHz
12 | 100
13 | cpuCount
14 | 1
15 | cpuKind
16 | Quad-Core Intel Core i7
17 | cpuSpeedInMHz
18 | 4000
19 | logicalCPUCoresPerPackage
20 | 8
21 | modelCode
22 | iMac17,1
23 | physicalCPUCoresPerPackage
24 | 4
25 | platformIdentifier
26 | com.apple.platform.macosx
27 |
28 | targetArchitecture
29 | x86_64
30 | targetDevice
31 |
32 | modelCode
33 | iPhone13,3
34 | platformIdentifier
35 | com.apple.platform.iphonesimulator
36 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/Tests/LayoutTests/SpacerTests.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 | import XCTest
3 |
4 | import Layout
5 |
6 | class SpacerTests: XCTestCase {
7 | func testVertical() {
8 | let spacer = VerticalSpacer(minimumLength: 100)
9 | XCTAssertEqual(spacer.intrinsicContentSize, CGSize(width: UIView.noIntrinsicMetric, height: 8000))
10 | XCTAssertEqual(spacer.contentHuggingPriority(for: .vertical), .init(1))
11 | XCTAssertEqual(spacer.contentCompressionResistancePriority(for: .vertical), .init(1))
12 | let constraint = spacer.constraints.first
13 | XCTAssertEqual(constraint?.firstAttribute, .height)
14 | XCTAssertEqual(constraint?.constant, 100)
15 | XCTAssertEqual(constraint?.priority, .defaultLow)
16 | }
17 |
18 | func testHorizontal() {
19 | let spacer = HorizontalSpacer(minimumLength: 100)
20 | XCTAssertEqual(spacer.intrinsicContentSize, CGSize(width: 8000, height: UIView.noIntrinsicMetric))
21 | XCTAssertEqual(spacer.contentHuggingPriority(for: .horizontal), .init(1))
22 | XCTAssertEqual(spacer.contentCompressionResistancePriority(for: .horizontal), .init(1))
23 | let constraint = spacer.constraints.first
24 | XCTAssertEqual(constraint?.firstAttribute, .width)
25 | XCTAssertEqual(constraint?.constant, 100)
26 | XCTAssertEqual(constraint?.priority, .defaultLow)
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/Sources/Layout/Extensions/ArrayBuilder.swift:
--------------------------------------------------------------------------------
1 | @resultBuilder
2 | public enum ArrayBuilder {}
3 |
4 | public extension ArrayBuilder {
5 | typealias Expression = Element
6 |
7 | typealias Component = [Element]
8 |
9 | static func buildExpression(_ expression: Expression) -> Component {
10 | [expression]
11 | }
12 |
13 | static func buildExpression(_ expression: Expression?) -> Component {
14 | expression.map({ [$0] }) ?? []
15 | }
16 |
17 | static func buildBlock(_ children: Component...) -> Component {
18 | children.flatMap({ $0 })
19 | }
20 |
21 | static func buildExpression(_ expressions: [Expression]) -> Component {
22 | expressions
23 | }
24 |
25 | static func buildOptional(_ children: Component?) -> Component {
26 | children ?? []
27 | }
28 |
29 | static func buildBlock(_ component: Component) -> Component {
30 | component
31 | }
32 |
33 | static func buildEither(first child: Component) -> Component {
34 | child
35 | }
36 |
37 | static func buildEither(second child: Component) -> Component {
38 | child
39 | }
40 |
41 | static func buildArray(_ components: [Component]) -> Component {
42 | components.flatMap({ $0 })
43 | }
44 |
45 | static func buildLimitedAvailability(_ component: Component) -> Component {
46 | component
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/.swiftlint.yml:
--------------------------------------------------------------------------------
1 | disabled_rules:
2 | - file_length
3 | - for_where
4 | - force_cast
5 | - force_try
6 | - function_body_length
7 | - function_parameter_count
8 | - identifier_name
9 | - large_tuple
10 | - line_length
11 | - multiple_closures_with_trailing_closure
12 | - nesting
13 | - prohibited_interface_builder
14 | - type_body_length
15 | - type_name
16 |
17 | opt_in_rules:
18 | - anyobject_protocol
19 | - closure_end_indentation
20 | - closure_spacing
21 | - collection_alignment
22 | - conditional_returns_on_newline
23 | - contains_over_first_not_nil
24 | - convenience_type
25 | - discouraged_object_literal
26 | - empty_count
27 | - empty_string
28 | - empty_xctest_method
29 | - fallthrough
30 | - fatal_error_message
31 | - first_where
32 | - joined_default_parameter
33 | - legacy_random
34 | - lower_acl_than_parent
35 | - modifier_order
36 | - multiline_literal_brackets
37 | - multiline_parameters
38 | - multiline_parameters_brackets
39 | - operator_usage_whitespace
40 | - overridden_super_call
41 | - override_in_extension
42 | - prohibited_interface_builder
43 | - prohibited_super_call
44 | - redundant_type_annotation
45 | - single_test_class
46 | - sorted_first_last
47 | - sorted_imports
48 | - static_operator
49 | - switch_case_on_newline
50 | - toggle_bool
51 | - unavailable_function
52 | - unneeded_parentheses_in_closure_argument
53 |
--------------------------------------------------------------------------------
/Tests/LayoutTests/Utils/Utils.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 | import XCTest
3 |
4 | func XCTAssertEqualConstraints(_ sut: S, _ desired: [NSLayoutConstraint], file: StaticString = #file, line: UInt = #line) where S: Collection, S.Element == NSLayoutConstraint {
5 | guard desired.count == sut.count else {
6 | return XCTFail("Constraint count \(sut.count) does not match \(desired.count)", file: file, line: line)
7 | }
8 |
9 | let missingConstraints = desired.filter { c1 in
10 | !sut.contains(where: { c2 in c1.isEqualToConstraint(c2) })
11 | }
12 |
13 | guard missingConstraints.isEmpty else {
14 | return XCTFail("Could not find constraint\(missingConstraints.count > 1 ? "s" : "") matching: \(missingConstraints)", file: file, line: line)
15 | }
16 | }
17 |
18 | extension NSLayoutConstraint {
19 | func isEqualToConstraint(_ otherConstraint: NSLayoutConstraint) -> Bool {
20 | firstItem === otherConstraint.firstItem
21 | && secondItem === otherConstraint.secondItem
22 | && firstAttribute == otherConstraint.firstAttribute
23 | && secondAttribute == otherConstraint.secondAttribute
24 | && relation == otherConstraint.relation
25 | && multiplier == otherConstraint.multiplier
26 | && constant == otherConstraint.constant
27 | && priority == otherConstraint.priority
28 | && isActive == otherConstraint.isActive
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/Sources/Layout/Constraints/TypeSafeAttributes.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | public struct XAttribute {
4 | public let attribute: NSLayoutConstraint.Attribute
5 |
6 | public init(_ attribute: NSLayoutConstraint.Attribute) {
7 | self.attribute = attribute
8 | }
9 | }
10 |
11 | public extension XAttribute {
12 | static let left = XAttribute(.left)
13 |
14 | static let right = XAttribute(.right)
15 |
16 | static let leading = XAttribute(.leading)
17 |
18 | static let trailing = XAttribute(.trailing)
19 |
20 | static let centerX = XAttribute(.centerX)
21 | }
22 |
23 | public struct YAttribute {
24 | public let attribute: NSLayoutConstraint.Attribute
25 |
26 | public init(_ attribute: NSLayoutConstraint.Attribute) {
27 | self.attribute = attribute
28 | }
29 | }
30 |
31 | public extension YAttribute {
32 | static let top = YAttribute(.top)
33 |
34 | static let bottom = YAttribute(.bottom)
35 |
36 | static let firstBaseline = YAttribute(.firstBaseline)
37 |
38 | static let lastBaseline = YAttribute(.lastBaseline)
39 |
40 | static let centerY = YAttribute(.centerY)
41 | }
42 |
43 | public struct DimensionAttribute {
44 | public let attribute: NSLayoutConstraint.Attribute
45 |
46 | public init(_ attribute: NSLayoutConstraint.Attribute) {
47 | self.attribute = attribute
48 | }
49 | }
50 |
51 | public extension DimensionAttribute {
52 | static let width = DimensionAttribute(.width)
53 |
54 | static let height = DimensionAttribute(.height)
55 | }
56 |
--------------------------------------------------------------------------------
/Sources/Layout/Extensions/Spacers.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | public final class HorizontalSpacer: UIView {
4 | private let minimumLength: CGFloat
5 |
6 | public init(minimumLength: CGFloat = 0, file: String = #file, line: UInt = #line) {
7 | self.minimumLength = minimumLength
8 | super.init(frame: .zero)
9 | setContentHuggingPriority(.init(1), for: .horizontal)
10 | setContentCompressionResistancePriority(.init(1), for: .horizontal)
11 | applyConstraints {
12 | Width(minimumLength, file: file, line: line) ~ .defaultLow
13 | }
14 | }
15 |
16 | @available(*, unavailable)
17 | required init?(coder: NSCoder) {
18 | fatalError("init(coder:) has not been implemented")
19 | }
20 |
21 | override public var intrinsicContentSize: CGSize {
22 | CGSize(width: 8000, height: UIView.noIntrinsicMetric)
23 | }
24 | }
25 |
26 | public final class VerticalSpacer: UIView {
27 | private let minimumLength: CGFloat
28 |
29 | public init(minimumLength: CGFloat = 0, file: String = #file, line: UInt = #line) {
30 | self.minimumLength = minimumLength
31 | super.init(frame: .zero)
32 | setContentHuggingPriority(.init(1), for: .vertical)
33 | setContentCompressionResistancePriority(.init(1), for: .vertical)
34 | applyConstraints {
35 | Height(minimumLength, file: file, line: line) ~ .defaultLow
36 | }
37 | }
38 |
39 | @available(*, unavailable)
40 | required init?(coder: NSCoder) {
41 | fatalError("init(coder:) has not been implemented")
42 | }
43 |
44 | override public var intrinsicContentSize: CGSize {
45 | CGSize(width: UIView.noIntrinsicMetric, height: 8000)
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 | #
3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
4 |
5 | .DS_Store
6 |
7 | ## Build generated
8 | build/
9 | DerivedData/
10 |
11 | ## Various settings
12 | *.pbxuser
13 | !default.pbxuser
14 | *.mode1v3
15 | !default.mode1v3
16 | *.mode2v3
17 | !default.mode2v3
18 | *.perspectivev3
19 | !default.perspectivev3
20 | xcuserdata/
21 |
22 | ## Other
23 | *.moved-aside
24 | *.xccheckout
25 | *.xcscmblueprint
26 |
27 | ## Obj-C/Swift specific
28 | *.hmap
29 | *.ipa
30 | *.dSYM.zip
31 | *.dSYM
32 |
33 | ## Playgrounds
34 | timeline.xctimeline
35 | playground.xcworkspace
36 |
37 | # Swift Package Manager
38 | #
39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
40 | # Packages/
41 | # Package.pins
42 | # Package.resolved
43 | .build/
44 |
45 | # CocoaPods
46 | #
47 | # We recommend against adding the Pods directory to your .gitignore. However
48 | # you should judge for yourself, the pros and cons are mentioned at:
49 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
50 | #
51 | # Pods/
52 |
53 | # Carthage
54 | #
55 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
56 | # Carthage/Checkouts
57 |
58 | Carthage/Build
59 |
60 | # fastlane
61 | #
62 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
63 | # screenshots whenever they are needed.
64 | # For more information about the recommended setup visit:
65 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
66 |
67 | fastlane/report.xml
68 | fastlane/Preview.html
69 | fastlane/screenshots/**/*.png
70 | fastlane/test_output
71 |
--------------------------------------------------------------------------------
/Sources/Layout/DynamicLayout/DynamicLayoutTraitEnvironmentProtocol.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | /// A protocol for declaring that a `DynamicLayout`'s `Environment` has a trait
4 | /// collection property.
5 | public protocol DynamicLayoutTraitEnvironmentProtocol {
6 | /// The current `UITraitCollection`.
7 | var traitCollection: UITraitCollection { get }
8 | }
9 |
10 | extension UITraitCollection: DynamicLayoutTraitEnvironmentProtocol {
11 | /// Returns the receiver. This exists to satisfy
12 | /// `DynamicLayoutTraitEnvironmentProtocol`.
13 | public var traitCollection: UITraitCollection {
14 | self
15 | }
16 | }
17 |
18 | /// A protocol for declaring that a `DynamicLayout`'s `Environment` has a size
19 | /// property.
20 | public protocol DynamicLayoutSizeEnvironmentProtocol {
21 | /// The current `CGSize`.
22 | var size: CGSize { get }
23 | }
24 |
25 | extension CGSize: DynamicLayoutSizeEnvironmentProtocol {
26 | /// Returns the receiver. This exists to satisfy
27 | /// `DynamicLayoutSizeEnvironmentProtocol`.
28 | public var size: CGSize {
29 | self
30 | }
31 | }
32 |
33 | /// A struct that implements both `DynamicLayoutTraitEnvironmentProtocol` and
34 | /// `DynamicLayoutSizeEnvironmentProtocol`. It is useful for a
35 | /// `UIViewController`'s `DynamicLayout.Environment`.
36 | public struct DynamicLayoutTraitAndSizeEnvironment: DynamicLayoutTraitEnvironmentProtocol, DynamicLayoutSizeEnvironmentProtocol {
37 | /// The trait collection.
38 | public var traitCollection: UITraitCollection
39 |
40 | /// The size.
41 | public var size: CGSize
42 |
43 | public init(traitCollection: UITraitCollection, size: CGSize) {
44 | self.traitCollection = traitCollection
45 | self.size = size
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/Sources/Layout/Constraints/ConstraintGenerators/ConstraintGenerator+Operators.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | // swiftlint:disable static_operator
4 |
5 | precedencegroup ConstraintGeneratorPriorityAssignment {
6 | associativity: left
7 | lowerThan: ComparisonPrecedence
8 | higherThan: ConstraintGeneratorIdentifierAssignment
9 | }
10 |
11 | infix operator ~: ConstraintGeneratorPriorityAssignment
12 |
13 | /// Sets the priority of a `ConstraintGroup`.
14 | ///
15 | /// - Parameters:
16 | /// - lhs: The constraint generator.
17 | /// - rhs: The priority.
18 | /// - Returns: A new constraint generator whose priority has been modified.
19 | public func ~ (lhs: MultipleConstraintGenerator, rhs: UILayoutPriority) -> MultipleConstraintGenerator {
20 | var new = lhs
21 | new.priority = rhs
22 | return new
23 | }
24 |
25 | /// Sets the priority of a `ConstraintGroup`.
26 | ///
27 | /// - Parameters:
28 | /// - lhs: The constraint generator.
29 | /// - rhs: The priority.
30 | /// - Returns: A new constraint generator whose priority has been modified.
31 | public func ~ (lhs: MultipleConstraintGenerator, rhs: Float) -> MultipleConstraintGenerator {
32 | var new = lhs
33 | new.priority = .init(rawValue: rhs)
34 | return new
35 | }
36 |
37 | precedencegroup ConstraintGeneratorIdentifierAssignment {
38 | associativity: left
39 | lowerThan: ComparisonPrecedence
40 | }
41 |
42 | infix operator <-: ConstraintGeneratorIdentifierAssignment
43 |
44 | /// Sets the identifier of a `ConstraintGroup`.
45 | ///
46 | /// - Parameters:
47 | /// - lhs: The constraint generator.
48 | /// - rhs: The identifier.
49 | /// - Returns: A new constraint generator whose identifier has been modified.
50 | public func <- (lhs: MultipleConstraintGenerator, rhs: String?) -> MultipleConstraintGenerator {
51 | var new = lhs
52 | new.identifier = rhs
53 | return new
54 | }
55 |
--------------------------------------------------------------------------------
/Sources/Layout/Constraints/Generators/Center.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | public func CenterX(
4 | _ relation: NSLayoutConstraint.Relation = .equal,
5 | to secondItem: ConstrainableItem? = nil,
6 | attribute: XAttribute = .centerX,
7 | multiplier: CGFloat = 1,
8 | constant: CGFloat = 0,
9 | file: String = #file,
10 | line: UInt = #line
11 | ) -> SingleConstraintGenerator {
12 | SingleConstraint(
13 | attribute: .centerX,
14 | relatedBy: relation,
15 | to: secondItem.map(SingleConstraint.SecondItem.other) ?? .parent,
16 | attribute: attribute.attribute,
17 | multiplier: multiplier,
18 | constant: constant,
19 | file: file,
20 | line: line
21 | )
22 | }
23 |
24 | public func CenterY(
25 | _ relation: NSLayoutConstraint.Relation = .equal,
26 | to secondItem: ConstrainableItem? = nil,
27 | attribute: YAttribute = .centerY,
28 | multiplier: CGFloat = 1,
29 | constant: CGFloat = 0,
30 | file: String = #file,
31 | line: UInt = #line
32 | ) -> SingleConstraintGenerator {
33 | SingleConstraint(
34 | attribute: .centerY,
35 | relatedBy: relation,
36 | to: secondItem.map(SingleConstraint.SecondItem.other) ?? .parent,
37 | attribute: attribute.attribute,
38 | multiplier: multiplier,
39 | constant: constant,
40 | file: file,
41 | line: line
42 | )
43 | }
44 |
45 | public func Center(
46 | in secondItem: ConstrainableItem? = nil,
47 | offset: CGPoint = .zero,
48 | file: String = #file,
49 | line: UInt = #line
50 | ) -> MultipleConstraintGenerator {
51 | MultipleConstraints {
52 | CenterX(
53 | to: secondItem,
54 | constant: offset.x,
55 | file: file,
56 | line: line
57 | )
58 | CenterY(
59 | to: secondItem,
60 | constant: offset.y,
61 | file: file,
62 | line: line
63 | )
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/Playground.playground/Contents.swift:
--------------------------------------------------------------------------------
1 | import Layout
2 | import PlaygroundSupport
3 | import SwiftUI
4 | import UIKit
5 |
6 | class MyViewController: UIViewController {
7 | let image: UIView = {
8 | let view = UIView()
9 | view.backgroundColor = .red
10 | return view
11 | }()
12 |
13 | let badge: UIView = {
14 | let view = UIView()
15 | view.backgroundColor = .green
16 | return view
17 | }()
18 |
19 | let titleLabel: UILabel = {
20 | let label = UILabel()
21 | label.text = "Title!"
22 | label.font = .preferredFont(forTextStyle: .headline)
23 | label.numberOfLines = 0
24 | return label
25 | }()
26 |
27 | let summaryLabel: UILabel = {
28 | let label = UILabel()
29 | label.text = "Summary!"
30 | label.textColor = .secondaryLabel
31 | label.numberOfLines = 0
32 | return label
33 | }()
34 |
35 | let timeLabel: UILabel = {
36 | let label = UILabel()
37 | label.text = "30 minutes"
38 | label.textColor = .secondaryLabel
39 | return label
40 | }()
41 |
42 | let playButton: UIButton = {
43 | let view = UIButton(type: .system)
44 | view.setImage(UIImage(systemName: "play"), for: .normal)
45 | return view
46 | }()
47 |
48 | override func loadView() {
49 | view = UIStackView.vertical(spacing: 10) {
50 | image
51 | .overlay {
52 | badge.constraints {
53 | AlignEdges([.bottom, .trailing], insets: 8)
54 | Size(width: 20, height: 20)
55 | }
56 | }
57 | .constraints {
58 | AspectRatio(3 / 2)
59 | }
60 |
61 | UIStackView.vertical(spacing: 10) {
62 | titleLabel
63 | summaryLabel
64 | .spacingAfter(20)
65 | UIStackView.horizontal {
66 | timeLabel
67 | HorizontalSpacer()
68 | playButton
69 | }
70 | }
71 | .padding(.horizontal, insets: 8)
72 |
73 | VerticalSpacer()
74 | }
75 | }
76 | }
77 |
78 | PlaygroundPage.current.liveView = MyViewController()
79 |
--------------------------------------------------------------------------------
/Sources/Layout/DynamicLayout/DynamicLayoutConfiguration.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | public extension DynamicLayout {
4 | final class Configuration {
5 | var currentContext: DynamicLayout.Context {
6 | didSet {
7 | _globalConstraintContainer = currentContext
8 | }
9 | }
10 |
11 | init(_ currentContext: DynamicLayout.Context) {
12 | self.currentContext = currentContext
13 | }
14 | }
15 | }
16 |
17 | public extension DynamicLayout.Configuration {
18 | func addAction(_ action: @escaping (Environment) -> Void) {
19 | currentContext.actions.append(action)
20 | }
21 |
22 | func addAction(_ action: @escaping () -> Void) {
23 | currentContext.actions.append({ _ in action() })
24 | }
25 |
26 | func when(_ predicate: DynamicLayout.Predicate, _ when: () -> Void, otherwise: () -> Void) {
27 | let whenCtx = DynamicLayout.Context(predicate: predicate)
28 | let otherwiseCtx = DynamicLayout.Context(predicate: !predicate)
29 | let cc = currentContext
30 | currentContext = whenCtx
31 | when()
32 | currentContext = otherwiseCtx
33 | otherwise()
34 | if otherwiseCtx.hasConstraintsOrActions {
35 | whenCtx.otherwise = otherwiseCtx
36 | }
37 | currentContext = cc
38 | currentContext.children.append(whenCtx)
39 | }
40 |
41 | func when(_ predicate: DynamicLayout.Predicate, _ when: () -> Void) {
42 | self.when(predicate, when, otherwise: {})
43 | }
44 |
45 | func when(_ predicate: @escaping (_ env: Environment) -> Bool, _ when: () -> Void, otherwise: () -> Void) {
46 | self.when(.init(predicate), when, otherwise: otherwise)
47 | }
48 |
49 | func when(_ predicate: @escaping (_ env: Environment) -> Bool, _ when: () -> Void) {
50 | self.when(.init(predicate), when, otherwise: {})
51 | }
52 | }
53 |
54 | public extension DynamicLayout.Configuration where Environment: Equatable {
55 | func when(_ environment: Environment, _ when: () -> Void, otherwise: () -> Void) {
56 | self.when(.init({ $0 == environment }), when, otherwise: otherwise)
57 | }
58 |
59 | func when(_ environment: Environment, _ when: () -> Void) {
60 | self.when(.init({ $0 == environment }), when, otherwise: {})
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/Tests/LayoutTests/ConvenienceTests.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 | import XCTest
3 |
4 | import Layout
5 |
6 | class ConvenienceTests: XCTestCase {
7 | var view: UIView!
8 |
9 | override func setUp() {
10 | super.setUp()
11 | view = UIView()
12 | view.setContentHuggingPriority(.defaultLow, for: .vertical)
13 | view.setContentHuggingPriority(.defaultLow, for: .horizontal)
14 | view.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
15 | view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
16 | }
17 |
18 | func testVerticalContentHuggingPriority() {
19 | view.verticalContentHuggingPriority(.defaultHigh)
20 | XCTAssertEqual(view.contentHuggingPriority(for: .vertical), .defaultHigh)
21 | }
22 |
23 | func testHorizontalContentHuggingPriority() {
24 | view.horizontalContentHuggingPriority(.defaultHigh)
25 | XCTAssertEqual(view.contentHuggingPriority(for: .horizontal), .defaultHigh)
26 | }
27 |
28 | func testContentHuggingPriority() {
29 | view.contentHuggingPriority(.defaultHigh)
30 | XCTAssertEqual(view.contentHuggingPriority(for: .vertical), .defaultHigh)
31 | XCTAssertEqual(view.contentHuggingPriority(for: .horizontal), .defaultHigh)
32 | }
33 |
34 | func testVerticalContentCompressionResistance() {
35 | view.verticalContentCompressionResistance(.defaultHigh)
36 | XCTAssertEqual(view.contentCompressionResistancePriority(for: .vertical), .defaultHigh)
37 | }
38 |
39 | func testHorizontalContentCompressionResistance() {
40 | view.horizontalContentCompressionResistance(.defaultHigh)
41 | XCTAssertEqual(view.contentCompressionResistancePriority(for: .horizontal), .defaultHigh)
42 | }
43 |
44 | func testContentCompressionResistance() {
45 | view.contentCompressionResistance(.defaultHigh)
46 | XCTAssertEqual(view.contentCompressionResistancePriority(for: .vertical), .defaultHigh)
47 | XCTAssertEqual(view.contentCompressionResistancePriority(for: .horizontal), .defaultHigh)
48 | }
49 |
50 | func testContentCompressionResistanceAndHuggingPriority() {
51 | view.contentCompressionResistanceAndHuggingPriority(.defaultHigh)
52 | XCTAssertEqual(view.contentCompressionResistancePriority(for: .vertical), .defaultHigh)
53 | XCTAssertEqual(view.contentCompressionResistancePriority(for: .horizontal), .defaultHigh)
54 | XCTAssertEqual(view.contentHuggingPriority(for: .vertical), .defaultHigh)
55 | XCTAssertEqual(view.contentHuggingPriority(for: .horizontal), .defaultHigh)
56 | }
57 |
58 | func testAddAsSubview() {
59 | let parent = UIView()
60 | view.addAsSubview(to: parent)
61 | XCTAssertEqual(view.superview, parent)
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/Sources/Layout/Constraints/ConstraintGenerators/SingleConstraint.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | struct SingleConstraint: SingleConstraintGenerator {
4 | enum SecondItem {
5 | case `self`
6 | case parent
7 | case other(ConstrainableItem)
8 |
9 | fileprivate func item(for firstItem: ConstrainableItem) -> ConstrainableItem? {
10 | switch self {
11 | case .self:
12 | return firstItem
13 | case .parent:
14 | return firstItem.toItem ?? firstItem.parentView
15 | case .other(let other):
16 | return other
17 | }
18 | }
19 | }
20 |
21 | var firstAttribute: NSLayoutConstraint.Attribute
22 | var relation: NSLayoutConstraint.Relation
23 | var secondItem: SecondItem?
24 | var secondAttribute: NSLayoutConstraint.Attribute
25 | var multiplier: CGFloat
26 | var constant: CGFloat
27 | var priority: UILayoutPriority = .required
28 | var identifier: String?
29 |
30 | init(
31 | attribute firstAttribute: NSLayoutConstraint.Attribute,
32 | relatedBy relation: NSLayoutConstraint.Relation,
33 | to secondItem: SecondItem?,
34 | attribute secondAttribute: NSLayoutConstraint.Attribute,
35 | multiplier: CGFloat,
36 | constant: CGFloat,
37 | file: String,
38 | line: UInt
39 | ) {
40 | self.firstAttribute = firstAttribute
41 | self.relation = relation
42 | self.secondItem = secondItem
43 | self.secondAttribute = secondAttribute
44 | self.multiplier = multiplier
45 | self.constant = constant
46 | self.identifier = "\((file as NSString).lastPathComponent):\(line)"
47 | }
48 |
49 | func constraint(withItem firstItem: ConstrainableItem) -> NSLayoutConstraint {
50 | let secondItem = self.secondItem?.item(for: firstItem)
51 | #if DEBUG
52 | if secondAttribute == .notAnAttribute, secondItem != nil {
53 | FatalError.crash("Do not pass a second item when using 'notAnAttribute'.")
54 | }
55 |
56 | if firstItem === secondItem, firstAttribute == secondAttribute {
57 | FatalError.crash("Do not pass same item to 'to' and use the same attribute.")
58 | }
59 | #endif
60 |
61 | if secondItem == nil, secondAttribute != .notAnAttribute {
62 | FatalError.crash("You must have a parent view.")
63 | // Return a bogus constraint.
64 | return NSLayoutConstraint(item: firstItem, attribute: .width, relatedBy: .equal, toItem: nil, attribute: .notAnAttribute, multiplier: 1, constant: 0)
65 | }
66 |
67 | let constraint = NSLayoutConstraint(
68 | item: firstItem,
69 | attribute: firstAttribute,
70 | relatedBy: relation,
71 | toItem: secondItem,
72 | attribute: secondAttribute,
73 | multiplier: multiplier,
74 | constant: constant
75 | )
76 | constraint.priority = priority
77 | constraint.identifier = identifier
78 | return constraint
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/Tests/LayoutTests/PredicateExtensionTests.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 | import XCTest
3 |
4 | import Layout
5 |
6 | class PredicateExtensionTests: XCTestCase {
7 | func testOr() {
8 | let alwaysTrue = DynamicLayout.Predicate({ _ in true })
9 | let alwaysFalse = DynamicLayout.Predicate({ _ in false })
10 |
11 | XCTAssertTrue((alwaysTrue || alwaysTrue).evaluate(with: ()))
12 | XCTAssertTrue((alwaysTrue || alwaysFalse).evaluate(with: ()))
13 | XCTAssertFalse((alwaysFalse || alwaysFalse).evaluate(with: ()))
14 | }
15 |
16 | func testAnd() {
17 | let alwaysTrue = DynamicLayout.Predicate({ _ in true })
18 | let alwaysFalse = DynamicLayout.Predicate({ _ in false })
19 |
20 | XCTAssertTrue((alwaysTrue && alwaysTrue).evaluate(with: ()))
21 | XCTAssertFalse((alwaysTrue && alwaysFalse).evaluate(with: ()))
22 | XCTAssertFalse((alwaysFalse && alwaysFalse).evaluate(with: ()))
23 | }
24 |
25 | func testAlwaysTrue() {
26 | XCTAssertTrue(DynamicLayout.Predicate.always.evaluate(with: ()))
27 | }
28 |
29 | func testNegate() {
30 | let never = !DynamicLayout.Predicate.always
31 | XCTAssertFalse(never.evaluate(with: ()))
32 | }
33 |
34 | func testSizeClasses() {
35 | XCTAssertTrue(
36 | DynamicLayout
37 | .Predicate
38 | .verticallyUnspecified
39 | .evaluate(with: UITraitCollection(verticalSizeClass: .unspecified))
40 | )
41 | XCTAssertTrue(
42 | DynamicLayout
43 | .Predicate
44 | .verticallyRegular
45 | .evaluate(with: UITraitCollection(verticalSizeClass: .regular))
46 | )
47 | XCTAssertTrue(
48 | DynamicLayout
49 | .Predicate
50 | .verticallyCompact
51 | .evaluate(with: UITraitCollection(verticalSizeClass: .compact))
52 | )
53 |
54 | XCTAssertTrue(
55 | DynamicLayout
56 | .Predicate
57 | .horizontallyUnspecified
58 | .evaluate(with: UITraitCollection(horizontalSizeClass: .unspecified))
59 | )
60 | XCTAssertTrue(
61 | DynamicLayout
62 | .Predicate
63 | .horizontallyRegular
64 | .evaluate(with: UITraitCollection(horizontalSizeClass: .regular))
65 | )
66 | XCTAssertTrue(
67 | DynamicLayout
68 | .Predicate
69 | .horizontallyCompact
70 | .evaluate(with: UITraitCollection(horizontalSizeClass: .compact))
71 | )
72 | }
73 |
74 | func testSize() {
75 | XCTAssertTrue(
76 | DynamicLayout
77 | .Predicate
78 | .width(is: >, 10)
79 | .evaluate(with: .init(width: 11, height: 10))
80 | )
81 | XCTAssertTrue(
82 | DynamicLayout
83 | .Predicate
84 | .height(is: >, 10)
85 | .evaluate(with: .init(width: 10, height: 11))
86 | )
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/Sources/Layout/Extensions/ViewGuideBuilder.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | public extension UIView {
4 | static func build(@ArrayBuilder items: () -> [ConstrainableItem]) -> UIView {
5 | let previous = allowAdditionalConstraints
6 | allowAdditionalConstraints = true
7 | defer { allowAdditionalConstraints = previous }
8 | let superview = UIView()
9 | add(constrainableItems: items(), to: superview)
10 | return superview
11 | }
12 |
13 | @discardableResult
14 | func overlay(@ArrayBuilder items: () -> [ConstrainableItem]) -> Self {
15 | let previous = allowAdditionalConstraints
16 | allowAdditionalConstraints = true
17 | defer { allowAdditionalConstraints = previous }
18 | add(constrainableItems: items(), to: self)
19 | return self
20 | }
21 |
22 | func padding(
23 | _ edges: NSDirectionalRectEdge = .all,
24 | insets: NSDirectionalEdgeInsets = .zero,
25 | file: String = #file,
26 | line: UInt = #line
27 | ) -> UIView {
28 | let view = UIView()
29 | view.addSubview(self)
30 | applyConstraints {
31 | AlignEdges(edges, insets: insets, file: file, line: line)
32 | }
33 | return view
34 | }
35 | }
36 |
37 | public extension UILayoutGuide {
38 | static func build(@ArrayBuilder items: () -> [ConstrainableItem]) -> UILayoutGuide {
39 | let previous = allowAdditionalConstraints
40 | allowAdditionalConstraints = true
41 | defer { allowAdditionalConstraints = previous }
42 | return GuideBuilder(constrainableItems: items())
43 | }
44 | }
45 |
46 | private final class GuideBuilder: UILayoutGuide {
47 | var constrainableItems: [ConstrainableItem]
48 |
49 | init(constrainableItems: [ConstrainableItem]) {
50 | self.constrainableItems = constrainableItems
51 | super.init()
52 | }
53 |
54 | @available(*, unavailable)
55 | required init?(coder: NSCoder) {
56 | fatalError("init(coder:) has not been implemented")
57 | }
58 |
59 | override var owningView: UIView? {
60 | didSet {
61 | if let superview = owningView, !constrainableItems.isEmpty {
62 | constrainableItems.forEach({ $0.toItem = self })
63 | add(constrainableItems: constrainableItems, to: superview)
64 | constrainableItems.forEach({ $0.toItem = nil })
65 | constrainableItems.removeAll()
66 | }
67 | }
68 | }
69 | }
70 |
71 | private func add(constrainableItems: [ConstrainableItem], to superview: UIView) {
72 | constrainableItems.flatMap { item -> [NSLayoutConstraint] in
73 | if let view = item as? UIView {
74 | superview.addSubview(view)
75 | return view._additionalConstraints.map(view.makeConstraints(groups:)) ?? []
76 | } else if let guide = item as? UILayoutGuide {
77 | superview.addLayoutGuide(guide)
78 | return guide._additionalConstraints.map(guide.makeConstraints(groups:)) ?? []
79 | } else {
80 | FatalError.crash("Unknown item type: \(type(of: item))")
81 | return []
82 | }
83 | }
84 | .activate()
85 | }
86 |
--------------------------------------------------------------------------------
/Tests/LayoutTests/CenterTests.swift:
--------------------------------------------------------------------------------
1 | import Layout
2 | import UIKit
3 | import XCTest
4 |
5 | final class CenterTests: XCTestCase {
6 | var parentView: UIView!
7 | var view1: UIView!
8 | var view2: UIView!
9 |
10 | override func setUp() {
11 | super.setUp()
12 | parentView = .init()
13 | view1 = .init()
14 | view2 = .init()
15 | parentView.addSubview(view1)
16 | parentView.addSubview(view2)
17 | }
18 |
19 | func testCenterX() {
20 | let desiredConstraints = [
21 | NSLayoutConstraint(
22 | item: view1!,
23 | attribute: .centerX,
24 | relatedBy: .greaterThanOrEqual,
25 | toItem: view2,
26 | attribute: .leading,
27 | multiplier: 2,
28 | constant: 8
29 | )
30 | ]
31 | let constraints = view1.makeConstraints {
32 | CenterX(.greaterThanOrEqual, to: view2, attribute: .leading, multiplier: 2, constant: 8)
33 | }
34 | XCTAssertEqualConstraints(constraints, desiredConstraints)
35 | }
36 |
37 | func testCenterXDefaults() {
38 | let desiredConstraints = [
39 | view1.centerXAnchor.constraint(equalTo: parentView.centerXAnchor)
40 | ]
41 | let constraints = view1.makeConstraints {
42 | CenterX()
43 | }
44 | XCTAssertEqualConstraints(constraints, desiredConstraints)
45 | }
46 |
47 | func testCenterY() {
48 | let desiredConstraints = [
49 | NSLayoutConstraint(
50 | item: view1!,
51 | attribute: .centerY,
52 | relatedBy: .greaterThanOrEqual,
53 | toItem: view2,
54 | attribute: .top,
55 | multiplier: 2,
56 | constant: 8
57 | )
58 | ]
59 | let constraints = view1.makeConstraints {
60 | CenterY(.greaterThanOrEqual, to: view2, attribute: .top, multiplier: 2, constant: 8)
61 | }
62 | XCTAssertEqualConstraints(constraints, desiredConstraints)
63 | }
64 |
65 | func testCenterYDefaults() {
66 | let desiredConstraints = [
67 | view1.centerYAnchor.constraint(equalTo: parentView.centerYAnchor)
68 | ]
69 | let constraints = view1.makeConstraints {
70 | CenterY()
71 | }
72 | XCTAssertEqualConstraints(constraints, desiredConstraints)
73 | }
74 |
75 | func testCenter() {
76 | let desiredConstraints = [
77 | view1.centerXAnchor.constraint(equalTo: view2.centerXAnchor),
78 | view1.centerYAnchor.constraint(equalTo: view2.centerYAnchor)
79 | ]
80 | let constraints = view1.makeConstraints {
81 | Center(in: view2)
82 | }
83 | XCTAssertEqualConstraints(constraints, desiredConstraints)
84 | }
85 |
86 | func testCenterDefaults() {
87 | let desiredConstraints = [
88 | view1.centerXAnchor.constraint(equalTo: parentView.centerXAnchor),
89 | view1.centerYAnchor.constraint(equalTo: parentView.centerYAnchor)
90 | ]
91 | let constraints = view1.makeConstraints {
92 | Center()
93 | }
94 | XCTAssertEqualConstraints(constraints, desiredConstraints)
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Layout
2 |
3 | An expressive and extensible DSL for creating Auto Layout constraints and defining declarative layouts.
4 |
5 | - [Availability and Requirements](#availability-and-requirements)
6 | - [Creating Constraints](#creating-constraints)
7 | - [View builder DSL](#view-builder-dsl)
8 | - [`UIView` and `UILayoutGuide`](#uiview-and-uilayoutguide)
9 | - [Autoresizing Mask](#autoresizing-mask)
10 | - [Debugging Constraints](#debugging-constraints)
11 | - [License](#license)
12 |
13 | ## Availability and Requirements
14 |
15 | - Available as a Swift Package.
16 | - Requires Xcode 12 or higher.
17 | - Supports iOS 13+, and tvOS 13+.
18 |
19 | ## Creating Constraints
20 |
21 | ``` swift
22 | // Creating inactive constraints (save and activate/manipulate later)
23 | let constraints = view.makeConstraints {
24 | Center()
25 | Size(width: 100, height: 100)
26 | }
27 | ```
28 |
29 | ``` swift
30 | // Creating active constraints
31 | view.applyConstraints {
32 | Center()
33 | Size(width: 100, height: 100)
34 | }
35 | ```
36 |
37 | ## View builder DSL
38 |
39 | Create UIKit layouts with a view builder DSL.
40 |
41 | ``` swift
42 | UIStackView.vertical(spacing: 10) {
43 | image
44 | .overlay {
45 | badge.constraints {
46 | AlignEdges([.bottom, .trailing], insets: 8)
47 | Size(width: 20, height: 20)
48 | }
49 | }
50 | .constraints {
51 | AspectRatio(3 / 2)
52 | }
53 |
54 | UIStackView.vertical(spacing: 10) {
55 | titleLabel
56 |
57 | summaryLabel
58 | .spacingAfter(20)
59 |
60 | UIStackView.horizontal {
61 | timeLabel
62 | HorizontalSpacer()
63 | playButton
64 | }
65 | }
66 | .padding(.horizontal, insets: 8)
67 |
68 | VerticalSpacer()
69 | }
70 |
71 | ```
72 |
73 | Generates this layout:
74 |
75 | 
76 |
77 | ## `UIView` and `UILayoutGuide`
78 |
79 | `makeConstraints` and `applyConstraints` operate on both `UIView` and `UILayoutGuide`. All constraints that are setup up in relation to other items may also be either `UIView` or `UILayoutGuide`.
80 |
81 | ``` swift
82 | button.applyConstraints {
83 | Center(in: view.safeAreaLayoutGuide), // relating to a `UILayoutGuide`
84 | Size(to: otherButton) // relating to a `UIView`
85 | }
86 | ```
87 |
88 | Constraints that are related to another item default to the receiver’s parent view. Therefore, the following two examples are identical:
89 |
90 | ``` swift
91 | // Preferred
92 | button.applyConstraints {
93 | Center()
94 | }
95 | ```
96 |
97 | ``` swift
98 | // Not-preferred
99 | button.applyConstraints {
100 | Center(in: button.superview)
101 | }
102 | ```
103 |
104 | ## Autoresizing Mask
105 |
106 | Layout sets `translatesAutoresizingMaskIntoConstraints` to `false` on the receiver of the `makeConstraints` and `applyConstraints` calls.
107 |
108 | ## Debugging Constraints
109 |
110 | Layout automatically adds debug identifiers to all constraints that include the file, and line number of where the constraint was created.
111 |
112 | You may also set custom identifiers:
113 |
114 | ``` swift
115 | button.applyConstraints {
116 | Center() <- "my first custom identifier"
117 | }
118 | ```
119 |
120 | ## License
121 |
122 | This code and tool is under the MIT License. See `LICENSE` file in this repository.
123 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/Layout.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
29 |
35 |
36 |
37 |
38 |
39 |
45 |
46 |
48 |
54 |
55 |
56 |
57 |
58 |
68 |
69 |
75 |
76 |
82 |
83 |
84 |
85 |
87 |
88 |
91 |
92 |
93 |
--------------------------------------------------------------------------------
/Sources/Layout/Extensions/UIView+Extensions.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | public extension UIView {
4 | @discardableResult
5 | func contentCompressionResistanceAndHuggingPriority(_ priority: UILayoutPriority) -> Self {
6 | setContentCompressionResistancePriority(priority, for: .vertical)
7 | setContentCompressionResistancePriority(priority, for: .horizontal)
8 | setContentHuggingPriority(priority, for: .vertical)
9 | setContentHuggingPriority(priority, for: .horizontal)
10 | return self
11 | }
12 |
13 | @discardableResult
14 | func contentCompressionResistance(_ priority: UILayoutPriority) -> Self {
15 | setContentCompressionResistancePriority(priority, for: .vertical)
16 | setContentCompressionResistancePriority(priority, for: .horizontal)
17 | return self
18 | }
19 |
20 | @discardableResult
21 | func verticalContentCompressionResistance(_ priority: UILayoutPriority) -> Self {
22 | setContentCompressionResistancePriority(priority, for: .vertical)
23 | return self
24 | }
25 |
26 | @discardableResult
27 | func horizontalContentCompressionResistance(_ priority: UILayoutPriority) -> Self {
28 | setContentCompressionResistancePriority(priority, for: .horizontal)
29 | return self
30 | }
31 |
32 | @discardableResult
33 | func contentHuggingPriority(_ priority: UILayoutPriority) -> Self {
34 | setContentHuggingPriority(priority, for: .vertical)
35 | setContentHuggingPriority(priority, for: .horizontal)
36 | return self
37 | }
38 |
39 | @discardableResult
40 | func verticalContentHuggingPriority(_ priority: UILayoutPriority) -> Self {
41 | setContentHuggingPriority(priority, for: .vertical)
42 | return self
43 | }
44 |
45 | @discardableResult
46 | func horizontalContentHuggingPriority(_ priority: UILayoutPriority) -> Self {
47 | setContentHuggingPriority(priority, for: .horizontal)
48 | return self
49 | }
50 |
51 | @discardableResult
52 | func addAsSubview(to parentView: UIView) -> Self {
53 | parentView.addSubview(self)
54 | return self
55 | }
56 |
57 | func spacingAfter(_ spacing: CGFloat) -> Self {
58 | _spacingAfter = spacing
59 | return self
60 | }
61 | }
62 |
63 | var allowAdditionalConstraints = false
64 |
65 | private var _additionalConstraintsKey: UInt8 = 0
66 |
67 | public extension ConstrainableItem {
68 | internal var _additionalConstraints: [MultipleConstraintGenerator]? {
69 | get {
70 | objc_getAssociatedObject(self, &_additionalConstraintsKey) as? [MultipleConstraintGenerator]
71 | }
72 | set {
73 | objc_setAssociatedObject(self, &_additionalConstraintsKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
74 | }
75 | }
76 |
77 | func constraints(@ArrayBuilder _ constraints: () -> [MultipleConstraintGenerator]) -> Self {
78 | guard allowAdditionalConstraints else {
79 | FatalError.crash("\(#function) is only allowed when using the layout DSL.")
80 | return self
81 | }
82 | _additionalConstraints = constraints()
83 | return self
84 | }
85 | }
86 |
87 | extension UIView {
88 | static var _spacingAfterKey: UInt8 = 0
89 |
90 | var _spacingAfter: CGFloat? {
91 | get {
92 | objc_getAssociatedObject(self, &Self._spacingAfterKey) as? CGFloat
93 | }
94 | set {
95 | objc_setAssociatedObject(self, &Self._spacingAfterKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
96 | }
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/Sources/Layout/Extensions/StackBuilder.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | public extension UIStackView {
4 | private convenience init(
5 | axis: NSLayoutConstraint.Axis,
6 | distribution: UIStackView.Distribution,
7 | alignment: UIStackView.Alignment,
8 | spacing: CGFloat,
9 | isLayoutMarginsRelativeArrangement: Bool,
10 | directionalLayoutMargins: NSDirectionalEdgeInsets?,
11 | isBaselineRelativeArrangement: Bool,
12 | arrangedSubviews: [UIView]
13 | ) {
14 | self.init(arrangedSubviews: arrangedSubviews)
15 | self.axis = axis
16 | self.distribution = distribution
17 | self.alignment = alignment
18 | self.spacing = spacing
19 | self.isLayoutMarginsRelativeArrangement = isLayoutMarginsRelativeArrangement
20 | if let directionalLayoutMargins = directionalLayoutMargins {
21 | self.directionalLayoutMargins = directionalLayoutMargins
22 | }
23 | self.isBaselineRelativeArrangement = isBaselineRelativeArrangement
24 |
25 | arrangedSubviews.flatMap { view -> [NSLayoutConstraint] in
26 | if let spacing = view._spacingAfter {
27 | setCustomSpacing(spacing, after: view)
28 | }
29 |
30 | if let groups = view._additionalConstraints {
31 | return view.makeConstraints(groups: groups)
32 | } else {
33 | return []
34 | }
35 | }
36 | .activate()
37 | }
38 |
39 | static func horizontal(
40 | distribution: UIStackView.Distribution = .fill,
41 | alignment: UIStackView.Alignment = .fill,
42 | spacing: CGFloat = UIStackView.spacingUseDefault,
43 | isLayoutMarginsRelativeArrangement: Bool = false,
44 | directionalLayoutMargins: NSDirectionalEdgeInsets? = nil,
45 | @ArrayBuilder arrangedSubviews: () -> [UIView]
46 | ) -> UIStackView {
47 | let previous = allowAdditionalConstraints
48 | allowAdditionalConstraints = true
49 | defer { allowAdditionalConstraints = previous }
50 | return UIStackView(
51 | axis: .horizontal,
52 | distribution: distribution,
53 | alignment: alignment,
54 | spacing: spacing,
55 | isLayoutMarginsRelativeArrangement: isLayoutMarginsRelativeArrangement,
56 | directionalLayoutMargins: directionalLayoutMargins,
57 | isBaselineRelativeArrangement: false,
58 | arrangedSubviews: arrangedSubviews()
59 | )
60 | }
61 |
62 | static func vertical(
63 | distribution: UIStackView.Distribution = .fill,
64 | alignment: UIStackView.Alignment = .fill,
65 | spacing: CGFloat = UIStackView.spacingUseDefault,
66 | isLayoutMarginsRelativeArrangement: Bool = false,
67 | directionalLayoutMargins: NSDirectionalEdgeInsets? = nil,
68 | isBaselineRelativeArrangement: Bool = false,
69 | @ArrayBuilder arrangedSubviews: () -> [UIView]
70 | ) -> UIStackView {
71 | let previous = allowAdditionalConstraints
72 | allowAdditionalConstraints = true
73 | defer { allowAdditionalConstraints = previous }
74 | return UIStackView(
75 | axis: .vertical,
76 | distribution: distribution,
77 | alignment: alignment,
78 | spacing: spacing,
79 | isLayoutMarginsRelativeArrangement: isLayoutMarginsRelativeArrangement,
80 | directionalLayoutMargins: directionalLayoutMargins,
81 | isBaselineRelativeArrangement: isBaselineRelativeArrangement,
82 | arrangedSubviews: arrangedSubviews()
83 | )
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/Sources/Layout/Constraints/ConstrainableItem.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | /// This protocol defines an item that constraints can be applied to.
4 | /// - Note: Only `UIView` and `UILayoutGuide` should implement this protocol.
5 | public protocol ConstrainableItem: AnyObject {
6 | /// - Returns: The `UIView`'s `superview` or the `UILayoutGuide`'s
7 | /// `owningView`.
8 | var parentView: UIView? { get }
9 |
10 | /// Sets `translatesAutoresizingMaskIntoConstraints` to `false` for
11 | /// `UIView`s. It does nothing for `UILayoutGuide`s.
12 | func setTranslatesAutoresizingMaskIntoConstraintsFalseIfNecessary()
13 | }
14 |
15 | public extension ConstrainableItem {
16 | @inlinable
17 | func makeConstraints(groups: [MultipleConstraintGenerator]) -> [NSLayoutConstraint] {
18 | setTranslatesAutoresizingMaskIntoConstraintsFalseIfNecessary()
19 | let constraints = groups.reduce(into: [NSLayoutConstraint]()) { acc, generator in
20 | generator.insertConstraints(withItem: self, into: &acc)
21 | }
22 | if let container = _globalConstraintContainer {
23 | container.addConstraints(constraints)
24 | }
25 | return constraints
26 | }
27 |
28 | /// Creates and returns an array of `NSLayoutConstraint`s corresponding to
29 | /// the given groups.
30 | ///
31 | /// - Parameter groups: The groups of constraints you'd like.
32 | /// - Returns: The `NSLayoutConstraint`s corresponding to the given
33 | /// `ConstraintGroup`s.
34 | ///
35 | /// - Note: This method will call
36 | /// `setTranslatesAutoresizingMaskIntoConstraintsFalseIfNecessary()` on
37 | /// the receiver automatically.
38 | @inlinable
39 | @discardableResult
40 | func makeConstraints(@ArrayBuilder _ groups: () -> [MultipleConstraintGenerator]) -> [NSLayoutConstraint] {
41 | makeConstraints(groups: groups())
42 | }
43 |
44 | /// Creates, immediately activates, and returns an array of
45 | /// `NSLayoutConstraint`s corresponding to the given groups.
46 | ///
47 | /// - Parameter groups: The groups of constraints you'd like.
48 | /// - Returns: The `NSLayoutConstraint`s corresponding to the given
49 | /// `ConstraintGroup`s.
50 | ///
51 | /// - Note: This method will call
52 | /// `setTranslatesAutoresizingMaskIntoConstraintsFalseIfNecessary()` on
53 | /// the receiver automatically.
54 | @inlinable
55 | @discardableResult
56 | func applyConstraints(file: StaticString = #file, line: UInt = #line, @ArrayBuilder _ groups: () -> [MultipleConstraintGenerator]) -> [NSLayoutConstraint] {
57 | guard _globalConstraintContainer == nil else {
58 | FatalError.crash("Call makeConstraints, not applyConstraints, when configurationg a DynamicLayout.", file: file, line: line)
59 | return []
60 | }
61 | let constraints = makeConstraints(groups: groups())
62 | constraints.activate()
63 | return constraints
64 | }
65 | }
66 |
67 | // MARK: - Implementations
68 |
69 | extension UIView: ConstrainableItem {
70 | /// Returns the receiver's `superview`.
71 | public var parentView: UIView? {
72 | superview
73 | }
74 |
75 | /// Sets `translatesAutoresizingMaskIntoConstraints` to `false` on the
76 | /// receiver.
77 | public func setTranslatesAutoresizingMaskIntoConstraintsFalseIfNecessary() {
78 | translatesAutoresizingMaskIntoConstraints = false
79 | }
80 | }
81 |
82 | extension UILayoutGuide: ConstrainableItem {
83 | /// Returns the receiver's `owningView`.
84 | public var parentView: UIView? {
85 | owningView
86 | }
87 |
88 | /// This does nothing on `UILayoutGuide`s.
89 | public func setTranslatesAutoresizingMaskIntoConstraintsFalseIfNecessary() {}
90 | }
91 |
92 | // MARK: Internal
93 |
94 | private var constrainableItemToItemKey: UInt8 = 0
95 |
96 | extension ConstrainableItem {
97 | var toItem: ConstrainableItem? {
98 | get {
99 | objc_getAssociatedObject(self, &constrainableItemToItemKey) as? ConstrainableItem
100 | }
101 | set {
102 | objc_setAssociatedObject(self, &constrainableItemToItemKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
103 | }
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/Sources/Layout/Constraints/Generators/Size.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | // MARK: Width
4 |
5 | public func Width(
6 | _ relation: NSLayoutConstraint.Relation,
7 | _ constant: CGFloat,
8 | file: String = #file,
9 | line: UInt = #line
10 | ) -> SingleConstraintGenerator {
11 | SingleConstraint(
12 | attribute: .width,
13 | relatedBy: relation,
14 | to: nil,
15 | attribute: .notAnAttribute,
16 | multiplier: 1,
17 | constant: constant,
18 | file: file,
19 | line: line
20 | )
21 | }
22 |
23 | public func Width(
24 | _ constant: CGFloat,
25 | file: String = #file,
26 | line: UInt = #line
27 | ) -> SingleConstraintGenerator {
28 | Width(
29 | .equal,
30 | constant,
31 | file: file,
32 | line: line
33 | )
34 | }
35 |
36 | public func Width(
37 | _ relation: NSLayoutConstraint.Relation = .equal,
38 | to secondItem: ConstrainableItem? = nil,
39 | attribute: DimensionAttribute = .width,
40 | multiplier: CGFloat = 1,
41 | constant: CGFloat = 0,
42 | file: String = #file,
43 | line: UInt = #line
44 | ) -> SingleConstraintGenerator {
45 | SingleConstraint(
46 | attribute: .width,
47 | relatedBy: relation,
48 | to: secondItem.map(SingleConstraint.SecondItem.other) ?? .parent,
49 | attribute: attribute.attribute,
50 | multiplier: multiplier,
51 | constant: constant,
52 | file: file,
53 | line: line
54 | )
55 | }
56 |
57 | // MARK: Height
58 |
59 | public func Height(
60 | _ relation: NSLayoutConstraint.Relation,
61 | _ constant: CGFloat,
62 | file: String = #file,
63 | line: UInt = #line
64 | ) -> SingleConstraintGenerator {
65 | SingleConstraint(
66 | attribute: .height,
67 | relatedBy: relation,
68 | to: nil,
69 | attribute: .notAnAttribute,
70 | multiplier: 1,
71 | constant: constant,
72 | file: file,
73 | line: line
74 | )
75 | }
76 |
77 | public func Height(
78 | _ constant: CGFloat,
79 | file: String = #file,
80 | line: UInt = #line
81 | ) -> SingleConstraintGenerator {
82 | Height(
83 | .equal,
84 | constant,
85 | file: file,
86 | line: line
87 | )
88 | }
89 |
90 | public func Height(
91 | _ relation: NSLayoutConstraint.Relation = .equal,
92 | to secondItem: ConstrainableItem? = nil,
93 | attribute: DimensionAttribute = .height,
94 | multiplier: CGFloat = 1,
95 | constant: CGFloat = 0,
96 | file: String = #file,
97 | line: UInt = #line
98 | ) -> SingleConstraintGenerator {
99 | SingleConstraint(
100 | attribute: .height,
101 | relatedBy: relation,
102 | to: secondItem.map(SingleConstraint.SecondItem.other) ?? .parent,
103 | attribute: attribute.attribute,
104 | multiplier: multiplier,
105 | constant: constant,
106 | file: file,
107 | line: line
108 | )
109 | }
110 |
111 | // MARK: Size
112 |
113 | public func Size(
114 | _ size: CGSize,
115 | file: String = #file,
116 | line: UInt = #line
117 | ) -> MultipleConstraintGenerator {
118 | MultipleConstraints {
119 | Width(
120 | size.width,
121 | file: file,
122 | line: line
123 | )
124 | Height(
125 | size.height,
126 | file: file,
127 | line: line
128 | )
129 | }
130 | }
131 |
132 | public func Size(
133 | width: CGFloat,
134 | height: CGFloat,
135 | file: String = #file,
136 | line: UInt = #line
137 | ) -> MultipleConstraintGenerator {
138 | Size(
139 | CGSize(width: width, height: height),
140 | file: file,
141 | line: line
142 | )
143 | }
144 |
145 | public func Size(
146 | _ relation: NSLayoutConstraint.Relation = .equal,
147 | to secondItem: ConstrainableItem? = nil,
148 | multiplier: CGFloat = 1,
149 | constant: CGFloat = 0,
150 | file: String = #file,
151 | line: UInt = #line
152 | ) -> MultipleConstraintGenerator {
153 | MultipleConstraints {
154 | Width(
155 | relation,
156 | to: secondItem,
157 | multiplier: multiplier,
158 | constant: constant,
159 | file: file,
160 | line: line
161 | )
162 | Height(
163 | relation,
164 | to: secondItem,
165 | multiplier: multiplier,
166 | constant: constant,
167 | file: file,
168 | line: line
169 | )
170 | }
171 | }
172 |
173 | // MARK: Aspect ratio
174 |
175 | public func AspectRatio(
176 | _ ratio: CGFloat,
177 | file: String = #file,
178 | line: UInt = #line
179 | ) -> SingleConstraintGenerator {
180 | SingleConstraint(
181 | attribute: .width,
182 | relatedBy: .equal,
183 | to: .self,
184 | attribute: .height,
185 | multiplier: ratio,
186 | constant: 0,
187 | file: file,
188 | line: line
189 | )
190 | }
191 |
192 | public func AspectRatio(
193 | _ size: CGSize,
194 | file: String = #file,
195 | line: UInt = #line
196 | ) -> SingleConstraintGenerator {
197 | AspectRatio(
198 | size.width / size.height,
199 | file: file,
200 | line: line
201 | )
202 | }
203 |
--------------------------------------------------------------------------------
/Sources/Layout/DynamicLayout/DynamicLayoutPredicate.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | public extension DynamicLayout.Predicate {
4 | /// Evaluates the receiver with the given environment.
5 | ///
6 | /// - Parameter environment: An `Environment` value.
7 | /// - Returns: The vale of the predicate with the given environment.
8 | func evaluate(with environment: Environment) -> Bool {
9 | predicate(environment)
10 | }
11 | }
12 |
13 | public extension DynamicLayout.Predicate {
14 | /// Creates a new composite `Predicate` that `or`s together two others.
15 | ///
16 | /// - Parameters:
17 | /// - lhs: The first `Predicate`.
18 | /// - rhs: The second `Predicate`.
19 | /// - Returns: A new composite `Predicate` that `or`s together two others.
20 | static func || (lhs: DynamicLayout.Predicate, rhs: DynamicLayout.Predicate) -> DynamicLayout.Predicate {
21 | .init { env in
22 | lhs.evaluate(with: env) || rhs.evaluate(with: env)
23 | }
24 | }
25 |
26 | /// Creates a new composite `Predicate` that `and`s together two others.
27 | ///
28 | /// - Parameters:
29 | /// - lhs: The first `Predicate`.
30 | /// - rhs: The second `Predicate`.
31 | /// - Returns: A new composite `Predicate` that `and`s together two others.
32 | static func && (lhs: DynamicLayout.Predicate, rhs: DynamicLayout.Predicate) -> DynamicLayout.Predicate {
33 | .init { env in
34 | lhs.evaluate(with: env) && rhs.evaluate(with: env)
35 | }
36 | }
37 |
38 | /// Creates a new `Predicate` that negates the given `Predicate`.
39 | ///
40 | /// - Parameter predicate: The `Predicate` to negate.
41 | /// - Returns: A new `Predicate` that negates the given `Predicate`.
42 | static prefix func ! (predicate: DynamicLayout.Predicate) -> DynamicLayout.Predicate {
43 | .init { env in
44 | !predicate.evaluate(with: env)
45 | }
46 | }
47 | }
48 |
49 | public extension DynamicLayout.Predicate {
50 | /// Returns a `Predicate` that always evaluates to `true`.
51 | static var always: DynamicLayout.Predicate {
52 | .init { _ in true }
53 | }
54 | }
55 |
56 | public extension DynamicLayout.Predicate where Environment: DynamicLayoutTraitEnvironmentProtocol {
57 | /// Returns a `Predicate` that is `true` if the `Environment`'s current
58 | /// vertical size class is unspecified.
59 | static var verticallyUnspecified: DynamicLayout.Predicate {
60 | .init { env in
61 | env.traitCollection.verticalSizeClass == .unspecified
62 | }
63 | }
64 |
65 | /// Returns a `Predicate` that is `true` if the `Environment`'s current
66 | /// vertical size class is regular.
67 | static var verticallyRegular: DynamicLayout.Predicate {
68 | .init { env in
69 | env.traitCollection.verticalSizeClass == .regular
70 | }
71 | }
72 |
73 | /// Returns a `Predicate` that is `true` if the `Environment`'s current
74 | /// vertical size class is compact.
75 | static var verticallyCompact: DynamicLayout.Predicate {
76 | .init { env in
77 | env.traitCollection.verticalSizeClass == .compact
78 | }
79 | }
80 |
81 | /// Returns a `Predicate` that is `true` if the `Environment`'s current
82 | /// horizontal size class is unspecified.
83 | static var horizontallyUnspecified: DynamicLayout.Predicate {
84 | .init { env in
85 | env.traitCollection.horizontalSizeClass == .unspecified
86 | }
87 | }
88 |
89 | /// Returns a `Predicate` that is `true` if the `Environment`'s current
90 | /// horizontal size class is regular.
91 | static var horizontallyRegular: DynamicLayout.Predicate {
92 | .init { env in
93 | env.traitCollection.horizontalSizeClass == .regular
94 | }
95 | }
96 |
97 | /// Returns a `Predicate` that is `true` if the `Environment`'s current
98 | /// horizontal size class is compact.
99 | static var horizontallyCompact: DynamicLayout.Predicate {
100 | .init { env in
101 | env.traitCollection.horizontalSizeClass == .compact
102 | }
103 | }
104 | }
105 |
106 | public extension DynamicLayout.Predicate where Environment: DynamicLayoutSizeEnvironmentProtocol {
107 | /// Returns a `Predicate` that is `true` when the given closure is `true`.
108 | ///
109 | /// - Parameters:
110 | /// - f: The closure.
111 | /// - width: The `Environment`'s width.
112 | /// - other: The "other" value.
113 | /// - Returns: A `Predicate` that is `true` when the given closure is
114 | /// `true`.
115 | static func width(is f: @escaping (_ width: CGFloat, _ other: CGFloat) -> Bool, _ other: CGFloat) -> DynamicLayout.Predicate {
116 | .init { env in
117 | f(env.size.width, other)
118 | }
119 | }
120 |
121 | /// Returns a `Predicate` that is `true` when the given closure is `true`.
122 | ///
123 | /// - Parameters:
124 | /// - f: The closure.
125 | /// - height: The `Environment`'s height.
126 | /// - other: The "other" value.
127 | /// - Returns: A `Predicate` that is `true` when the given closure is
128 | /// `true`.
129 | static func height(is f: @escaping (_ height: CGFloat, _ other: CGFloat) -> Bool, _ other: CGFloat) -> DynamicLayout.Predicate {
130 | .init { env in
131 | f(env.size.height, other)
132 | }
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/Sources/Layout/DynamicLayout/DynamicLayout.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | @usableFromInline
4 | protocol _GlobalConstraintContainer {
5 | func addConstraints(_ constraints: [NSLayoutConstraint])
6 | }
7 |
8 | @usableFromInline
9 | var _globalConstraintContainer: _GlobalConstraintContainer?
10 |
11 | /// A class for creating, storing, and activating `NSLayoutConstraint`s based on
12 | /// arbitrary predicates.
13 | ///
14 | /// `Context`'s are stored as a tree and thus may be nested. A child `Context`'s
15 | /// `Predicate` is only evaluated if its parent's `Predicate` evaluates to
16 | /// `true`.
17 | ///
18 | /// The initial `Context` received in the `configure` block is always `true`.
19 | /// Constraints that should always be active regardless of any state may
20 | /// be added there.
21 | public class DynamicLayout {
22 | /// A struct for defining a condition to be met for constraints to be
23 | /// activated.
24 | public struct Predicate {
25 | /// The predicate.
26 | public let predicate: (Environment) -> Bool
27 |
28 | /// Initializes a new `Predicate` with the given closure.
29 | ///
30 | /// - Parameter predicate: The closure.
31 | public init(_ predicate: @escaping (Environment) -> Bool) {
32 | self.predicate = predicate
33 | }
34 | }
35 |
36 | /// A struct for holding a `Predicate` and its associated
37 | /// `NSLayoutConstraint`s.
38 | final class Context: _GlobalConstraintContainer {
39 | var predicate: Predicate
40 |
41 | var constraints: [NSLayoutConstraint] = []
42 |
43 | var actions: [(Environment) -> Void] = []
44 |
45 | var children: [Context] = []
46 |
47 | var otherwise: Context?
48 |
49 | init(predicate: Predicate) {
50 | self.predicate = predicate
51 | }
52 |
53 | /// Inspects self and children/other for constraints or actions.
54 | var hasConstraintsOrActions: Bool {
55 | if !constraints.isEmpty || !actions.isEmpty {
56 | return true
57 | }
58 | if children.contains(where: \.hasConstraintsOrActions) {
59 | return true
60 | }
61 | return otherwise?.hasConstraintsOrActions ?? false
62 | }
63 |
64 | func activeContexts(for environment: Environment) -> [DynamicLayout.Context] {
65 | if predicate.evaluate(with: environment) {
66 | return children.reduce(into: [self], { $0 += $1.activeContexts(for: environment) })
67 | }
68 | return otherwise?.activeContexts(for: environment) ?? []
69 | }
70 |
71 | func addConstraints(_ constraints: [NSLayoutConstraint]) {
72 | self.constraints += constraints
73 | }
74 | }
75 |
76 | var hasBeenConfigured = false
77 |
78 | var mainContext: Context = .init(predicate: .always)
79 |
80 | var activeConstraints: Set = []
81 |
82 | /// Initializes a DynamicLayout.
83 | public init() {}
84 |
85 | /// Add constraints here. The closure receives the main `Context` whose
86 | /// `Predicate` will always evaluate to `true`. From here, create as many
87 | /// `Context`s as needed and nest them as desired.
88 | ///
89 | /// - Parameters:
90 | /// - main: The closure where constraints are configured.
91 | /// - ctx: The main `Context` whose `Predicate` is always `true`.
92 | ///
93 | /// - Note: You may add constraints that should always be active regardless
94 | /// of any other condition to the main `Context`. These constraints will
95 | /// not be activated until the first call to `updateActiveConstraints`.
96 | ///
97 | /// - Attention: A `fatalError` will be hit if this method is called more
98 | /// than once.
99 | public func configure(file: StaticString = #file, line: UInt = #line, _ main: (_ configuration: Configuration) -> Void) {
100 | guard !hasBeenConfigured else {
101 | return FatalError.crash("\(#function) should only be called once", file: file, line: line)
102 | }
103 | hasBeenConfigured = true
104 | _globalConstraintContainer = mainContext
105 | main(Configuration(mainContext))
106 | _globalConstraintContainer = nil
107 | }
108 |
109 | /// Walks the tree of `Context`s and activates all the constraints whose
110 | /// `Predicate`s are `true` in the given `Environment`.
111 | ///
112 | /// - Parameter environment: The `Environment` to use for evaluation.
113 | public func update(environment: Environment) {
114 | let contexts = mainContext.activeContexts(for: environment)
115 | let (newConstraints, actions) = contexts.reduce(into: ([NSLayoutConstraint](), [(Environment) -> Void]())) { result, context in
116 | result.0.append(contentsOf: context.constraints)
117 | result.1.append(contentsOf: context.actions)
118 | }
119 | let newSet = Set(newConstraints)
120 | var constraintsToDeactivate = [NSLayoutConstraint]()
121 | var constraintsToActivate = [NSLayoutConstraint]()
122 | for newConstraint in newSet {
123 | if !activeConstraints.contains(newConstraint) {
124 | constraintsToActivate.append(newConstraint)
125 | }
126 | }
127 | for oldConstraint in activeConstraints {
128 | if !newSet.contains(oldConstraint) {
129 | constraintsToDeactivate.append(oldConstraint)
130 | }
131 | }
132 | constraintsToDeactivate.deactivate()
133 | constraintsToActivate.activate()
134 | activeConstraints = newSet
135 | actions.forEach { $0(environment) }
136 | }
137 | }
138 |
--------------------------------------------------------------------------------
/Sources/Layout/Constraints/Generators/Edge.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | public func Top(
4 | _ relation: NSLayoutConstraint.Relation = .equal,
5 | to secondItem: ConstrainableItem? = nil,
6 | attribute: YAttribute = .top,
7 | multiplier: CGFloat = 1,
8 | constant: CGFloat = 0,
9 | file: String = #file,
10 | line: UInt = #line
11 | ) -> SingleConstraintGenerator {
12 | SingleConstraint(
13 | attribute: .top,
14 | relatedBy: relation,
15 | to: secondItem.map(SingleConstraint.SecondItem.other) ?? .parent,
16 | attribute: attribute.attribute,
17 | multiplier: multiplier,
18 | constant: constant,
19 | file: file,
20 | line: line
21 | )
22 | }
23 |
24 | public func Leading(
25 | _ relation: NSLayoutConstraint.Relation = .equal,
26 | to secondItem: ConstrainableItem? = nil,
27 | attribute: XAttribute = .leading,
28 | multiplier: CGFloat = 1,
29 | constant: CGFloat = 0,
30 | file: String = #file,
31 | line: UInt = #line
32 | ) -> SingleConstraintGenerator {
33 | SingleConstraint(
34 | attribute: .leading,
35 | relatedBy: relation,
36 | to: secondItem.map(SingleConstraint.SecondItem.other) ?? .parent,
37 | attribute: attribute.attribute,
38 | multiplier: multiplier,
39 | constant: constant,
40 | file: file,
41 | line: line
42 | )
43 | }
44 |
45 | public func Bottom(
46 | _ relation: NSLayoutConstraint.Relation = .equal,
47 | to secondItem: ConstrainableItem? = nil,
48 | attribute: YAttribute = .bottom,
49 | multiplier: CGFloat = 1,
50 | constant: CGFloat = 0,
51 | file: String = #file,
52 | line: UInt = #line
53 | ) -> SingleConstraintGenerator {
54 | SingleConstraint(
55 | attribute: .bottom,
56 | relatedBy: relation,
57 | to: secondItem.map(SingleConstraint.SecondItem.other) ?? .parent,
58 | attribute: attribute.attribute,
59 | multiplier: multiplier,
60 | constant: constant,
61 | file: file,
62 | line: line
63 | )
64 | }
65 |
66 | public func Trailing(
67 | _ relation: NSLayoutConstraint.Relation = .equal,
68 | to secondItem: ConstrainableItem? = nil,
69 | attribute: XAttribute = .trailing,
70 | multiplier: CGFloat = 1,
71 | constant: CGFloat = 0,
72 | file: String = #file,
73 | line: UInt = #line
74 | ) -> SingleConstraintGenerator {
75 | SingleConstraint(
76 | attribute: .trailing,
77 | relatedBy: relation,
78 | to: secondItem.map(SingleConstraint.SecondItem.other) ?? .parent,
79 | attribute: attribute.attribute,
80 | multiplier: multiplier,
81 | constant: constant,
82 | file: file,
83 | line: line
84 | )
85 | }
86 |
87 | public func AlignEdges(
88 | _ edges: NSDirectionalRectEdge = .all,
89 | to secondItem: ConstrainableItem? = nil,
90 | insets: NSDirectionalEdgeInsets = .zero,
91 | file: String = #file,
92 | line: UInt = #line
93 | ) -> MultipleConstraintGenerator {
94 | MultipleConstraints {
95 | if edges.contains(.top) {
96 | Top(
97 | to: secondItem,
98 | constant: insets.top,
99 | file: file,
100 | line: line
101 | )
102 | }
103 |
104 | if edges.contains(.leading) {
105 | Leading(
106 | to: secondItem,
107 | constant: insets.leading,
108 | file: file,
109 | line: line
110 | )
111 | }
112 |
113 | if edges.contains(.bottom) {
114 | Bottom(
115 | to: secondItem,
116 | constant: -insets.bottom,
117 | file: file,
118 | line: line
119 | )
120 | }
121 |
122 | if edges.contains(.trailing) {
123 | Trailing(
124 | to: secondItem,
125 | constant: -insets.trailing,
126 | file: file,
127 | line: line
128 | )
129 | }
130 | }
131 | }
132 |
133 | public func Contained(
134 | _ edges: NSDirectionalRectEdge = .all,
135 | within secondItem: ConstrainableItem? = nil,
136 | insets: NSDirectionalEdgeInsets = .zero,
137 | file: String = #file,
138 | line: UInt = #line
139 | ) -> MultipleConstraintGenerator {
140 | MultipleConstraints {
141 | if edges.contains(.top) {
142 | Top(
143 | .greaterThanOrEqual,
144 | to: secondItem,
145 | constant: insets.top,
146 | file: file,
147 | line: line
148 | )
149 | }
150 |
151 | if edges.contains(.leading) {
152 | Leading(
153 | .greaterThanOrEqual,
154 | to: secondItem,
155 | constant: insets.leading,
156 | file: file,
157 | line: line
158 | )
159 | }
160 |
161 | if edges.contains(.bottom) {
162 | Bottom(
163 | .lessThanOrEqual,
164 | to: secondItem,
165 | constant: -insets.bottom,
166 | file: file,
167 | line: line
168 | )
169 | }
170 |
171 | if edges.contains(.trailing) {
172 | Trailing(
173 | .lessThanOrEqual,
174 | to: secondItem,
175 | constant: -insets.trailing,
176 | file: file,
177 | line: line
178 | )
179 | }
180 | }
181 | }
182 |
183 | public func Left(
184 | _ relation: NSLayoutConstraint.Relation = .equal,
185 | to secondItem: ConstrainableItem? = nil,
186 | attribute: XAttribute = .left,
187 | multiplier: CGFloat = 1,
188 | constant: CGFloat = 0,
189 | file: String = #file,
190 | line: UInt = #line
191 | ) -> SingleConstraintGenerator {
192 | SingleConstraint(
193 | attribute: .left,
194 | relatedBy: relation,
195 | to: secondItem.map(SingleConstraint.SecondItem.other) ?? .parent,
196 | attribute: attribute.attribute,
197 | multiplier: multiplier,
198 | constant: constant,
199 | file: file,
200 | line: line
201 | )
202 | }
203 |
204 | public func Right(
205 | _ relation: NSLayoutConstraint.Relation = .equal,
206 | to secondItem: ConstrainableItem? = nil,
207 | attribute: XAttribute = .right,
208 | multiplier: CGFloat = 1,
209 | constant: CGFloat = 0,
210 | file: String = #file,
211 | line: UInt = #line
212 | ) -> SingleConstraintGenerator {
213 | SingleConstraint(
214 | attribute: .right,
215 | relatedBy: relation,
216 | to: secondItem.map(SingleConstraint.SecondItem.other) ?? .parent,
217 | attribute: attribute.attribute,
218 | multiplier: multiplier,
219 | constant: constant,
220 | file: file,
221 | line: line
222 | )
223 | }
224 |
--------------------------------------------------------------------------------
/Tests/LayoutTests/SizeTests.swift:
--------------------------------------------------------------------------------
1 | import Layout
2 | import UIKit
3 | import XCTest
4 |
5 | final class SizeTests: XCTestCase {
6 | var parentView: UIView!
7 | var view1: UIView!
8 | var view2: UIView!
9 |
10 | override func setUp() {
11 | super.setUp()
12 | parentView = .init()
13 | view1 = .init()
14 | view2 = .init()
15 | parentView.addSubview(view1)
16 | parentView.addSubview(view2)
17 | }
18 |
19 | func testWidth1() {
20 | let desiredConstraints = [
21 | NSLayoutConstraint(
22 | item: view1!,
23 | attribute: .width,
24 | relatedBy: .greaterThanOrEqual,
25 | toItem: nil,
26 | attribute: .notAnAttribute,
27 | multiplier: 1,
28 | constant: 8
29 | )
30 | ]
31 | let constraints1 = view1.makeConstraints {
32 | Width(.greaterThanOrEqual, 8)
33 | }
34 | XCTAssertEqualConstraints(constraints1, desiredConstraints)
35 | }
36 |
37 | func testWidth2() {
38 | let desiredConstraints = [
39 | NSLayoutConstraint(
40 | item: view1!,
41 | attribute: .width,
42 | relatedBy: .equal,
43 | toItem: nil,
44 | attribute: .notAnAttribute,
45 | multiplier: 1,
46 | constant: 8
47 | )
48 | ]
49 | let constraints1 = view1.makeConstraints {
50 | Width(8)
51 | }
52 | XCTAssertEqualConstraints(constraints1, desiredConstraints)
53 | }
54 |
55 | func testWidth3() {
56 | let desiredConstraints = [
57 | NSLayoutConstraint(
58 | item: view1!,
59 | attribute: .width,
60 | relatedBy: .greaterThanOrEqual,
61 | toItem: view2!,
62 | attribute: .height,
63 | multiplier: 2,
64 | constant: 8
65 | )
66 | ]
67 | let constraints1 = view1.makeConstraints {
68 | Width(.greaterThanOrEqual, to: view2, attribute: .height, multiplier: 2, constant: 8)
69 | }
70 | XCTAssertEqualConstraints(constraints1, desiredConstraints)
71 | }
72 |
73 | func testWidth3Defaults() {
74 | let desiredConstraints = [
75 | NSLayoutConstraint(
76 | item: view1!,
77 | attribute: .width,
78 | relatedBy: .equal,
79 | toItem: parentView,
80 | attribute: .width,
81 | multiplier: 1,
82 | constant: 0
83 | )
84 | ]
85 | let constraints1 = view1.makeConstraints {
86 | Width()
87 | }
88 | XCTAssertEqualConstraints(constraints1, desiredConstraints)
89 | }
90 |
91 | func testHeight() {
92 | let desiredConstraints = [
93 | NSLayoutConstraint(
94 | item: view1!,
95 | attribute: .height,
96 | relatedBy: .greaterThanOrEqual,
97 | toItem: nil,
98 | attribute: .notAnAttribute,
99 | multiplier: 1,
100 | constant: 8
101 | )
102 | ]
103 | let constraints1 = view1.makeConstraints {
104 | Height(.greaterThanOrEqual, 8)
105 | }
106 | XCTAssertEqualConstraints(constraints1, desiredConstraints)
107 | }
108 |
109 | func testHeight2() {
110 | let desiredConstraints = [
111 | NSLayoutConstraint(
112 | item: view1!,
113 | attribute: .height,
114 | relatedBy: .equal,
115 | toItem: nil,
116 | attribute: .notAnAttribute,
117 | multiplier: 1,
118 | constant: 8
119 | )
120 | ]
121 | let constraints1 = view1.makeConstraints {
122 | Height(8)
123 | }
124 | XCTAssertEqualConstraints(constraints1, desiredConstraints)
125 | }
126 |
127 | func testHeight3() {
128 | let desiredConstraints = [
129 | NSLayoutConstraint(
130 | item: view1!,
131 | attribute: .height,
132 | relatedBy: .greaterThanOrEqual,
133 | toItem: view2!,
134 | attribute: .width,
135 | multiplier: 2,
136 | constant: 8
137 | )
138 | ]
139 | let constraints1 = view1.makeConstraints {
140 | Height(.greaterThanOrEqual, to: view2, attribute: .width, multiplier: 2, constant: 8)
141 | }
142 | XCTAssertEqualConstraints(constraints1, desiredConstraints)
143 | }
144 |
145 | func testHeight3Defaults() {
146 | let desiredConstraints = [
147 | NSLayoutConstraint(
148 | item: view1!,
149 | attribute: .height,
150 | relatedBy: .equal,
151 | toItem: parentView,
152 | attribute: .height,
153 | multiplier: 1,
154 | constant: 0
155 | )
156 | ]
157 | let constraints1 = view1.makeConstraints {
158 | Height()
159 | }
160 | XCTAssertEqualConstraints(constraints1, desiredConstraints)
161 | }
162 |
163 | func testSize1() {
164 | let desiredConstraints = [
165 | NSLayoutConstraint(
166 | item: view1!,
167 | attribute: .width,
168 | relatedBy: .equal,
169 | toItem: nil,
170 | attribute: .notAnAttribute,
171 | multiplier: 1,
172 | constant: 1
173 | ),
174 | NSLayoutConstraint(
175 | item: view1!,
176 | attribute: .height,
177 | relatedBy: .equal,
178 | toItem: nil,
179 | attribute: .notAnAttribute,
180 | multiplier: 1,
181 | constant: 2
182 | )
183 | ]
184 | let constraints1 = view1.makeConstraints {
185 | Size(width: 1, height: 2)
186 | }
187 | XCTAssertEqualConstraints(constraints1, desiredConstraints)
188 | }
189 |
190 | func testSize2() {
191 | let desiredConstraints = [
192 | NSLayoutConstraint(
193 | item: view1!,
194 | attribute: .width,
195 | relatedBy: .greaterThanOrEqual,
196 | toItem: view2!,
197 | attribute: .width,
198 | multiplier: 2,
199 | constant: 3
200 | ),
201 | NSLayoutConstraint(
202 | item: view1!,
203 | attribute: .height,
204 | relatedBy: .greaterThanOrEqual,
205 | toItem: view2!,
206 | attribute: .height,
207 | multiplier: 2,
208 | constant: 3
209 | )
210 | ]
211 | let constraints1 = view1.makeConstraints {
212 | Size(.greaterThanOrEqual, to: view2!, multiplier: 2, constant: 3)
213 | }
214 | XCTAssertEqualConstraints(constraints1, desiredConstraints)
215 | }
216 |
217 | func testSize2Defaults() {
218 | let desiredConstraints = [
219 | NSLayoutConstraint(
220 | item: view1!,
221 | attribute: .width,
222 | relatedBy: .equal,
223 | toItem: parentView!,
224 | attribute: .width,
225 | multiplier: 1,
226 | constant: 0
227 | ),
228 | NSLayoutConstraint(
229 | item: view1!,
230 | attribute: .height,
231 | relatedBy: .equal,
232 | toItem: parentView!,
233 | attribute: .height,
234 | multiplier: 1,
235 | constant: 0
236 | )
237 | ]
238 | let constraints1 = view1.makeConstraints {
239 | Size()
240 | }
241 | XCTAssertEqualConstraints(constraints1, desiredConstraints)
242 | }
243 |
244 | func testAspectRatio() {
245 | let desiredConstraints = [
246 | NSLayoutConstraint(
247 | item: view1!,
248 | attribute: .width,
249 | relatedBy: .equal,
250 | toItem: view1!,
251 | attribute: .height,
252 | multiplier: 1.5,
253 | constant: 0
254 | )
255 | ]
256 | let constraints = view1.makeConstraints {
257 | AspectRatio(3 / 2)
258 | }
259 | XCTAssertEqualConstraints(constraints, desiredConstraints)
260 | }
261 | }
262 |
--------------------------------------------------------------------------------
/Tests/LayoutTests/BuilderTests.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import UIKit
3 | import XCTest
4 |
5 | @testable import Layout
6 |
7 | enum TestEnum {
8 | case one
9 | case two
10 | case three
11 | }
12 |
13 | extension UILabel {
14 | convenience init(int: Int) {
15 | self.init(frame: .zero)
16 | text = "\(int)"
17 | }
18 | }
19 |
20 | final class BadConstrainableItem: ConstrainableItem {
21 | var parentView: UIView?
22 |
23 | func setTranslatesAutoresizingMaskIntoConstraintsFalseIfNecessary() {}
24 | }
25 |
26 | final class StackBuilderTests: XCTestCase {
27 | func testVerticalDefaultInitializers() {
28 | let stack = UIStackView.vertical {}
29 | XCTAssertEqual(stack.axis, .vertical)
30 | XCTAssertEqual(stack.distribution, .fill)
31 | XCTAssertEqual(stack.alignment, .fill)
32 | XCTAssertEqual(stack.spacing, UIStackView.spacingUseDefault)
33 | }
34 |
35 | func testVerticalInitializerPassThrough() {
36 | let stack = UIStackView.vertical(distribution: .fillEqually, alignment: .top, spacing: 100) {}
37 | XCTAssertEqual(stack.axis, .vertical)
38 | XCTAssertEqual(stack.distribution, .fillEqually)
39 | XCTAssertEqual(stack.alignment, .top)
40 | XCTAssertEqual(stack.spacing, 100)
41 | }
42 |
43 | func testHorizontalDefaultInitializers() {
44 | let stack = UIStackView.horizontal {}
45 | XCTAssertEqual(stack.axis, .horizontal)
46 | XCTAssertEqual(stack.distribution, .fill)
47 | XCTAssertEqual(stack.alignment, .fill)
48 | XCTAssertEqual(stack.spacing, UIStackView.spacingUseDefault)
49 | }
50 |
51 | func testHorizontalInitializerPassThrough() {
52 | let stack = UIStackView.horizontal(distribution: .fillEqually, alignment: .top, spacing: 100) {}
53 | XCTAssertEqual(stack.axis, .horizontal)
54 | XCTAssertEqual(stack.distribution, .fillEqually)
55 | XCTAssertEqual(stack.alignment, .top)
56 | XCTAssertEqual(stack.spacing, 100)
57 | }
58 |
59 | func testAddsSubviews() {
60 | let label = UILabel()
61 | let stack = UIStackView.vertical {
62 | label
63 | }
64 |
65 | XCTAssertEqual(label.superview, stack)
66 | XCTAssertEqual(stack.arrangedSubviews, [label])
67 | }
68 |
69 | func testCustomSpacing() {
70 | let label = UILabel()
71 | let stack = UIStackView.vertical {
72 | label
73 | .spacingAfter(10)
74 | }
75 |
76 | XCTAssertEqual(stack.customSpacing(after: label), 10)
77 | }
78 |
79 | func testIfTrue() {
80 | let label1 = UILabel()
81 | let label2 = UILabel()
82 | let value = true
83 | let stack = UIStackView.vertical {
84 | if value {
85 | label1
86 | } else {
87 | label2
88 | }
89 | }
90 |
91 | XCTAssertEqual(stack.arrangedSubviews, [label1])
92 | }
93 |
94 | func testIfFalse() {
95 | let label1 = UILabel()
96 | let label2 = UILabel()
97 | let value = false
98 | let stack = UIStackView.vertical {
99 | if value {
100 | label1
101 | } else {
102 | label2
103 | }
104 | }
105 |
106 | XCTAssertEqual(stack.arrangedSubviews, [label2])
107 | }
108 |
109 | func testIfTrueWithoutElse() {
110 | let label1 = UILabel()
111 | let value = true
112 | let stack = UIStackView.vertical {
113 | if value {
114 | label1
115 | }
116 | }
117 |
118 | XCTAssertEqual(stack.arrangedSubviews, [label1])
119 | }
120 |
121 | func testIfFalseWithoutElse() {
122 | let label1 = UILabel()
123 | let value = false
124 | let stack = UIStackView.vertical {
125 | if value {
126 | label1
127 | }
128 | }
129 |
130 | XCTAssertEqual(stack.arrangedSubviews, [])
131 | }
132 |
133 | func testSwitchOne() {
134 | let label1 = UILabel()
135 | let label2 = UILabel()
136 | let label3 = UILabel()
137 | let value = TestEnum.one
138 | let stack = UIStackView.vertical {
139 | switch value {
140 | case .one:
141 | label1
142 | case .two:
143 | label2
144 | case .three:
145 | label3
146 | }
147 | }
148 |
149 | XCTAssertEqual(stack.arrangedSubviews, [label1])
150 | }
151 |
152 | func testSwitchTwo() {
153 | let label1 = UILabel()
154 | let label2 = UILabel()
155 | let label3 = UILabel()
156 | let value = TestEnum.two
157 | let stack = UIStackView.vertical {
158 | switch value {
159 | case .one:
160 | label1
161 | case .two:
162 | label2
163 | case .three:
164 | label3
165 | }
166 | }
167 |
168 | XCTAssertEqual(stack.arrangedSubviews, [label2])
169 | }
170 |
171 | func testSwitchThree() {
172 | let label1 = UILabel()
173 | let label2 = UILabel()
174 | let label3 = UILabel()
175 | let value = TestEnum.three
176 | let stack = UIStackView.vertical {
177 | switch value {
178 | case .one:
179 | label1
180 | case .two:
181 | label2
182 | case .three:
183 | label3
184 | }
185 | }
186 |
187 | XCTAssertEqual(stack.arrangedSubviews, [label3])
188 | }
189 |
190 | func testOptionalNil() {
191 | let label: UILabel? = nil
192 | let stack = UIStackView.vertical {
193 | label
194 | }
195 | XCTAssertEqual(stack.arrangedSubviews, [])
196 | }
197 |
198 | func testOptionalNotNil() {
199 | let label: UILabel? = UILabel()
200 | let stack = UIStackView.vertical {
201 | label
202 | }
203 | XCTAssertEqual(stack.arrangedSubviews, [label!])
204 | }
205 |
206 | func testUIViewBuilder() {
207 | let label = UILabel()
208 | let guide = UILayoutGuide()
209 | let view = UIView.build {
210 | label
211 | guide
212 | }
213 |
214 | XCTAssertEqual(label.superview, view)
215 | XCTAssertEqual(guide.owningView, view)
216 | }
217 |
218 | func testViewParent() {
219 | let label = UILabel()
220 | let view = UIView.build {
221 | label.constraints {
222 | AlignEdges()
223 | }
224 | }
225 | let constraint = view.constraints.first
226 | XCTAssertTrue(constraint?.secondItem === view)
227 | }
228 |
229 | func testGuideParent() {
230 | let guide = UILayoutGuide()
231 | let label = UILabel()
232 | let view = UIView.build {
233 | UILayoutGuide.build {
234 | guide.constraints {
235 | AlignEdges()
236 | }
237 | label.constraints {
238 | AlignEdges()
239 | }
240 | }
241 | }
242 | XCTAssertEqual(guide.owningView, view)
243 | XCTAssertEqual(label.superview, view)
244 | XCTAssertEqual(view.constraints.count, 8)
245 | XCTAssertTrue(view.constraints.allSatisfy({ $0.secondItem is UILayoutGuide }))
246 | }
247 |
248 | func testFatalErrorsIfYouPassSomethingWrong() {
249 | let crashed = FatalError.withTestFatalError {
250 | _ = UIView.build {
251 | BadConstrainableItem()
252 | }
253 | }
254 | XCTAssertTrue(crashed)
255 | }
256 |
257 | func testArray() {
258 | let view = UIStackView.vertical {
259 | (0..<100).map(UILabel.init(int:))
260 | }
261 | XCTAssertEqual(view.arrangedSubviews.count, 100)
262 | }
263 |
264 | func testForEach() {
265 | let view = UIStackView.vertical {
266 | for i in 0..<100 {
267 | UILabel(int: i)
268 | }
269 | }
270 | XCTAssertEqual(view.arrangedSubviews.count, 100)
271 | }
272 |
273 | func testAvailabilityAvailable() throws {
274 | let view = UIStackView.vertical {
275 | if #available(iOS 13, *) {
276 | UILabel()
277 | } else {
278 | UITextField()
279 | }
280 | }
281 | let item = try XCTUnwrap(view.arrangedSubviews.first)
282 | XCTAssertTrue(item is UILabel)
283 | }
284 |
285 | func testAvailabilityNotAvailable() throws {
286 | let view = UIStackView.vertical {
287 | if #available(iOS 9999, *) {
288 | UILabel()
289 | } else {
290 | UITextField()
291 | }
292 | }
293 | let item = try XCTUnwrap(view.arrangedSubviews.first)
294 | XCTAssertTrue(item is UITextField)
295 | }
296 | }
297 |
--------------------------------------------------------------------------------
/Tests/LayoutTests/EdgeTests.swift:
--------------------------------------------------------------------------------
1 | import Layout
2 | import UIKit
3 | import XCTest
4 |
5 | final class EdgeTests: XCTestCase {
6 | var parentView: UIView!
7 | var view1: UIView!
8 | var view2: UIView!
9 |
10 | override func setUp() {
11 | super.setUp()
12 | parentView = .init()
13 | view1 = .init()
14 | view2 = .init()
15 | parentView.addSubview(view1)
16 | parentView.addSubview(view2)
17 | }
18 |
19 | func testTop() {
20 | let desiredConstraints = [
21 | NSLayoutConstraint(
22 | item: view1!,
23 | attribute: .top,
24 | relatedBy: .greaterThanOrEqual,
25 | toItem: view2,
26 | attribute: .centerY,
27 | multiplier: 2,
28 | constant: 8
29 | )
30 | ]
31 | let constraints = view1.makeConstraints {
32 | Top(.greaterThanOrEqual, to: view2, attribute: .centerY, multiplier: 2, constant: 8)
33 | }
34 | XCTAssertEqualConstraints(constraints, desiredConstraints)
35 | }
36 |
37 | func testTopDefaults() {
38 | let desiredConstraints = [
39 | view1.topAnchor.constraint(equalTo: parentView.topAnchor)
40 | ]
41 | let constraints = view1.makeConstraints {
42 | Top()
43 | }
44 | XCTAssertEqualConstraints(constraints, desiredConstraints)
45 | }
46 |
47 | func testLeading() {
48 | let desiredConstraints = [
49 | NSLayoutConstraint(
50 | item: view1!,
51 | attribute: .leading,
52 | relatedBy: .greaterThanOrEqual,
53 | toItem: view2,
54 | attribute: .trailing,
55 | multiplier: 2,
56 | constant: 8
57 | )
58 | ]
59 | let constraints = view1.makeConstraints {
60 | Leading(.greaterThanOrEqual, to: view2, attribute: .trailing, multiplier: 2, constant: 8)
61 | }
62 | XCTAssertEqualConstraints(constraints, desiredConstraints)
63 | }
64 |
65 | func testLeadingDefaults() {
66 | let desiredConstraints = [
67 | view1.leadingAnchor.constraint(equalTo: parentView.leadingAnchor)
68 | ]
69 | let constraints = view1.makeConstraints {
70 | Leading()
71 | }
72 | XCTAssertEqualConstraints(constraints, desiredConstraints)
73 | }
74 |
75 | func testBottom() {
76 | let desiredConstraints = [
77 | NSLayoutConstraint(
78 | item: view1!,
79 | attribute: .bottom,
80 | relatedBy: .greaterThanOrEqual,
81 | toItem: view2,
82 | attribute: .centerY,
83 | multiplier: 2,
84 | constant: 8
85 | )
86 | ]
87 | let constraints = view1.makeConstraints {
88 | Bottom(.greaterThanOrEqual, to: view2, attribute: .centerY, multiplier: 2, constant: 8)
89 | }
90 | XCTAssertEqualConstraints(constraints, desiredConstraints)
91 | }
92 |
93 | func testBottomDefaults() {
94 | let desiredConstraints = [
95 | view1.bottomAnchor.constraint(equalTo: parentView.bottomAnchor)
96 | ]
97 | let constraints = view1.makeConstraints {
98 | Bottom()
99 | }
100 | XCTAssertEqualConstraints(constraints, desiredConstraints)
101 | }
102 |
103 | func testTrailing() {
104 | let desiredConstraints = [
105 | NSLayoutConstraint(
106 | item: view1!,
107 | attribute: .trailing,
108 | relatedBy: .greaterThanOrEqual,
109 | toItem: view2,
110 | attribute: .leading,
111 | multiplier: 2,
112 | constant: 8
113 | )
114 | ]
115 | let constraints = view1.makeConstraints {
116 | Trailing(.greaterThanOrEqual, to: view2, attribute: .leading, multiplier: 2, constant: 8)
117 | }
118 | XCTAssertEqualConstraints(constraints, desiredConstraints)
119 | }
120 |
121 | func testTrailingDefaults() {
122 | let desiredConstraints = [
123 | view1.trailingAnchor.constraint(equalTo: parentView.trailingAnchor)
124 | ]
125 | let constraints = view1.makeConstraints {
126 | Trailing()
127 | }
128 | XCTAssertEqualConstraints(constraints, desiredConstraints)
129 | }
130 |
131 | func testAlignEdgesTop() {
132 | let desiredConstraints = [
133 | view1.topAnchor.constraint(equalTo: view2.topAnchor, constant: 1)
134 | ]
135 | let constraints = view1.makeConstraints {
136 | AlignEdges(.top, to: view2, insets: 1)
137 | }
138 | XCTAssertEqualConstraints(constraints, desiredConstraints)
139 | }
140 |
141 | func testAlignEdgesLeading() {
142 | let desiredConstraints = [
143 | view1.leadingAnchor.constraint(equalTo: view2.leadingAnchor, constant: 1)
144 | ]
145 | let constraints = view1.makeConstraints {
146 | AlignEdges(.leading, to: view2, insets: 1)
147 | }
148 | XCTAssertEqualConstraints(constraints, desiredConstraints)
149 | }
150 |
151 | func testAlignEdgesBottom() {
152 | let desiredConstraints = [
153 | view1.bottomAnchor.constraint(equalTo: view2.bottomAnchor, constant: -1)
154 | ]
155 | let constraints = view1.makeConstraints {
156 | AlignEdges(.bottom, to: view2, insets: 1)
157 | }
158 | XCTAssertEqualConstraints(constraints, desiredConstraints)
159 | }
160 |
161 | func testAlignEdgesTrailing() {
162 | let desiredConstraints = [
163 | view1.trailingAnchor.constraint(equalTo: view2.trailingAnchor, constant: -1)
164 | ]
165 | let constraints = view1.makeConstraints {
166 | AlignEdges(.trailing, to: view2, insets: 1)
167 | }
168 | XCTAssertEqualConstraints(constraints, desiredConstraints)
169 | }
170 |
171 | func testAlignEdgesHorizontal() {
172 | let desiredConstraints = [
173 | view1.leadingAnchor.constraint(equalTo: parentView.leadingAnchor),
174 | view1.trailingAnchor.constraint(equalTo: parentView.trailingAnchor)
175 | ]
176 | let constraints = view1.makeConstraints {
177 | AlignEdges(.horizontal)
178 | }
179 | XCTAssertEqualConstraints(constraints, desiredConstraints)
180 | }
181 |
182 | func testAlignEdgesVertical() {
183 | let desiredConstraints = [
184 | view1.topAnchor.constraint(equalTo: parentView.topAnchor),
185 | view1.bottomAnchor.constraint(equalTo: parentView.bottomAnchor)
186 | ]
187 | let constraints = view1.makeConstraints {
188 | AlignEdges(.vertical)
189 | }
190 | XCTAssertEqualConstraints(constraints, desiredConstraints)
191 | }
192 |
193 | func testAlignEdgesDefaults() {
194 | let desiredConstraints = [
195 | view1.topAnchor.constraint(equalTo: parentView.topAnchor),
196 | view1.leadingAnchor.constraint(equalTo: parentView.leadingAnchor),
197 | view1.bottomAnchor.constraint(equalTo: parentView.bottomAnchor),
198 | view1.trailingAnchor.constraint(equalTo: parentView.trailingAnchor)
199 | ]
200 | let constraints = view1.makeConstraints {
201 | AlignEdges()
202 | }
203 | XCTAssertEqualConstraints(constraints, desiredConstraints)
204 | }
205 |
206 | func testContainedDefaults() {
207 | let desiredConstraints = [
208 | view1.topAnchor.constraint(greaterThanOrEqualTo: parentView.topAnchor),
209 | view1.leadingAnchor.constraint(greaterThanOrEqualTo: parentView.leadingAnchor),
210 | view1.bottomAnchor.constraint(lessThanOrEqualTo: parentView.bottomAnchor),
211 | view1.trailingAnchor.constraint(lessThanOrEqualTo: parentView.trailingAnchor)
212 | ]
213 | let constraints = view1.makeConstraints {
214 | Contained()
215 | }
216 | XCTAssertEqualConstraints(constraints, desiredConstraints)
217 | }
218 |
219 | func testLeft() {
220 | let desiredConstraints = [
221 | NSLayoutConstraint(
222 | item: view1!,
223 | attribute: .left,
224 | relatedBy: .greaterThanOrEqual,
225 | toItem: view2,
226 | attribute: .right,
227 | multiplier: 2,
228 | constant: 8
229 | )
230 | ]
231 | let constraints = view1.makeConstraints {
232 | Left(.greaterThanOrEqual, to: view2, attribute: .right, multiplier: 2, constant: 8)
233 | }
234 | XCTAssertEqualConstraints(constraints, desiredConstraints)
235 | }
236 |
237 | func testLeftDefaults() {
238 | let desiredConstraints = [
239 | view1.leftAnchor.constraint(equalTo: parentView.leftAnchor)
240 | ]
241 | let constraints = view1.makeConstraints {
242 | Left()
243 | }
244 | XCTAssertEqualConstraints(constraints, desiredConstraints)
245 | }
246 |
247 | func testRight() {
248 | let desiredConstraints = [
249 | NSLayoutConstraint(
250 | item: view1!,
251 | attribute: .right,
252 | relatedBy: .greaterThanOrEqual,
253 | toItem: view2,
254 | attribute: .left,
255 | multiplier: 2,
256 | constant: 8
257 | )
258 | ]
259 | let constraints = view1.makeConstraints {
260 | Right(.greaterThanOrEqual, to: view2, attribute: .left, multiplier: 2, constant: 8)
261 | }
262 | XCTAssertEqualConstraints(constraints, desiredConstraints)
263 | }
264 |
265 | func testRightDefaults() {
266 | let desiredConstraints = [
267 | view1.rightAnchor.constraint(equalTo: parentView.rightAnchor)
268 | ]
269 | let constraints = view1.makeConstraints {
270 | Right()
271 | }
272 | XCTAssertEqualConstraints(constraints, desiredConstraints)
273 | }
274 | }
275 |
--------------------------------------------------------------------------------
/Tests/LayoutTests/DynamicLayoutTests.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 | import XCTest
3 |
4 | @testable import Layout
5 |
6 | class DynamicLayoutTests: XCTestCase {
7 | var parentView: UIView!
8 | var view: UIView!
9 |
10 | override func setUp() {
11 | super.setUp()
12 | parentView = .init()
13 | view = .init()
14 | parentView.addSubview(view)
15 | }
16 |
17 | func testConfigureOnlyCalledOnce() {
18 | let sut = DynamicLayout()
19 | sut.configure { _ in }
20 | let crashed = FatalError.withTestFatalError {
21 | sut.configure { _ in }
22 | }
23 | XCTAssertTrue(crashed)
24 | }
25 |
26 | func testApplyConstraintsFatalError() {
27 | let crashed = FatalError.withTestFatalError {
28 | let sut = DynamicLayout()
29 | sut.configure { _ in
30 | view.applyConstraints {
31 | Leading()
32 | }
33 | }
34 | }
35 | XCTAssertTrue(crashed)
36 | }
37 |
38 | func testGlobalConstraints() {
39 | let sut = DynamicLayout()
40 | sut.configure { _ in
41 | view.makeConstraints {
42 | Leading()
43 | }
44 | }
45 | sut.update(environment: ())
46 | XCTAssertEqualConstraints(sut.activeConstraints, view.applyConstraints { Leading() })
47 | }
48 |
49 | func testGlobalActions() {
50 | var called = false
51 | let sut = DynamicLayout()
52 | sut.configure { config in
53 | config.addAction {
54 | called = true
55 | }
56 | }
57 | sut.update(environment: ())
58 | XCTAssertTrue(called)
59 | }
60 |
61 | func testPredicateOtherwiseConstraints() {
62 | let sut = DynamicLayout()
63 | sut.configure { config in
64 | config.when(.init { $0 }) {
65 | view.makeConstraints {
66 | Leading()
67 | }
68 | } otherwise: {
69 | view.makeConstraints {
70 | Trailing()
71 | }
72 | }
73 | }
74 | sut.update(environment: true)
75 | XCTAssertEqualConstraints(sut.activeConstraints, view.applyConstraints { Leading() })
76 | sut.update(environment: false)
77 | XCTAssertEqualConstraints(sut.activeConstraints, view.applyConstraints { Trailing() })
78 | }
79 |
80 | func testPredicateWithoutOtherwise() {
81 | let sut = DynamicLayout()
82 | sut.configure { config in
83 | config.when(.init { $0 }) {
84 | view.makeConstraints {
85 | Leading()
86 | }
87 | }
88 | }
89 | sut.update(environment: true)
90 | XCTAssertEqualConstraints(sut.activeConstraints, view.applyConstraints { Leading() })
91 | sut.update(environment: false)
92 | XCTAssertEqualConstraints(sut.activeConstraints, [])
93 | }
94 |
95 | func testClosureOtherwise() {
96 | let sut = DynamicLayout()
97 | sut.configure { config in
98 | config.when({ $0 }) {
99 | view.makeConstraints {
100 | Leading()
101 | }
102 | } otherwise: {
103 | view.makeConstraints {
104 | Trailing()
105 | }
106 | }
107 | }
108 | sut.update(environment: true)
109 | XCTAssertEqualConstraints(sut.activeConstraints, view.applyConstraints { Leading() })
110 | sut.update(environment: false)
111 | XCTAssertEqualConstraints(sut.activeConstraints, view.applyConstraints { Trailing() })
112 | }
113 |
114 | func testClosureWithoutOtherwise() {
115 | let sut = DynamicLayout()
116 | sut.configure { config in
117 | config.when({ $0 }) {
118 | view.makeConstraints {
119 | Leading()
120 | }
121 | }
122 | }
123 | sut.update(environment: true)
124 | XCTAssertEqualConstraints(sut.activeConstraints, view.applyConstraints { Leading() })
125 | sut.update(environment: false)
126 | XCTAssertEqualConstraints(sut.activeConstraints, [])
127 | }
128 |
129 | func testEquatableOtherwise() {
130 | let sut = DynamicLayout()
131 | sut.configure { config in
132 | config.when(true) {
133 | view.makeConstraints {
134 | Leading()
135 | }
136 | } otherwise: {
137 | view.makeConstraints {
138 | Trailing()
139 | }
140 | }
141 | }
142 | sut.update(environment: true)
143 | XCTAssertEqualConstraints(sut.activeConstraints, view.applyConstraints { Leading() })
144 | sut.update(environment: false)
145 | XCTAssertEqualConstraints(sut.activeConstraints, view.applyConstraints { Trailing() })
146 | }
147 |
148 | func testEquatableWithoutOtherwise() {
149 | let sut = DynamicLayout()
150 | sut.configure { config in
151 | config.when(true) {
152 | view.makeConstraints {
153 | Leading()
154 | }
155 | }
156 | }
157 | sut.update(environment: true)
158 | XCTAssertEqualConstraints(sut.activeConstraints, view.applyConstraints { Leading() })
159 | sut.update(environment: false)
160 | XCTAssertEqualConstraints(sut.activeConstraints, [])
161 | }
162 |
163 | func testMoreComplexConditions() {
164 | let sut = DynamicLayout()
165 | sut.configure { config in
166 | config.when(.horizontallyRegular) {
167 | view.makeConstraints {
168 | Top()
169 | }
170 |
171 | config.when(.width(is: >=, 1024)) {
172 | view.makeConstraints {
173 | Leading()
174 | }
175 | } otherwise: {
176 | view.makeConstraints {
177 | Trailing()
178 | }
179 | }
180 | } otherwise: {
181 | view.makeConstraints {
182 | Bottom()
183 | }
184 | }
185 | }
186 |
187 | sut.update(environment: .init(traitCollection: .init(horizontalSizeClass: .compact), size: CGSize(width: 1024, height: 1024)))
188 | XCTAssertEqualConstraints(sut.activeConstraints, view.applyConstraints { Bottom() })
189 | sut.update(environment: .init(traitCollection: .init(horizontalSizeClass: .regular), size: CGSize(width: 1024, height: 1024)))
190 | XCTAssertEqualConstraints(sut.activeConstraints, view.applyConstraints { Top(); Leading() })
191 | sut.update(environment: .init(traitCollection: .init(horizontalSizeClass: .regular), size: CGSize(width: 1023, height: 1024)))
192 | XCTAssertEqualConstraints(sut.activeConstraints, view.applyConstraints { Top(); Trailing() })
193 | }
194 |
195 | func testMultipleTopLevelConditions() {
196 | let sut = DynamicLayout()
197 | sut.configure { ctx in
198 | ctx.when(.horizontallyRegular) {
199 | view.makeConstraints {
200 | Top()
201 | }
202 | } otherwise: {
203 | view.makeConstraints {
204 | Bottom()
205 | }
206 | }
207 |
208 | ctx.when(.width(is: >=, 1024)) {
209 | view.makeConstraints {
210 | Leading()
211 | }
212 | } otherwise: {
213 | view.makeConstraints {
214 | Trailing()
215 | }
216 | }
217 | }
218 |
219 | sut.update(environment: .init(traitCollection: .init(horizontalSizeClass: .regular), size: CGSize(width: 1024, height: 1024)))
220 | XCTAssertEqualConstraints(sut.activeConstraints, view.applyConstraints { Top(); Leading() })
221 |
222 | sut.update(environment: .init(traitCollection: .init(horizontalSizeClass: .compact), size: CGSize(width: 1024, height: 1024)))
223 | XCTAssertEqualConstraints(sut.activeConstraints, view.applyConstraints { Bottom(); Leading() })
224 |
225 | sut.update(environment: .init(traitCollection: .init(horizontalSizeClass: .regular), size: CGSize(width: 10, height: 10)))
226 | XCTAssertEqualConstraints(sut.activeConstraints, view.applyConstraints { Top(); Trailing() })
227 |
228 | sut.update(environment: .init(traitCollection: .init(horizontalSizeClass: .compact), size: CGSize(width: 10, height: 10)))
229 | XCTAssertEqualConstraints(sut.activeConstraints, view.applyConstraints { Bottom(); Trailing() })
230 | }
231 |
232 | func testOtherCaseWithoutDirectConstraints() {
233 | let sut = DynamicLayout()
234 | sut.configure { config in
235 | config.when({ $0 < 10 }) {
236 | view.makeConstraints {
237 | Leading()
238 | }
239 | } otherwise: {
240 | config.when(10) {
241 | view.makeConstraints {
242 | Top()
243 | }
244 | } otherwise: {
245 | view.makeConstraints {
246 | Bottom()
247 | }
248 | }
249 | }
250 | }
251 | sut.update(environment: 1)
252 | XCTAssertEqualConstraints(sut.activeConstraints, view.applyConstraints { Leading() })
253 | sut.update(environment: 10)
254 | XCTAssertEqualConstraints(sut.activeConstraints, view.applyConstraints { Top() })
255 | sut.update(environment: 11)
256 | XCTAssertEqualConstraints(sut.activeConstraints, view.applyConstraints { Bottom() })
257 | }
258 |
259 | func testBasicActions() {
260 | var x = 0
261 | let sut = DynamicLayout()
262 | sut.configure { config in
263 | config.when(true) {
264 | config.addAction {
265 | x = 1
266 | }
267 | } otherwise: {
268 | config.addAction {
269 | x = 2
270 | }
271 | }
272 | }
273 | XCTAssertEqual(x, 0)
274 | sut.update(environment: true)
275 | XCTAssertEqual(x, 1)
276 | sut.update(environment: false)
277 | XCTAssertEqual(x, 2)
278 | }
279 |
280 | func testMultipleTopLevelConditionsActions() {
281 | let sut = DynamicLayout()
282 | var sizeClass = UIUserInterfaceSizeClass.unspecified
283 | var biggerThan1024 = false
284 |
285 | sut.configure { config in
286 | config.when(.horizontallyRegular) {
287 | config.addAction {
288 | sizeClass = .regular
289 | }
290 | } otherwise: {
291 | config.addAction {
292 | sizeClass = .compact
293 | }
294 | }
295 |
296 | config.when(.width(is: >=, 1024)) {
297 | config.addAction {
298 | biggerThan1024 = true
299 | }
300 | } otherwise: {
301 | config.addAction {
302 | biggerThan1024 = false
303 | }
304 | }
305 | }
306 |
307 | sut.update(environment: .init(traitCollection: .init(horizontalSizeClass: .regular), size: CGSize(width: 1024, height: 1024)))
308 | XCTAssertEqual(sizeClass, .regular)
309 | XCTAssertTrue(biggerThan1024)
310 | sut.update(environment: .init(traitCollection: .init(horizontalSizeClass: .compact), size: CGSize(width: 1024, height: 1024)))
311 | XCTAssertEqual(sizeClass, .compact)
312 | XCTAssertTrue(biggerThan1024)
313 | sut.update(environment: .init(traitCollection: .init(horizontalSizeClass: .regular), size: CGSize(width: 10, height: 10)))
314 | XCTAssertEqual(sizeClass, .regular)
315 | XCTAssertFalse(biggerThan1024)
316 | sut.update(environment: .init(traitCollection: .init(horizontalSizeClass: .compact), size: CGSize(width: 10, height: 10)))
317 | XCTAssertEqual(sizeClass, .compact)
318 | XCTAssertFalse(biggerThan1024)
319 | }
320 |
321 | func testUpdatePerformance() {
322 | measureMetrics([.wallClockTime], automaticallyStartMeasuring: false, for: {
323 | let sut = DynamicLayout()
324 | let parentView = UIView()
325 | let views = (1...10000).map({ _ in UIView() })
326 | views.forEach({ parentView.addSubview($0) })
327 | sut.configure { config in
328 | for view in views {
329 | view.makeConstraints {
330 | Size(width: 100, height: 100)
331 | }
332 | }
333 |
334 | config.when(true) {
335 | for view in views {
336 | view.makeConstraints {
337 | Center()
338 | }
339 | }
340 | } otherwise: {
341 | for view in views {
342 | view.makeConstraints {
343 | Leading()
344 | Top()
345 | }
346 | }
347 | }
348 | }
349 |
350 | startMeasuring()
351 | sut.update(environment: true)
352 | sut.update(environment: false)
353 | stopMeasuring()
354 | })
355 | }
356 | }
357 |
--------------------------------------------------------------------------------