← 所有文章

SwiftUI Layout 协议:从 sizeThatFits 到 placeSubviews 构建自定义布局

iOS 16 为 SwiftUI 新增了 Layout 协议,这是用于构建自定义容器视图、参与 SwiftUI 布局流程的公开 API1。在 Layout 出现之前,自定义容器形态要么依赖 GeometryReader 的奇技淫巧(这会破坏组合性,因为它会请求完整的 proposed size),要么用与系统对抗的自定义 ViewModifier 来实现。Layout 才是正确答案:一个包含两个方法(sizeThatFits 和 placeSubviews)的协议,再加上可选的 spacing 与缓存扩展,其契约能与 SwiftUI 的”父级提议、子级处置”布局模型干净地融合。

本文将对照 Apple 官方文档来梳理这一协议。视角是”Layout 实际上承诺了什么”,因为常见的误用模式(把 Layout 当作坐标空间工具而非尺寸协商工具)会产生在某块屏幕上正常、换块屏幕就崩坏的布局,而本系列的 What SwiftUI Is Made Of 一文已经主张过:理解 SwiftUI 架构的最佳方式就是阅读它的公开协议。

TL;DR

  • Layout 是一个包含两个必需方法的协议:sizeThatFits(proposal:subviews:cache:) 在给定父级提议下返回布局期望的尺寸;placeSubviews(in:proposal:subviews:cache:) 通过调用每个子视图的 place(at:anchor:proposal:) 方法来定位它们2。
  • proposal 参数是一个 ProposedViewSize,其中 width 和 height 都是可选的 CGFloat。nil 表示”使用你的理想尺寸”;有限值是父级给出的提议;.infinity 表示”想用多少用多少”。
  • Subviews 是 LayoutSubviews 的类型别名,它是一个 LayoutSubview 代理的集合。每个代理都可以在任何提议下被查询尺寸,并被放置在任意位置。这些代理是 Layout 与子视图交互的唯一通道。
  • 自定义布局值通过附加在子视图上的 LayoutValueKey 类型从子级流向父级,借助 .layoutValue(...) 设置,并可在布局方法内通过 LayoutSubview 的下标读取。
  • cache 用于在 sizeThatFits 和 placeSubviews 之间分摊计算成本(每次布局流程都会调用这两个方法,往往涉及相同的中间值)。把缓存类型化为一个持有预计算尺寸的结构体;构建一次,两个方法共享。

协议契约

Layout 通常是一个结构体,它声明了两个方法供 Apple 框架在布局流程中调用2:

struct DiagonalLayout: Layout {
    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        // Compute and return the size your layout wants
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        // Position each subview by calling subview.place(...)
    }
}

像使用内置容器一样使用它:

DiagonalLayout {
    Text("First")
    Text("Second")
    Text("Third")
}

框架先用父级传来的 proposed size(一个 ProposedViewSize)调用 sizeThatFits,再用布局获得的边界调用 placeSubviews。两个方法合在一起描述了布局的行为:它想要多大,以及每个子视图在这块分配空间内的位置。

ProposedViewSize:父级的提议

SwiftUI 中的布局遵循”父级提议、子级处置”契约3。父级传入一个 proposed size;子级返回它实际的尺寸;父级在自己的边界内定位子级。Layout 通过 ProposedViewSize 参与这一契约:

struct ProposedViewSize {
    var width: CGFloat?
    var height: CGFloat?
}

可选的轴向值携带着语义信息:

  • nil表示某轴向上”使用你的理想/自然尺寸”。Text 在 .zero 提议下返回最小宽度(每行一个字符);在 nil 提议下返回理想宽度(一行不换行)。
  • 有限值表示”父级提议这么多空间,由你决定如何处理”。Text 在 100pt 宽度提议下,可能换行,可能用更少,也可能正好用 100。
  • .infinity表示”想用多少用多少”。Color 在 .infinity 提议下会占据全部可用空间。

约定俗成:ProposedViewSize.unspecified(width: nil, height: nil)代表对理想尺寸的请求;ProposedViewSize.zero 代表对最小尺寸的请求;ProposedViewSize.infinity 代表对贪婪扩展的请求。

自定义 Layout 的 sizeThatFits 应当尊重 proposal:返回布局在所提议边界下真正想要的尺寸,而不是永远返回同一个硬编码的值。硬编码尺寸会破坏布局对不同容器(卡片视图、列表单元、sheet)的适配能力。

通过 LayoutSubview 读取子视图尺寸

在 sizeThatFits 内部,布局会针对各种 proposal 询问每个子视图想要多大。查询通过 LayoutSubview 代理完成4:

