制图员
制图员是一个专注于建造模式的Swift DSL框架。如果您仍在使用2.3.x版本,分离的README可在此处找到。如果您仍在使用Swift 5.1,请使用1.1.x版本。分离的README可在此处找到。
示例
要运行示例项目,克隆库,首先从示例目录运行 pod install
。
要求
- Swift 5.5或更高版本
- iOS 13.0或更高版本
- XCode 13或更高版本
安装
Cocoapods
Draftsman 通过 CocoaPods 提供。要安装它,只需将以下行添加到您的 Podfile 中
pod 'Draftsman', '~> 3.2.4'
从 Xcode 使用 Swift Package Manager
- 使用 Xcode 菜单 文件 > Swift 包 > 添加包依赖
- 添加 https://github.com/hainayanda/Draftsman.git 作为 Swift 包 URL
- 在 版本 中设置规则,选择 最高下一个主要版本 选项,并将 3.2.4 作为其版本
- 单击下一步并等待
从 Package.swift 使用 Swift Package Manager
在 Package.swift 中将其添加为目标依赖
dependencies: [
.package(url: "https://github.com/hainayanda/Draftsman.git", .upToNextMajor(from: "3.2.4"))
]
在你的目标中使用它作为 Draftsman
.target(
name: "MyModule",
dependencies: ["Draftsman"]
)
作者
Nayanda Haberty, [email protected]
许可
Draftsman遵守MIT许可证。更多详情请见LICENSE文件。
基本用法
Draftsman是NSLayoutConstraints
和UIView
层级构建器。Draftsman使用Swift中新的resultBuilder从Swift,使得声明式方法成为可能。
基本
创建约束非常简单。你只需要调用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()
有两种方法来结束规划约束,可以从任何UIView
或UIViewController
调用它们
func apply() -> [NSLayoutConstraint]
func build() -> [NSLayoutConstraint]
两者的区别在于apply
将激活约束,而build
只创建约束而不激活它们。Apply返回值是可丢弃的,因此是否使用创建的NSLayoutConstraint
由您决定。
你可以始终创建一个UIViewController
或UIView
并实现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()
}
}
视图层次
您可以在创建约束的同时创建视图层次,使用draftContent
或drf.insert
方法和子视图草稿的insert
方法来插入(例如,draftStackedContent
或drf.insertStacked
,如果有通过UIStackView
排列的子视图,则使用insertStacked
)。别忘了调用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()
视图的层次结构与您代码中声明的闭包很相似。上面的代码将按顺序执行以下操作
- 创建一个新的
UIView
- 新的
UIView
将创建约束 - 新的
UIView
将插入myView
myView
将创建约束- 所有约束都将被创建并激活
因此,如果层次是按伪层次结构样式编写的,它应类似于以下内容
view
|____new UIView
| |____myView
传递给闭包的可兼容类型
UIView
的任何子类UIViewController
的任何子类
如果传递UIViewController
,它将自动将UIViewController
视图作为子节点添加,并将UIViewController
作为其当前UIViewController
的子节点。您可以插入所需数量的组件,它将恰好符合您编写的所有视图。
使用Builder
在大多数情况下,您可以通过调用属性名称并通过再次调用drf
来回到Draftsman来使用内置在Draftsman中的Builder库来构建您的视图
myView.drf
.backgroundColor(.black)
.drf.bottom.moreThan(to: .safeArea)
.center.equal(to: .parent)
如果属性未映射,您可以在调用属性名称之前调用构建器
myView.drf.builder
.backgroundColor(.black)
.drf.bottom.moreThan(to: .safeArea)
.center.equal(to: .parent)
使用Combine
在大多数情况下,您可以通过调用属性名称来使用Combine Publisher自动分配给属性,并通过调用storeAll(in:)
或stored()
来回到Draftsman
@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()`》会将可取消订阅项保留到视图本身中。如果您只需一次性使用这种方法来订阅此属性,则可以更好使用,除非您了解自己在做什么。
如果属性未映射,则可以在调用属性名称之前调用订阅者。
UILabel().drf.subscriber
.text(assignedBy: $myText)
.textColor(by: $myColor)
.storeAll(in: &cancellables)
.center.equal(to: .parent)
.bottom.moreThan(to: .safeArea)
基本定位
定位视图非常简单。您只需声明哪个锚点应与其他锚点相关联即可
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 提供的基本定位锚点有
- top(顶部)
- left(左)
- bottom(底部)
- right(右)
- centerX(水平中心)
- centerY(垂直中心)
- leading(前导)
- trailing(尾部)
所有这些对于UIView
和UILayoutGuide
都是可用的。您可以使用以下三种方法之一来创建约束:
- equal(to:)
- moreThan(to:)
- lessThan(to:)
这些方法可以接受来自UIKit
的基本NSLayoutAnchor
或者使用来自Draftsman
的Anchor
,只要它们位于同一轴线上。要添加常数,请使用其中一个offset(by:)
或inset(by:)
方法。其中,`offset`是锚点外部的间距,而`inset`是锚点内部的间距。
对于中心锚点,offset和inset的描述如下图所示:
您还可以为创建的约束添加优先级或/和标识符。
基本尺寸设置
尺寸设置视图非常简单。您只需声明哪个锚点应与其他锚点或常数相关联即可}
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 提供的基本尺寸锚点有
- height(高度)
- width(宽度)
所有这些对于UIView
和UILayoutGuide
都是可用的。您可以使用以下三种方法之一来创建约束:
- equal(to:)
- moreThan(to:)
- lessThan(to:)
这些方法可以接受来自《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)
它将与单个锚点类似,但您只能传递具有相同轴组合的绘图员锚点
- 所有相同的锚点组合都可以相互关联
- top.lefttop.right、bottom.left、bottom.right 和 centerX.centerY 都可以相互关联
- top.leading、top.trailing、bottom.leading、bottom.trailing 和 centerX.centerY 都可以相互关联
- top.left.bottom 和 top.right.bottom 都可以相互关联
- top.left.right 和 bottom.left.right 都可以相互关联
- top.leading.bottom 和 top.trailing.bottom 都可以相互关联
- top.leading.trailing 和 bottom.leading.trailing 都可以相互关联
锚点组合有一些快捷方式
- vertical 与 top.bottom 相同
- horizontal 与 left.right 相同
- localizedHorizontal 与 leading.trailing 相同
- center 与 centerX.centerY 相同
- edges 与 top.left.bottom.right 相同
- localizedEdges 与 top.leading.bottom.trailing 相同
- size 与 width.height 相同
示例
myView.drf
.vertical.equal(to: other.drf.vertical)
.bottom.horizontal.moreThan(to: anyOther.drf.top.horizontal)
使用 size 或 width.height 时,如果需要,也可以通过使用 CGSize
实现
myView.drf
.size.equal(to: CGSize(sides: 30))
对于偏移量和内边距,CGFloat
与所有尺寸兼容。但如果您需要为每个边缘显式分配,您总是可以传递其他内容
- VerticalOffsets 用于垂直锚点的偏移量
- VerticalInsets 用于垂直锚点的内边距
- HorizontalOffsets 用于水平锚点的偏移量
- HorizontalInsets 用于水平锚点的内边距
- AxisOffsets 用于交叉位置锚点的偏移量,它仅仅是
CGPoint
的别名 - AxisInsets 用于交叉位置锚点的内边距,它仅仅是
CGPoint
的别名 - EdgeOffsets 用于 3 和 4 位置锚点的偏移量,它仅仅是
UIEdgeInsets
的别名 - EdgeInsets 用于 3 和 4 位置锚点的内边距,它仅仅是
UIEdgeInsets
的别名
隐含关系
您可以传递UIView
或UILayoutGuide
而不是明确传递Anchor
,它将使用相同的锚点来创建约束。
myView.drf
.vertical.equal(to: otherView)
.bottom.horizontal.moreThan(to: view.safeAreaLayoutGuide)
在上面的示例中,它会创建myView
垂直锚点和otherView
垂直锚点之间的等距约束,然后它将创建另一个以myView
的底部和底部。
匿名锚点
有时您不想甚至不能显式使用锚点。在这些情况下,您始终可以使用AnonymousLayout
myView.drf
.top.left.equal(with: .parent)
.bottom.moreThan(with: .safeArea).offset(by: 16)
.size.lessThan(with: .previous)
可用AnonymousLayout
包括
- mySelf,这将自动获取当前视图
- parent,这将自动获取当前父视图
- safeArea,这将自动获取当前父视图的safeAreaLayoutGuide
- keyboard,这将自动获取键盘布局指南(由Clavier提供)
- keyboardSafeArea,这将自动获取带有safeArea的键盘布局指南(由Clavier提供)
- previous,这将自动获取上一个视图
- previousSafeArea,这将自动获取上一个safeAreaLayoutGuide
这与一个常规锚点相同,但它将自动获取匿名视图的相同锚点。如果您想显式获取匿名视图的不同锚点,则可以进行如下操作
myView.drf
.top.equal(with: .top(of:.parent))
.bottom.moreThan(with: .bottom(of: .safeArea)).offset(by: 16)
.width.lessThan(with: .height(of: .previous))
可用的显式锚点包括
- left(of:)
- leading(of:)
- right(of:)
- trailing(of:)
- centerX(of:)
- top(of:)
- bottom(of:)
- centerY(of:)
- topLeft(of:)
- topLeading(of:)
- topRight(of:)
- topTrailing(of:)
- bottomLeft(of:)
- bottomLeading(of:)
- bottomRight(of:)
- bottomTrailing(of:)
- center(of:)
- centerLeft(of:)
- centerLeading(of:)
- centerRight(of:)
- centerTrailing(of:)
- centerTop(of:)
- centerBottom(of:)
布局约束快捷键
构建布局约束的快捷方式有多种,可以通过 drf
访问。
- fillParent() 等同于
edges.equal(with: .parent)
- fillSafeArea() 等同于
edges.equal(with: .safeArea)
- matchParentH() 等同于
horizontal.equal(with: .parent)
- matchParentV() 等同于
vertical.equal(with: .parent)
- matchSafeAreaH() 等同于
horizontal.equal(with: .safeArea)
- matchSafeAreaV() 等同于
vertical.equal(with: .safeArea)
- matchParentSize() 等同于
size.equal(with: .parent)
- centered() 等同于
center.equal(with: .parent)
- centeredH() 等同于
centerX.equal(with: .parent)
- centeredV() 等同于
centerY.equal(with: .parent)
- cornered(atestone 等同于
top.left.equal(with: .parent)
,或任何其他角落 - widthMatchHeight() 等同于
width.equal(with: .height(of: .mySelf))
- heightMatchWidth() 等同于
height.equal(with: .width(of: .mySelf))
- sized(_:) 等同于
size.equal(with: givenSize)
UITableView 和 UICollectionView
使用 Draftsman 与 UITableView
和 UICollectionView
一起工作非常简单。只需调用 renderCells
和任何一系列可哈希对象。
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)
}
}
此功能由 UITableViewDiffableDataSource
和 UICollectionViewDiffableDataSource
提供支持。
SectionCompatible
如果您使用分区 UITableView
或 UICollectionView
,请调用 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 渲染单元格
大多数情况下,您的单元格会随着时间的推移而改变,您不希望再次渲染整个表格。在这种情况下,您可以使用合并 Publisher
而不是序列。
@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)
}
}
所有这些功能都在 UITableView
和 UICollectionView
上都可用。
自定义视图
空格视图(SpacerView)
您可以使用SpacerView作为UIStackView内容的间隔。
UIScrollView().drf.insertStacked {
MyView()
SpacerView(12)
OtherView()
}
或者,如果您希望间隔的大小是动态的,可以在初始化时留空。
UIScrollView().drf.insertStacked {
MyView()
SpacerView()
OtherView()
}
可滚动栈视图(ScrollableStackView)
有两种自定义的 UIView
被命名为 ScrollableStackView
,它是一个在 UIScrollView
内的 UIStackView
。如果您需要一个内容大于容器的 UIStackView
并可滚动的,可以使用它。它有两个公开的初始化方法可供使用:
- init(frame: CGRect)
- init(frame: CGRect = .zero, axis: NSLayoutConstraintAxisSize, margins: UIEdgeInsets? = nil, alignment: UIStackViewAlignment = .center, spacing: CGFloat = .zero)
除此之外,它可以像常规 UIStackView
和常规 UIScrollView
一样使用,但不能更改其分布,因为这需要确保视图的行为正确。
布局辅助工具
如果在缩短 viewPlan
和使其更简洁时有所需求,可以使用一些辅助工具。此辅助工具将接受 LayoutPlan
闭包,因此您不需要通过 drf
访问它,而是可以直接在它的初始化时访问。
水平堆叠和垂直堆叠
水平堆叠
和 垂直堆叠
是创建水平和垂直 UIStackView 的快捷方式,无需显式创建。它有 3 个公共初始化器可供使用。
- init(_ stack: UIStackView = UIStackView(), @LayoutPlan _ layouter: () -> ViewPlan) {
- init(margins: UIEdgeInsets? = nil, distribution: UIStackView.Distribution = .fill, alignment: UIStackView.Alignment = .fill, spacing: CGFloat = .zero, @LayoutPlan _ layouter: () -> ViewPlan)
- init(margin: CGFloat, distribution: UIStackView.Distribution = .fill, alignment: UIStackView.Alignment = .fill, spacing: CGFloat = .zero, @LayoutPlan _ layouter: () -> ViewPlan)
示例
VStacked(distribution: .fillEqually) {
SomeView()
MyView()
OtherView()
}
.fillParent()
这将与以下操作等效
UIStackView(axis: .vertical, distribution: .fillEqually).drf
.fillParent()
.insertStacked {
SomeView()
MyView()
OtherView()
}
水平可滚动堆叠和垂直可滚动堆叠
水平可滚动堆叠
和 垂直可滚动堆叠
是创建水平和垂直 ScrollableStackView
的快捷方式,无需显式创建。它有 3 个公共初始化器可供使用。
- init(_ stack: ScrollableStackView = ScrollableStackView(), @LayoutPlan _ layouter: () -> ViewPlan) {
- init(margins: UIEdgeInsets? = nil, alignment: UIStackView.Alignment = .fill, spacing: CGFloat = .zero, @LayoutPlan _ layouter: () -> ViewPlan)
- init(margin: CGFloat, alignment: UIStackView.Alignment = .fill, spacing: CGFloat = .zero, @LayoutPlan _ layouter: () -> ViewPlan)
示例
HScrollableStacked(alignment: .fill) {
SomeView()
MyView()
OtherView()
}
.fillParent()
这将与以下操作等效
ScrollableStackView(axis: .horizontal, alignment: .fill).drf
.fillParent()
.insertStacked {
SomeView()
MyView()
OtherView()
}
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)
}
}
Collectioned(forCell: MyCell.self, observing: $items) { cell, item in
cell.apply(item)
}
Margined(by: 12) {
MyView()
}
.fillParent()
这将与以下操作等效
UIView().drf.builder
.backgroundColor(.clear)
.drf.fillParent()
.insert {
MyView().fillParent().offsetted(by: 12)
}
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]
}
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()
}
}
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()
}
}
- UITablePlannedCell,它是
UITableViewCell & PlannedCell
- UICollectionPlannedCell,它是
UICollectionViewCell & PlannedCell
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()
}
}
您可以使用 UIPlannedStack
,因为是 UIStackView & PlannedStack
的别称
贡献
您知道怎么做,只需克隆并提交一个 pull request