第44章 表单与用户输入:把数据请进门

Form 自动排版、TextField 的三种取值方式、各种输入控件、焦点管理与表单校验该放在哪

第四十四章:表单与用户输入:把数据请进门

前面所有章节的数据都是"你自己造的"。真实的 App 里,绝大部分数据是用户敲进去的——而用户会空着不填、写错格式、在密码框里粘贴表情符号。这一章把输入控件、焦点和校验一次讲完,顺便给出一个能直接抄的表单模型写法。

44.1 Form:不是容器,是"排版风格"

Form 在 iOS 上会渲染成系统的分组列表样式(灰底白卡片),在 macOS 上渲染成带标签栏的设置面板。它和 VStack 最大的区别是:你几乎不用管排版。

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

struct PreferencesForm: View {
    @State private var nickname = ""
    @State private var notifyByEmail = true
    @State private var theme = "跟随系统"

    var body: some View {
        Form {
            Section("个人资料") {
                TextField("昵称", text: $nickname)
                Toggle("邮件通知", isOn: $notifyByEmail)
            }
            Section {
                Picker("主题", selection: $theme) {
                    Text("跟随系统").tag("跟随系统")
                    Text("浅色").tag("浅色")
                    Text("深色").tag("深色")
                }
            } header: {
                Text("外观")
            } footer: {
                Text("深色模式会在系统切换时自动跟随。")
            }
        }
        .formStyle(.grouped)     // macOS 上更像设置面板;iOS 上可省略
    }
}

// 界面效果:两个分组。第一组两行;第二组是一行选择器,
// 上方分组标题,下方一句灰色说明文字

三个要点:

  • Section 的 header / footer 用尾随闭包写在内容之后,顺序不能反。
  • .formStyle(.grouped) 在 macOS 上把表单渲染成"设置面板"样式;不写会得到更紧凑的样式。iOS 上 Form 本身已经是分组样式。
  • Form 里的每一行只放一个主要控件,系统会自动帮你排版并加分隔线。硬塞一个 HStack 塞三个控件,是把自己辛苦赚来的自动排版又还回去。

44.2 TextField 的三种取值方式

同一个 TextField 有三种"拿数据"的方式,选错了要么编译不过,要么悄悄引入 bug:

 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 TextFieldKinds: View {
    @State private var text = ""          // 方式一:绑定字符串
    @State private var count = 0          // 方式二:绑定数值 + 格式

    var body: some View {
        Form {
            // 方式一:text
            TextField("评论", text: $text)

            // 方式二:value + format,控件负责解析与格式化
            TextField("数量", value: $count, format: .number)

            // 方式三:axis 让它在视觉上变多行,但依然是"单行字符串"
            TextField("备注", text: $text, axis: .vertical)
                .lineLimit(2...5)
        }
    }
}

// 界面效果:三个输入行;第三个会随文字增多自动长高,最多 5 行
写法绑定类型用户输入非法时
text: $stringString无所谓,什么都能存
value: $number, format:Int / Double / Date 等不会写回绑定,原值保持不变
text: $string, axis: .vertical仍是 String无所谓,只是视觉上多行

方式二的"非法输入不写回"是个很贴心的默认行为:用户敲了 abc 时,count 还是原来的值,界面上那一格会显示成非法状态。想要"用户输什么我都接住再自己校验",就用方式一。

日期和数字还能顺手加上格式:

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

struct FormattedInputs: View {
    @State private var price = 0.0
    @State private var deadline = Date()

    var body: some View {
        Form {
            TextField("价格", value: $price, format: .currency(code: "CNY"))
            TextField("截止日期", value: $deadline, format: .dateTime.year().month().day())
        }
    }
}

44.3 多行文本:TextEditor 还是 TextField(axis:)

要一段真正能写几百字、带滚动的文本域,用 TextEditor:

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

struct NoteEditor: View {
    @State private var body_ = ""

