WWDC Quick Look 💓 By SwiftGGTeam
Compose custom layouts with SwiftUI

Compose custom layouts with SwiftUI

观看原视频

Highlight

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


核心内容

SwiftUI 早就有 HStackVStack 和 lazy grids。它们能解决大部分界面排列问题。

问题出现在更具体的场景里。

投票 App 的排行榜需要三列:名称、进度条、票数。名称列只要容纳最长名称,票数列只要容纳最大数字,进度条拿走剩余空间。这个界面内容固定,不需要滚动。lazy grid 为了只加载可见内容,不能同时自动测量行和列。

SwiftUI 2022 补上了这个空白。Grid 一次加载所有子视图,所以能同时计算行高和列宽。Layout 协议让你写自己的容器,直接参与 SwiftUI 的测量和放置流程。ViewThatFits 从多个候选视图里选择第一个能放下的版本。AnyLayout 在不同布局之间切换时保留视图身份,让动画连续。

这场演讲的例子很完整:排行榜用 Grid,投票按钮用等宽自定义 stack,大字号时用 ViewThatFits 切成竖排,顶部头像用 radial layout,再用 AnyLayout 在三方平局时切回 HStack


详细内容

Grid:静态二维内容交给 SwiftUI 自动测量

04:28)排行榜是典型的二维布局。每一行是一只宠物,每一列分别显示名称、投票比例和票数。

struct Leaderboard: View {
    var pets: [Pet]
    var totalVotes: Int

    var body: some View {
        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()
            }
        }
        .padding()
    }
}

关键点:

  • Grid(alignment: .leading) 设置整个网格的默认对齐方式。
  • ForEach(pets) 用数据生成多行内容,Pet 在示例中实现了 Identifiable
  • GridRow 里的三个视图分别进入三列。
  • ProgressView 是 flexible view,会拿到文本列之外的剩余空间。
  • Text("\(pet.votes)") 显示票数。
  • .gridColumnAlignment(.trailing) 只影响票数所在的这一列,让数字右对齐。
  • Divider() 放在 GridRow 外面时会跨过整个网格宽度。

Grid 适合这里,是因为排行榜不是长列表。它可以测量所有 cell,自动得到每一列和每一行需要的尺寸。

Layout:把自定义容器写进布局引擎

10:53)投票按钮还有另一个需求:每个按钮宽度相同,但宽度只等于最宽文字的理想宽度。HStack 会让按钮按自身文字宽度排列;给文字加 flexible frame 又会让按钮填满容器。

Layout 协议解决的是这个中间地带。

struct MyEqualWidthHStack: Layout {
    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout Void
    ) -> CGSize {
        // Return a size.
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout Void
    ) {
        // Place child views.
    }
}

关键点:

  • MyEqualWidthHStack 声明自己是一个 SwiftUI layout container。
  • sizeThatFits 负责回答容器想要多大。
  • proposal 是父容器给这个布局的尺寸建议。
  • subviews 是一组代理,用来向子视图提出尺寸建议并读取结果。
  • cache 可以在两个方法之间共享中间计算;这个简单示例暂时不用。
  • placeSubviews 负责把子视图放进 bounds
  • bounds 的 origin 不能假设是 (0, 0),放置时要用 bounds.minXbounds.midY 这类属性。

13:44)等宽布局先测量每个按钮的理想尺寸,再找出最大宽度和最大高度。

private func maxSize(subviews: Subviews) -> CGSize {
    let subviewSizes = subviews.map { $0.sizeThatFits(.unspecified) }
    let maxSize: CGSize = subviewSizes.reduce(.zero) { currentMax, subviewSize in
        CGSize(
            width: max(currentMax.width, subviewSize.width),
            height: max(currentMax.height, subviewSize.height))
    }

    return maxSize
}

