在 SwiftUI 中构建 Flow Layout:像 CSS Flexbox 一样实现视图换行
介绍
SwiftUI 中最受欢迎的布局功能之一是 flow layout(流式布局)——一个可以水平排列元素并在放不下时自动换行的容器。如果你用过 CSS Flexbox 的 flex-wrap: wrap,你就完全理解我们在说什么。
SwiftUI 没有内置的 flow layout,但从 iOS 16 开始,Layout 协议让我们可以自己构建一个。在本文中,我们将创建一个可用于生产环境的 FlowLayout,非常适合:
- 标签云 - 显示不同宽度的标签
- 芯片集合 - 过滤芯片、建议芯片
- 技能徽章 - 显示技能或类别列表
- 任何需要换行的内容 - 元素宽度不一的场景
前置条件
- iOS 16+ / macOS 13+(
Layout协议需要) - SwiftUI 视图的基础知识
- Xcode 14 或更高版本
理解 Layout 协议
Layout 协议需要实现两个方法:
sizeThatFits- 计算布局需要多少空间placeSubviews- 在边界内定位每个子视图
swift
protocol Layout {
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout CacheData
) -> CGSize
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout CacheData
)
}核心思想是我们只计算一次布局,然后在尺寸计算和位置放置时使用相同的逻辑。
FlowLayout 的实现
这是一个完整的、可用于生产环境的实现:
swift
import SwiftUI
/// 一个将子视图按水平流式排列的布局,
/// 在需要时自动换行。
struct FlowLayout: Layout {
var horizontalSpacing: CGFloat = 8
var verticalSpacing: CGFloat = 8
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
return layout(sizes: sizes, containerWidth: proposal.width ?? .infinity).size
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
let offsets = layout(sizes: sizes, containerWidth: bounds.width).offsets
for (index, subview) in subviews.enumerated() {
subview.place(
at: CGPoint(
x: bounds.minX + offsets[index].x,
y: bounds.minY + offsets[index].y
),
proposal: ProposedViewSize(sizes[index])
)
}
}
private func layout(
sizes: [CGSize],
containerWidth: CGFloat
) -> (offsets: [CGPoint], size: CGSize) {
var offsets: [CGPoint] = []
var currentX: CGFloat = 0
var currentY: CGFloat = 0
var lineHeight: CGFloat = 0
var maxWidth: CGFloat = 0
for size in sizes {
// 检查是否需要换行
if currentX + size.width > containerWidth, currentX > 0 {
currentX = 0
currentY += lineHeight + verticalSpacing
lineHeight = 0
}
offsets.append(CGPoint(x: currentX, y: currentY))
lineHeight = max(lineHeight, size.height)
currentX += size.width + horizontalSpacing
maxWidth = max(maxWidth, currentX - horizontalSpacing)
}
let totalHeight = currentY + lineHeight
return (offsets, CGSize(width: maxWidth, height: totalHeight))
}
}工作原理
让我们分解这个算法:
1. 测量子视图
swift
let sizes = subviews.map { $0.sizeThatFits(.unspecified) }我们使用 .unspecified 向每个子视图询问其理想尺寸,这告诉视图"给我你没有约束时的自然尺寸。"
2. 布局算法
layout 函数中的核心逻辑:
- 从位置 (0, 0) 开始
- 对于每个视图,检查它是否能放在当前行
- 如果放不下且不在行首,则换到下一行
- 记录视图位置并更新当前位置
swift
if currentX + size.width > containerWidth, currentX > 0 {
// 换到下一行
currentX = 0
currentY += lineHeight + verticalSpacing
lineHeight = 0
}为什么要检查 currentX > 0?
这可以防止当单个视图比容器更宽时出现无限循环。如果一行的第一个元素太宽,我们会直接放置它,而不是无限换行。
3. 追踪行高
每行可能有不同高度的元素。我们追踪当前行的最大高度:
swift
lineHeight = max(lineHeight, size.height)换行时,我们用这个值来正确定位下一行。
基本用法
swift
struct TagCloudView: View {
let tags = ["Swift", "SwiftUI", "iOS 开发", "Xcode",
"UIKit", "Combine", "Async/Await", "Core Data"]
var body: some View {
FlowLayout(horizontalSpacing: 8, verticalSpacing: 8) {
ForEach(tags, id: \.self) { tag in
Text(tag)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Capsule().fill(Color.blue.opacity(0.2)))
}
}
.padding()
}
}实际案例:建议芯片
这是一个更完整的交互式芯片示例:
swift
struct SuggestionChip: View {
let title: String
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: 6) {
Text(title)
.font(.subheadline)
if isSelected {
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(.blue)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(chipBackground)
}
.buttonStyle(.plain)
}
private var chipBackground: some View {
Capsule()
.fill(isSelected ? Color.blue.opacity(0.15) : Color.gray.opacity(0.1))
.overlay(
Capsule()
.strokeBorder(
isSelected ? Color.blue : Color.clear,
lineWidth: 1.5
)
)
}
}
struct SuggestionPickerView: View {
@State private var selected: Set<String> = []
let suggestions = [
"早间例行 ☀️",
"锻炼 💪",
"读书 📚",
"喝水 💧",
"冥想 🧘",
"给朋友打电话 📞",
"学习新东西 🧠"
]
var body: some View {
FlowLayout(horizontalSpacing: 8, verticalSpacing: 10) {
ForEach(suggestions, id: \.self) { suggestion in
SuggestionChip(
title: suggestion,
isSelected: selected.contains(suggestion),
onTap: {
withAnimation(.spring(response: 0.3)) {
if selected.contains(suggestion) {
selected.remove(suggestion)
} else {
selected.insert(suggestion)
}
}
}
)
}
}
.padding()
}
}添加对齐支持
你可能想要居中或右对齐你的 flow layout。这是一个扩展版本:
swift
struct AlignedFlowLayout: Layout {
enum HorizontalAlignment {
case leading, center, trailing
}
var horizontalSpacing: CGFloat = 8
var verticalSpacing: CGFloat = 8
var alignment: HorizontalAlignment = .leading
// ... sizeThatFits 和 placeSubviews 的实现 ...
private func layout(
sizes: [CGSize],
containerWidth: CGFloat
) -> (offsets: [CGPoint], size: CGSize) {
// 第一遍:计算行
var lines: [[Int]] = [[]]
var lineWidths: [CGFloat] = [0]
var currentX: CGFloat = 0
for (index, size) in sizes.enumerated() {
if currentX + size.width > containerWidth, currentX > 0 {
lines.append([])
lineWidths.append(0)
currentX = 0
}
lines[lines.count - 1].append(index)
lineWidths[lineWidths.count - 1] = currentX + size.width
currentX += size.width + horizontalSpacing
}
// 第二遍:计算带对齐的偏移量
var offsets = [CGPoint](repeating: .zero, count: sizes.count)
var currentY: CGFloat = 0
for (lineIndex, line) in lines.enumerated() {
let lineWidth = lineWidths[lineIndex]
let lineOffset: CGFloat = switch alignment {
case .leading: 0
case .center: (containerWidth - lineWidth) / 2
case .trailing: containerWidth - lineWidth
}
var currentX = lineOffset
var lineHeight: CGFloat = 0
for index in line {
offsets[index] = CGPoint(x: currentX, y: currentY)
lineHeight = max(lineHeight, sizes[index].height)
currentX += sizes[index].width + horizontalSpacing
}
currentY += lineHeight + verticalSpacing
}
return (offsets, CGSize(width: containerWidth, height: currentY - verticalSpacing))
}
}性能考虑
布局计算了两次
注意我们在 sizeThatFits 和 placeSubviews 中都计算了尺寸。对于大多数情况这是没问题的,但如果你有数百个元素,可以考虑使用 cache 参数来存储计算值。
swift
struct FlowLayout: Layout {
struct CacheData {
var sizes: [CGSize] = []
}
func makeCache(subviews: Subviews) -> CacheData {
CacheData(sizes: subviews.map { $0.sizeThatFits(.unspecified) })
}
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout CacheData
) -> CGSize {
layout(sizes: cache.sizes, containerWidth: proposal.width ?? .infinity).size
}
// ... 其余实现使用 cache.sizes
}与其他方法的比较
| 方法 | 优点 | 缺点 |
|---|---|---|
Layout 协议 | 原生、高性能、API 简洁 | 仅支持 iOS 16+ |
GeometryReader | 支持更早的 iOS 版本 | 导致布局传递,实现更困难 |
LazyVGrid | 内置、支持懒加载 | 列是固定的,不是真正的流式布局 |
| 第三方库 | 功能丰富 | 外部依赖 |
结论
Layout 协议使得在 SwiftUI 中构建自定义布局变得简单直接。我们的 FlowLayout:
- 自然换行 - 就像 CSS
flex-wrap: wrap - 处理不同高度 - 每行适应其最高的元素
- 支持自定义间距 - 水平和垂直方向都可以
- 可用于生产环境 - 处理边缘情况如超大元素
完整实现不到 60 行代码,零依赖。用于标签云、过滤芯片、技能徽章,或任何需要灵活换行行为的 UI。

