本节摘要:新枝的最后一节长出完整树冠:List 是 SwiftUI 对 UITableView 的声明式回应——不用数据源问答协议,数据集合直接长成行;滑动动作、删除、下拉刷新都是一行修饰符。NavigationStack 接管主干,跳转从"手动压栈"变成"声明目的地"。本节合成 SwiftUI 版阳台花园的列表加详情加表单,与第 3、4 章的 UIKit 版逐项对照收束本章。
第 4 章实现表格列表写了多长你还记得:注册、数据源两问、委托数问、复用填装、手动 reload。SwiftUI 版一口气写完:
struct GardenListView: View { @StateObject private var store = PlantStore() @State private var showingAddSheet = false var body: some View { NavigationStack { // 主干(对偶 3.6 的 UINavigationController) List { // 列表(对偶 UITableView) ForEach(store.plants) { plant in // 数据直接长成行(Identifiable 见下) NavigationLink(value: plant) { // 行即入口:点击前往该植物的详情 PlantRow(plant: plant) } .swipeActions { // 左滑动作:一行修饰符(4.1 写了十几行) Button("已浇水") { store.water(plant) }.tint(.blue) Button("删除", role: .destructive) { store.remove(plant) } } } } .navigationTitle("阳台花园") // 导航栏标题:一行 .toolbar { // 右上角加号 Button { showingAddSheet = true } label: { Image(systemName: "plus") } } .sheet(isPresented: $showingAddSheet) { // 模态:一行(3.6 手动 present 的对照) AddPlantSheet(store: store) } .refreshable { // 下拉刷新:一行 await store.reloadFromStorage() } } } }
ForEach 能"认行"靠的是可标识协议——差量更新(5.1 第三步)要知道谁是谁,行数据的身份由 id 提供:
struct Plant: Identifiable { // 第 2 章模型的 SwiftUI 补丁:给身份 let id = UUID() // 通用唯一标识 var name: String var kind: PlantKind var passedDays: Int // ...其余同前 } // 效果:删除一株后 ForEach 按 id 找差异,只移除那一行(带默认动画) // 若不给身份:ForEach(plants, id: \.name) 也能跑,但重名植物会错乱——身份要选真正唯一的
导航目的地在树冠外面声明一次,主干自动管理压栈与返回:
// 续在 NavigationStack 闭包内:声明"值 → 页面"的对应关系 .navigationDestination(for: Plant.self) { plant in PlantPageView(plant: plant) // 5.4 排好的详情页,现成挂上 } // 也可以用老式的按路径压栈(对照 3.6 的 pushViewController): // NavigationStack(path: $path) ... path.append(plant) // 声明式的默认选择是 value 式:描述"去哪",不描述"怎么压"

合成完毕,把第 3、4 章与本章的实现并排放:
| 任务 | UIKit(第 3、4 章) | SwiftUI(本章) |
|---|---|---|
| 列表数据绑定 | 数据源问答协议答两问 | ForEach 数据集合直接展开 |
| 行点击跳转 | didSelectRowAt 里手动 push | NavigationLink 声明入口 |
| 左滑操作 | 委托方法配 UIContextualAction | swipeActions 修饰符 |
| 下拉刷新 | 刷新控件加目标方法 | refreshable 一行 |
| 模态表单 | present 加导航包装 | sheet 修饰符绑定布尔 |
| 数据变了之后 | 手动 reload(忘了就灵异) | 自动差量更新 |
| 行身份 | IndexPath 行号 | Identifiable 的 id |
最后一行的差别最深:IndexPath 是位置坐标,数据重排后坐标全变;id 是身份,数据怎么动框架都认得——这也是插入删除自带动画的原因。
背景:4.1 在 UIKit 版实现了左滑浇水(十几行),现在搬到 SwiftUI 版。操作:如上 GardenListView 里 swipeActions 一行完成,但有个新需求——浇水后这行要立刻变绿点。结果:直接可用,无任何刷新代码。解读:swipeActions 的闭包调 store.water,@Published 广播、ForEach 差量、PlantRow 重算,三次接力全在 5.3 的水路上;对照 UIKit 版里"改模型加 reloadRows"两步缺一不可,这里的刷新代码不存在也就忘不了。变式:要加"撤销"提示,用提示修饰符包一层确认对话框;要按品种分区列表,ForEach 外再套一层 Section——结构随数据形状走,代码总量几乎不变。
💡 关键直觉:SwiftUI 版代码量少不是语法糖,是职责转移——数据源、复用、reload、压栈这些你曾经手写的同步代码,如今都在框架里跑。你省下的不是打字,是漏改同步的心智负担。
新枝已成冠。最后一章把两棵树合进一个园子:综合实战、排错手册与选型。