Chapter 06

列表、表单与用户输入

掌握 List、Form 及各种输入控件,构建数据驱动的交互界面

6.1 List — 列表视图

List 是 SwiftUI 中最常用的视图之一,用于展示一组数据项。它内置了滚动、分割线、系统样式等功能,并且支持懒加载(只渲染可见的行),适合大量数据展示。

静态列表

静态列表的内容在编译时固定,适合设置页、菜单等固定选项列表。

// 静态列表:直接在 List 内写子视图
List {
    Text("第一行")
    Text("第二行")
    HStack {
        Image(systemName: "gear")
        Text("设置")
    }
}

// listStyle:列表外观样式
List { /* ... */ }
    .listStyle(.plain)          // 纯净,无背景
    // .listStyle(.insetGrouped)  // 圆角分组(设置页常用)
    // .listStyle(.grouped)       // 分组,无圆角
    // .listStyle(.sidebar)       // iPad 侧边栏样式

动态列表

动态列表根据数据数组自动生成行,使用 ForEach 或直接向 List 传入集合。

let fruits = ["苹果", "香蕉", "橙子", "葡萄"]

// 方式1:List 直接接收集合(需要 id 参数)
List(fruits, id: \.self) { fruit in
    Text(fruit)
}

// 方式2:List + ForEach(更灵活,可混合静态和动态内容)
List {
    Text("水果列表").font(.headline)   // 静态头部

    ForEach(fruits, id: \.self) { fruit in
        Label(fruit, systemImage: "leaf")
    }

    Text("共 \(fruits.count) 种").foregroundColor(.secondary)
}

6.2 ForEach 与 Identifiable 协议

ForEach 是根据集合数据生成多个视图的容器。每个元素需要有唯一标识符,这样 SwiftUI 才能在数据变化时高效地更新视图(只更新发生变化的行,而不是全部重建)。

Identifiable 协议

Identifiable 协议要求类型提供一个 id 属性。遵循此协议的类型在 ForEachList 中使用时无需显式指定 id: 参数。

// Identifiable:提供稳定的唯一标识
struct TodoItem: Identifiable {
    let id = UUID()     // UUID 保证唯一性
    var title: String
    var isDone: Bool = false
    var priority: Int = 1
}

// 遵循 Identifiable 后,ForEach 自动用 id 属性
let todos: [TodoItem] = [/* ... */]

List {
    ForEach(todos) { todo in   // 无需写 id: \.id
        Text(todo.title)
    }
}

// 对于没有实现 Identifiable 的类型,手动指定 id
ForEach(["a", "b", "c"], id: \.self) { str in
    Text(str)
}

// 数字范围(注意:Range 不是 Identifiable,需要 id: \.self)
ForEach(0..<10) { i in   // Range 实现了特殊处理
    Text("项目 \(i)")
}

6.3 列表行操作:删除与移动

SwiftUI 的 List 内置了滑动删除和拖拽移动功能,只需在 ForEach 上添加相应修饰符即可。

struct EditableListView: View {
    @State private var items = ["苹果", "香蕉", "橙子", "葡萄"]

    var body: some View {
        List {
            ForEach(items, id: \.self) { item in
                Text(item)
            }
            // onDelete:向左滑动出现删除按钮
            .onDelete { indexSet in
                // indexSet 包含要删除的行索引
                items.remove(atOffsets: indexSet)
            }
            // onMove:长按后拖拽移动行
            .onMove { source, destination in
                // source: 源位置集合,destination: 目标位置
                items.move(fromOffsets: source, toOffset: destination)
            }
        }
        // EditButton:系统内置编辑模式切换按钮
        // 进入编辑模式后会显示删除按钮和移动手柄
        .toolbar { EditButton() }
    }
}

// 自定义滑动操作(iOS 15+)
List {
    ForEach(items, id: \.self) { item in
        Text(item)
            // swipeActions:完全自定义滑动操作
            .swipeActions(edge: .trailing) {     // 从右向左滑
                Button(role: .destructive) {
                    // 删除操作
                } label: {
                    Label("删除", systemImage: "trash")
                }

                Button {
                    // 归档
                } label: {
                    Label("归档", systemImage: "archivebox")
                }
                .tint(.orange)
            }
            .swipeActions(edge: .leading) {      // 从左向右滑
                Button {
                    // 标记为已读
                } label: {
                    Label("已读", systemImage: "envelope.open")
                }
                .tint(.blue)
            }
    }
}