func sizeThatFits(
    proposal: ProposedViewSize,
    subviews: Subviews,
    cache: inout ()
) -> CGSize {
    let proposed = ProposedViewSize(
        width: proposal.width.map { $0 / CGFloat(subviews.count) },
        height: proposal.height
    )

    let sizes = subviews.map { $0.sizeThatFits(proposed) }
    let totalWidth = sizes.reduce(0) { $0 + $1.width }
    let maxHeight = sizes.map(\.height).max() ?? 0

    return CGSize(width: totalWidth, height: maxHeight)
}

subviews.map { $0.sizeThatFits(proposal) } 这种模式正是布局发现子视图所需尺寸的方式。LayoutSubview 代理的 sizeThatFits(_:) 方法与 Layout 协议的同名方法并不是同一个东西;它是代理对子视图在给定 proposal 下偏好尺寸的查询。两者同名是因为它们参与同一场协商,但属于契约中的不同层。

想了解子视图尺寸的布局会调用 proxy.sizeThatFits(_:)。想定位子视图的布局则在 placeSubviews 内调用 proxy.place(at:anchor:proposal:)。

放置子视图

placeSubviews 是布局做出定位决策的地方2:

func placeSubviews(
    in bounds: CGRect,
    proposal: ProposedViewSize,
    subviews: Subviews,
    cache: inout ()
) {
    var x = bounds.minX
    let y = bounds.midY

    for subview in subviews {
        let size = subview.sizeThatFits(.unspecified)
        subview.place(
            at: CGPoint(x: x + size.width / 2, y: y),
            anchor: .center,
            proposal: ProposedViewSize(size)
        )
        x += size.width
    }
}

place(at:anchor:proposal:) 调用用于放置单个子视图,三个参数:

  • at:在父级坐标空间中的位置。
  • anchor:子视图的哪一个点对齐到 at。.center 把子视图中心放在 at;.topLeading 则把左上角放在那里。
  • proposal:子视图应当渲染的尺寸。传入同一子视图 sizeThatFits 返回的尺寸以尊重其偏好,或者传入自定义 proposal 以约束它。

每个子视图在每次 placeSubviews 调用中必须且仅能被放置一次。漏掉子视图会让它无法定位(在渲染结果中消失);放置两次则是运行时错误。

通过 LayoutValueKey 传递自定义布局值

当子视图需要向父布局传达某些信息(优先级、跨度、类别)时,通道就是 LayoutValueKey5:

struct PriorityKey: LayoutValueKey {
    static let defaultValue: Int = 0
}

extension View {
    func layoutPriority(_ value: Int) -> some View {
        layoutValue(key: PriorityKey.self, value: value)
    }
}

// Inside the Layout:
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
    let sortedSubviews = subviews.sorted {
        $0[PriorityKey.self] > $1[PriorityKey.self]
    }
    // ... place sortedSubviews
}

LayoutValueKey 协议为父子通信提供了类型化的通道。子视图通过 layout-value 修饰符附加值;父级通过 LayoutSubview 的下标读取它。每个 key 对未显式指定值的子视图都有一个默认值。

这一模式在概念上等同于 .layoutPriority(_:) 等内置修饰符所表达的逻辑。框架将这一具体值通过 LayoutSubview 上专门的 priority: Double 属性暴露出来,而不是借助公开的 LayoutValueKey,因此布局优先级的代理访问方式是 subview.priority,而不是 key 的下标访问。自定义布局可以为它们需要从子视图获取的任何其他结构化数据声明自己的 LayoutValueKey 类型。

cache 参数

两个布局方法都接收一个 cache: inout 参数。缓存是布局在 sizeThatFits 与 placeSubviews 之间分摊工作的地方6:

struct DiagonalLayout: Layout {
    struct Cache {
        var sizes: [CGSize]
    }

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

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

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize {
        let totalWidth = cache.sizes.reduce(0) { $0 + $1.width }
        let totalHeight = cache.sizes.reduce(0) { $0 + $1.height }
        return CGSize(width: totalWidth, height: totalHeight)
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) {
        var x = bounds.minX
        var y = bounds.minY
        for (subview, size) in zip(subviews, cache.sizes) {
            subview.place(
                at: CGPoint(x: x, y: y),
                anchor: .topLeading,
                proposal: ProposedViewSize(size)
            )
            x += size.width
            y += size.height
        }
    }
}

默认的 cache 类型是 Void。大多数布局可以忽略缓存;只有当尺寸计算确实昂贵(递归测量、动态尺寸决策)且相同的中间值同时供给两个布局方法时,缓存才真正物有所值。

makeCache(subviews:) 在每次布局流程中运行一次;updateCache(_:subviews:) 在两次布局之间子视图发生变化时运行。这一模式让布局能在子视图本身改变时正确地使缓存状态失效。

值得动手实现的常见自定义布局