关键点:

  • subviews.map 遍历所有子视图代理。
  • $0.sizeThatFits(.unspecified) 向子视图询问理想尺寸。
  • reduce(.zero) 从零尺寸开始累积最大值。
  • max(currentMax.width, subviewSize.width) 找到最宽按钮。
  • max(currentMax.height, subviewSize.height) 找到最高按钮。
  • 返回的 maxSize 会同时用于测量容器和放置子视图。

15:40)间距不要随手写成固定值。SwiftUI 的子视图代理能提供系统推荐 spacing。

private func spacing(subviews: Subviews) -> [CGFloat] {
    subviews.indices.map { index in
        guard index < subviews.count - 1 else { return 0 }
        return subviews[index].spacing.distance(
            to: subviews[index + 1].spacing,
            along: .horizontal)
    }
}

关键点:

  • subviews.indices.map 为每个子视图生成一个到下一个视图的间距。
  • guard index < subviews.count - 1 else { return 0 } 让最后一个视图后面没有额外间距。
  • subviews[index].spacing 读取当前视图的 spacing preference。
  • distance(to:along:) 同时考虑相邻两个视图在公共边上的偏好。
  • .horizontal 表示计算水平方向的间距。

16:33)有了最大子视图尺寸和间距,sizeThatFits 就能返回容器尺寸。

func sizeThatFits(
    proposal: ProposedViewSize,
    subviews: Subviews,
    cache: inout Void
) -> CGSize {
    // Return a size.
    guard !subviews.isEmpty else { return .zero }

    let maxSize = maxSize(subviews: subviews)
    let spacing = spacing(subviews: subviews)
    let totalSpacing = spacing.reduce(0) { $0 + $1 }

    return CGSize(
        width: maxSize.width * CGFloat(subviews.count) + totalSpacing,
        height: maxSize.height)
}

关键点:

  • 空布局直接返回 .zero
  • maxSize(subviews:) 得到所有按钮共享的宽高。
  • spacing(subviews:) 得到相邻按钮之间的系统推荐间距。
  • spacing.reduce(0) { $0 + $1 } 求总间距。
  • 宽度等于最大按钮宽度乘以按钮数量,再加总间距。
  • 高度等于最高按钮的高度。

16:51placeSubviews 用同样的尺寸,把每个按钮放到自己的中心点。

func placeSubviews(
    in bounds: CGRect,
    proposal: ProposedViewSize,
    subviews: Subviews,
    cache: inout Void
) {
    // Place child views.
    guard !subviews.isEmpty else { return }
  
    let maxSize = maxSize(subviews: subviews)
    let spacing = spacing(subviews: subviews)

    let placementProposal = ProposedViewSize(width: maxSize.width, height: maxSize.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: placementProposal)
        x += maxSize.width + spacing[index]
    }
}

关键点:

  • 空布局不需要放置任何子视图。
  • placementProposal 给每个按钮相同的宽高建议。
  • bounds.minX + maxSize.width / 2 得到第一个按钮中心点的 x 坐标。
  • bounds.midY 让按钮垂直居中。
  • subviews[index].place 把子视图放到指定点。
  • anchor: .center 表示 at 指向子视图中心。
  • 每轮循环后,x 前进一个按钮宽度和当前间距。

18:07)使用自定义 layout 的方式和使用内置 stack 一样。

MyEqualWidthHStack {
    ForEach($pets) { $pet in
        Button {
            pet.votes += 1
        } label: {
            Text(pet.type)
                .frame(maxWidth: .infinity)
        }
        .buttonStyle(.bordered)
    }
}

关键点:

  • MyEqualWidthHStack 包住一组投票按钮。
  • ForEach($pets) 遍历绑定数组,所以按钮能直接修改 pet.votes
  • pet.votes += 1 是投票动作。
  • Text(pet.type) 显示候选项名称。
  • .frame(maxWidth: .infinity) 允许文字视图扩展到 layout 给出的宽度。
  • .buttonStyle(.bordered) 使用系统 bordered button 样式。

