自定义布局不是“读取屏幕宽度后算 frame”。它是一个协商者:父容器提出可用尺寸,布局询问子视图在特定提议下想要多大,随后报告自己的尺寸,并在最终边界内放置子视图。只要把这三步混在一起,旋转、分屏、Dynamic Type、嵌套 ScrollView 或布局动画就会很快暴露错误。

本文面向已经熟悉 HStackGridGeometryReader,但需要实现标签流、环形布局或等宽容器的开发者。目标不是造一个万能容器,而是建立一套可测试的布局算法:输入是 proposal 与子视图测量结果,输出是容器尺寸和位置。

版本边界

Layout protocol、AnyLayout 与新版 Grid 从 iOS 16、macOS 13 对齐的平台版本起可用。Apple 的 Layout 文档将两个必需入口定义为 sizeThatFitsplaceSubviews;官方 Composing custom layouts 示例提供可运行组合,WWDC22:Compose custom layouts with SwiftUI展示了等宽按钮、缓存与布局类型切换。本文按 Xcode 26.6 稳定 SDK 复核,并以 examples 编译结果标注代码状态。

截至 2026-07-16,Xcode 27 仍是 Beta。SwiftUI updates 中出现的 reorderable、自定义容器 swipe action 等新能力不作为本文实现前提;它们未来能与自定义 Layout 组合,但应等待正式 SDK 后重新验证。

一次布局协商究竟发生什么

父容器给 ProposedViewSize,其中宽或高可以是具体值,也可以未指定。布局再通过 LayoutSubview.sizeThatFits(_:) 测量代理,而不是读取真实 View。最后,sizeThatFits 返回容器所需尺寸,placeSubviews 在系统给出的 bounds 中确定每个代理的位置。Custom layout 总览 对这一职责划分有明确说明。

父容器提议、子视图测量、布局放置的协商流程

图 1:布局是双向协商而非单向命令;本站原创 1400×800 程序化 SVG。

Proposal 不是承诺。子视图可能返回与提议不同的尺寸,布局也可能被不同父容器用多个 proposal 询问。nil 表示父级没有在该轴给出约束,不等于零;.zero 常用于询问最小尺寸,.infinity 可能用于询问最大倾向。实现不应缓存“上一次屏幕宽度”并假定之后不变。

示例:带测量缓存的标签流

标签流的规则是:按输入顺序横向排列,放不下时换行;每行高度取该行最高项。把纯排版算法和 SwiftUI 协议胶水分开,会更容易单元测试。

import SwiftUI

struct TagFlowLayout: Layout {
    var spacing: CGFloat = 8

    struct Cache {
        var sizes: [CGSize]
    }

    struct Row {
        var indices: [Int] = []
        var width: CGFloat = 0
        var height: CGFloat = 0
    }

    func makeCache(subviews: Subviews) -> Cache {
        Cache(sizes: subviews.map { $0.sizeThatFits(.unspecified) })
    }

    func updateCache(_ cache: inout Cache, subviews: Subviews) {
        cache.sizes = subviews.map { $0.sizeThatFits(.unspecified) }
    }

    private func makeRows(maxWidth: CGFloat, sizes: [CGSize]) -> [Row] {
        var rows: [Row] = []

        for (index, size) in sizes.enumerated() {
            let itemSpacing = rows.last?.indices.isEmpty == false ? spacing : 0
            let candidateWidth = (rows.last?.width ?? 0) + itemSpacing + size.width

            if rows.isEmpty || candidateWidth > maxWidth {
                rows.append(Row(indices: [index], width: size.width, height: size.height))
            } else {
                rows[rows.count - 1].indices.append(index)
                rows[rows.count - 1].width = candidateWidth
                rows[rows.count - 1].height = max(rows[rows.count - 1].height, size.height)
            }
        }

        return rows
    }

    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout Cache
    ) -> CGSize {
        let constrainedWidth = proposal.width.flatMap { width in
            width.isFinite ? max(0, width) : nil
        }
        let availableWidth = constrainedWidth ?? .greatestFiniteMagnitude
        let rows = makeRows(maxWidth: availableWidth, sizes: cache.sizes)
        let contentWidth = rows.map(\.width).max() ?? 0
        let contentHeight = rows.map(\.height).reduce(0, +)
            + spacing * CGFloat(max(0, rows.count - 1))

        return CGSize(
            width: constrainedWidth ?? contentWidth,
            height: contentHeight
        )
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout Cache
    ) {
        let rows = makeRows(maxWidth: bounds.width, sizes: cache.sizes)
        var y = bounds.minY

        for row in rows {
            var x = bounds.minX
            for index in row.indices {
                let size = cache.sizes[index]
                subviews[index].place(
                    at: CGPoint(x: x, y: y),
                    anchor: .topLeading,
                    proposal: ProposedViewSize(width: size.width, height: size.height)
                )
                x += size.width + spacing
            }
            y += row.height + spacing
        }
    }
}

这个示例刻意使用子视图的 ideal size,再根据容器宽度换行。真实产品还要决定:超宽单项是否允许溢出、行内如何对齐、系统 spacing 是否优先、从右到左语言如何排列。算法没有普适答案,所以这些都应该成为初始化参数或明确的产品规则,而不是隐藏在魔法数字里。

为什么 cache 只能缓存测量,不应缓存世界

makeCacheupdateCache 适合保存重复计算昂贵、且可由当前 subviews 重建的数据。这里缓存 ideal sizes,避免尺寸与放置阶段重复询问。缓存不是持久数据库:子视图集合、环境、Dynamic Type 或内容改变时,SwiftUI 可以要求更新;实现必须能从新代理恢复正确结果。

