第 8 章 列表与导航:多页面应用怎么搭

List 的三种写法、身份在滚动视图里的真实后果、增删改查、NavigationStack 的路径与 programmatic 导航、弹窗家族的分工

第 8 章:列表与导航

前面七章讲的是"怎么把一个界面做对"。这一章开始讲 App:一堆页面怎么组织,数据怎么变成一行行看得见的东西。

⚠️ 这一章也是第 6 章"身份"最实战的考场——列表是唯一一个"身份选错会出真 bug"的地方。

8.1 List 的三种写法

List 是 SwiftUI 里最重要的容器。它同时提供三件别的东西给不了的东西:平台原生外观(iOS 上是那种分组圆角列表)、懒加载(只渲染可见行)、以及一整套行为(滑动删除、拖动排序、选择、编辑模式)。

它有三种写法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// ① 手动列出每一行:适合静态内容或少量行
List {
    Text("第一行")
    Text("第二行")
}

// ② ForEach 遍历一个集合:最常用
List {
    ForEach(items) { item in
        Text(item.name)
    }
}

// ③ 直接传集合:语法糖,等价于 ②
List(items) { item in
    Text(item.name)
}

三种写法都能用,但有一件事只有 ② 能做:给行加 .onDelete / .onMove。因为这两个修饰符要加在 ForEach 上,而不是 List 上:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
struct Task: Identifiable {
    let id = UUID()
    var name: String
}

struct Demo: View {
    @State private var items: [Task] = [
        Task(name: "第一行"),
        Task(name: "第二行"),
    ]

    var body: some View {
        List {
            ForEach(items) { item in
                Text(item.name)
            }
            .onDelete { offsets in items.remove(atOffsets: offsets) }   // ← 加在 ForEach 上
            .onMove { from, to in items.move(fromOffsets: from, toOffset: to) }
        }
    }
}

⚠️ 顺序细节:.onDelete 必须写在 ForEach 后面、List 的闭包里面。写到 List 外面不生效(也不报错,只是没反应)。

8.2 身份:这一章真正的主题

ForEach 需要一个东西来判断"这是不是同一行"——就是身份。有三种给法:

写法什么时候用风险
ForEach(items)items 元素遵守 Identifiable✅ 推荐
ForEach(items, id: \.someKeyPath)没有 Identifiable,但有天然唯一键✅ 好用
ForEach(items.indices, id: \.self) 或 id: \.offset没有唯一键🛑 危险

为什么"用下标当 id"是危险的

这是 SwiftUI 里最经典的一个坑,值得用第 6 章的模型推一遍:

1
2
3
4
5
6
7
8
9
struct Item: Identifiable {
    let id = UUID()
    var name: String
}

// 🛑 用下标当身份(虽然能编译、能显示,但身份不稳定)
ForEach(Array(items.enumerated()), id: \.offset) { index, item in
    Row(item: item)
}

现在 items 是 [A, B, C],你删掉了 B。新的列表是 [A, C]。对 SwiftUI 来说发生了什么?

位置删除前删除后SwiftUI 的判断
id = 0AA同一个视图,内容没变
id = 1BC同一个视图,内容从 B 变成了 C
id = 2C(消失)视图消失

问题出在第 2 行:SwiftUI 认为"id=1 那一行还在,只是内容换了",于是它会复用那一行的状态。如果 Row 里有 @State private var isExpanded = false、或者有输入框,那个状态就"继承"给了 C。

🔥 症状:删除一行后,另一行莫名其妙变成"展开"状态,或者输入框里残留着上一行的文字。

⚠️ 而这个 bug 在没有状态的行上完全看不出来——所以它经常在项目后期才冒出来,那时已经很难定位了。

正确的身份

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
struct Item: Identifiable {
    let id: UUID          // 注意是 let,不是 var
    var name: String
    var sku: String
}

struct A: View {
    let items: [Item]
    var body: some View {
        // ✅ 元素遵守 Identifiable,id 是稳定标识(通常来自模型 / 数据库)
        List {
            ForEach(items) { item in Text(item.name) }
        }
    }
}

struct B: View {
    let items: [Item]
    var body: some View {
        // ✅ 用业务上的天然唯一键
        List {
            ForEach(items, id: \.sku) { item in Text(item.name) }
        }
    }
}

⚠️ id 必须是"跟着数据走"的,要满足两条:

要求为什么
稳定:同一份数据每次生成的 id 相同否则每次刷新都重建所有行状态
唯一:不同数据不共享 id否则 SwiftUI 会当成同一行

🛑 两个常见的违例:

1
2
3
4
5
6
struct Item: Identifiable {
    var id = UUID()       // 🛑 id 是 var:改了它等于换了身份,状态会被重置
    var name: String
}