这里也解释了为什么不推荐用 GeometryReader 反推按钮宽度。GeometryReader 测量的是它的容器,再把信息传给子视图;如果用它把测量结果传回上层 frame,可能绕开布局引擎并形成反复测量和重排。

ViewThatFits:为 Dynamic Type 准备备用布局

21:08)大字号会让横向按钮放不下。这个时候,示例没有把 MyEqualWidthHStack 写得更复杂,而是给 ViewThatFits 两个候选布局。

struct StackedButtons: View {
    @Binding var pets: [Pet]

    var body: some View {
        ViewThatFits {
            MyEqualWidthHStack {
                Buttons(pets: $pets)
            }
            MyEqualWidthVStack {
                Buttons(pets: $pets)
            }
        }
    }
}

关键点:

  • StackedButtons 接收 @Binding var pets,保持按钮可以修改投票数据。
  • ViewThatFits 会按顺序检查内部候选视图。
  • 第一个候选是横向等宽布局。
  • 第二个候选是竖向等宽布局。
  • 横向版本放不下时,SwiftUI 选择竖向版本。
  • Buttons(pets: $pets) 把按钮内容抽成一个子视图,避免在两个候选布局里重复按钮代码。

这适合处理 Dynamic Type(动态字体)、窄屏和分屏。你提供几个清晰的布局版本,让 SwiftUI 在当前可用空间里选择。

LayoutValueKey:让子视图把布局数据交给容器

22:52)顶部头像使用 radial layout。基础版本根据子视图数量,把每个头像放在圆周上。

func placeSubviews(
    in bounds: CGRect,
    proposal: ProposedViewSize,
    subviews: Subviews,
    cache: inout Void
) {
    let radius = min(bounds.size.width, bounds.size.height) / 3.0
    let angle = Angle.degrees(360.0 / Double(subviews.count)).radians
    let offset = 0 // This depends on rank...

    for (index, subview) in subviews.enumerated() {
        var point = CGPoint(x: 0, y: -radius)
            .applying(CGAffineTransform(
                rotationAngle: angle * Double(index) + offset))

        point.x += bounds.midX
        point.y += bounds.midY

        subview.place(at: point, anchor: .center, proposal: .unspecified)
    }
}

关键点:

  • radius 取布局区域宽高较小值的三分之一。
  • angle 按子视图数量把 360 度均分。
  • 初始点 CGPoint(x: 0, y: -radius) 位于圆心正上方。
  • CGAffineTransform(rotationAngle:) 根据索引旋转这个点。
  • point.x += bounds.midXpoint.y += bounds.midY 把本地坐标移动到 layout 的中心。
  • subview.place 把头像放在计算出的圆周点上。

23:42)要让圆形布局按排名旋转,layout 需要知道每个头像的 rank。LayoutValueKey 是这里的通信通道。

private struct Rank: LayoutValueKey {
    static let defaultValue: Int = 1
}

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

关键点:

  • Rank 遵守 LayoutValueKey
  • defaultValue 给未设置 rank 的视图提供默认值。
  • defaultValue 也确定了这个 layout value 的类型是 Int
  • extension View 增加一个更好读的 .rank(_:) 修饰符。
  • layoutValue(key:value:) 把 rank 存到视图上,供 layout 的子视图代理读取。

24:21)读取 rank 后,layout 就能按排名计算整体旋转偏移。

func placeSubviews(
    in bounds: CGRect,
    proposal: ProposedViewSize,
    subviews: Subviews,
    cache: inout Void
) {
    let radius = min(bounds.size.width, bounds.size.height) / 3.0
    let angle = Angle.degrees(360.0 / Double(subviews.count)).radians

    let ranks = subviews.map { subview in
        subview[Rank.self]
    }
    let offset = getOffset(ranks)

    for (index, subview) in subviews.enumerated() {
        var point = CGPoint(x: 0, y: -radius)
            .applying(CGAffineTransform(
                rotationAngle: angle * Double(index) + offset))
        point.x += bounds.midX
        point.y += bounds.midY
        subview.place(at: point, anchor: .center, proposal: .unspecified)
    }
}