    var body: some View {
        TextEditor(text: $body_)
            .frame(minHeight: 160)
            .overlay(alignment: .topLeading) {
                // TextEditor 没有占位符,自己叠一个
                if body_.isEmpty {
                    Text("写点什么……")
                        .foregroundStyle(.tertiary)
                        .padding(.top, 8)
                        .padding(.leading, 5)
                        .allowsHitTesting(false)
                }
            }
            .padding()
    }
}

// 界面效果:一块可滚动的大文本框,空的时候显示灰色提示语

两者的选择标准很直接:

需求用
一两行、要回车提交TextField
两三行可以长高,但要随父布局伸缩TextField(_, text:, axis: .vertical) + .lineLimit(_:)
一整块正文、内部自己滚动TextEditor

⚠️ TextEditor 没有 placeholder,也没有 .lineLimit 语义,占位提示得自己叠;同时它默认有内边距偏移,所以上面用了 .padding(.leading, 5) 让提示语和文字对齐。这类"手工对齐"是 TextEditor 唯一的麻烦之处。

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

struct LoginForm: View {
    enum Field: Hashable { case account, password }
    @FocusState private var focus: Field?
    @State private var account = ""
    @State private var password = ""
    @State private var submitted = false

    var body: some View {
        Form {
            TextField("账号", text: $account)
                .focused($focus, equals: .account)
                .submitLabel(.next)                     // 回车键显示"下一项"
                .onSubmit { focus = .password }         // 回车后跳到密码框

            SecureField("密码", text: $password)         // 输入内容显示为圆点
                .focused($focus, equals: .password)
                .submitLabel(.go)
                .onSubmit { submitted = true }
        }
        .onAppear { focus = .account }                   // 进入页面自动聚焦
        .alert("登录中…", isPresented: $submitted) { Button("好") { } }
    }
}

// 界面效果:打开页面光标在账号栏;回车跳到密码栏;
// 密码栏回车弹出提示

@FocusState 是表单里最值得早学的东西,它解决三个实际问题:

  1. 回车依次跳转:.submitLabel 决定回车键文字,.onSubmit 决定跳到哪。
  2. 收起键盘:把焦点设为 nil。
  3. 滚动到出错项:校验失败时把焦点挪到第一个不合格的输入框,用户立刻知道问题在哪。

平台差异提醒:\.keyboardType、.textInputAutocapitalization、.autocorrectionDisabled 这些修饰符只在 iOS 系列平台可用,在 macOS 上编译会报"没有这个成员"。跨平台项目要包条件编译,比如:

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

struct CrossPlatformField: View {
    @State private var email = ""

    var body: some View {
        TextField("邮箱", text: $email)
            #if os(iOS)
            .keyboardType(.emailAddress)
            .textInputAutocapitalization(.never)
            #endif
            .autocorrectionDisabled()      // 这个 macOS 也支持
    }
}

44.5 选择类控件:Toggle / Picker / Slider / Stepper / DatePicker

它们全都遵循同一个模式:一个 Binding 管值,一个"怎么显示"的闭包管样子。 值一律是 Hashable 的标签,不要手动去解析字符串。

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

enum Plan: String, CaseIterable, Identifiable {
    case free = "免费"
    case pro = "专业版"
    case team = "团队版"
    var id: Self { self }
}

struct ControlGallery: View {
    @State private var plan: Plan = .free
    @State private var enableLimit = false
    @State private var limit = 10.0
    @State private var guests = 2
    @State private var start = Date()
    @State private var segments = 1

    var body: some View {
        Form {
            // 1. 下拉/菜单式选择:值用枚举,天然避免拼错的字符串
            Picker("套餐", selection: $plan) {
                ForEach(Plan.allCases) { p in
                    Text(p.rawValue).tag(p)
                }
            }

            // 2. 分段控件:iOS/macOS 上都支持的紧凑多选一
            Picker("频率", selection: $segments) {
                Text("每天").tag(1)
                Text("每周").tag(7)
                Text("每月").tag(30)
            }
            .pickerStyle(.segmented)

            // 3. 开关 + 联动禁用
            Toggle("限制用量", isOn: $enableLimit)
            Slider(value: $limit, in: 1...100, step: 5) {
                Text("上限")
            } minimumValueLabel: {
                Text("1")
            } maximumValueLabel: {
                Text("100")
            }
            .disabled(!enableLimit)      // 关掉开关,滑块就变灰
            Text("当前上限:\(Int(limit))")

            // 4. 步进器:自带 +/-
            Stepper("访客人数:\(guests)", value: $guests, in: 1...20)

            // 5. 日期时间
            DatePicker("开始时间", selection: $start, displayedComponents: [.date, .hourAndMinute])
        }
    }
}

