WWDC Quick Look 💓 By SwiftGGTeam
The craft of SwiftUI API design: Progressive disclosure

The craft of SwiftUI API design: Progressive disclosure

观看原视频

Highlight

SwiftUI 团队的 Sam Lazarus 从一个很巧妙的角度切入:macOS 的保存对话框。简单模式只显示文件名和几个常用位置,展开后变成完整的文件浏览器。不同复杂度的需求看到不同复杂度的 UI——这就是渐进式披露(Progressive Disclosure)。


核心内容

这场 Session 讲 SwiftUI 团队如何设计 API。

Sam Lazarus 给出的定义很直接:渐进式披露(Progressive Disclosure)就是让调用点(call site)的复杂度,随着使用场景的复杂度增长(02:19)。简单场景只写必要信息。复杂场景再打开更多能力。

这和 macOS 的保存对话框类似。默认状态已经给出保存位置和常用位置;需要浏览文件系统时,用户再展开完整界面(01:14)。SwiftUI 想把这种体验放进 API:初次使用快,后续扩展不受限。

Sam 强调,任何可复用组件或抽象都是 API。写组件的人通常看声明处(declaration site),但使用体验发生在调用处(call site)(02:00)。这也是本场的主线:从调用处反推 API 形状。

详细内容

从常见用例开始

04:18)SwiftUI 的 Button 是第一个例子。按钮必须有 label。大多数按钮的 label 只是文字,所以最常见的写法很短。

Button("Next Page") {
    currentPage += 1
}

关键点:

  • Button("Next Page") 把最常见的文字 label 放到第一层 API。
  • 闭包里只保留按钮动作,这里是把当前页加一。
  • 调用点没有出现 TextHStack 或额外参数,因为这个场景不需要它们。

当 label 需要自定义时,SwiftUI 再提供完整版本(04:36)。

Button {
    currentPage += 1
} label: {
    Text("Next Page")
}

关键点:

  • 第一个闭包仍然是按钮动作。
  • label 闭包开始暴露 label 的视图结构。
  • Text("Next Page") 让调用者可以继续套用普通 SwiftUI 视图能力。

更复杂的 label 继续沿用同一个入口(04:43)。

Button {
    currentPage += 1
} label: {
    HStack {
        Text("Next Page")
        NextPagePreview()
    }
}

关键点:

  • HStack 把文字和预览视图组合成一个 label。
  • Text("Next Page") 仍然承担可读名称。
  • NextPagePreview() 是额外的自定义内容,只在需要时出现。

这就是渐进式披露的第一条策略:先识别最常见的用例,再为它提供简洁入口。复杂能力保留在下一层 API 里。

给常见场景设置智能默认值

05:30Text 是智能默认值的例子。

Text("Hello WWDC22!")

关键点:

  • 调用点只写出要显示的内容。
  • SwiftUI 会用环境里的 locale 到 app bundle 中查找本地化字符串。
  • 文本会自动适配当前颜色方案,支持深色模式。
  • 文本会根据当前辅助功能 Dynamic Type 尺寸自动缩放。

这些行为都可以手动指定。但在普通场景里,它们不该占据调用点。

Text 还有一个细节。两个 Text 放进栈里时,文本之间的间距会自动调整为当前上下文的正确行距(06:12)。

VStack {
    Text("Hello WWDC22!")
    Text("Call to Code.")
}

关键点:

  • VStack 只表达两个文本的垂直排列。
  • 每个 Text 仍然只声明内容。
  • 行距这类排版细节由 SwiftUI 默认值处理。

Toolbar 也使用同一思路。没有指定位置时,SwiftUI 会按平台惯例放置按钮:macOS 放在 toolbar leading edge,iOS 放在 navigation bar trailing edge,watchOS 只显示第一个 item 并固定在 navigation bar 下方(06:43)。需要控制位置时,再使用带 placement 的 ToolbarItemGroup07:20)。

优化调用点:Table 的四次收缩

08:09Table 是本场最完整的例子。多列表格可以支持排序、富内容单元格、分组 rows 等能力。完整写法很强,但普通表格不该每次都写完整结构。

复杂版本长这样:

@State var sortOrder = [KeyPathComparator(\Book.title)]

var body: some View {
    Table(sortOrder: $sortOrder) {
        TableColumn("Title", value: \Book.title) { book in
            Text(book.title).bold()
        }
        TableColumn("Author", value: \Book.author) { book in
            Text(book.author).italic()
        }
    } rows: {
        Section("Favorites") {
            ForEach(favorites) { book in
                TableRow(book)
            }
        }
        Section("Currently Reading") {
            ForEach(currentlyReading) { book in
                TableRow(book)
            }
        }
    }
    .onChange(of: sortOrder) { newValue in
        favorites.sort(using: newValue)
        currentlyReading.sort(using: newValue)
    }
}

关键点:

  • sortOrder 保存当前排序规则。
  • Table(sortOrder:) 允许表格根据列标题点击改变排序状态。
  • TableColumn 声明列名、取值 key path 和单元格视图。
  • Section 把 rows 分成不同区域。
  • ForEach 把集合中的每本书转换成 TableRow
  • .onChange(of: sortOrder) 在排序变化时重新排序数据源。

这个版本适合复杂表格。接下来 SwiftUI 逐步移除普通场景不需要的信息。

第一步,普通 rows 经常只是对集合做 ForEach,再为每个元素生成 TableRow。SwiftUI 让集合直接传给 Table09:58)。

@State var sortOrder = [KeyPathComparator(\Book.title)]

