Highlight
Apple 用 Sandwiches 示例从零构建一个 iOS、iPadOS 和 macOS 通用 SwiftUI App,展示 View 组合、source of truth、
@State、@StateObject、列表编辑、动画和 Xcode 预览如何串成声明式开发流程。
核心内容
很多 UI 入门教程会从控件清单开始。这个 session 的做法更直接:打开 Xcode,新建一个多平台项目,然后把一个记录三明治的 App 一步步做出来。开发者先看到代码和 canvas 同步更新,再看到列表、导航、详情页、状态、动画、数据模型和预览如何接上同一条线。
传统 UI 开发里,视图对象通常承担太多职责。开发者要创建对象、设置属性、响应事件、维护导航和列表状态,还要手动处理刷新时机。SwiftUI 的切入点是把界面写成值:body 描述当前数据应该呈现出的界面,框架在数据变化后重新取一份新的描述。
Sandwiches 示例正好覆盖一个真实 App 的基本骨架。列表从硬编码单元格开始,接入 Identifiable 模型后变成数据驱动列表;详情页用 @State 控制图片缩放;根数据从静态数组升级成 ObservableObject;最后再用预览检查深色模式、动态字体、阿拉伯语和从右到左布局。
这场 session 不是 2020 年所有 SwiftUI 新 API 的总览。它更像一条入门路径:先理解 View 很轻,放心拆小组件;再理解 source of truth,知道状态应该放在哪里;最后把 Xcode 预览当作开发循环的一部分,而不是运行 App 之后的补救工具。
详细内容
View 是轻量的值
(17:18)Kyle 在中段把演示暂停下来,解释 SwiftUI View 的模型。View 是符合 View 协议的 struct,不继承 UIView 这样的基类,也不拥有一串框架分配的存储。框架会把 view hierarchy 折叠成适合渲染的数据结构,所以开发者可以把界面拆成小的单用途 View。
struct SandwichDetail: View {
let sandwich: Sandwich
var body: some View {
Image(sandwich.imageName)
.resizable()
.aspectRatio(contentMode: .fit)
}
}
关键点:
SandwichDetail是一个 struct,存储的输入只有sandwich。body是View协议唯一要求的属性,负责返回界面描述。Image(sandwich.imageName)从模型里的图片名创建图片视图。.resizable()和.aspectRatio(contentMode: .fit)都是修饰器,它们返回新的 View 描述。- 这段代码来自官方 snippet,依赖 session 前面导入的
Sandwich模型。
View 通过组合变大
(18:30)SwiftUI 的组合方式和 demo 前半段一致:VStack 垂直排文字,HStack 水平排图片和文字,List 包住多行,NavigationLink 再把单元格接到详情页。SandwichDetail 里也能看到同样的模式,图片、缩放和比例控制被组合成一个新的 View。
struct SandwichDetail: View {
let sandwich: Sandwich
var body: some View {
Image(sandwich.imageName)
.resizable()
.aspectRatio(contentMode: .fit)
}
}
关键点:
Image、resizable和aspectRatio在渲染时合成一个详情页图片区域。- 修饰器参与构造新的 View 描述,不走事后修改已有对象的路径。
- 当
body被重新读取时,SwiftUI 会拿到一份新的描述,再决定哪些渲染需要更新。 - 这种模型让 “Extract Subview” 变成低成本重构,session 中把列表 cell 拆成
SandwichCell就基于这个前提。
@State 让状态成为 source of truth
(19:52)详情页需要在 “fit” 和 “fill” 之间切换图片。SwiftUI 的做法是声明一个状态变量 zoomed,再让 aspectRatio 的 contentMode 从这个状态派生出来。点击手势只修改状态,界面刷新由框架完成。
struct SandwichDetail: View {
let sandwich: Sandwich
@State private var zoomed = false
var body: some View {
Image(sandwich.imageName)
.resizable()
.aspectRatio(contentMode: zoomed ? .fill : .fit)
.onTapGesture { zoomed.toggle() }
}
}
关键点:
@State private var zoomed = false是这个 View 自己拥有的 source of truth。contentMode是派生值,它由zoomed决定。.onTapGesture只调用zoomed.toggle(),不直接操作图片对象。- SwiftUI 观察到
body读取过zoomed,所以zoomed改变后会重新请求body。 - 这解释了 session 里说的 “每个可能的 UI 状态都来自权威数据”。
动画和条件视图仍然由数据驱动
(31:31)图片缩放后,demo 又加了 ignoresSafeArea、withAnimation、Label、Spacer、条件渲染和 .transition(.move(edge: .bottom))。重点不在某个控件,而在同一条规则:改变状态,SwiftUI 根据新状态插入、移除或移动 View。
Image(sandwich.imageName)
.resizable()
.aspectRatio(contentMode: zoomed ? .fill : .fit)
.ignoresSafeArea(edges: .bottom)
.onTapGesture {
withAnimation {
zoomed.toggle()
}
}
关键点:
.ignoresSafeArea(edges: .bottom)对应 demo 中让图片扩展到底部安全区之外的步骤。withAnimation包住状态变化,动画和最终状态绑定在一起。- 如果用户在动画过程中再次点击,新的状态仍然是
zoomed的结果,框架会让动画反向或继续。 - 后面隐藏 spicy banner 时,条件判断只检查
sandwich.isSpicy和zoomed,不需要手写插入、删除视图的生命周期。
App 级数据用 ObservableObject 接入
(42:55)列表最初由静态数组驱动。为了支持新增、删除和排序,session 把数据升级为一个可变 store:store 符合 ObservableObject,会变化的 sandwiches 标成 @Published。App 入口用 @StateObject 拥有 store,列表 View 用 @ObservedObject 观察它。
final class SandwichStore: ObservableObject {
@Published var sandwiches: [Sandwich] = []
}
@main
struct SandwichesApp: App {
@StateObject private var store = SandwichStore()
var body: some Scene {
WindowGroup {
SandwichList(store: store)
}
}
}
struct SandwichList: View {
@ObservedObject var store: SandwichStore
var body: some View {
List {
ForEach(store.sandwiches) { sandwich in
SandwichCell(sandwich: sandwich)
}
.onMove(perform: moveSandwiches)
.onDelete(perform: deleteSandwiches)
}
}
}
关键点:
ObservableObject让模型对象能通知 SwiftUI 数据即将变化。@Published标记需要被观察的属性,session 中用它包装三明治数组。@StateObject放在 App 入口,适合拥有 App 级 store。@ObservedObject放在列表 View,表示这个 View 使用外部传入的 store。ForEach依赖Sandwich符合Identifiable,这样插入、删除、移动时 SwiftUI 可以合成正确的列表更新。
预览也是 SwiftUI 代码
(49:36)结尾部分展示了预览的价值。开发者可以复制一份预览,把 Dynamic Type 调大;再复制一份设置深色模式;再设置从右到左布局和阿拉伯语 locale。Xcode 添加的内容仍然是 SwiftUI 修饰器。
SandwichList(store: .test)
.environment(\.sizeCategory, .accessibilityExtraExtraExtraLarge)
SandwichList(store: .test)
.environment(\.colorScheme, .dark)
SandwichList(store: .test)
.environment(\.layoutDirection, .rightToLeft)
.environment(\.locale, Locale(identifier: "ar"))
关键点:
.environment把上下文信息注入到整个 View 层级。- Dynamic Type、深色模式、布局方向和 locale 都能在预览里单独覆盖。
Text使用字符串字面量时,SwiftUI 默认把它当作可本地化文本。- 模型里的字符串值会按原值展示,适合标题、名称这类来自数据源的内容。
- session 最后强调,整个 Sandwiches App 的许多行为都在预览里完成验证,没有先完整 build-and-run。
核心启发
1. 做一个可预览的详情页缩放交互。做什么:给图片详情页加点击缩放和退出缩放。为什么值得做:session 已经用 @State、aspectRatio 和 withAnimation 证明这类交互可以只围绕一个布尔状态展开。怎么开始:先把图片详情页写成独立 View,声明 @State private var zoomed = false,再让 contentMode 和 banner 是否显示都从 zoomed 派生。
2. 做一个跨平台清单 App 原型。做什么:用同一套 SwiftUI 列表和详情页跑在 iPhone、iPad 和 macOS 上。为什么值得做:demo 里 NavigationView 在 iPhone 上是 push,在 iPad 和 macOS 上能变成分栏。怎么开始:用多平台模板创建项目,把共享模型放在 shared group,用列表作为第一栏,再给 NavigationView 添加占位详情视图。
3. 做一个可编辑的数据列表。做什么:让用户新增、删除、重排项目。为什么值得做:session 用 ObservableObject、@Published、ForEach、onMove 和 onDelete 演示了完整数据流。怎么开始:先让模型符合 Identifiable,再把数组放进 store,由 App 入口用 @StateObject 持有,列表通过 @ObservedObject 读取并调用修改方法。
4. 做一组极端环境预览。做什么:在同一份 preview 里覆盖大字号、深色模式、从右到左布局和目标语言。为什么值得做:结尾演示说明这些系统行为可以在 canvas 中快速验证。怎么开始:给关键页面创建多份 preview,分别添加 .environment(\.sizeCategory, ...)、.environment(\.colorScheme, .dark)、.environment(\.layoutDirection, .rightToLeft) 和 .environment(\.locale, ...)。
5. 做组件级重构练习。做什么:把大型列表 cell、详情 header、底部状态 banner 拆成独立 View。为什么值得做:session 明确说明 SwiftUI View 很轻,抽取子视图几乎没有运行时负担。怎么开始:先用 Xcode 的 “Extract Subview” 拆出 SandwichCell,再用输入属性接收模型,把状态留在真正拥有它的父级或当前 View。
关联 Session
- App essentials in SwiftUI — 继续讲 App、Scene、WindowGroup 和多窗口结构,把本文的入门 App 扩展成完整 SwiftUI 应用。
- Data Essentials in SwiftUI — 深入拆解
@State、@Binding、ObservableObject、@StateObject和数据生命周期。 - Stacks, Grids, and Outlines in SwiftUI — 聚焦 List、ForEach、懒加载栈、网格和层级数据展示,适合作为本文列表部分的下一步。
- Structure your app for SwiftUI previews — 讲如何组织输入、样例数据和数据流,让 SwiftUI 预览更稳定、更容易测试。
- Visually edit SwiftUI views — 继续讲 Xcode canvas、预览和可视化编辑工作流。
评论
GitHub Issues · utterances