Draftsman

Draftsman 是一个用于 Swift 的 DSL 框架,专注于构建器模式。如果您仍然在使用 2.3.x 版本,请参考独立的 README 文件:这里。如果您仍然使用 Swift 5.1,请使用 1.1.x 版本。独立的 README 文件:这里

Codacy Badge build test SwiftPM Compatible Version License Platform


示例

要运行示例项目,请克隆 repo,然后首先从 Example 目录运行 pod install

要求

安装

Cocoapods

Draftsman 可以通过 CocoaPods 获得。 要安装它,只需将以下行添加到您的 Podfile 中

pod 'Draftsman', '~> 3.2.5'

从 XCode 使用 Swift Package Manager

从 Package.swift 使用 Swift Package Manager

Package.swift 中将 Draftsman 添加为您的 target 依赖项

dependencies: [
    .package(url: "https://github.com/hainayanda/Draftsman.git", .upToNextMajor(from: "3.2.5"))
]

在您的 target 中将它用作 Draftsman

 .target(
    name: "MyModule",
    dependencies: ["Draftsman"]
)

作者

Nayanda Haberty, hainayanda@outlook.com

许可证

Draftsman 在 MIT 许可证下可用。 有关更多信息,请参见 LICENSE 文件。


基本用法

Draftsman 是 NSLayoutConstraintsUIView 层级结构的构建器。 Draftsman 使用 Swift 中的新的 resultBuilder,使声明式方法成为可能。


基本

创建约束非常容易。 您所需要做的就是调用 drf 以获取 LayoutDraft 对象

myView.drf
    .left.equal(to: otherView.drf.right)
    .right.equal(with: .parent).offset(by: 16)
    .top.lessThan(with: .safeArea).offSet(8)
    .bottom.moreThan(with: .top(of: .keyboard))
    .apply()

有两种方法可以结束规划约束,这两种方法都可以从任何 UIViewUIViewController 中调用

两者之间的区别在于 apply 将激活约束,而 build 只会创建约束而不激活它们。 Apply 返回值是可丢弃的,因此您可以选择使用创建的 NSLayoutConstraint 或不使用。

您可以始终创建一个 UIViewControllerUIView 并实现 Planned 协议,并在您希望应用 viewPlan 时调用 applyPlan()

import Draftsman

class MyViewController: UIViewController, Planned {
    
    var models: [MyModel] = []
    
    @LayoutPlan
    var viewPlan: ViewPlan {
        VStacked(spacing: 32) { 
            if models.isEmpty {
                MyView()
                MyOtherView()
                SomeOtherView()
            } else {
                for model in models {
                    MyModeledView(model)
                }
            }
        }
        .centered()
        .matchSafeAreaH().offset(by: 16)
        .vertical.moreThan(with: .safeArea).offset(by: 16)
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        applyPlan()
    }
}

ViewPlan 始终可以组合以使代码更简洁

import Draftsman

class MyViewController: UIViewController, Planned {
    
    var models: [MyModel] = []
    
    @LayoutPlan
    var viewPlan: ViewPlan {
        VStacked(spacing: 32) { 
            stackPlan
        }
        .centered()
        .matchSafeAreaH().offset(by: 16)
        .vertical.moreThan(with: .safeArea).offset(by: 16)
    }

    @LayoutPlan
    var stackPlan: ViewPlan {
        if models.isEmpty {
            emptyStackPlan
        } else {
            modeledStackPlan(for: models)
        }
    }

    @LayoutPlan
    var emptyStackPlan: ViewPlan {
        MyView()
        MyOtherView()
        SomeOtherView()
    }

    @LayoutPlan
    func modeledStackPlan(for models: [MyModel]) -> ViewPlan {
        for model in models {
            MyModeledView(model)
        }
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        applyPlan()
    }
}

使用 UITableView 或 UICollectionView? 使用 Draftsman 和 Combine 轻松完成

import Draftsman
import Combine

class MyViewController: UIViewController, Planned {
    
    @Published var models: [MyModel] = []
    
