第43章 列表、导航与弹窗:多页面应用怎么搭

List 与身份、选择删除移动、NavigationStack 路由、sheet 与 alert、TabView 多标签

第四十三章:列表、导航与弹窗:多页面应用怎么搭

一个能用的 App 基本就是"一列数据 + 一层层页面 + 偶尔蹦出来的弹窗"。SwiftUI 把这三件事的 API 都做得很短,短到你很容易忽略它们各自的坑。这一章只干一件事:把每个坑提前挖出来,让你第一次写列表就写对。

43.1 List 的三种写法

 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
import SwiftUI

// 写法一:静态内容,一行一行写死
struct SettingsStatic: View {
    var body: some View {
        List {
            Section("账号") {
                Label("个人资料", systemImage: "person.crop.circle")
                Label("安全", systemImage: "lock")
            }
            Section("关于") {
                Label("版本 1.0", systemImage: "info.circle")
            }
        }
    }
}

// 写法二:数据驱动,元素遵循 Identifiable
struct Note: Identifiable, Hashable {
    let id = UUID()
    var title: String
}

struct NoteList: View {
    let notes: [Note]

    var body: some View {
        List(notes) { note in          // 不用写 id:,因为 Note 自带
            Text(note.title)
        }
    }
}

// 写法三:元素不带 id,自己指定"按什么认身份"
struct TagList: View {
    let tags = ["Swift", "SwiftUI", "并发"]

    var body: some View {
        List(tags, id: \.self) { tag in
            Text(tag)
        }
    }
}

// 界面效果:三种列表外观一致——分区标题 + 一行行内容

三种写法在屏幕上看不出差别,区别藏在"身份"上,下一节专门讲。

43.2 身份:SwiftUI 里最贵的一课

第 40 章提过身份决定"是不是同一个视图"。在列表里,身份的代价最直观——身份变了,那行视图的状态就被丢掉重建。

 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
import SwiftUI

struct EditableRow: View {
    let title: String
    @State private var draft = ""

    var body: some View {
        TextField("给「\(title)」写备注", text: $draft)
    }
}

struct BadIdentity: View {
    @State private var items = ["苹果", "香蕉", "樱桃"]

    var body: some View {
        List {
            // ❌ 用会被编辑的字段当身份
            ForEach(items, id: \.self) { item in
                EditableRow(title: item)
            }
            Button("把「苹果」改名成「苹果(进口)」") {
                items[0] = "苹果(进口)"
            }
        }
    }
}

// 界面效果:如果你在「苹果」那行的输入框里打了备注,
// 点下面的按钮改名后,那一行的备注会消失——
// 因为在 SwiftUI 眼里,原来的 "苹果" 走了,新来了一个 "苹果(进口)"

正确做法是给元素一个稳定且不随内容变化的身份:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import SwiftUI

struct Item: Identifiable {
    let id = UUID()          // 身份在创建时定下,之后编辑内容也不变
    var name: String
}

struct GoodIdentity: View {
    @State private var items = [Item(name: "苹果"), Item(name: "香蕉")]

    var body: some View {
        List(items) { item in
            Text(item.name)   // 改名后身份不变,状态不会被丢
        }
    }
}

三条判据,帮你判断一个 id 是否合格:

问自己不合格的例子
它会随用户编辑而变吗?id: \.title、id: \.self(元素内容会被改)
它在列表里唯一吗?id: \.category(同分类有多个)
它跨刷新稳定吗?id: \.hashValue、id: \.offset

⚠️ 还有个"进阶版"坑:ForEach(items.indices, id: \.self) 看起来很方便,但删除中间某一行后,后面所有下标都变了,SwiftUI 会认为"最后一行没了",于是把状态错位到别的行上。这个现象是"输入框内容跑到别的行去"的头号原因。要么用 Identifiable,要么用 enumerated() 配合稳定 id。

43.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
import SwiftUI

struct ManageList: View {
    @State private var rows = ["草稿", "待审", "已发布"]
    @State private var selection: String?

