Compose custom layouts with SwiftUI
Swift & UI 进阶 20m

用 SwiftUI 组合自定义布局

Compose custom layouts with SwiftUI

2022年6月6日

在 Apple 官方观看视频

一句话判断

SwiftUI 终于把布局系统的最后一公里补上了——Layout 协议让你能直接跟布局引擎对话,不再需要用 GeometryReader 做那些 hack 了。

这场 Session 讲了什么

这场 Session 围绕 SwiftUI 布局能力的四个新工具展开。先是一个全新的 Grid 容器,填补了非懒加载二维布局的空白。之前我们只有 LazyHGrid/LazyVGrid,它们为了滚动性能牺牲了双向自动尺寸计算;新 Grid 则一次加载所有子视图,能自动算出行高和列宽,特别适合排行榜、设置面板这类内容固定的场景。

然后是重头戏 Layout 协议。你可以定义自己的布局容器,实现 sizeThatFitsplaceSubviews 两个方法,直接参与 SwiftUI 的布局协商过程。Session 用一个”等宽按钮水平堆栈”做例子,从测量子视图的理想尺寸、计算间距、到逐个放置,完整展示了自定义布局的思路。

接着介绍了 ViewThatFits——一个会从你提供的候选视图中自动选择第一个能放得下的视图的容器。非常适合处理 Dynamic Type 或不同屏幕尺寸下的布局切换。最后是 AnyLayout,它让你在不同布局类型之间做动画过渡,同时保持视图的身份不变,实现丝滑的布局切换效果。

值得深挖的点

Layout 协议 vs GeometryReader:为什么这是一个范式转变。 以前要在 SwiftUI 里做”根据子视图尺寸决定父视图布局”这种事,很多人会拿 GeometryReader 套 overlay,试图把测量结果反向传递给父视图。但这本质上是在绕过布局引擎——你在外部改 frame,可能触发新一轮布局,然后又触发测量,形成循环。Layout 协议把这个流程正规化了:你在 sizeThatFits 里测量子视图、返回容器尺寸,在 placeSubviews 里放置它们,全程在布局引擎内部完成,没有循环风险。我的判断是,以后凡是需要”测量后再布局”的场景,都应该优先考虑 Layout 协议,GeometryReader 应该退回到它的本职工作:给子视图提供容器的尺寸信息。

Grid 与 Lazy Grid 的取舍。 新 Grid 看起来比 Lazy Grid 好用很多——不用预定义行高或列宽了,双向自动对齐也支持了。但别忘了它的代价:一次性加载所有子视图。如果你的 Grid 里有几百行数据,内存开销会很可观。我觉得 Apple 的意图很明确:Lazy Grid 用于长列表滚动场景,新 Grid 用于固定内容的二维展示。别因为新 API 好用就滥用,该用 Lazy 的地方还是用 Lazy。

代码片段

用 Grid 构建一个带对齐和分隔线的排行榜:

// Grid 会自动根据最宽/最高的子视图来调整列宽和行高
Grid(alignment: .leading) {
    ForEach(pets) { pet in
        GridRow {
            Text(pet.type)
            ProgressView(value: Double(pet.votes), total: Double(totalVotes))
            Text("\(pet.votes)")
                .gridColumnAlignment(.trailing) // 单独设置某一列的对齐方式
        }
        Divider() // 不在 GridRow 里的视图会自动横跨所有列
    }
}

这段代码的关键点是 gridColumnAlignment 可以覆盖单列的对齐方式,而不在 GridRow 中的视图会自动跨越整行。

用 Layout 协议实现等宽水平布局:

struct MyEqualWidthHStack: Layout {
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
        guard !subviews.isEmpty else { return .zero }
        let maxSize = subviews.map { $0.sizeThatFits(.unspecified) }
            .reduce(CGSize.zero) { CGSize(
                width: max($0.width, $1.width),
                height: max($0.height, $1.height)
            ) }
        let totalSpacing = subviews.indices.dropLast().reduce(0.0) { sum, i in
            sum + subviews[i].spacing.distance(to: subviews[i+1].spacing, along: .horizontal)
        }
        return CGSize(width: maxSize.width * CGFloat(subviews.count) + totalSpacing, height: maxSize.height)
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
        guard !subviews.isEmpty else { return }
        let maxSize = subviews.map { $0.sizeThatFits(.unspecified) }
            .reduce(CGSize.zero) { CGSize(width: max($0.width, $1.width), height: max($0.height, $1.height)) }
        var x = bounds.minX + maxSize.width / 2
        for index in subviews.indices {
            subviews[index].place(at: CGPoint(x: x, y: bounds.midY), anchor: .center,
                                  proposal: ProposedViewSize(width: maxSize.width, height: maxSize.height))
            x += maxSize.width // 简化版,省略了 spacing 计算
        }
    }
}

注意 subviews 是代理集合,你拿不到真正的 View,只能通过 proxy 测量和放置。这是 SwiftUI 刻意的设计,避免你直接操作视图导致布局循环。

用 ViewThatFits + AnyLayout 实现自适应布局切换:

// ViewThatFits 会按顺序选第一个能放得下的布局
ViewThatFits {
    MyEqualWidthHStack { Buttons(pets: $pets) }
    MyEqualWidthVStack { Buttons(pets: $pets) }
}

// AnyLayout 实现布局类型的动画切换
let layout = isThreeWayTie ? AnyLayout(HStackLayout()) : AnyLayout(MyRadialLayout())
layout {
    ForEach(pets) { pet in
        Avatar(pet: pet).rank(rank(pet))
    }
}
.animation(.default, value: pets)

ViewThatFits 的判断逻辑很直觉:从第一个子视图开始,看它能不能在给定空间内放得下,放不下就试下一个。AnyLayout 的巧妙之处在于它保持了视图的结构身份,所以动画过渡才能生效。

最佳实践

用 LayoutValueKey 给子视图附加自定义数据(比如排名、权重),然后在 placeSubviews 里读取这些值来做差异化布局,比用 Environment 或 PreferenceKey 传递数据干净得多。

不要在 Layout 协议方法里假设 bounds 的 origin 是 (0, 0)。它可能是非零值,特别是在布局嵌套的场景下。始终用 bounds.minXbounds.midY 这类属性来计算位置。

尊重子视图的 spacing 偏好。用 subview.spacing.distance(to:along:) 获取系统推荐间距,这会让你的自定义布局自动符合 Apple 的界面规范,在不同平台上看起来都协调。

如果 Layout 的计算比较重(比如子视图很多),用 cache 参数缓存中间结果。两个方法共享同一个 cache,避免重复计算。

还有什么值得关注

  • gridCellColumns 修饰符可以让单个视图横跨多列,非常适合做表格里的合并单元格效果
  • Layout 协议支持在右到左语言环境下自动翻转 x 坐标,你不需要手动处理 RTL
  • ProposedViewSize.replacingUnspecifiedDimensions() 可以安全处理容器请求理想尺寸时传入的 nil 值
WWDC 2022