// 界面效果:五行控件;关掉"限制用量"后滑块变灰不可拖;
// 拖动滑块时下方文字同步显示 5 的倍数

⚠️ Picker 有一个经典报错:“每个 tag 的类型必须和 selection 的类型一致”。如果你写 selection: $plan(Plan 类型)却给 Text("免费").tag("免费")(String),标签就选不中——界面能显示,点击却没反应。要么统一成枚举,要么统一成字符串。

44.6 表单校验:三处地方,只有一个是对的

校验逻辑放哪,决定了这份表单是"好维护"还是"一改就炸"。三种做法摆一起看:

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

struct ValidationThreeWays: View {
    @State private var email = ""

    var body: some View {
        Form {
            // ❌ 做法一:在 body 里临时算,一旦要复用就得复制粘贴
            TextField("邮箱", text: $email)
            if !email.isEmpty && !email.contains("@") {
                Text("缺少 @").foregroundStyle(.red)
            }

            // ⚠️ 做法二:onChange 里算完存进另一个 @State,
            // 状态变多了两倍,容易出现"两份状态不一致"
            TextField("再次输入", text: $email)
                .onChange(of: email) { _, newValue in
                    _ = newValue.contains("@")
                }

            // ✅ 做法三:规则写在表单模型里,界面只负责显示
        }
    }
}

推荐做法三——把校验写成模型上的计算属性:

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

@Observable
final class SignupForm {
    var email = ""
    var password = ""
    var acceptedTerms = false

    var emailError: String? {
        guard !email.isEmpty else { return nil }        // 还没填就不报错
        return email.contains("@") ? nil : "邮箱格式看起来不对"
    }

    var passwordError: String? {
        guard !password.isEmpty else { return nil }
        return password.count >= 8 ? nil : "密码至少 8 位"
    }

    var isValid: Bool {
        emailError == nil && passwordError == nil
            && !email.isEmpty && acceptedTerms
    }
}

struct SignupView: View {
    @State private var form = SignupForm()

    var body: some View {
        @Bindable var form = form
        Form {
            Section("账号") {
                TextField("邮箱", text: $form.email)
                if let error = form.emailError {
                    Text(error).font(.caption).foregroundStyle(.red)
                }
                SecureField("密码(至少 8 位)", text: $form.password)
                if let error = form.passwordError {
                    Text(error).font(.caption).foregroundStyle(.red)
                }
            }
            Section {
                Toggle("我已阅读并同意条款", isOn: $form.acceptedTerms)
                Button("注册") { }
                    .disabled(!form.isValid)
            }
        }
    }
}

// 界面效果:输入 "abc" 时邮箱栏下方出现红字;
// 三个条件都满足前,"注册"按钮一直是灰的

这种写法有三个好处,值得逐条对照:

  1. 规则只有一份,界面和测试读的都是 form.isValid;
  2. 错误信息是算出来的,不存在"改了值忘了清错误"的可能;
  3. 想给"点了提交之后才高亮错误"这种更细的体验,只要在模型上加一个 var didAttemptSubmit = false,错误显示条件改成 didAttemptSubmit ? emailError : nil 即可,界面一行都不用动。

44.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
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
import Observation

struct Expense: Hashable {
    var title: String
    var amount: Double
    var date: Date
    var category: String
}

@Observable
final class ExpenseForm {
    var title = ""
    var amountText = ""
    var date = Date()
    var category = "餐饮"

    static let categories = ["餐饮", "交通", "购物", "其他"]

    var amount: Double? { Double(amountText) }

    var titleError: String? {
        title.isEmpty ? "请填写名称" : (title.count > 20 ? "名称最多 20 个字" : nil)
    }