三种值得自己动手实现的模式:

Flow 布局(项目自动换行)。 项目在超出可用宽度时自动折到多行。Apple 的 HStack 不会换行。自定义 Layout 可以做到:测量每个子视图,从左到右放置,当行宽超过 proposal 的宽度时换到下一行。

对角栈。 项目对角错落(每个子视图相对前一个略向右下偏移)。适合堆叠卡片 UI、画廊预览布局、视差感的堆叠效果。

饼图/圆形布局。 项目沿圆周排列。适合径向菜单、基于时间的 UI、等距分布的分类标签。

这几种都可以用 sizeThatFits + placeSubviews + (可选的)自定义缓存来实现。框架处理”父级提议、子级处置”的协商;开发者处理放置数学。

常见的布局失败

三种会产生破损自定义布局的模式:

忽略 proposal 的硬编码尺寸。 一个永远返回 CGSize(width: 200, height: 100) 的布局无法适配它的容器。结果就是:布局在模拟器里看着没问题,但在更小的屏幕、不同方向,或可调整尺寸的容器中崩坏。

在 placeSubviews 中跳过子视图。 每个子视图在每次调用中必须且仅能被放置一次。带 continue 跳过某些条件的 for 循环会让那些子视图无法定位;它们会从渲染结果中消失。

在自定义 Layout 的子视图内使用 GeometryReader。 GeometryReader 总是把它收到的全部空间提议给它的内容,这与布局的逐子视图 proposal 相互冲突。组合的结果就是产生荒谬的尺寸。自定义布局不应在内部嵌入 GeometryReader;如果子视图需要知道自己被分配的尺寸,布局协议的 proposal 机制才是正确通道。

何时该上 Layout(何时不必)

三个信号说明自定义 Layout 是合适的工具:

  1. 形状无法用 HStack/VStack/ZStack/Grid 组合表达。 饼图布局、瀑布流网格、自定义流式换行。内置基元无法组合出这些形状。
  2. 逐子视图信息驱动定位。 子视图带有优先级、权重或类别,由父级用来定位它们的布局。LayoutValueKey 是合适的通道。
  3. 布局的尺寸取决于与子视图的协商。 询问”能容纳最长行的最小高度是多少?”或”什么宽度能让 N 个子视图获得相等的列宽?”的布局,需要访问 subviews.sizeThatFits(...) 查询。

三个信号说明内置组合就够用:

  1. 标准的水平/垂直/深度堆叠。 HStack、VStack、ZStack 涵盖了常见情况。
  2. 规则行列的网格。 Grid 与 LazyVGrid/LazyHGrid 处理大多数网格场景。
  3. 少量叠加定位。 .overlay、.background、带对齐的 ZStack 涵盖大多数”X 在 Y 之上”的模式。

经验法则:不要为内置组件能搞定的形状构建自定义 Layout。当形状真正超出内置组件的表达集时再动手。

这一模式对 iOS 26+ 应用意味着什么

三点要点。

  1. 在 sizeThatFits 中尊重 proposal。 一个无视 proposal 永远返回相同尺寸的布局,并未真正参与 SwiftUI 的布局系统。读 proposal,返回与之相称的尺寸。

  2. 用 LayoutValueKey 进行结构化的父子通信。 通过附加在视图修饰符上的 key 传递数据,是 SwiftUI 原生的模式。不要为专门关于布局层级决策的数据动用 @Environment 或自定义 PreferenceKey;LayoutValueKey 才是为此而设的类型化通道。

  3. 仅在测量昂贵时才构建缓存。 默认的 Void 缓存对大多数布局都够用了。只有当同一项昂贵计算在 sizeThatFits 和 placeSubviews 中都会出现时,才动用自定义缓存类型。

完整的 Apple 生态系列:类型化的 App Intents;MCP servers;路由问题;Foundation Models;运行时与工具链 LLM 的区分;三个表面;单一信源模式;Two MCP Servers;Apple 开发的 hooks;Live Activities;watchOS 运行时;SwiftUI 内幕;RealityKit 的空间心智模型;SwiftData 模式纪律;Liquid Glass 模式;多平台发布;平台矩阵;Vision 框架;Symbol Effects;Core ML 推理;Writing Tools API;Swift Testing;Privacy Manifest;作为平台的 Accessibility;SF Pro 排印体系;visionOS 空间模式;Speech 框架;SwiftData 迁移;tvOS focus engine;@Observable 内幕;我拒绝写的。中心枢纽位于 Apple Ecosystem 系列。更广泛的 iOS 与 AI agents 上下文,请参阅 iOS Agent Development guide。

FAQ

为什么不直接用 GeometryReader?