6.4 Section — 分组

Section 将列表内容分为多个命名区域,常用于设置页面和分类列表。

List {
    // 带标题和底部注释的分组
    Section {
        Text("通知")
        Text("声音")
        Text("振动")
    } header: {
        Text("提醒设置")    // 组标题(自动大写)
    } footer: {
        Text("通知将在勿扰模式下被屏蔽")
            .font(.caption)
            .foregroundColor(.secondary)
    }

    Section("账号") {     // 简写:直接传字符串标题
        Text("修改密码")
        Text("注销登录")
    }
}
.listStyle(.insetGrouped)   // 圆角分组样式(最常见于设置页)

6.5 Form — 表单

Form 是专为设置页面设计的容器,外观类似 List(insetGrouped 样式),但对表单控件(Toggle、TextField、Picker 等)有更好的默认样式支持。

struct UserProfileForm: View {
    @State private var name = ""
    @State private var email = ""
    @State private var bio = ""
    @State private var isPublic = true
    @State private var gender = "保密"
    @State private var birthday = Date()

    let genders = ["男", "女", "保密"]

    var body: some View {
        Form {
            Section("基本信息") {
                TextField("姓名", text: $name)
                TextField("邮箱", text: $email)
                    .keyboardType(.emailAddress)    // 邮件键盘
                    .textInputAutocapitalization(.never)  // 不自动大写
            }

            Section("个人简介") {
                TextField("写点什么...", text: $bio, axis: .vertical)
                    .lineLimit(3...6)    // 多行自动扩展(iOS 16+)
            }

            Section("隐私") {
                Toggle("公开主页", isOn: $isPublic)

                Picker("性别", selection: $gender) {
                    ForEach(genders, id: \.self) { Text($0) }
                }

                DatePicker("生日", selection: $birthday,
                            displayedComponents: .date)
            }
        }
        .navigationTitle("编辑资料")
    }
}

6.6 TextField — 文本输入

TextField 是单行文本输入框,配合不同的 keyboardType 可以唤起对应类型的键盘,提升用户输入效率。

@State private var phone = ""
@State private var price = ""
@State private var url = ""
@State private var password = ""

// 电话号码键盘
TextField("手机号", text: $phone)
    .keyboardType(.phonePad)

// 数字小键盘
TextField("价格", text: $price)
    .keyboardType(.decimalPad)     // 带小数点
    // .keyboardType(.numberPad)    — 纯数字,无小数点

// URL 键盘(带 .com 按钮)
TextField("网址", text: $url)
    .keyboardType(.URL)
    .textInputAutocapitalization(.never)
    .autocorrectionDisabled()

// 密码输入(隐藏字符)
SecureField("密码", text: $password)

// keyboardType 常用值:
// .default        — 默认键盘
// .emailAddress   — 邮件键盘(含 @ 和 .)
// .numberPad      — 纯数字
// .decimalPad     — 数字 + 小数点
// .phonePad       — 电话号码
// .URL            — URL 键盘
// .twitter        — 含 @ 和 # 的键盘

// textFieldStyle:输入框外观
TextField("搜索", text: $phone)
    .textFieldStyle(.roundedBorder)  // 圆角边框
    // .textFieldStyle(.plain)        — 无边框

// onSubmit:按下回车/完成时触发
TextField("搜索", text: $phone)
    .onSubmit {
        print("用户提交:\(phone)")
    }
    .submitLabel(.search)    // 键盘右下角按钮文字:search/done/go/next/send

// 多行文本输入(iOS 16+:axis: .vertical)
TextField("评论...", text: $url, axis: .vertical)
    .lineLimit(1...5)   // 最少1行,最多5行,自动扩展

6.7 常用输入控件

Toggle — 开关

@State private var isEnabled = true
@State private var useLocation = false

Toggle("启用通知", isOn: $isEnabled)

// 自定义样式
Toggle(isOn: $useLocation) {
    Label("位置服务", systemImage: "location.fill")
}
.toggleStyle(.switch)     // .switch(默认)/ .button / .checkbox(macOS)