    var body: some View {
        List(selection: $selection) {
            ForEach(rows, id: \.self) { row in
                Text(row)
                    .swipeActions(edge: .trailing) {
                        Button("删除", role: .destructive) {
                            rows.removeAll { $0 == row }
                        }
                        Button("置顶") {
                            rows.removeAll { $0 == row }
                            rows.insert(row, at: 0)
                        }
                        .tint(.indigo)
                    }
            }
            .onDelete { offsets in rows.remove(atOffsets: offsets) }
            .onMove { from, to in rows.move(fromOffsets: from, toOffset: to) }
        }
        .refreshable {
            try? await Task.sleep(for: .seconds(1))   // 模拟网络刷新
        }
    }
}

// 界面效果:左滑一行出现「删除」和「置顶」;
// 下拉列表出现刷新指示器,一秒后收起

两点补充:

  • .onDelete / .onMove 只有写在 ForEach 上才生效(它需要"一组可编辑的行"这个概念)。写在 List 上无效。
  • 用 .swipeActions 自定义按钮时,role: .destructive 会让按钮自动变红,而且滑动删除时系统不会自动向数据源发 onDelete——你得在按钮动作里自己删数据。这是最容易"界面删了数据没删"的地方。

关于选中:在 iOS 上单指点击默认是"点一下触发跳转",要让整行变成"可选中",需要 List(selection:) 并配合编辑模式(EditButton() 或 \.editMode 环境值);在 macOS 上选中是默认行为。这类平台差异在第二篇讲条件编译时提过思路,实际写的时候以真机/预览为准。

43.4 分区、搜索与分组

 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
import SwiftUI

struct Inbox: View {
    @State private var query = ""
    private let all = ["发票 3 月", "发票 4 月", "会议纪要", "报销单"]

    private var results: [String] {
        query.isEmpty ? all : all.filter { $0.localizedCaseInsensitiveContains(query) }
    }

    var body: some View {
        NavigationStack {
            List {
                Section("搜索结果") {
                    if results.isEmpty {
                        Text("没有匹配项").foregroundStyle(.secondary)
                    } else {
                        ForEach(results, id: \.self) { Text($0) }
                    }
                }
            }
            .searchable(text: $query, prompt: "搜索文件")
            .navigationTitle("收件箱")
            // 内容为空时给一个比空白更友好的界面
            .overlay {
                if all.isEmpty {
                    ContentUnavailableView("空空如也", systemImage: "tray", description: Text("新文件会自动出现在这里"))
                }
            }
        }
    }
}

// 界面效果:顶部出现系统搜索框;输入「发票」后列表只剩两条;
// 数据为空时中部显示图标 + 一句说明

Section 除了给标题,还能给页脚(Section { ... } header: { } footer: { })。ContentUnavailableView 是这几版新增的标准空状态组件,比手搓一张灰色图省事得多。

43.5 NavigationStack:一条路径,一层页面

先看最常用的"点进去",它同时引入了两个概念:路由值和目的地声明。

 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
import SwiftUI

struct Fruit: Identifiable, Hashable {
    let id = UUID()
    var name: String
    var note: String
}

struct FruitRoot: View {
    private let fruits = [
        Fruit(name: "苹果", note: "脆的"),
        Fruit(name: "香蕉", note: "软的"),
    ]

    var body: some View {
        NavigationStack {
            List(fruits) { fruit in
                // 注意:这里传的是"值",不是目标视图
                NavigationLink(value: fruit) {
                    Label(fruit.name, systemImage: "leaf")
                }
            }
            .navigationTitle("水果")
            // 目的地只声明一次,任何 push 这个类型的值都会走到这里
            .navigationDestination(for: Fruit.self) { fruit in
                VStack(spacing: 12) {
                    Text(fruit.name).font(.largeTitle)
                    Text(fruit.note).foregroundStyle(.secondary)
                }
                .navigationTitle(fruit.name)
            }
        }
    }
}

// 界面效果:列表两行;点「苹果」滑入详情页,标题变「苹果」