    @LayoutPlan
    var viewPlan: ViewPlan {
        Tabled(forCell: MyTableCell.self, observing: $models) { cell, model in
            cell.apply(model)
        }
        .fillParent()
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        applyPlan()
    }
}

视图层级结构

您可以通过使用 draftContentdrf.insert 方法以及子视图 draft 的 insert 方法(如果是 UIStackView 中的 arranged subviews,则使用 draftStackedContentdrf.insertStackedinsertStacked)来创建视图层级结构,同时创建约束。 不要忘记调用 apply()build(),两者都将重新排列视图层级结构,但只有 apply() 将激活创建的约束。

view.draftContent {
    UIView().drf
        .center.equal(to: .parent)
        .horizontal.equal(to: .safeArea)
        .vertical.moreThan(with: .safeArea)
        .insert {
            myView.drf
                .edges(.equal, to: .parent)
        }
}.apply()

视图的层级结构就像闭包在代码中声明的方式一样。 上面的代码实际上将按顺序执行以下指令

  1. view 创建并插入一个新的 UIView()
  2. 新的 UIView 然后将创建约束
  3. 新的 UIView 然后将插入 myView
  4. myView 然后将创建约束
  5. 然后将创建并激活所有约束

因此,如果层级结构以伪层级结构样式编写,则应类似于此

view
|____new UIView
|    |____myView

可以在闭包中传递的兼容类型是

如果您传递 UIViewController,它将自动将 UIViewController 视图添加为子视图,并将 UIViewController 作为其当前 UIViewController 的子视图。 您可以根据需要插入尽可能多的组件,它将适合所有 View,就像您编写它们的方式一样。

使用 Builder

在大多数情况下,您可以通过调用属性名称,然后再次调用 drf 来返回 Draftsman,从而使用 Draftsman 中内置的 Builder 库来构建视图

myView.drf
    .backgroundColor(.black)
    .drf.bottom.moreThan(to: .safeArea)
    .center.equal(to: .parent)

如果属性未映射,则可以在调用属性名称之前调用 builder

myView.drf.builder
    .backgroundColor(.black)
    .drf.bottom.moreThan(to: .safeArea)
    .center.equal(to: .parent)

使用 Combine

在大多数情况下,您可以通过调用属性名称,然后调用 storeAll(in:)stored() 来返回 Draftsman,从而使用 Combine Publisher 自动赋值给属性

@Published var myText: String?
@Published var myColor: UIColor?

var cancellables: Set<AnyCancellable> = .init()

...
...

UILabel().drf
    .text(assignedBy: $myText)
    .textColor(by: $myColor)
    .storeAll(in: &cancellables)
    .center.equal(to: .parent)
    .bottom.moreThan(to: .safeArea)

storeAll(in:)stored() 之间的区别在于,stored() 将可取消的对象保留在视图本身中。 如果您只想使用此方法订阅此属性一次,最好使用此方法,除非您知道自己在做什么。

如果属性未映射,则可以在调用属性名称之前调用 subscriber

UILabel().drf.subscriber
    .text(assignedBy: $myText)
    .textColor(by: $myColor)
    .storeAll(in: &cancellables)
    .center.equal(to: .parent)
    .bottom.moreThan(to: .safeArea)

基本定位

定位 View 很容易。 您只需要声明哪个锚点应该与其他锚点具有关系

myView.drf
    .top.equal(to: other.drf.top)
    .right.moreThan(to: other.drf.right).offset(by: 8)
    .bottom.lessThan(to: other.drf.bottom).offset(by: 8).priority(.required)
    .left.equal(to: other.leftAnchor)
    .centerX.moreThan(to: other.centerXAnchor).inset(by: 8)
    .centerY.lessThan(to: other.centerYAnchor).inset(by: 8).identifier("centerY")

Draftsman 中可用的基本位置锚点是

所有这些都适用于 UIViewUILayoutGuide。 这可用于使用以下三种方法之一创建约束

