SwiftUI 自定义 Layout 实现标签流布局(Flow Layout)

169 阅读4分钟

SwiftUI 自定义 Layout 实现流布局(Flow Layout)

在 SwiftUI 中,我们常常使用 HStack、VStack 或 LazyVGrid 来排列视图,但当我们想要实现「自动换行」的布局时(比如标签云、关键词流、兴趣选择器),这些内置容器往往力不从心。

例如下面这种布局:

标签一个接一个从左往右排列,宽度根据内容自适应,当右边放不下时自动换行。

IMG_8097.PNG

这类布局在 UIKit 里可能用 UICollectionViewFlowLayout 实现,而在 SwiftUI 里,我们可以通过 自定义 Layout 来实现。


一、为什么选择自定义 Layout

在 iOS 16 之后,SwiftUI 引入了新的 Layout 协议,让我们可以直接定义一个布局算法。

相比过去常用的 GeometryReader + alignmentGuide 的组合方式,新 Layout API:

  1. 性能更高;

  2. API 更干净;

  3. 与 SwiftUI 动画系统兼容性更好;

  4. 可复用性强。

使用自定义布局可以像这样:

FlowLayout(spacing: 10) {
    ForEach(tags, id: \.self) { tag in
        TagView(title: tag)
    }
}

二、FlowLayout 的核心逻辑

我们要实现的布局规则非常简单:

  1. 从左往右依次排列子视图;

  2. 如果放不下,就换到下一行;

  3. 每行的高度等于该行中最高视图的高度;

  4. 行间和列间留有统一的间距。

换句话说,我们只需要控制两个参数:

  • 当前行的 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,

不妨试试这种方式,你会发现代码更简洁、行为更自然。