// button 样式:点击整行切换
Toggle("夜间模式", isOn: $isEnabled)
    .toggleStyle(.button)
    .tint(.indigo)

Slider — 滑块

@State private var volume: Double = 0.5
@State private var fontSize: Double = 16

// 基本滑块
Slider(value: $volume, in: 0...1)

// 带步进的滑块 + 标签
Slider(
    value: $fontSize,
    in: 12...36,
    step: 1              // 每次移动 1 个单位
) {
    Text("字体大小")    // 无障碍标签
} minimumValueLabel: {
    Text("小").font(.caption)
} maximumValueLabel: {
    Text("大").font(.caption)
}

Text("当前大小:\(Int(fontSize))pt")

Stepper — 步进器

@State private var quantity = 1
@State private var hours = 8

// 基本步进器(默认步进 1)
Stepper("数量:\(quantity)", value: $quantity, in: 1...99)

// 自定义步进值
Stepper("工时:\(hours) 小时",
         value: $hours,
         in: 1...24,
         step: 2)    // 每次加减 2

// 自定义操作
Stepper {
    Text("值:\(quantity)")
} onIncrement: {
    quantity = min(quantity + 1, 10)
    // 自定义增加逻辑
} onDecrement: {
    quantity = max(quantity - 1, 1)
    // 自定义减少逻辑
}

DatePicker — 日期选择器

@State private var selectedDate = Date()
@State private var meetingTime = Date()

// 完整日期时间选择
DatePicker("选择日期", selection: $selectedDate)

// 仅选择日期
DatePicker("日期", selection: $selectedDate,
            displayedComponents: .date)

// 仅选择时间
DatePicker("时间", selection: $meetingTime,
            displayedComponents: .hourAndMinute)

// 限制日期范围
DatePicker("出发日期", selection: $selectedDate,
            in: Date()...,          // 只能选今天和之后
            displayedComponents: .date)

// datePickerStyle
DatePicker("日期", selection: $selectedDate)
    .datePickerStyle(.graphical)   // 日历图形样式(最直观)
    // .datePickerStyle(.wheel)      — 滚轮样式
    // .datePickerStyle(.compact)    — 紧凑样式(默认,点击展开)

Picker — 选择器

@State private var selectedFruit = "苹果"
@State private var selectedIndex = 0

let fruits = ["苹果", "香蕉", "橙子"]

// 基本选择器(在 Form/List 中默认跳转新页面选择)
Picker("水果", selection: $selectedFruit) {
    ForEach(fruits, id: \.self) { fruit in
        Text(fruit).tag(fruit)   // .tag() 对应 selection 的值
    }
}

// pickerStyle:显示样式
Picker("水果", selection: $selectedFruit) {
    ForEach(fruits, id: \.self) { Text($0).tag($0) }
}
.pickerStyle(.segmented)   // 分段控件(常用于筛选栏)
// .pickerStyle(.wheel)      — 滚轮
// .pickerStyle(.menu)       — 下拉菜单
// .pickerStyle(.inline)     — 内联列表
// .pickerStyle(.navigationLink) — 跳转页面(Form 默认)

// Picker 支持枚举(遵循 CaseIterable)
enum Theme: String, CaseIterable, Identifiable {
    case light = "浅色"
    case dark = "深色"
    case system = "跟随系统"
    var id: Self { self }
}

@State private var theme = Theme.system

Picker("主题", selection: $theme) {
    ForEach(Theme.allCases) { t in
        Text(t.rawValue).tag(t)
    }
}
.pickerStyle(.segmented)

6.8 搜索功能:.searchable()

.searchable() 修饰符为视图添加系统样式的搜索栏,通常应用在 NavigationStack 内部的视图上,搜索栏会自动出现在导航栏下方。

struct SearchableListView: View {
    let allFruits = ["苹果", "香蕉", "橙子", "葡萄", "草莓", "西瓜", "菠萝"]
    @State private var searchText = ""

    // 计算属性:根据搜索词过滤结果
    var filteredFruits: [String] {
        if searchText.isEmpty {
            return allFruits
        }
        return allFruits.filter { $0.contains(searchText) }
    }