这种"传值 + 集中声明目的地"的写法,比旧版 NavigationLink(destination:) 好在两点:目的地只写一次,而且导航状态可以被代码控制(下面马上讲)。旧写法在简单的两层跳转里依然能用,但超过两层就会到处散落目标视图。

用代码控制导航:path

把路径交给一个 @State 数组,就能做到"点按钮跳三层"“登录后清空返回栈"这类需求:

 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
import SwiftUI

struct Step: Hashable {
    let index: Int
}

struct CheckoutFlow: View {
    @State private var path: [Step] = []

    var body: some View {
        NavigationStack(path: $path) {
            VStack(spacing: 16) {
                Button("开始结算") { path.append(Step(index: 1)) }
                Button("直接跳到第三步") { path = [Step(index: 1), Step(index: 2), Step(index: 3)] }
            }
            .navigationTitle("购物车")
            .navigationDestination(for: Step.self) { step in
                VStack(spacing: 16) {
                    Text("第 \(step.index) 步").font(.title)
                    Button("下一步") { path.append(Step(index: step.index + 1)) }
                    Button("回到根页面") { path.removeAll() }
                }
                .navigationTitle("步骤 \(step.index)")
            }
        }
    }
}

// 界面效果:点「直接跳到第三步」会一次性推入三层;
// 在任意一层点「回到根页面」直接弹回购物车

需要混合多种路由类型(商品、订单、设置各是一种)时,把 [Step] 换成 NavigationPath:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import SwiftUI

struct Product: Hashable { let id: Int }
struct Order: Hashable { let id: String }

struct MixedRouter: View {
    @State private var path = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
            List {
                Button("看商品") { path.append(Product(id: 1)) }
                Button("看订单") { path.append(Order(id: "A-9")) }
            }
            .navigationDestination(for: Product.self) { Text("商品 \($0.id)") }
            .navigationDestination(for: Order.self) { Text("订单 \($0.id)") }
        }
    }
}

NavigationPath 是类型擦除的容器,代价是取元素时必须再转回来(path[0] 得到的是 AnyHashable)。如果所有路由都是同一种类型,用 [T] 数组更舒服;想让导航状态能被保存和恢复(比如 Codable 持久化),也要用数组或自定义 Codable 路径。

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

SwiftUI 的弹窗 API 长得像,用途差别不小。一张表先分清:

组件典型平台表现用途
.sheet底部抽屉 / 弹出面板次要任务、编辑表单
.fullScreenCover全屏覆盖(iOS 专属,macOS 上不存在)沉浸式流程、引导页
.alert居中对话框必须让用户确认或知道的事
.confirmationDialog底部动作列表(iOS)/ 弹出菜单多个破坏性选项
.popover气泡(iPad/macOS 上是锚定气泡,iPhone 上会退化成 sheet)附带说明、小工具
 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
import SwiftUI

struct DrawerDemo: View {
    @State private var showEditor = false
    @State private var showConfirm = false
    @State private var showAlert = false
    @State private var name = "未命名"
    @State private var draft = ""

    var body: some View {
        VStack(spacing: 16) {
            Text("当前:\(name)").font(.headline)

            Button("编辑") {
                draft = name              // 打开前把当前值抄进草稿
                showEditor = true
            }

            Button("删除", role: .destructive) { showConfirm = true }
        }
        // 编辑面板
        .sheet(isPresented: $showEditor) {
            NavigationStack {
                Form {
                    TextField("名称", text: $draft)
                }
                .navigationTitle("编辑名称")
                .toolbar {
                    ToolbarItem(placement: .cancellationAction) {
                        Button("取消") { showEditor = false }
                    }
                    ToolbarItem(placement: .confirmationAction) {
                        Button("保存") {
                            name = draft        // 确认后才落库
                            showEditor = false
                        }
                    }
                }
            }
            .frame(minWidth: 260, minHeight: 160)
        }
        // 破坏性动作确认
        .confirmationDialog("确定删除吗?", isPresented: $showConfirm, titleVisibility: .visible) {
            Button("删除", role: .destructive) {
                name = "已删除"
                showAlert = true
            }
            Button("取消", role: .cancel) { }
        } message: {
            Text("这个操作不可撤销。")
        }
        // 结果告知
        .alert("已删除", isPresented: $showAlert) {
            Button("好") { }
        }
    }
}