    var amountError: String? {
        guard !amountText.isEmpty else { return "请填写金额" }
        guard let amount else { return "金额必须是数字" }
        return amount > 0 ? nil : "金额要大于 0"
    }

    var isValid: Bool { titleError == nil && amountError == nil }

    func makeExpense() -> Expense? {
        guard let amount, isValid else { return nil }
        return Expense(title: title, amount: amount, date: date, category: category)
    }
}

struct ExpenseEditor: View {
    @State private var form = ExpenseForm()
    @Environment(\.dismiss) private var dismiss
    var onSave: (Expense) -> Void

    var body: some View {
        @Bindable var form = form
        NavigationStack {
            Form {
                Section("基本信息") {
                    TextField("名称", text: $form.title)
                    if let message = form.titleError, !form.title.isEmpty {
                        Text(message).font(.caption).foregroundStyle(.red)
                    }
                    TextField("金额", text: $form.amountText)
                    if let message = form.amountError, !form.amountText.isEmpty {
                        Text(message).font(.caption).foregroundStyle(.red)
                    }
                    DatePicker("日期", selection: $form.date, displayedComponents: .date)
                }
                Section("分类") {
                    Picker("分类", selection: $form.category) {
                        ForEach(ExpenseForm.categories, id: \.self) { Text($0).tag($0) }
                    }
                    .pickerStyle(.segmented)
                }
            }
            .navigationTitle("记一笔")
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("取消") { dismiss() }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("保存") {
                        if let expense = form.makeExpense() {
                            onSave(expense)
                            dismiss()
                        }
                    }
                    .disabled(!form.isValid)
                }
            }
        }
        .frame(minWidth: 320, minHeight: 320)
    }
}

// 界面效果:一个"记一笔"表单;名称为空或金额不是正数时,
// 对应栏下方出现红色提示,"保存"按钮保持灰色不可点

注意 makeExpense() 返回的是可选值:表单模型负责"能不能转成有效数据",界面的保存按钮只负责调用。 这个分工让"按钮禁用"和"实际保存条件"用的是同一套判断,不会出现"按钮亮了但保存失败"的不一致。

44.8 本章小结

概念一句话
Form / Section系统排版的分组表单;一行一个主控件
TextField(text:)绑字符串,什么输入都接受
TextField(value:format:)绑数值/日期,解析失败不写回
TextField(axis: .vertical)视觉多行但仍是单行字符串
TextEditor大块多行文本,需自己加占位符
SecureField密码输入,显示圆点
@FocusState控制焦点:自动聚焦、回车跳转、收起键盘
Picker值的 tag 类型必须与 selection 一致
Toggle / Slider / Stepper / DatePicker统一模式:Binding 管值,闭包管显示
校验写成模型的计算属性,界面只读 error / isValid
保存模型提供"生成有效数据"的方法,按钮只负责调用

44.9 本章易错点速查

容易踩的地方正确认识
Picker 点了没反应tag 的类型和 selection 不一致,二者必须同类型
用 value: 绑定却想接住任意输入解析失败不会写回;要"什么都收"就用 text:
在 body 里写 if 不合法 { 显示 } 的复杂规则规则会到处复制;提到模型的计算属性里
用 onChange 把校验结果另存一份状态两份状态容易不同步,直接算出来
TextEditor 里期待占位符它没有;用 .overlay 自己叠一个并关掉命中测试
忘了 .allowsHitTesting(false)占位文字会挡住点击,光标点不进去
在 macOS 上用 .keyboardType / .textInputAutocapitalization这些是 iOS 专属,要条件编译
编辑表单直接改主数据用草稿模型,“取消"才不需要回滚逻辑
sheet 太窄给 .frame(minWidth:minHeight:)

44.10 下章预告

表单让界面"能用了”,但还不好看——准确地说是没有"说清楚发生了什么"。下一章讲动画:withAnimation 和 .animation 的区别、转场、matchedGeometryEffect 和新的 phaseAnimator,以及那条最实用的原则——动画只该用来解释变化,不该用来炫耀。

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