    var body: some View {
        List(filteredFruits, id: \.self) { fruit in
            Text(fruit)
        }
        .navigationTitle("水果")
        // searchable:绑定搜索文本
        .searchable(
            text: $searchText,
            placement: .navigationBarDrawer(displayMode: .always),
            prompt: "搜索水果..."   // 占位符
        )
        // placement 选项:
        // .automatic                          — 自动(默认)
        // .navigationBarDrawer(displayMode:)   — 导航栏下拉
        // .sidebar                            — 侧边栏(iPad)
        // .toolbar                            — 工具栏
    }
}

// 搜索建议(iOS 16+)
struct SearchWithSuggestionsView: View {
    @State private var searchText = ""
    let suggestions = ["Swift", "SwiftUI", "Xcode", "iOS"]

    var body: some View {
        List { /* 内容 */ }
        .searchable(text: $searchText)
        .searchSuggestions {
            // 搜索建议列表
            ForEach(suggestions.filter { $0.lowercased().contains(searchText.lowercased()) },
                     id: \.self) { suggestion in
                Text(suggestion)
                    .searchCompletion(suggestion)   // 点击后填入搜索框
            }
        }
    }
}

6.9 实战示例:Todo List 应用

综合本章所有知识,构建一个完整的 Todo List App,包含添加、删除、完成标记、搜索和优先级分组功能。

// ─── 数据模型 ───
enum Priority: String, CaseIterable, Identifiable, Comparable {
    case high = "高"
    case medium = "中"
    case low = "低"

    var id: Self { self }

    var color: Color {
        switch self {
        case .high:   return .red
        case .medium: return .orange
        case .low:    return .blue
        }
    }

    static func < (lhs: Priority, rhs: Priority) -> Bool {
        let order: [Priority] = [.high, .medium, .low]
        return order.firstIndex(of: lhs)! < order.firstIndex(of: rhs)!
    }
}

struct TodoItem: Identifiable {
    let id = UUID()
    var title: String
    var isDone: Bool = false
    var priority: Priority = .medium
    var dueDate: Date = Date()
}

// ─── 添加 Todo 的 Sheet ───
struct AddTodoSheet: View {
    @Environment(\.dismiss) var dismiss
    var onAdd: (TodoItem) -> Void

    @State private var title = ""
    @State private var priority = Priority.medium
    @State private var dueDate = Date()

    var body: some View {
        NavigationStack {
            Form {
                Section("任务内容") {
                    TextField("输入任务标题...", text: $title)
                }

                Section("任务属性") {
                    Picker("优先级", selection: $priority) {
                        ForEach(Priority.allCases) { p in
                            Label(p.rawValue, systemImage: "flag.fill")
                                .foregroundColor(p.color)
                                .tag(p)
                        }
                    }

                    DatePicker("截止日期", selection: $dueDate,
                                displayedComponents: .date)
                }
            }
            .navigationTitle("新建任务")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("取消") { dismiss() }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("添加") {
                        let newTodo = TodoItem(
                            title: title,
                            priority: priority,
                            dueDate: dueDate
                        )
                        onAdd(newTodo)
                        dismiss()
                    }
                    .disabled(title.trimmingCharacters(in: .whitespaces).isEmpty)
                }
            }
        }
    }
}

// ─── Todo 列表行 ───
struct TodoRow: View {
    let item: TodoItem
    var onToggle: () -> Void

    var body: some View {
        HStack(spacing: 12) {
            // 完成状态按钮
            Button(action: onToggle) {
                Image(systemName: item.isDone
                      ? "checkmark.circle.fill"
                      : "circle")
                    .font(.title2)
                    .foregroundColor(item.isDone ? .green : .gray)
            }
            .buttonStyle(.plain)   // 防止点击触发 NavigationLink

            VStack(alignment: .leading, spacing: 2) {
                Text(item.title)
                    .strikethrough(item.isDone)     // 完成后加删除线
                    .foregroundColor(item.isDone ? .secondary : .primary)

                HStack(spacing: 6) {
                    Image(systemName: "flag.fill")
                        .foregroundColor(item.priority.color)
                        .font(.caption)
                    Text(item.priority.rawValue)
                        .font(.caption)
                        .foregroundColor(item.priority.color)

                    Text("·").foregroundColor(.secondary)

                    Text(item.dueDate, style: .date)
                        .font(.caption)
                        .foregroundColor(.secondary)
                }
            }
        }
        .opacity(item.isDone ? 0.6 : 1.0)
    }
}