ForEach(items, id: \.name) { ... }   // 🛑 名字可能重复,也可能被改

💭 一条经验法则:id 应该来自"这条数据在世界上是什么",而不是"它现在排第几"。 数据库主键、UUID、SKU 都是好的 id;下标、name、Date() 都是坏的。

⚠️ 诚实标注:上面这套"复用了错误的行状态"的后果是从第 6 章的身份模型推导出来的(状态属于身份,不随位置走),社区也有大量一致报告。但本教程的无界面测量环境无法直接演示它——复现需要真实的数据变更事件。所以这里给出的是机制层面的推理,不是实测截图。

8.3 增删改查:一套完整的行为

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import SwiftUI

struct Task: Identifiable {
    let id = UUID()
    var title: String
    var done = false
}

struct TaskListView: View {
    @State private var tasks: [Task] = [
        Task(title: "读完第 8 章"),
        Task(title: "写一个列表页面"),
    ]

    var body: some View {
        NavigationStack {
            List {
                ForEach($tasks) { $task in            // ← 注意 $:拿到每一项的绑定
                    Toggle(task.title, isOn: $task.done)
                }
                .onDelete { tasks.remove(atOffsets: $0) }
                .onMove { tasks.move(fromOffsets: $0, toOffset: $1) }
            }
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button("添加", systemImage: "plus") {
                        tasks.append(Task(title: "新任务"))
                    }
                }
            }
        }
    }
}

三个要点:

写法作用
ForEach($tasks) { $task in ... }传绑定集合,于是每一项都能拿到 Binding,可以直接改属性
.onDelete { indexSet in ... }滑动删除,参数是 IndexSet,用 remove(atOffsets:) 消费
.onMove { from, to in ... }拖动排序,用 move(fromOffsets:toOffset:)

🔥 ForEach($tasks) 这个写法很值得记:它让"列表里每一行都能就地编辑数据"变得非常干净。它要求集合元素是 Identifiable。

⚠️ 关于 .onDelete 的可用性:它在滑动删除的平台上有效(iOS、watchOS)。在 macOS 上列表行没有"滑动",用户会用菜单或 Delete 键——所以同一份代码在 macOS 上行为不同,这不是 bug。要做跨平台,可以额外加一个工具栏按钮:

1
2
3
4
5
ToolbarItem(placement: .primaryAction) {
    Button("删除已完成") {
        tasks.removeAll { $0.done }
    }
}

选择(Selection)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
struct Picker: View {
    @State private var single: String?
    @State private var multiple = Set<String>()
    let items = ["一", "二", "三"]

    var body: some View {
        // 单选:Binding<SelectionValue?>
        List(items, id: \.self, selection: $single) { Text($0) }

        // 多选:Binding<Set<SelectionValue>>
        List(items, id: \.self, selection: $multiple) { Text($0) }
    }
}

⚠️ 选择值必须和 id 的类型一致。多选用 Set,单选用 Optional——用错类型编译器会直接报错,这一点很友好。

8.4 层级列表

List 原生支持折叠树,只要你的数据有"子节点"这个概念:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
struct Folder: Identifiable {
    let id = UUID()
    var name: String
    var children: [Folder]? = nil        // nil 表示叶子节点
}

struct OutlineView: View {
    @State private var folders: [Folder] = [
        Folder(name: "文档", children: [
            Folder(name: "工作"),
            Folder(name: "个人"),
        ]),
        Folder(name: "下载"),
    ]

    var body: some View {
        List(folders, children: \.children) { folder in
            Label(folder.name, systemImage: folder.children == nil ? "doc" : "folder")
        }
    }
}

🔥 children: 参数是 KeyPath,指向"子节点数组"。nil 表示这一行不可展开。系统会自动加上展开/折叠的箭头和动画,不用自己写。

8.5 导航:NavigationStack

SwiftUI 的导航在 iOS 16 之后换成了 NavigationStack(老的 NavigationView 已不推荐)。它的模型非常清晰:一个路径(path)→ 一叠页面。

最简形态:由用户点击推入

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
struct Item: Identifiable, Hashable {
    let id = UUID()
    var name: String
}

struct ListNav: View {
    let items = [Item(name: "一"), Item(name: "二")]

    var body: some View {
        NavigationStack {
            List(items) { item in
                NavigationLink(item.name, value: item)   // ← 推入一个"值"
            }
            .navigationDestination(for: Item.self) { item in
                Text(item.name).navigationTitle("详情")   // ← 值到页面的映射
            }
        }
    }
}

⚠️ 这个模型和老的 NavigationLink(destination:) 有本质区别:

NavigationLink(value:) + navigationDestinationNavigationLink(destination:)(老写法)
链接的是什么一个数据值一个已经构造好的视图
页面何时构造真正被推入时列表渲染时就构造了(浪费)
能否编程导航✅ 改 path 就行❌ 很难
类型安全✅ navigationDestination(for:) 按类型分发弱