// 界面效果:点「编辑」弹出带工具栏的编辑面板,保存后主界面标题更新;
// 点「删除」先弹确认,确认后弹一条结果提示

这段代码里藏着两个实用模式,值得单独记住:

一、“草稿 + 保存"模式。 编辑类面板不要在打开时直接改主数据,而是先拷一份 draft,用户点保存才写回。这样"取消"天然成立——不用写任何回滚逻辑。

二、.frame(minWidth:minHeight:) 给 sheet 一个尺寸。 在 iPad/macOS 上,不带尺寸的 sheet 可能小得只剩一条缝;给个最小尺寸能让它在各平台都像回事。

.fullScreenCover 在 macOS 上不可用,写跨平台代码时要留意:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import SwiftUI

struct ImmersiveEntry: View {
    @State private var show = false

    var body: some View {
        Button("开始引导") { show = true }
            #if os(iOS)
            .fullScreenCover(isPresented: $show) { Text("全屏引导内容") }
            #else
            .sheet(isPresented: $show) { Text("全屏引导内容") }
            #endif
    }
}

43.7 标题、工具栏与多标签

 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
import SwiftUI

struct ToolbarDemo: View {
    @State private var isFavorite = false

    var body: some View {
        NavigationStack {
            Text("正文")
                .navigationTitle("详情")
                .toolbar {
                    ToolbarItem(placement: .primaryAction) {
                        Button {
                            isFavorite.toggle()
                        } label: {
                            Label("收藏", systemImage: isFavorite ? "star.fill" : "star")
                        }
                    }
                    ToolbarItem(placement: .automatic) {
                        Button("分享") { }
                    }
                }
        }
    }
}

// 界面效果:标题栏右侧出现星标按钮,点亮后变实心;旁边一个"分享"按钮

⚠️ 工具栏里常被引用的 EditButton()(切换列表编辑模式)只在 iOS 上可用,macOS 上根本没有"编辑模式"这个概念。跨平台代码里要么用条件编译包住它,要么改用 macOS 也能用的自定义按钮,就像上面这样。

多标签用 TabView:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import SwiftUI

struct MainTabs: View {
    @State private var selection = 0

    var body: some View {
        TabView(selection: $selection) {
            Text("首页内容")
                .tabItem { Label("首页", systemImage: "house") }
                .tag(0)
            Text("消息内容")
                .tabItem { Label("消息", systemImage: "bubble") }
                .badge(3)
                .tag(1)
            Text("我的内容")
                .tabItem { Label("我的", systemImage: "person") }
                .tag(2)
        }
    }
}

// 界面效果:底部(或 macOS 顶部)三个标签,第二个带红色角标 3

⚠️ TabView 里每个标签的内容默认会被同时保留(切回来时状态还在),这和 NavigationStack 的 push/pop 语义不同。别把"每次进入都要重新加载"的逻辑写成 onAppear,那在标签切换时会反复触发;要"只加载一次"用 .task(第 46 章)。

43.8 一个可运行的多页面骨架

把本章零件装起来,就是一个像样的应用结构:

  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
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
import SwiftUI
import Observation

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

@Observable
final class TaskStore {
    var tasks: [Task] = [Task(title: "写周报"), Task(title: "买咖啡豆")]
    func toggle(_ task: Task) {
        guard let i = tasks.firstIndex(where: { $0.id == task.id }) else { return }
        tasks[i].done.toggle()
    }
}

struct TaskListView: View {
    @Environment(TaskStore.self) private var store
    @State private var showingNew = false

