├── .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 | ![Layout](./images/layout.png) 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 | --------------------------------------------------------------------------------