// ─── 主视图 ───
struct TodoListView: View {
    @State private var todos: [TodoItem] = [
        TodoItem(title: "学习 SwiftUI 布局", priority: .high),
        TodoItem(title: "完成状态管理章节", priority: .high),
        TodoItem(title: "阅读官方文档", priority: .medium),
        TodoItem(title: "写一个练习项目", priority: .medium),
        TodoItem(title: "整理笔记", priority: .low, isDone: true),
    ]
    @State private var searchText = ""
    @State private var showAddSheet = false
    @State private var showDoneItems = true
    @State private var showDeleteDoneAlert = false

    // 过滤后的待办列表
    var pendingTodos: [TodoItem] {
        todos.filter { !$0.isDone && (searchText.isEmpty || $0.title.contains(searchText)) }
             .sorted { $0.priority < $1.priority }
    }

    // 已完成列表
    var doneTodos: [TodoItem] {
        todos.filter { $0.isDone && (searchText.isEmpty || $0.title.contains(searchText)) }
    }

    var body: some View {
        NavigationStack {
            List {
                // 待办分组
                Section {
                    ForEach(pendingTodos) { todo in
                        TodoRow(item: todo) {
                            toggleTodo(id: todo.id)
                        }
                        .swipeActions(edge: .trailing) {
                            Button(role: .destructive) {
                                deleteTodo(id: todo.id)
                            } label: {
                                Label("删除", systemImage: "trash")
                            }
                        }
                    }
                    .onDelete { indexSet in
                        // 支持系统滑动删除
                        for index in indexSet {
                            deleteTodo(id: pendingTodos[index].id)
                        }
                    }
                } header: {
                    HStack {
                        Text("待完成")
                        Spacer()
                        Text("\(pendingTodos.count) 项")
                            .foregroundColor(.secondary)
                    }
                }

                // 已完成分组(可折叠)
                if !doneTodos.isEmpty {
                    Section {
                        if showDoneItems {
                            ForEach(doneTodos) { todo in
                                TodoRow(item: todo) {
                                    toggleTodo(id: todo.id)
                                }
                            }
                        }
                    } header: {
                        Button {
                            showDoneItems.toggle()
                        } label: {
                            HStack {
                                Text("已完成 (\(doneTodos.count))")
                                Spacer()
                                Image(systemName: showDoneItems ? "chevron.down" : "chevron.right")
                            }
                            .font(.subheadline)
                        }
                        .buttonStyle(.plain)
                        .foregroundColor(.secondary)
                    } footer: {
                        if showDoneItems && !doneTodos.isEmpty {
                            Button("清除已完成任务") {
                                showDeleteDoneAlert = true
                            }
                            .foregroundColor(.red)
                        }
                    }
                }
            }
            .navigationTitle("我的任务")
            .searchable(text: $searchText, prompt: "搜索任务...")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button {
                        showAddSheet = true
                    } label: {
                        Image(systemName: "plus")
                    }
                }
            }
            .sheet(isPresented: $showAddSheet) {
                AddTodoSheet { newTodo in
                    todos.append(newTodo)
                }
            }
            .alert("清除已完成", isPresented: $showDeleteDoneAlert) {
                Button("清除", role: .destructive) {
                    todos.removeAll { $0.isDone }
                }
                Button("取消", role: .cancel) { }
            } message: {
                Text("将删除所有已完成的任务,此操作不可撤销。")
            }
        }
    }

    // ─── 辅助方法 ───
    func toggleTodo(id: UUID) {
        if let index = todos.firstIndex(where: { $0.id == id }) {
            todos[index].isDone.toggle()
        }
    }

    func deleteTodo(id: UUID) {
        todos.removeAll { $0.id == id }
    }
}
小结

本章学习了 SwiftUI 的数据展示与交互输入体系:List 展示数据列表,ForEach+Identifiable 驱动动态内容,onDelete/onMove 实现编辑操作,Section 分组组织内容,Form 构建设置页面,以及 TextFieldToggleSliderStepperDatePickerPicker 等输入控件的使用,最后通过 .searchable() 快速实现搜索功能。