🔥 新写法的关键优势是**“导航状态可以是一个普通的值数组”**——这让"恢复到上次浏览位置"、“从通知直接跳到详情页"这类需求变得自然。

编程导航:操作 path

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
struct Item: Identifiable, Hashable {
    let id = UUID()
    var name: String
}

struct ShopView: View {
    let items = [Item(name: "苹果"), Item(name: "香蕉")]
    @State private var path: [Item] = []          // 用一个数组当路径

    var body: some View {
        NavigationStack(path: $path) {
            List(items) { item in
                NavigationLink(item.name, value: item)
            }
            .navigationDestination(for: Item.self) { item in
                Text(item.name).navigationTitle("详情")
            }
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button("直接看第一个") {
                        if let first = items.first {
                            path.append(first)          // ← 编程推入
                        }
                    }
                }
            }
        }
    }
}
操作怎么写
推入一页path.append(value)
返回一页path.removeLast()
回到根path.removeAll()
跳转多级path = [a, b, c]
页面里拿"当前在哪”也读这个 path

⚠️ path 的类型要和 navigationDestination(for:) 的类型对上:

  • 路径里只有一种类型 → 用 [Item],简单直接;
  • 多种类型混着推 → 用 NavigationPath(类型擦除的容器),代价是读不出具体内容。
1
2
@State private var path = NavigationPath()
// path.append(item); path.append("某个字符串"); 都能装

导航的三条纪律

纪律说明
navigationDestination 要写在被推入区域的内部,不是 NavigationStack 外面写错位置会不生效,而且不报错
同一个 NavigationStack 里,for: 的类型要唯一同类型注册两次,行为未定义
不要在 navigationDestination 里读"会随导航变化的 @State"页面构造时机可能和你想的不同

8.6 弹窗家族:五种"浮在上面"的东西

SwiftUI 有一整套"临时出现在当前页面上方"的呈现方式。它们的分工经常被搞混:

工具外观用途
.sheet从底部推上来的整页需要用户完成一件事(表单、设置)
.fullScreenCover全屏覆盖(iOS)需要完全占据注意力的流程(引导、相机)
.popover气泡指向来源(iPad / macOS 常用)轻量的上下文操作
.alert居中的系统弹窗必须让用户知道的通知 / 二选一确认
.confirmationDialog底部动作表(iOS)多个破坏性选项(删除/取消)

sheet:用 item: 还是 isPresented:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
struct Item: Identifiable {
    let id = UUID()
    var name: String
}

struct SheetDemo: View {
    // 方式一:只控制"显示 / 隐藏"
    @State private var showSettings = false

    // 方式二:要展示"某一条具体数据"
    @State private var editing: Item?

    let item = Item(name: "样本")

    var body: some View {
        VStack {
            Button("设置") { showSettings = true }
                .sheet(isPresented: $showSettings) {
                    Text("设置页").padding()
                }

            Button("编辑") { editing = item }
                .sheet(item: $editing) { item in
                    Text("编辑:\(item.name)").padding()
                }
        }
    }
}

🔥 优先用 item:。原因是 isPresented: + 一个单独的 @State var currentItem 会引入一个真实 bug:

1
2
3
4
5
6
7
8
// 🛑 两个状态可能不同步
@State private var showEdit = false
@State private var editingItem: Item?

Button("编辑") { editingItem = item; showEdit = true }
    .sheet(isPresented: $showEdit) {
        EditView(item: editingItem!)      // ← 万一 editingItem 是 nil 就崩
    }

.sheet(item:) 把"显不显示"和"显示哪一条"合并成一个状态,从结构上消除了不同步的可能。而且它要求类型遵守 Identifiable,正好逼你给数据一个稳定身份(回到第 6 章)。

💭 一句话判据:只要弹窗里需要"某一条数据",就用 item:。

alert 与 confirmationDialog

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
struct DeleteDemo: View {
    @State private var confirmDelete = false
    @State private var deleted = false

    var body: some View {
        VStack {
            if deleted {
                Text("已删除")
            } else {
                Button("删除", role: .destructive) { confirmDelete = true }
                    .alert("确定删除?", isPresented: $confirmDelete) {
                        Button("删除", role: .destructive) { deleted = true }
                        Button("取消", role: .cancel) { }
                    } message: {
                        Text("这个操作无法撤销。")
                    }
            }
        }
    }
}

两者的选择:

场景用哪个
需要用户读一句话并确认.alert
需要用户读一句话并选择(比如"保存 / 不保存 / 取消").alert
多个操作选项,其中一个是破坏性的.confirmationDialog
只是通知,不需要选择别用弹窗——用页面内的提示条

