从 iOS 16 开始,SwiftUI 引入了 Layout 协议,让开发者可以自定义视图的排列方式,不再局限于 HStack、VStack 和 ZStack 这三大件。这篇文章带你从零实现一个实用的自定义布局容器。
Layout 协议的核心思想
Layout 协议只有两个必需方法:
1
2
3
4
|
protocol Layout: Animatable {
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache)
}
|
sizeThatFits 决定容器整体要占多大空间,placeSubviews 决定每个子视图放在什么位置。SwiftUI 的布局系统会先问"你要多大?",然后告诉你"给你这么大地方,去摆吧"。
小贴士:ProposedViewSize 是父视图给你的建议尺寸,可以是固定值、.infinity(尽可能大)或 .unspecified(不限制)。Subviews 包含了所有子视图的布局信息,你可以遍历它们获取每个子视图的尺寸需求。
实战:实现等高自适应标签云
前端常见的标签云效果——标签从左到右排列,一行排不下自动换行,且每一行高度相同。用 Layout 协议实现优雅又高效:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
struct EqualHeightFlowLayout: Layout {
var spacing: CGFloat = 8
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
guard !subviews.isEmpty else { return .zero }
let maxWidth = proposal.width ?? .infinity
var height: CGFloat = 0
var rowWidth: CGFloat = 0
var rowHeight: CGFloat = 0
for view in subviews {
let size = view.sizeThatFits(.unspecified)
if rowWidth + size.width + spacing > maxWidth {
height += rowHeight + spacing
rowWidth = 0; rowHeight = 0
}
rowWidth += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
height += rowHeight
return CGSize(width: maxWidth, height: height)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let maxWidth = bounds.width
var origin = CGPoint(x: bounds.minX, y: bounds.minY)
var rowHeight: CGFloat = 0
for view in subviews {
let size = view.sizeThatFits(.unspecified)
if origin.x + size.width > maxWidth + bounds.minX {
origin.x = bounds.minX
origin.y += rowHeight + spacing
rowHeight = 0
}
let alignedH = rowHeight == 0 ? size.height : rowHeight
view.place(at: CGPoint(x: origin.x, y: origin.y),
proposal: ProposedViewSize(width: size.width, height: alignedH))
origin.x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
}
}
|
这段代码做了什么:sizeThatFits 遍历所有子视图模拟换行,计算出容器总高度。placeSubviews 按同样逻辑将子视图摆放到对应位置,并强制同一行所有子视图高度一致。
使用起来非常简单直观:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
struct TagCloudView: View {
let tags = ["SwiftUI", "iOS 16", "Layout 协议", "自定义布局",
"响应式设计", "Swift 5.7", "WWDC 2022", "Xcode"]
var body: some View {
EqualHeightFlowLayout(spacing: 10) {
ForEach(tags, id: \.self) { tag in
Text(tag)
.font(.caption)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.blue.opacity(0.1))
.cornerRadius(8)
}
}
.padding()
}
}
|
性能优化:缓存机制
如果涉及大量子视图,可以实现 Cache 关联类型来缓存中间结果:
1
2
3
4
5
6
7
8
|
struct CachedFlowLayout: Layout {
struct Cache {
let sizes: [CGSize]
}
func makeCache(subviews: Subviews) -> Cache {
Cache(sizes: subviews.map { $0.sizeThatFits(.unspecified) })
}
}
|
为什么需要缓存:每次布局变化(如旋转屏幕)都会调用两个必需方法,缓存能避免重复计算每个子视图的尺寸。
Layout vs GeometryReader
| 方案 |
优势 |
劣势 |
Layout 协议 |
只计算一次布局、支持动画、不破坏布局优先级 |
需 iOS 16+ |
GeometryReader |
兼容旧版本 |
撑满父视图、破坏惰性容器性能 |
怎么选:如果目标版本 >= iOS 16,优先用 Layout 协议。需兼容 iOS 15 及以下时用 GeometryReader。
为什么 GeometryReader 撑满父视图:GeometryReader 本身是一个 View,遵循 SwiftUI 标准布局规则——无约束时会尽可能占用全部可用空间。而 Layout 协议返回你指定的尺寸,不影响父视图。
结语
Layout 协议是 SwiftUI 布局系统的一次重要开放——它把框架内置的布局能力暴露给了开发者。用 Layout 协议实现自定义容器,代码更直观、性能更好、动画更自然。下次遇到非标准布局需求时,不妨写一个自定义的 Layout 试试。