var body: some View {
    Table(currentlyReading, sortOrder: $sortOrder) {
        TableColumn("Title", value: \.title) { book in
            Text(book.title)
        }
        TableColumn("Author", value: \.author) { book in
            Text(book.author)
        }
    }
    .onChange(of: sortOrder) { newValue in
        currentlyReading.sort(using: newValue)
    }
}

关键点:

  • Table(currentlyReading, sortOrder:) 把数据集合交给表格。
  • 调用点不再手写 ForEachTableRow
  • \.title\.author 使用相对 key path,因为集合元素类型已经由 currentlyReading 推出。
  • 排序逻辑仍然保留,因为这个版本还支持排序。

第二步,如果列的 value key path 指向字符串,最常见的显示方式就是 Text。SwiftUI 允许省略单元格视图(10:23)。

@State var sortOrder = [KeyPathComparator(\Book.title)]

var body: some View {
    Table(currentlyReading, sortOrder: $sortOrder) {
        TableColumn("Title", value: \.title)
        TableColumn("Author", value: \.author)
    }
    .onChange(of: sortOrder) { newValue in
        currentlyReading.sort(using: newValue)
    }
}

关键点:

  • TableColumn("Title", value: \.title) 已经足够生成标题列。
  • TableColumn("Author", value: \.author) 同理生成作者列。
  • 单元格中的 Text(book.title)Text(book.author) 被默认行为覆盖。
  • 排序绑定和排序回调仍然显示在调用点。

第三步,最简单的表格不关心排序。SwiftUI 再提供不带 sort order 的版本(10:51)。

var body: some View {
    Table(currentlyReading) {
        TableColumn("Title", value: \.title)
        TableColumn("Author", value: \.author)
    }
}

关键点:

  • Table(currentlyReading) 表示数据源。
  • 两个 TableColumn 表示必须显示的列。
  • 没有 sortOrder,因为这个用例不需要排序。
  • 没有 rows 闭包和 cell 闭包,因为默认行为足够表达这个表格。

Sam 总结了两个检查问题:哪些常见用例值得做 convenience;哪些信息属于永远必须提供的 essential information(11:03)。Table 的最终形态正是这两个问题的结果。

用组合替代枚举所有可能

11:37)最后一个策略是 Compose, don’t enumerate。

HStack 的核心信息有两类:内容是什么,以及这些内容如何排列。内容已经由 view builder 表达。排列方式如果用 enum 设计,很快会失控:leading、center、trailing 之后,还会有均匀分布、只在元素之间留空、只在最后一个元素前留空等情况(12:08)。

SwiftUI 没有为每种排列增加 enum case,而是提供 Spacer,让调用者组合出排列。

struct StackExample: View {
    var body: some View {
        HStack { // centered
            Spacer()
            Box().tint(.red)
            Box().tint(.green)
            Box().tint(.blue)
            Spacer()
        }
    }
}

关键点:

  • HStack 仍然只负责水平排列内容。
  • 第一个 Spacer() 把盒子整体向中间推。
  • 三个 Box().tint(...) 是实际内容。
  • 最后的 Spacer() 与第一个 spacer 配合,形成居中效果。

均匀分布也不用新增 API(13:42)。

struct StackExample: View {
    var body: some View {
        HStack { // evenly spaced
            Spacer()
            Box().tint(.red)
            Spacer()
            Box().tint(.green)
            Spacer()
            Box().tint(.blue)
            Spacer()
        }
    }
}

关键点:

  • 每个 Spacer() 都是一个可组合的布局元素。
  • 内容之间插入 spacer 后,空白被分摊到多个位置。
  • API 没有增长,表达能力却扩展了。

只在元素之间留空也一样(13:43)。

struct StackExample: View {
    var body: some View {
        HStack { // space only between elements
            Box().tint(.red)
            Spacer()
            Box().tint(.green)
            Spacer()
            Box().tint(.blue)
        }
    }
}

关键点:

  • Box().tint(.red)Box().tint(.green)Box().tint(.blue) 是三个内容视图。
  • 两个 Spacer() 只出现在内容之间。
  • 开头和结尾没有 spacer,所以空白不会被放到两端。

这部分提醒很实用:不要把“常见情况”误解成“把所有常见情况都写成枚举”。如果可能性会继续增长,就应该拆成可组合的小单元(13:20)。


核心启发

  • 做什么:为团队内部的 SwiftUI 组件写一个最短调用入口。 为什么值得做:Session 反复强调调用点复杂度要跟使用场景匹配。常见场景应该先跑起来。 怎么开始:找出组件的 80% 调用方式,为它添加一个只包含必要参数的 initializer,再把高级配置留给完整 initializer。

  • 做什么:给自定义 view 的参数设置智能默认值。 为什么值得做Text 和 toolbar 的例子说明,默认值可以把本地化、颜色方案、平台惯例这类细节从普通调用点移走。 怎么开始:检查每个参数是否每次都必须写;可省略的参数,改成默认参数或从 Environment 中读取。

  • 做什么:重写一个表格或列表组件的调用点。 为什么值得做:Table 的例子展示了从完整能力到简单入口的收缩过程。 怎么开始:先保留完整版本,再为集合输入、字符串列、无排序场景分别添加 convenience API。

  • 做什么:把 enum 配置拆成可组合的 view 或 modifier。 为什么值得做:HStack 没有枚举所有排列方式,而是用 Spacer 让开发者组合布局。 怎么开始:如果某个 enum 不断增加 case,先问这些 case 能不能由更小的结构组合出来。

  • 做什么:在写 API 前先写调用示例。 为什么值得做:本场的核心视角是 call site,优先从使用处检查 API 是否顺手。 怎么开始:先写三段目标调用代码:最常见、稍复杂、最高级。再根据这些调用点反推类型、initializer 和默认值。


关联 Session

评论

GitHub Issues · utterances