    var body: some View {
        List {
            ForEach(store.tasks) { task in
                NavigationLink(value: task) {
                    HStack {
                        Image(systemName: task.done ? "checkmark.circle.fill" : "circle")
                            .foregroundStyle(task.done ? .green : .secondary)
                        Text(task.title).strikethrough(task.done)
                    }
                }
            }
        }
        .navigationTitle("待办")
        .navigationDestination(for: Task.self) { task in
            TaskDetailView(task: task)
        }
        .toolbar {
            Button("新建", systemImage: "plus") { showingNew = true }
        }
        .sheet(isPresented: $showingNew) {
            NewTaskSheet()
        }
    }
}

struct TaskDetailView: View {
    let task: Task
    @Environment(TaskStore.self) private var store

    var body: some View {
        VStack(spacing: 16) {
            Text(task.title).font(.title2)
            Button(task.done ? "标记未完成" : "标记完成") { store.toggle(task) }
        }
        .padding()
        .navigationTitle("详情")
    }
}

struct NewTaskSheet: View {
    @Environment(TaskStore.self) private var store
    @Environment(\.dismiss) private var dismiss
    @State private var title = ""

    var body: some View {
        NavigationStack {
            Form { TextField("任务名称", text: $title) }
                .navigationTitle("新建")
                .toolbar {
                    ToolbarItem(placement: .cancellationAction) {
                        Button("取消") { dismiss() }
                    }
                    ToolbarItem(placement: .confirmationAction) {
                        Button("添加") {
                            store.tasks.append(Task(title: title))
                            dismiss()
                        }
                        .disabled(title.trimmingCharacters(in: .whitespaces).isEmpty)
                    }
                }
        }
    }
}

@main
struct TasksApp: App {
    @State private var store = TaskStore()

    var body: some Scene {
        WindowGroup {
            NavigationStack { TaskListView() }
                .environment(store)
        }
    }
}

// 界面效果:一个待办列表,点任意一行进入详情;
// 右上角「新建」弹出表单,添加后新任务立刻出现在列表里

这个骨架体现的正是第 41 章的数据流:数据放在环境的 TaskStore 里,列表读它、详情页改它、表单往它里面追加,三个视图没有任何互相持有关系。

43.9 本章小结

概念一句话
List 三种写法静态、Identifiable、手写 id:,差别在身份策略
身份稳定且不可编辑;用错会让行状态错位或丢失
\.self / indices 当 id内容或顺序一变身份就变,是经典陷阱
swipeActions自定义滑动按钮,删除要自己在动作里改数据
onDelete / onMove只对 ForEach 有效
searchable / ContentUnavailableView搜索框与标准空状态
NavigationLink(value:) + navigationDestination传值式路由,目的地集中声明
path[T] 或 NavigationPath,代码控制跳转与返回栈
sheet / alert / confirmationDialog三种不同"浮层"语义,按用途选
TabView多标签;内容默认保留,别用 onAppear 当"只加载一次”

43.10 本章易错点速查

容易踩的地方正确认识
ForEach(items, id: \.self) 里允许编辑元素内容一变身份就变,状态会重置;用 Identifiable
ForEach(items.indices) + 删除下标整体前移,状态会错位到别的行
navigationDestination 写在 List 内部的子视图上应写在 NavigationStack 的直接内容上,否则跳转无效
NavigationLink(destination:) 与 NavigationLink(value:) 混用同一条路径里可能重复 push 同一个页面;统一用 value 更安全
swipeActions 删除后数据还在系统不会自动调 onDelete,要在动作里删
编辑面板直接改主数据用"草稿 + 保存"模式,取消才不用写回滚
fullScreenCover 在 macOS 上编译报错它不可用于 macOS,用条件编译退回 sheet
以为切标签回来会重新加载TabView 会保留内容;“只跑一次"的逻辑放 .task
在 sheet 里忘了 dismiss用 @Environment(\.dismiss) 或自己控制 isPresented

43.11 下章预告

列表、页面、弹窗都有了,还差"让用户往里填东西”。下一章讲表单:Form 的自动排版、TextField 的几种取值方式、Picker/Toggle/Slider、焦点管理,以及表单校验该放在哪里才不啰嗦。

最后修改 September 19, 2026: 更新 (3489033b1)