UIStackView 入坑指南

23,677 阅读6分钟

前言

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 }

arrangedSubviewssubviews 的顺序意义是不同的:

  • 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 resistancehugging两个 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 (默认) 尽可能铺满

  • .leadingaxisvertical 的时候,按 leading 方向对齐 等价于: 当 axishorizontal 的时候,按 top 方向对齐

  • .topaxishorizontal 的时候,按 top 方向对齐 等价于: 当 axisvertical 的时候,按 leading 方向对齐

  • .trailingaxisvertical 的时候,按 trailing 方向对齐 等价于: 当 axishorizontal 的时候,按 bottom 方向对齐

  • bottomaxishorizontal 的时候,按 bottom 方向对齐 等价于: 当 axisvertical 的时候,按 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 就行了。 是不是优雅了一点?