GeometryReader 总是把它收到的全部尺寸提议给它的内容(它对内容想要什么没有任何意见)。结果是:GeometryReader 内的任何视图,在 reader 不约束的轴向上都会得到 infinity proposal,而像 Text 这样的视图会贪婪地扩张自身。组合开始自相矛盾:reader 原样透传,内容索取最大尺寸,布局崩坏。Layout 才是正确的工具,因为它让开发者就 proposed size 做出明确的逐子视图决策。

我可以写一个自定义的 HStack 替代品吗?

可以。一个等价于 HStack 的自定义 Layout 会读取子视图的偏好尺寸、累加它们的宽度、取最大高度,并从左到右放置它们。真正的 HStack 还做了更多事(间距、对齐、布局优先级解析),但基本形态在 Layout 中是直截了当的。这个练习是吃透协议工作方式的好方法。

如何在我的自定义布局中支持 .layoutPriority(_:)?

通过 LayoutSubview 代理上专用的 priority: Double 属性读取它:subview.priority。SwiftUI 直接在代理上暴露 .layoutPriority(_:),而非借助公开的 LayoutValueKey。默认值是 0。在分配额外空间时使用优先级(优先给高优先级子视图),或在截断时使用(先截断低优先级子视图)。

proposal: .infinity 与 proposal: .zero 有什么区别?

.infinity 在每个轴向上提议最大尺寸(width: .infinity, height: .infinity)。响应贪婪 proposal 的子视图(如 Color)会占据全部可用空间。.zero 则提议最小尺寸(width: 0, height: 0)。子视图返回它们的最小尺寸(Text 返回其最长不可断 token 的尺寸)。两者都是测量子视图尺寸范围的有用端点;许多布局会使用 .unspecified(两者均为 nil)来询问”你的理想尺寸是多少?”。

Layout 在 watchOS、tvOS 和 visionOS 上能用吗?

能。Layout 协议位于 SwiftUI 的跨平台核心。自定义布局在 iOS、iPadOS、macOS、watchOS、tvOS 和 visionOS 上的运作方式完全一致。本系列的 Apple Platform Matrix 一文论证过:是否纳入某平台是产品决策;而 SwiftUI 的 Layout 机制对适用多平台的场景而言是平台无关的。

Layout 如何与 @Observable 模型交互?

Layout 是一个不直接持有可观察状态的结构体;它不追踪变化。当模型更新时,父视图的 body 重新求值,从而促使 Layout 在 body 产出的子视图基础上重新运行。Layout 是通过它所在的 body 而具备响应性的,并不依靠自身的观察 hooks。本系列的 @Observable 内幕 一文涵盖了观察侧的内容。

References


  1. Apple Developer Documentation: Layout. The protocol reference covering sizeThatFits and placeSubviews requirements, plus the optional makeCache, updateCache, spacing, and explicit-alignment hooks. ↩

  2. Apple Developer Documentation: sizeThatFits(proposal:subviews:cache:) and placeSubviews(in:proposal:subviews:cache:). The two required methods of the Layout protocol. ↩↩↩

  3. Apple Developer Documentation: ProposedViewSize. The two-optional-CGFloat type that carries the parent’s size proposal, with the convention values .unspecified, .zero, and .infinity. ↩

  4. Apple Developer Documentation: LayoutSubview. The proxy type representing a child view inside Layout methods, with sizeThatFits(_:) for querying preferred sizes and place(at:anchor:proposal:) for positioning. ↩

  5. Apple Developer Documentation: LayoutValueKey and layoutValue(key:value:). The typed channel for child-to-parent layout-level data, accessed via subscript on LayoutSubview. ↩

  6. Apple Developer: Composing custom layouts with SwiftUI. The Apple guide covering caching, alignment guides, and when to reach for Layout versus built-in containers. ↩

相关文章

SwiftUI 中的 Liquid Glass:在 iOS 26 上交付 Return 总结的三种模式

苹果的 Liquid Glass 是一行 SwiftUI API。Return 的三种模式超越了 .glassEffect():通过 Core Text 字形路径在文本上实现玻璃效果、镜面反射,以及 HUD 叠层。

13 分钟阅读

iOS 26 上的 HealthKit + SwiftUI:来自两款已上架应用的授权流程、样本类型与跨平台模式

来自 Water(饮水追踪,HKQuantitySample)和 Return(正念会话,HKCategorySample)的真实生产模式。权限 UX、async 封装、watchOS 变体,以及需要避开的陷阱。

12 分钟阅读

安装与更新 Codex CLI:Mac、Linux、Windows

codex update 可升级通过脚本、npm 和 Homebrew 安装的版本,通过 winget 安装的则用 winget upgrade OpenAI.Codex 升级。本文讲解如何在任意操作系统上安装、更新、锁定版本与卸载 Cod…

17 分钟阅读