SwiftUI 的 Layout 协议让容器直接参与测量与放置,而不必通过多层 GeometryReader 和 preference 间接传递尺寸。一个 Layout 接收父级 proposal,询问子视图在不同 proposal 下希望多大,返回容器尺寸,再为每个子视图选择位置。难点不在实现两个方法,而在保持测量确定、理解 unspecified proposal,并让缓存只保存可安全复用的中间结果。

先写清布局规则

以标签流式布局为例:子项按行排列,剩余宽度不足时换行,行高取该行最大值。规则还要说明无限宽、没有子项、单个子项宽于容器以及 RTL 布局。若这些问题只藏在坐标运算里,代码很快会变成不可验证的特殊分支。

struct FlowLayout: Layout {
    var spacing: CGFloat = 8

    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout Cache
    ) -> CGSize {
        let width = proposal.width ?? .infinity
        let rows = arrange(subviews, in: width, spacing: spacing)
        cache.rows = rows
        return CGSize(
            width: rows.containerWidth,
            height: rows.containerHeight
        )
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout Cache
    ) {
        for item in cache.rows.items {
            subviews[item.index].place(
                at: CGPoint(x: bounds.minX + item.x, y: bounds.minY + item.y),
                anchor: .topLeading,
                proposal: ProposedViewSize(item.size)
            )
        }
    }
}

示例省略了 Cachearrange 的实现,目的是展示职责。实际代码必须在缓存无效或 proposal 改变时重新计算,不能假设 sizeThatFits 一定紧接着 placeSubviews 且参数完全相同。

proposal 是建议,不是命令

ProposedViewSize 的宽高可以是具体值、零或 unspecified。子视图会根据 proposal 返回自己的理想尺寸。测量标签时,先询问不限制宽度的理想值,再决定是否需要给更窄 proposal;对可换行文本,宽度变化会改变高度,因此只测一次理想尺寸可能不够。

不要把 nil 自动当屏幕宽度,也不要从全局设备尺寸推断容器空间。Layout 可能位于 sheet、分屏、窗口、列表或预览中。返回值应是能容纳放置结果的有限非负尺寸,遇到 infinity 和 NaN 时要有明确降级。

测量与放置使用同一份计划

最常见错误是 sizeThatFits 用一套换行算法,placeSubviews 又重新算一套,浮点或顺序差异导致返回高度和真实位置不一致。把排布结果建模为纯值:每个索引的 size、row、x、y,加上整体尺寸。两个协议方法共享计划。

排布函数可以脱离 SwiftUI 做单元测试:给定容器宽度与一组尺寸,断言行分配和整体高度。覆盖空数组、刚好放下、超宽元素、不同高度和 spacing。SwiftUI 适配层只负责测量 subview 与应用计划。

缓存优化计算,不缓存事实

缓存适合保存子视图测量和排布计划,但键必须包含影响结果的输入:proposal、子视图数量与尺寸、spacing 和布局方向。动态字体、文本内容、环境或子视图 identity 变化时,旧结果可能失效。先用 Instruments 证明重复测量昂贵,再增加复杂缓存。

不要在缓存中保存 Subview 供任意未来调用,也不要把它当业务状态。Layout 值会被 SwiftUI 重建,缓存生命周期由框架管理。正确性不能依赖某次缓存命中。

适配 RTL、间距与动画

使用 layout direction 决定起始边,不要把 leading 永久等同于左。系统 spacing 可以通过 subview spacing 信息协商;产品明确要求统一间距时,仍要说明相邻元素和换行的规则。动画期间 proposal 与子视图尺寸会连续变化,排布必须在中间值也保持有限、稳定,不能只有起点终点正确。

无障碍动态字体可能把一个标签变成多行并大幅提高行高。测试最大辅助字号、长本地化和粗体文本。视觉流式排列不应破坏语义顺序;VoiceOver 阅读顺序最好仍与数据顺序一致。

结论

自定义 Layout 的质量来自可解释的规则:proposal 如何理解、子视图怎样测量、何时换行、整体尺寸如何得出。先用纯排布计划保证测量与放置一致,再以证据添加缓存,并覆盖动态字体、RTL 和中间动画尺寸。若一个 HStack 或 Grid 已能表达需求,就使用系统容器;只有真正需要新布局规则时,Layout 协议才值得这份控制力。