概述
一般遇到两个或以上的控件进行一行或一列布局时,或行列组合成卡片形式的布局时,使用 UIStackView 是最简单有效的方案,例如一些 tab 的展示时,可简单使用 UIStackView + UIScrollView 实现。当然要排列的控件比较多,且需要分页加载的时候,请考虑使用 UICollectionView。
所以这里的 Stack 不是堆栈的意思,也不存在压栈、弹栈的操作,可以理解为"堆叠"。UIStackView 实现了 UI 界面 X 轴和 Y 轴方向上的堆叠。类比理解,SwiftUI 带来的 ZStack
就是 Z 轴方向上的视图堆叠。
一图概之:
基本要点:
- 基于 Auto Layout 布局子视图。UIStackView 自身也需要使用 Auto Layout 布局,使用 frame 布局可能效果不一定符合预期。
- 开发者负责定义 UIStackView 的位置和尺寸(可选),UIStackView 自身管理子视图的内容布局和自身大小,即至少给 UIStackView 添加两个邻边的位置约束。
- 可动态修改所有属性。
- 阉割了 UIView 基类的一些特性,如设置
backgroundColor
。
UIStackView 是 Apple 基于 Flexbox 思想来实现的布局,虽然是以一个控件(UIView 子类)呈现,但它做的更多是布局其加入子视图。这种布局思想更接近物理世界的直觉。
Flexbox 在 2009 年被 W3C 提出,可以很简单、完整地实现各种页面布局,而且还是响应式的,开始被应用于前端领域,目前所有浏览器都已支持。后来通过 React Native 和 Weex 等框架,它被带入到客户端开发中,同时支持了 iOS 和 Android。想了解 Flexbox 的详细 CSS 布局,可参阅 Flex 布局教程:语法篇 - 阮一峰的网络日志。要想更直观地体验把玩 Flexbox 布局,可参阅以下链接:
- display: flex
- Flexbox Froggy - A game for learning CSS flexbox
- Flexy Boxes --- CSS flexbox playground and code generation tool
要想在 iOS 完整体验 Flexbox 布局,可使用 Texture 中的 ASStackLayoutSpec
。一些聪明的开发者通过 UICollectionViewLayout 也实现了简单的 Flexbox 布局,如 UICollectionViewFlexboxLayout。
SwiftUI 也引入了些 Flexbox 布局,如 HStack
、VStack
和 ZStack
,可参阅 Building layouts with UIStackViews 简单体验其为布局带来的便利。
内容自适应规则
一句话概括:sub view content size + spacing
。
- UIStackView 沿轴方向长度 = 所有排列子视图大小之和 + 子视图之间的间距总和
- UIStackView 正交轴方向(垂直于轴方向)长度 = 最大排列子视图的长度
- 若
isLayoutMarginsRelativeArrangement
为true
,上述的长度还会包含相关的layoutMargins
。
上面所说的长度都是拟合大小(fitting size)。
注意:
这里的子视图大小是视图的 content size,内容大小,是指 Auto Layout 约束计算之后的 size,所以直接设置frame
是无效的。必须通过重写intrinsicContentSize
属性或给子视图的宽高添加 Auto Layout 约束。
这里还隐含了一些潜规则:
- 让 UIStackView 能自适应子视图大小的前提是子视图要有 content size。
- 最终子视图的 size 也不一定等于 content size,当 UIStackView 自身设置了宽高约束,其会为了填充空间会对子视图进行拉伸或收缩。
- 自适应子视图大小意味着其不允许子视图溢出其自身。这与 CSS flexbox 的
flex-wrap
表现有别。
另外 UIStackView 的这些布局属性会直接影响其自适应的大小:
axis
:定义了堆叠的轴方向,是在垂直方向还是水平方向进行堆叠。distribution
:定义了轴方向上的子视图布局。alignment
:定义了轴正交方向上的子视图布局。spacing
:定义了轴方向上的子视图之间的最小间距。isBaselineRelativeArrangement
:定义了视图之间的垂直间距是否从基线测量。isLayoutMarginsRelativeArrangement
:定义了是否要基于子视图的layoutMargins
来布局。
若修改上述属性无法达到你的预期效果,则优先检查 Xcode 控制台是否输出了 Auto Layout 约束冲突的错误日志,从中检查需要修改的属性或补充的约束。
调试
distribution
和alignment
在一定程度上还原调试 CSS 时捉襟见肘的体验😅。
NSLayoutConstraint.Axis
默认为 horizontal
水平方向。
UIStackView.Distribution
定义沿 UIStackView 轴方向的子视图的大小和位置的布局。
除了 fillEqually
以外的 distribution
,UIStackView 在沿轴方向计算尺寸时,会使用每个子视图的 intrinsicContentSize
属性。而 fillEqually
会相等调整子视图的大小,使其在轴方向的长度是一致的,如果可能, UIStackView 会拉伸所有子视图,以匹配轴方向最大内容大小的视图。
arduino
fill
默认。UIStackView 调整其子视图的大小,以填充轴方向上的可用空间。
当子视图塞不进 UIStackView 时,UIStackView 根据其抗压优先级(compression resistance priority)收缩视图。
当子视图没有塞满 UIStackView 时,UIStackView 根据其拥抱优先级(hugging priority)拉伸视图。
当存在歧义时,UIStackView 根据子视图在 arrangedSubviews
中的索引调整子视图的大小。
上述提到的"抗压优先级"、"拥抱优先级"可参阅 UIView 的这些 API 的相关资料:
Swift
func contentCompressionResistancePriority(for axis: NSLayoutConstraint.Axis) -> UILayoutPriority
func setContentCompressionResistancePriority(_ priority: UILayoutPriority, for axis: NSLayoutConstraint.Axis)
func contentHuggingPriority(for axis: NSLayoutConstraint.Axis) -> UILayoutPriority
func setContentHuggingPriority(_ priority: UILayoutPriority, for axis: NSLayoutConstraint.Axis)
fillEqually
UIStackView 调整其子视图的大小,以填充轴方向上的可用空间。子视图会拉伸调整大小(匹配最大的子视图),以保持轴方向上的大小都相等。
fillProportionally
UIStackView 调整其子视图的大小,以填充轴方向上的可用空间。视图根据其沿 UIStackView 轴的内在内容大小按比例调整大小。
equalSpacing
UIStackView 放置排列子视图,以填充轴方向上的可用空间。当排列的视图没有填充 UIStackView 时,UIStackView 会均匀地填充视图之间行间距。即此时的 spaicing
只限定了最小的间距。
当子视图塞不进 UIStackView 时,UIStackView 会根据其抗压优先级收缩视图。
当存在歧义时,UIStackView 会根据其在 arrangedSubviews
中的索引收缩子视图。
equalCentering
对子视图中点等距布局,同时保持子视图之间的间距。同样,此时的 spaicing
只限定了最小的间距。
当子视图塞不进 UIStackView 时,UIStackView 收缩间距,直到达到 spaicing
值。若子视图仍塞不进 UIStackView,则会根据其抗压优先级收缩子视图。
当存在歧义时,UIStackView 会根据其在 arrangedSubviews
中的索引收缩子视图。
为了保持子视图内容大小,UIStackView 会突破中点等距布局。同样,为保持子视图间的最小间距,UIStackView 会压缩子视图的内容大小。
UIStackView.Alignment
定义垂直于 UIStackView 轴方向的子视图布局。
对于除 fill
之外的所有 alignment
, UIStackView 在计算轴正交方向的大小时使用每个子视图的 intrinsicContentSize
属性。fill
则调整所有子视图的大小,以填充轴正交方向上的可用空间,如果可能, UIStackView 会拉伸所有子视图,以匹配轴正交方向上最大内在大小的视图。
arduino
fill
默认。 UIStackView 调整其子视图大小,以填充轴正交方向上的可用空间。
scss
center
UIStackView 把子视图中点沿轴对齐,即垂直方居中对齐。
leading
:横轴时也可使用 top
。
UIStackView 把子视图沿前边缘对齐。
trailing
:横轴时也可使用 bottom
。
UIStackView 把子视图沿后边缘对齐。
firstBaseline
:仅横轴有效。
UIStackView 根据首个基线对齐排列子视图。
lastBaseline
:仅横轴有效。
UIStackView 根据末尾基线对齐排列子视图。
间距
固定间距:
Swift
var spacing: CGFloat { get set }
默认为 0.0
。此属性定义了在 UIStackViewDistribution.fill
子视图之间的严格间距,也是 UIStackView.Distribution.equalSpacing
和 UIStackView.Distribution.equalCentering
的最小行间距。
使用负值会重叠子视图,其堆叠层级按子视图的层级索引排列。
更进一步,iOS 11.0+ 还增加了设置自定义间距的方法:
Swift
// Applies custom spacing after the specified view.
func setCustomSpacing(_ spacing: CGFloat, after arrangedSubview: UIView)
func customSpacing(after arrangedSubview: UIView) -> CGFloat
通过这个方法可设置 sub view 间 的自定义间距。但局限性也在这个"间"字。如下图所示,spaicng 只能设置到 UIStackView 的 sub view 之间,而 UIStackView 的边缘位置是不能设间距的。
暂时无法在飞书文档外展示此内容
这就需要一些小智慧了,可以直接给边缘位置添加一个占位视图。这里简单封装一个占位视图 SizeView
:
Swift
import UIKit
/// 自定义内容尺寸视图
public class SizeView: UIView {
public var size: CGSize = .zero
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
private func commonInit() {
isUserInteractionEnabled = false
}
public override var intrinsicContentSize: CGSize {
size
}
}
public extension SizeView {
convenience init(size: CGSize, color: UIColor? = nil) {
self.init(frame: .zero)
self.size = size
backgroundColor = color
}
/// 尽可能大
static func expanded(width: CGFloat = .greatestFiniteMagnitude, height: CGFloat = .greatestFiniteMagnitude, color: UIColor? = nil) -> SizeView {
SizeView(size: CGSize(width: width, height: height), color: color)
}
/// 尽可能小
static func shrinked(width: CGFloat = 0, height: CGFloat = 0, color: UIColor? = nil) -> SizeView {
SizeView(size: CGSize(width: width, height: height), color: color)
}
}
下面代码是音乐入口初始状态的布局代码:
Swift
noMusicStackView.then {
let musicIconView = makeMusicIconView()
// 使用占位视图添加左侧间距
$0.addArrangedSubview(SizeView.shrinked(width: 12))
$0.addArrangedSubview(musicIconView)
// 使用自定义间距 API 设置 sub view 间的间距
$0.setCustomSpacing(6, after: musicIconView)
let addLabel = self.addLabel
addLabel.font = UI.textFont
addLabel.textColor = UI.textColor
addLabel.layer.lv.setupShadow(color: UI.textShadow.color, offset: UI.textShadow.offset, radius: UI.textShadow.radius)
$0.addArrangedSubview(addLabel)
// 使用占位视图添加右侧间距
$0.addArrangedSubview(SizeView.shrinked(width: 12))
}
效果:
子视图管理
Swift
func addArrangedSubview(_ view: UIView)
var arrangedSubviews: [UIView] { get }
func insertArrangedSubview(_ view: UIView, at stackIndex: Int)
func removeArrangedSubview(_ view: UIView)
上述方法都会首先作用于 arrangedSubviews
数组。
调用 UIStackView 的 addArrangedSubview(_:)
时,添加的视图除了添加到 arrangedSubviews
中,同时也会添加到基类的 subviews
中,即成为子视图。
由于 UIStackView 内部会确保 arrangedSubviews
是 subviews
的子集,所以即使在调用 addArrangedSubview(_:)
前调用了基类的 addSubview(_:)
,也不会有什么影响,也不会改变其在 arrangedSubviews
中的顺序。但必须要调用 addArrangedSubview(_:)
来添加管理的子视图,否则设置 UIStackView 的各个属性将无法作用于添加的子视图。
移除视图的时候要注意,removeArrangedSubview(_:)
只是从 arrangedSubviews
中移除子视图,即移除的子视图不受 UIStackView 管理,但其还在基类的 subviews
中,即还在视图层级中。所以要直接从层级中移除子视图,可直接使用基类的 removeFromSuperview()
方法。
布局管理
UIStackView 会动态响应以下操作,并自动更新布局:
- 添加、删除或插入到
arrangedSubviews
。 - 修改 UIStackView 定义的所有属性。
- 修改子视图的
isHidden
属性。其效果跟 UIView 对子视图的效果不一致,当值为true
时 UIStackView 会重新计算布局(跟移除视图效果一致),还甚至默认添加了动画(轴方向收缩效果)!而 UIView 对子视图isHidden
为true
时不会有布局更新,更不会有动画。
处理第 1 点管理 arrangedSubviews
的几个方法,第 2、3 点涉及的属性都可以添加动画!另外要控制子视图 isHidden
的时长,可以放到 animate(withDuration:animations:)
中控制。