不要把 proposal、bounds 或设备宽度永久写入 cache。相同布局可能先被询问最小尺寸,再被询问实际宽度;如果第一次答案污染第二次,就会出现“偶发”换行。也不要在 placeSubviews 修改会影响 sizeThatFits 的外部状态,那会把布局过程变成反馈环。

缓存是否值得,需要真实数据。十个短标签直接测量可能已经足够;数百个复杂子视图才可能体现差异。本文没有给出耗时数字,因为缺少指定设备、字体、子视图数量与 Instruments 轨迹时,任何百分比都不可复现。

bounds 原点、proposal 与无限宽陷阱

放置时必须使用 bounds.minXbounds.minY,不能假定坐标从 (0, 0) 开始。容器可能被父级放到任意局部坐标;忽略原点常在简单 Preview 正常,在组合布局或动画中偏移。

当 proposal 的宽度未指定时,示例使用无限可用宽度算出单行 ideal size,并返回内容真实宽度。若你的布局语义是“未指定也按某个上限换行”,这个上限必须由调用者传入,而不是读取 UIScreen.main.bounds。屏幕不是容器:窗口、Stage Manager、macOS resize 和嵌套栏位都能让二者不同。

同理,不能强制子视图接受测得尺寸。place 时给出的 proposal 应与算法一致;如果测量用 .unspecified,放置却给整行宽度,文本可能重新换行,最终尺寸和缓存不一致。需要弹性宽度时,应在测量阶段就按相同约束询问。

用 AnyLayout 切换结构,而不是分叉内容身份

紧凑宽度使用纵向布局、宽屏使用横向布局时,直接写 if 生成两棵不同树,可能让子视图身份和局部状态随分支切换。AnyLayout 可以类型擦除 HStackLayoutVStackLayout 或自定义 Layout,让同一组内容在布局算法之间过渡。Apple 的 WWDC22 Session 特别演示了这种布局切换。

let layout = isCompact
    ? AnyLayout(VStackLayout(alignment: .leading, spacing: 12))
    : AnyLayout(HStackLayout(alignment: .firstTextBaseline, spacing: 20))

layout {
    TitleBlock()
    MetadataBlock()
}

这并不意味着任何布局切换都应动画。Dynamic Type 改变、Reduce Motion 开启或内容大幅重排时,直接更新可能更清晰。动画属于状态更新事务,Layout 只负责在给定时刻提供几何结果。

LayoutValueKey:让子视图提供语义,而非被猜测

某些算法需要子视图元数据,例如某个标签强制换行、节点权重决定环形半径。不要靠 index 或视图类型猜测;定义 LayoutValueKey,由子视图通过 layoutValue(key:value:) 提供。布局代理可以读取该值,而不需要知道内容的具体类型。这让算法依赖变得显式,也避免把业务模型塞进布局容器。

系统 spacing、对齐与从右到左不是收尾项

固定 8 点 spacing 适合讲解,却不是所有组件的正确默认值。LayoutSubview 暴露 spacing 偏好,ViewSpacing 可以计算相邻视图需要的距离;如果产品希望跟随系统控件间距,应合并子视图 spacing,而不是覆盖它。如果设计系统要求固定节奏,则把 spacing 作为明确参数,并记录它优先于系统偏好的原因。

垂直居中只是最简单的行内规则。文字标签常需要 first baseline,对齐图标可能需要自定义 alignment。布局应先在 Row 结果里保存所需的对齐指标,再在 place 阶段据此偏移,不能把最高项高度直接当作所有项的 y。否则普通字号看似正常,开启大号 Dynamic Type 后基线会明显漂移。

从右到左语言同样需要算法级决定。示例为了聚焦协商流程只展示从左到右;生产实现应接收 layout direction,并从 bounds.maxX 反向推进,或先生成逻辑位置再做物理坐标映射。仅对整个容器做水平镜像可能连图标、文字或手势方向一起翻转。RTL、超长本地化文本和混合字号应进入测试矩阵,而不是发布前肉眼补丁。

算法测试与 SwiftUI 测试应分层

makeRows(maxWidth:sizes:) 不依赖 View,可以抽成纯函数并对输入输出断言。协议适配层则负责 proposal、cache 与 bounds;它需要 Preview、snapshot 或宿主测试验证。分层后,换行错误不必启动完整 App 排查,SwiftUI 环境问题也不会被误归因于几何算法。

常见失败模式与验证

  • GeometryReader + offset 模拟容器,却不向父级报告真实高度,后续内容发生覆盖。
  • sizeThatFits 读写外部 @State,触发布局—状态—布局循环。
  • nil proposal 当成零,导致 ideal size 退化。
  • 测量和放置使用不同约束,文本在放置时二次换行。
  • 每次循环执行排序、文本解析或模型查询,却没有证明它属于布局职责。
  • AnyLayout 当性能优化;它解决的是类型擦除和身份连续性,性能仍需测量。

纯算法测试至少覆盖:空集合、单个超宽项、恰好贴边、不同高度、多行、零 spacing 和宽度变化。SwiftUI 集成测试再覆盖 Dynamic Type、从右到左语言、分屏与布局切换。若出现卡顿,用稳定 Xcode 的 SwiftUI Instruments 观察更新原因与耗时,不以 Preview 的主观流畅度作为结论。

自定义 Layout 的价值不是摆出更奇特的几何,而是把布局规则提升成一个有输入、有输出、可缓存、可测试的算法。始终坚持提议、测量、放置三阶段一致,复杂界面就不必依赖屏幕宽度和偶然坐标维持。

Sources