SwiftUI 自定义 Layout 实现流布局(Flow Layout)
在 SwiftUI 中,我们常常使用 HStack、VStack 或 LazyVGrid 来排列视图,但当我们想要实现「自动换行」的布局时(比如标签云、关键词流、兴趣选择器),这些内置容器往往力不从心。
例如下面这种布局:
标签一个接一个从左往右排列,宽度根据内容自适应,当右边放不下时自动换行。
这类布局在 UIKit 里可能用 UICollectionViewFlowLayout 实现,而在 SwiftUI 里,我们可以通过 自定义 Layout 来实现。
一、为什么选择自定义 Layout
在 iOS 16 之后,SwiftUI 引入了新的 Layout 协议,让我们可以直接定义一个布局算法。
相比过去常用的 GeometryReader + alignmentGuide 的组合方式,新 Layout API:
-
性能更高;
-
API 更干净;
-
与 SwiftUI 动画系统兼容性更好;
-
可复用性强。
使用自定义布局可以像这样:
FlowLayout(spacing: 10) {
ForEach(tags, id: \.self) { tag in
TagView(title: tag)
}
}
二、FlowLayout 的核心逻辑
我们要实现的布局规则非常简单:
-
从左往右依次排列子视图;
-
如果放不下,就换到下一行;
-
每行的高度等于该行中最高视图的高度;
-
行间和列间留有统一的间距。
换句话说,我们只需要控制两个参数:
- 当前行的 x 坐标(累计宽度);
- 当前的 y 坐标(换行后下移的距离)。
三、FlowLayout 实现代码
下面是完整的 FlowLayout 实现。
import SwiftUI
struct FlowLayout: Layout {
var spacing: CGFloat = 8
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
guard let containerWidth = proposal.width else { return .zero }
var x: CGFloat = 0
var y: CGFloat = 0
var lineHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
// 如果放不下则换行
if x + size.width > containerWidth {
x = 0
y += lineHeight + spacing
lineHeight = 0
}
x += size.width + spacing
lineHeight = max(lineHeight, size.height)
}
return CGSize(width: containerWidth, height: y + lineHeight)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
var x = bounds.minX
var y = bounds.minY
var lineHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
// 判断是否需要换行
if x + size.width > bounds.maxX {
x = bounds.minX
y += lineHeight + spacing
lineHeight = 0
}
subview.place(
at: CGPoint(x: x, y: y),
proposal: ProposedViewSize(width: size.width, height: size.height)
)
x += size.width + spacing
lineHeight = max(lineHeight, size.height)
}
}
}
四、使用 FlowLayout
我们来定义一个简单的标签视图:
struct TagView: View {
let title: String
var body: some View {
HStack(spacing: 6) {
Image(systemName: "tag.fill")
.font(.system(size: 12))
Text(title)
.font(.system(size: 14))
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.blue.opacity(0.1))
.clipShape(Capsule())
}
}
接下来在页面中使用 FlowLayout:
struct ContentView: View {
let tags = ["SwiftUI", "动画", "性能优化", "国际化", "Dark Mode", "发布到 App Store"]
var body: some View {
ScrollView {
FlowLayout(spacing: 10) {
ForEach(tags, id: \.self) { tag in
TagView(title: tag)
}
}
.padding()
}
}
}
运行效果:
- 每个标签宽度自适应;
- 自动换行;
- 行间距与列间距统一;
- 无需手动计算。
五、原理讲解
在 SwiftUI 的 Layout 系统中,视图的渲染会经过两个阶段:
1. 测量阶段(sizeThatFits)
SwiftUI 会调用 sizeThatFits() 来询问这个布局需要多大的空间。
此时我们遍历所有子视图,计算它们的理想宽高,并在到达行宽极限时换行,从而得出总高度。
2. 放置阶段(placeSubviews)
之后 SwiftUI 会调用 placeSubviews(),让我们根据前面的计算结果,实际放置每个子视图的坐标。
两者的核心差别:
- sizeThatFits 是“预估”,返回布局整体的尺寸;
- placeSubviews 是“实战”,真正将子视图摆放到容器里。
六、扩展思路
上面的布局是最基础的版本,但你可以轻松扩展它:
| 功能 | 思路 |
|---|---|
| 居中对齐 | 计算每行剩余宽度后偏移 x 起点 |
| 设置最大行数 | 在换行时判断行数是否超过上限 |
| 动态添加标签 | 外部绑定一个 @State 数组,动画更新 |
| 支持动画过渡 | 在 FlowLayout 外层加 .animation(.spring(), value: items) |
| 支持删除标签 | 在 TagView 上加 .onTapGesture { remove(tag) } |
例如,如果想让标签在行内居中,你可以修改 placeSubviews,在每次换行时先统计该行所有子视图宽度,再计算偏移量。
七、结语
FlowLayout 展示了 SwiftUI 自定义布局系统的灵活与强大。
相比传统的手动计算坐标或 GeometryReader 嵌套,这种方式更现代、更符合 SwiftUI 思想:
-
声明式;
-
可组合;
-
可动画化;
-
可复用。
如果你正在做标签页、兴趣选择器、图片墙等类似 UI,
不妨试试这种方式,你会发现代码更简洁、行为更自然。