目录

SwiftUI 自定义布局协议:用 Layout 构建弹性 UI 容器

从 iOS 16 开始,SwiftUI 引入了 Layout 协议,让开发者可以自定义视图的排列方式,不再局限于 HStackVStackZStack 这三大件。这篇文章带你从零实现一个实用的自定义布局容器。

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 试试。