前言
UIStackView 是 Apple 在 iOS9 推出的一套 API,它可以很好地减轻手动写或拖 constraint 带来的重复繁琐的工作,也可以自动化的处理排列和元素个数的变化。
正由于其 iOS9+ 的门槛,而国内 app 普遍要兼容 iOS8,再加上 UIStackView 的真正威力其实是 Storyboard, 即便有 FDStackView 这样的黑科技可以降低引入门槛,团队还是倾向于使用纯 Masonry/SnapKit 的方式来实现 Autolayout。
UIStackView 顾名思义,就是一个视图堆栈 ,换句话说:他是一个容器。这类容器型的控件我们不由联想到 UITableView,UICollectionView。相比于这两个传统容器,UIStackView 的定位是这样的:
- 容易编写
- 容易维护
- 方便组合叠加
- 轻量
UIStackView 和传统容器类另一个区别是他自己虽然继承自 UIView,但它本身不能自我渲染,比如他的 backgroundColor 是无效的,所以它注定要和 UIView 相辅相成的进行工作。它能够帮助 UIView 来处理 子View 的位置和大小等布局问题。
然而虽说是处理布局,但它也不能完全代替 constraint,他能做的,不多不少,就是一个堆栈能做到的事,除此之外,比如 子View 的自己内在 size,或是 CHP(Content Hugging Priority),CRP(Content Resistance Priority),更包括 UIStackView 本身的布局,都是离不开手写约束。所以一个好的 Autolayout 封装库还是需要的。
要说其定位,应该就是介于 手写约束 和 UITableView/UICollectionView 之间的工具。就像 iPad 是 笔记本电脑 和 手机 之间的设备一样。它谁也代替不了,但是它有自信的领域,那就是手写 Constraint 很累,但是用 UITableView/UICollectionView 又觉得很笨重的场合。
比如下面这个如果用原生实现,就可以看做是这些 UIStackView 的嵌套:
正题
1. 初始化
在极简情况下,引入 UIStackView 的 view hierarchy 是一个这样的状况:
要实现这个简单的模型,首先需要创建一个 UIStackView:
let stackView = UIStackView()
复制代码
然后把他加到父层的 UIView 上
view.addSubview(stackView)
复制代码
接着,把 子View 实例加到 UIStackView 里,这里调用的不是传统的 addSubview
,而是
stackView.addArrangedSubview(subView1)
stackView.addArrangedSubview(subView2)
复制代码
这时 UIStackView 的 arrangedSubviews 就有值了
open var arrangedSubviews: [UIView] { get }
复制代码
arrangedSubviews
和 subviews
的顺序意义是不同的:
subviews
:它的顺序实际上是图层覆盖顺序,也就是视图元素的 z轴arrangedSubviews
:它的顺序代表了 stack 堆叠的位置顺序,即视图元素的x轴和y轴
实战中,我用这样一个扩展来批量添加:
extension UIStackView {
func addArrangedSubviews(_ views: [UIView?]) {
views.compactMap({ $0 }).forEach { addArrangedSubview($0) }
}
}
复制代码
既然 UIStackView 是 UIView,意味着即可以调用 addSubview
,也可以 addArrangedSubview
,他们的关系是什么样的呢?
- 如果一个元素没有被
addSubview
,调用arrangedSubviews
会自动addSubview
- 当一个元素被
removeFromSuperview
,则arrangedSubviews
也会同步移除 - 当一个元素被
removeArrangedSubview
, 不会触发removeFromSuperview
,它依然在视图结构中
2. 控制布局的方式
UIStackView 有几个重要的属性,这也是我们唯一需要控制的开关,那解决一个页面的布局问题,就转换成如何用这几个有限的开关来描述这个页面的元素。
2.1. axis 轴
horizontal
水平方向 (默认)vertical
垂直方向
2.2. distribution 分布
定义:
The layout that defines the size and position of the arranged views along the stack view’s axis.
描述和 axis
方向一致的元素之间的布局关系
-
.fill
(默认) 根据compression resistance和hugging两个 priority 布局 -
.fillEqually
根据 等宽/高 布局 -
.fillProportionally
根据intrinsic content size按比例布局 -
equalSpacing
等间距布局,如果放不下,根据compression resistance压缩 -
.equalCentering
等中间线间距布局,元素间距不小于spacing
定义的值, 如果放不下,根据compression resistance压缩
2.3. alignment
定义
The alignment of the arranged subviews perpendicular to the stack view’s axis.
描述和 axis
垂直的元素之间的布局关系
-
.fill
(默认) 尽可能铺满 -
.leading
当axis
是vertical
的时候,按 leading 方向对齐 等价于: 当axis
是horizontal
的时候,按 top 方向对齐 -
.top
当axis
是horizontal
的时候,按 top 方向对齐 等价于: 当axis
是vertical
的时候,按 leading 方向对齐 -
.trailing
当axis
是vertical
的时候,按 trailing 方向对齐 等价于: 当axis
是horizontal
的时候,按 bottom 方向对齐 -
bottom
当axis
是horizontal
的时候,按 bottom 方向对齐 等价于: 当axis
是vertical
的时候,按 trailing 方向对齐 -
.center
居中对齐 -
.firstBaseline
仅横轴有用, 按首行基线对齐 -
.lastBaseline
仅横轴有用, 按文章底部基线对齐
2.4. spacing
设置元素之间的边距值
2.5. isBaselineRelativeArrangement(默认 false)
决定了垂直轴如果是文本的话,是否按照 baseline 来参与布局。
2.6. isLayoutMarginsRelativeArrangement (默认 false)
如果打开则通过 layout margins 布局,关闭则通过 bounds
3. 自定义边距能力
1、设置一个元素后面的边距
func setCustomSpacing(_ spacing: CGFloat,
after arrangedSubview: UIView)
复制代码
2、获取一个元素后面的边距
func customSpacing(after arrangedSubview: UIView) -> CGFloat
复制代码
3、获取内部元素默认边距
class let spacingUseDefault: CGFloat
复制代码
4、获取相邻 View 之间的默认边距
class let spacingUseSystem: CGFloat
复制代码
但是需要注意的是,自定义边距是 iOS11+ 的特性,如果需要 iOS9 兼容, 需要引入一个hack的方案:
extension UIStackView {
// How can I create UIStackView with variable spacing between views?
func addCustomSpacing(_ spacing: CGFloat, after arrangedSubview: UIView) {
if #available(iOS 11.0, *) {
self.setCustomSpacing(spacing, after: arrangedSubview)
} else {
let separatorView = UIView(frame: .zero)
separatorView.translatesAutoresizingMaskIntoConstraints = false
switch axis {
case .horizontal:
separatorView.widthAnchor.constraint(equalToConstant: spacing).isActive = true
case .vertical:
separatorView.heightAnchor.constraint(equalToConstant: spacing).isActive = true
}
if let index = self.arrangedSubviews.firstIndex(of: arrangedSubview) {
insertArrangedSubview(separatorView, at: index + 1)
}
}
}
复制代码
4. 处理布局变化
UIStackView 的布局会动态的同步数组 arrangedSubviews
的变化。 变化包括:
- 追加
- 删除
- 插入
- 隐藏
注意:对于隐藏(isHidden)的处理,UIStackView 会自动把空间利用起来,相当于暂时的删去,而不像 Autolayout 一般不破坏约束的做法。
5. 嵌套
如何让一层一层的 StackView 可以和睦相处呢? 答案就是约束完备:
- 保证 父View 上的布局是一个灵活布局,比如需要拉伸的 View 就不要定死宽或高
- 如果定死了尺寸,则 CHP、CRP 也无法解决问题
- 保证 子View 可以正确算出自己的 intrinsic size
结语
即便你目前正使用某种 Autolayout 的封装,引入UIStackView 都是一个有效降低页面约束复杂度的方式。它让你可以用一个大局观去看待排版,而不是陷入每个元素的约束细节里。最棒的是,它提供了更低的维护成本(比如茫茫约束中插入一个按钮)和更高的容错率(手写约束产生语义冲突)。
----- 1月7日更新 ----
有同学问实战用起来是什么感觉。下面举一个小例子:
这是一个有翻译功能的聊天气泡,只需关注深灰色的区域
- 一个暂态是翻译中
stackView.addArrangedSubviews([contentLabel,
translationLoadingSeparatorLine,
translationLoadingView])
复制代码
- 另一个是翻译成功
stackView.addArrangedSubviews([contentLabel,
translationResultTopSeparatorLine,
translationResultTextLabel,
translationResultBottomSeparatorLine,
translationResultBottomLabel])
复制代码
切换一个页面的布局方案,就是清空和重装对应的 stackView 就行了。 是不是优雅了一点?