⚠️ role: 不是装饰:.destructive 会让按钮变红(iOS)、.cancel 会绑定 Esc 键(macOS)并影响弹窗的默认行为。该标的角色一定要标,这是无障碍和平台一致性的基础。

8.7 完整可运行文件

下面这个例子是跨平台的(工具条用 .primaryAction,不依赖 iOS 专有的 EditButton):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
import SwiftUI

struct Task: Identifiable, Hashable {
    let id = UUID()
    var title: String
    var done = false
    var note: String = ""
}

struct TaskListView: View {
    @State private var tasks: [Task] = [
        Task(title: "读懂身份的规则"),
        Task(title: "给列表加上删除", done: true),
        Task(title: "试一次编程导航"),
    ]
    @State private var path: [Task] = []
    @State private var confirmingClear = false

    var body: some View {
        NavigationStack(path: $path) {
            List {
                Section("待办") {
                    ForEach($tasks) { $task in
                        NavigationLink(value: task) {
                            Label {
                                Text(task.title)
                                    .strikethrough(task.done)
                            } icon: {
                                Image(systemName: task.done ? "checkmark.circle.fill" : "circle")
                                    .foregroundStyle(task.done ? .green : .secondary)
                            }
                        }
                    }
                    .onDelete { tasks.remove(atOffsets: $0) }
                    .onMove { tasks.move(fromOffsets: $0, toOffset: $1) }
                }

                Section {
                    Button("清空全部", role: .destructive) {
                        confirmingClear = true
                    }
                }
            }
            .navigationTitle("任务")
            .navigationDestination(for: Task.self) { task in
                TaskDetail(task: task)
            }
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button("添加", systemImage: "plus") {
                        tasks.append(Task(title: "新任务"))
                    }
                }
                ToolbarItem(placement: .secondaryAction) {
                    Button("直接看第一个") {
                        if let first = tasks.first { path.append(first) }
                    }
                }
            }
            .alert("清空全部任务?", isPresented: $confirmingClear) {
                Button("清空", role: .destructive) { tasks.removeAll() }
                Button("取消", role: .cancel) { }
            } message: {
                Text("共 \(tasks.count) 条,无法撤销。")
            }
        }
    }
}

struct TaskDetail: View {
    let task: Task
    @State private var note = ""

    var body: some View {
        Form {
            Section("标题") { Text(task.title) }
            Section("备注") {
                TextField("写点什么", text: $note, axis: .vertical)
                    .lineLimit(3...6)
            }
        }
        .navigationTitle("详情")
    }
}

#Preview {
    TaskListView()
}

📦 这一段是 View + #Preview,没有 @main:新建 Chapter08.swift 放进 Xcode 的 App 项目,再把 App 文件里的 WindowGroup { ContentView() } 改成 WindowGroup { TaskListView() };只想看效果就直接看 #Preview——预览不需要入口。

💭 跑起来重点试三件事:

  1. 滑动删除一行(或 macOS 上用工具栏按钮),然后确认剩下的行状态没问题;
  2. 点"直接看第一个"——这是纯编程导航,和点击列表行走的是同一条路径;
  3. 拖动排序,看身份是否稳定(行内容跟着数据走,不会串)。

8.8 本章易错点速查

你会怎么写实际发生什么正确做法
ForEach(items, id: \.offset)删除后"id 还在、内容换了",行状态被错误复用(如展开态串行、输入框残留)用 Identifiable 或天然唯一键
var id = UUID()(var)改 id = 换身份,状态被重置id 用 let
ForEach(items, id: \.name)名字可重复 / 可修改用主键
.onDelete 写在 List 上不生效,也不报错写在 ForEach 上
.sheet(isPresented:) + 单独的 currentItem两个状态可能不同步,currentItem! 会崩用 .sheet(item:)
破坏性按钮不标 role: .destructive没有红色提示、没有平台一致的默认行为标上 role
navigationDestination 写在 NavigationStack 外面不生效,不报错写在里面的内容上
用 NavigationView老 API,不支持 path 编程导航用 NavigationStack
混用多种导航值类型却用 [Item] 当 path类型对不上,编译报错用 NavigationPath
依赖 EditButton / .topBarLeadingiOS 专有,macOS 上直接编译失败(实测 'EditButton' is unavailable in macOS)用 .primaryAction 等跨平台 placement,或加 #if os(iOS)

8.9 下一章

列表和导航解决的是"数据怎么变成页面"。还剩最后一块:数据本身从哪来、存在哪。

下一章讲数据落地:怎么建模"加载中 / 成功 / 失败"三态、网络请求怎么写才不把 body 搞乱、以及持久化有哪些选择(从 @AppStorage 到 SwiftData)。

最后修改 September 21, 2026: 更新 (ac821931b)