这些方法可以接受来自 UIKit 的基本 NSLayoutAnchor,也可以使用来自 DraftsmanAnchor,只要它在同一轴上。 要添加常量,请使用 offset(by:)inset(by:) 方法之一。 offset是到锚点外部的间距,inset` 是到锚点内部的间距

alt text

对于中心锚点,offset 和 inset 可以用这张图来描述

alt text

然后,您可以为创建的约束添加优先级和/或标识符。

基本尺寸

确定 View 的尺寸很容易。 您只需要声明哪个锚点应该与其他锚点或常量具有关系

myView.drf
    .height.equal(to: other.drf.width)
    .width.moreThan(to: other.drf.height).added(by: 8)
    .height.lessThan(to: anyOther.heightAnchor).substracted(by: 8).priority(.required)
    .width.equal(to: anyOther.widthAnchor).multiplied(by: 0.75).identifier("width")

Draftsman 中可用的基本尺寸锚点是

所有这些都适用于 UIViewUILayoutGuide。 这可用于使用以下三种方法之一创建约束

这些方法可以接受来自 UIKit 的基本 NSLayoutDimension,也可以使用来自 Draftsman 的尺寸 Anchor。 要添加常量,请使用 added(by:)substracted(by:)multiplied(by: ) 方法之一。 然后,您可以为创建的约束添加优先级和/或标识符。

也可以使用常量来实现尺寸确定

myView.drf
    .height.equal(to: 32)
    .width.moreThan(to: 64)
    .width.lessThan(to: 128).priority(.required).identifier("width")

非常相似,只是它接受 CGFloat

组合两个或多个锚点

使用多个锚点创建约束非常容易,您可以随时组合两个或多个锚点,并使用它们一次创建多个约束

myView.drf
    .top.left.equal(to: other.drf.top.left)
    .bottom.left.right.moreThan(to: anyOther.drf.top.left.right)

它类似于单个锚点,但您只能传递具有相同轴组合的 Draftsman Anchor

有一些锚点组合的快捷方式

示例

myView.drf
    .vertical.equal(to: other.drf.vertical)
    .bottom.horizontal.moreThan(to: anyOther.drf.top.horizontal)

如果需要,也可以使用 CGSize 来实现使用 sizewidth.height 进行尺寸确定

myView.drf
    .size.equal(to: CGSize(sides: 30))

对于 offsets 和 insets,CGFloat 与所有参数兼容。 但是,如果您需要显式地为每个边缘分配它,您可以始终传递其他内容

隐式关系

您可以只传递 UIViewUILayoutGuide 而不是显式地传递 Anchor,它将使用相同的锚点来创建约束

myView.drf
    .vertical.equal(to: otherView)
    .bottom.horizontal.moreThan(to: view.safeAreaLayoutGuide)

在上面的示例中,它将在 myView 垂直锚点和 otherView 垂直锚点之间创建相等约束,然后它将使用 myView 底部和 view.safeAreaLayoutGuide 底部创建另一个。

匿名锚点

有时您不想甚至不能显式地使用锚点。 在这种情况下,您可以随时使用 AnonymousLayout

myView.drf
    .top.left.equal(with: .parent)
    .bottom.moreThan(with: .safeArea).offset(by: 16)
    .size.lessThan(with: .previous)

可用的 AnonymousLayout

它与常规锚点相同,但它将自动获取匿名视图的相同锚点。 如果要显式地获取匿名视图的不同锚点,则可以执行以下操作

myView.drf
    .top.equal(with: .top(of:.parent))
    .bottom.moreThan(with: .bottom(of: .safeArea)).offset(by: 16)
    .width.lessThan(with: .height(of: .previous))

可用的显式锚点是

布局约束快捷方式

有一些快捷方式用于构建布局约束,可以通过 drf 访问


UITableView 和 UICollectionView

使用 Draftsman 可以很容易地处理 UITableViewUICollectionView。只需调用 renderCells 和任何 Hashable 序列

UITableView().drf.renderCells(using: myArrayOfHashables) { item in
    MyTableCell.render { cell in
        cell.apply(with: item)
    }
}

为不同的项使用多个单元格?只需渲染它们

UITableView().drf.renderCells(using: myArrayOfEnum) { item in
    switch item { 
    case .typeOne:
        MyTableCellOne.render { cell in
            cell.apply(with: item)
        }
    case .typeTwo:
        MyTableCellTwo.render { cell in
            cell.apply(with: item)
        }
    }
}

这也适用于 UICollectionView

UICollectionView().drf.renderCells(using: myArrayOfHashables) { item in
    MyCollectionCell.render { cell in
        cell.apply(with: item)
    }
}

此功能由 UITableViewDiffableDataSourceUICollectionViewDiffableDataSource 提供支持。

SectionCompatible

如果使用分段的 UITableViewUICollectionView,请改用 renderSections 并传递 SectionCompatible 序列

UITableView().drf.renderSections(using: myArrayOfSectionCompatibles) { item in
    MyTableCell.render { cell in
        cell.apply(with: item)
    }
}

SectionCompatible 是一个像这样声明的协议

public protocol SectionCompatible {
    associatedtype Identifier: Hashable
    associatedtype Item: Hashable
    var identifier: Identifier { get }
    var items: [Item] { get }
}

如果不希望实现 SectionCompatible,可以使用 Sectioned 结构体代替

Sectioned(myIdentifier, items: myArrayOfItems)

使用 Combine 渲染单元格

通常情况下,单元格会随着时间的推移而变化,您不想再次渲染整个表。在这种情况下,您可以使用 Combine Publisher 而不是 Sequence

@Published var myItems: [Item] = []

...
...

UITableView().drf.renderCells(observing: $myItems) { item in
    MyTableCell.render { cell in
        cell.apply(with: item)
    }
}

每当发布者发布更改时,表视图将根据发布的项目进行更新。 这也适用于 renderSections

@Published var mySections: [Sectioned<MySection, Item>] = []

...
...

UITableView().drf.renderSections(observing: $mySections) { item in
    MyTableCell.render { cell in
        cell.apply(with: item)
    }
}

所有这些都适用于 UITableViewUICollectionView

自定义视图

SpacerView

您可以将 SpacerView 用作 UIStackView 内容的间隔符

UIScrollView().drf.insertStacked { 
    MyView()
    SpacerView(12)
    OtherView()
}

或者,如果您希望间隔符大小是动态的,则可以留空 init

UIScrollView().drf.insertStacked { 
    MyView()
    SpacerView()
    OtherView()
}

ScrollableStackView

有一个名为 ScrollableStackView 的自定义 UIView,它是 UIScrollView 内的 UIStackView。 如果您需要一个 UIStackView,当内容大于容器时可以滚动,您可以使用它。 它有 2 个可以使用的公共 init

除此之外,它可以像常规 UIStackView 和常规 UIScrollView 一样使用,只是无法更改其 distribution,因为它需要确保视图按预期运行。


布局助手

如果您希望 viewPlan 更短且不太明确,可以使用一些助手。 此助手将接受 LayoutPlan 闭包,因此您无需通过 drf 访问它,而是直接在其 init 中访问

HStacked 和 VStacked

HStackedVStacked 是创建垂直和水平 UIStackView 的快捷方式,而无需显式创建。 它有 3 个可以使用的公共 init

示例

VStacked(distribution: .fillEqually) { 
    SomeView()
    MyView()
    OtherView()
}
.fillParent()

这将等同于

UIStackView(axis: .vertical, distribution: .fillEqually).drf
    .fillParent()
    .insertStacked { 
        SomeView()
        MyView()
        OtherView()
    }

HScrollableStacked 和 VScrollableStacked

HScrollableStackedVScrollableStacked 是创建垂直和水平 ScrollableStackView 的快捷方式,而无需显式创建。 它有 3 个可以使用的公共 init

示例

HScrollableStacked(alignment: .fill) { 
    SomeView()
    MyView()
    OtherView()
}
.fillParent()

这将等同于

ScrollableStackView(axis: .horizontal, alignment: .fill).drf
    .fillParent()
    .insertStacked { 
        SomeView()
        MyView()
        OtherView()
    }

Tabled 和 Collectioned

可以使用 TabledCollectioned 在 init 时创建和调用 renderCellsrenderSections(均在 UITableViewUICollectionView 上)

Tabled(forCell: MyCell.self, observing: $items) { cell, item in
    cell.apply(item)
}

这将等同于

UITableView().drf.renderCells(observing: $items) { item in
    MyCell.render { cell in
        cell.apply(with: item)
    }
}

UICollectionView 相同

Collectioned(forCell: MyCell.self, observing: $items) { cell, item in
    cell.apply(item)
}

Margined

Margined 是一种向任何 UIView 添加边距的简单方法。 例子

Margined(by: 12) { 
    MyView()
}
.fillParent()

这将等同于

UIView().drf.builder
    .backgroundColor(.clear)
    .drf.fillParent()
    .insert { 
        MyView().fillParent().offsetted(by: 12)
    }

Draftsman Planned

Draftsman Planned 协议是一个协议,它使任何 UIViewUIViewController 都可以拥有其预定义的视图计划,并使用 applyPlan 方法应用它。 该协议声明如下

public protocol Planned: AnyObject {
    var planIdentifier: ObjectIdentifier { get }
    var appliedConstraints: [NSLayoutConstraint] { get }
    var viewPlanApplied: Bool { get }
    @LayoutPlan
    var viewPlan: ViewPlan { get }
    @discardableResult
    func applyPlan() -> [NSLayoutConstraint]
}

您只需要实现 viewPlan getter,因为所有内容都将在扩展中实现

import Draftsman

class MyViewController: UIViewController, Planned {
    
    @LayoutPlan
    var viewPlan: ViewPlan {
        VStacked(spacing: 32) { 
            MyView()
            MyOtherView()
            SomeOtherView()
        }
        .centered()
        .matchSafeAreaH().offset(by: 16)
        .vertical.moreThan(with: .safeArea).offset(by: 16)
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        applyPlan()
    }
}

每次调用 applyPlan 时,它都会始终尝试重新创建视图,使其与 viewPlan 中声明的内容相同。

有一些带有 Planned 的类型别名可以使用

Planned Cell

PlannedCell 是专门为单元格构建的 Planned,声明如下

public protocol PlannedCell: Planned {
    @LayoutPlan
    var contentViewPlan: ViewPlan { get }
}

您只需要实现 contentViewPlan getter,因为所有内容都将在扩展中实现。 它将跳过 contentView 并直接进入其内容

class TableCell: UITableView, PlannedCell {
    
    @LayoutPlan
    var contentViewPlan: ViewPlan {
        HStacked(margin: 12, spacing: 8) { 
            UIImageView(image: UIImage(named: "icon_test")).drf
                .sized(CGSize(sides: 56))
            VStacked(margin: 12, spacing: 4) {
                UILabel(text: "title text")
                UILabel(text: "subtitle text")
            }
        }
    }
    
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        applyPlan()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        applyPlan()
    }
}

每次调用 applyPlan 时,它都会始终尝试重新创建视图,使其与 viewPlan 中声明的内容相同。

有一些带有 Planned 的类型别名可以使用

Planned Stack

PlannedStack 是专门为单元格构建的 Planned,声明如下

public protocol PlannedStack: Planned {
    @LayoutPlan
    var stackViewPlan: ViewPlan { get }
}

您只需要实现 stackViewPlan getter,因为所有内容都将在扩展中实现。 它将自动将计划视为堆栈的 arrangeSubviews

class MyStack: UIStackView, PlannedStack {
    
    @LayoutPlan
    var stackViewPlan: ViewPlan {
        UIImageView(image: UIImage(named: "icon_test"))
            .sized(CGSize(sides: 56))
        UILabel(text: "title text")
        UILabel(text: "subtitle text")
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        applyPlan()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        applyPlan()
    }
}

每次调用 applyPlan 时,它都会始终尝试重新创建视图,使其与 viewPlan 中声明的内容相同。

您可以使用 UIPlannedStack,因为它是 UIStackView & PlannedStack 的类型别名


贡献

您知道如何操作,只需克隆并进行拉取请求即可