Skip to content

在 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 协议需要实现两个方法:

  1. sizeThatFits - 计算布局需要多少空间
  2. 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 函数中的核心逻辑:

  1. 从位置 (0, 0) 开始
  2. 对于每个视图,检查它是否能放在当前行
  3. 如果放不下且不在行首,则换到下一行
  4. 记录视图位置并更新当前位置
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))
    }
}

性能考虑

布局计算了两次

注意我们在 sizeThatFitsplaceSubviews 中都计算了尺寸。对于大多数情况这是没问题的,但如果你有数百个元素,可以考虑使用 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

  1. 自然换行 - 就像 CSS flex-wrap: wrap
  2. 处理不同高度 - 每行适应其最高的元素
  3. 支持自定义间距 - 水平和垂直方向都可以
  4. 可用于生产环境 - 处理边缘情况如超大元素

完整实现不到 60 行代码,零依赖。用于标签云、过滤芯片、技能徽章,或任何需要灵活换行行为的 UI。