关键点:

  • subviews.map 遍历子视图代理。
  • subview[Rank.self] 读取每个子视图上的 rank。
  • getOffset(ranks) 根据排名数组计算旋转角度;演讲没有展开这个函数内部逻辑。
  • 放置循环与基础版本相同,只是 rotation angle 加上了 offset
  • 这样布局容器不需要访问业务模型,只读取每个子视图显式提供的布局值。

AnyLayout:切换布局时保留视图身份

25:18)三方平局时,圆形布局无法通过旋转把三个头像排成一条线。示例用 AnyLayout 在 radial layout 和 HStackLayout 之间切换。

struct Profile: View {
    var pets: [Pet]
    var isThreeWayTie: Bool

    var body: some View {
        let layout = isThreeWayTie ? AnyLayout(HStackLayout()) : AnyLayout(MyRadialLayout())

        Podium() // Creates the background that shows ranks.
            .overlay(alignment: .top) {
                layout {
                    ForEach(pets) { pet in
                        Avatar(pet: pet)
                            .rank(rank(pet))
                    }
                }
                .animation(.default, value: pets)
            }
    }
}

关键点:

  • isThreeWayTie 决定当前要使用哪种布局。
  • AnyLayout(HStackLayout()) 把内置横向布局包装成同一种类型。
  • AnyLayout(MyRadialLayout()) 把自定义 radial layout 也包装成同一种类型。
  • let layout = ... 让后面的 view hierarchy 保持结构一致。
  • layout { ... } 对同一组 Avatar 应用不同布局。
  • .rank(rank(pet)) 把排名继续传给 radial layout。
  • .animation(.default, value: pets) 让投票数据变化时产生平滑过渡。

重点是视图身份。SwiftUI 看到的是同一组头像在改变布局,而不是一组旧头像消失、一组新头像出现。动画因此能连续。


核心启发

  • 做什么:给设置页做一个自动测量的二维表格。 为什么值得做Grid 能让标签列和数值列按内容自动取宽,开关、进度条等 flexible view 拿剩余空间。 怎么开始:用 Grid(alignment:)GridRow 重写固定内容表格,对需要右对齐的列加 .gridColumnAlignment(.trailing)

  • 做什么:把一组工具按钮做成等宽但不铺满窗口的控件。 为什么值得做Layout 可以先测量所有按钮的理想尺寸,再用最宽按钮作为统一宽度。 怎么开始:实现一个 Layout 类型,在 sizeThatFits 中用 .unspecified 测子视图,在 placeSubviews 中用 ProposedViewSize(width:height:) 放置子视图。

  • 做什么:为大字号和窄窗口提供自动切换的按钮排列。 为什么值得做ViewThatFits 能先尝试横向布局,放不下时切到竖向布局,不需要把所有判断塞进一个自定义 layout。 怎么开始:把重复的按钮内容抽成 Buttons 子视图,再把横向和竖向两个 layout 放进 ViewThatFits

  • 做什么:做一个根据业务状态旋转的头像、徽章或排名展示。 为什么值得做LayoutValueKey 允许每个子视图向 layout 提供 rank、weight、priority 这类布局数据。 怎么开始:定义一个 LayoutValueKey,给 View 加便捷修饰符,在 placeSubviews 中用 subview[Key.self] 读取。

  • 做什么:在紧凑模式、展开模式和平局模式之间做连续布局动画。 为什么值得做AnyLayout 切换布局类型时保留子视图身份,适合做状态驱动的界面重排。 怎么开始:把候选布局包装成 AnyLayout,用同一个 layout { ForEach(...) } 承载内容,再对驱动状态加 .animation


关联 Session

评论

GitHub Issues · utterances