感觉像原生的 SwiftUI 表单验证:焦点与错误处理
SwiftUI 的原生感表单验证:正确处理焦点、在合适时机显示内联错误,并清晰展示服务端消息而不打扰用户。

在 SwiftUI 中让表单验证看起来像原生体验
一个“原生感”的 iOS 表单是安静的。它不会在用户输入时与之争辩。它在重要时刻提供清晰反馈,并且不会让你到处找哪里出错了。
主要的期待是可预测性。相同的操作应该每次带来同样类型的反馈。如果某个字段无效,表单应在一致的位置、用一致的语气、并给出明确的下一步操作来展示它。
大多数表单最终需要三类规则:
- 字段规则: 单个值是否有效(为空、格式、长度)?
- 跨字段规则: 值是否相互匹配或相互依赖(密码与确认密码)?
- 服务端规则: 后端是否接受(邮箱已被使用、需要邀请码)?
时机比巧妙的措辞更重要。好的验证会等待一个有意义的时刻,然后清晰地说一次。一个实用的节奏如下:
- 用户输入时保持安静,尤其是针对格式的规则。\n- 离开字段后或用户点击提交后展示反馈。\n- 错误保持可见直到修复,然后立即移除。
当用户还在构造答案(例如输入邮箱或密码)时,验证应该保持沉默。在第一个字符就显示错误会让人觉得烦人,尽管技术上可能是正确的。
当用户表明他们已完成时,验证应变得可见:焦点离开,或他们尝试提交。那一刻他们想要指引,这时你可以帮助他们定位需要注意的确切字段。
把时机做好,其他一切都会更简单。内联消息可以保持简短,焦点移动会显得有帮助,服务端错误也会像正常反馈而不是惩罚。
建立一个简单的验证状态模型
一个原生感的表单从清晰的分离开始:用户输入的文本和应用对该文本的判定不是同一件事。如果混在一起,你要么太早显示错误,要么在界面刷新时丢失服务端消息。
一个简单的方法是为每个字段提供自己的状态,包含四部分:当前值、用户是否与之交互、本地(设备端)错误,以及服务端错误(如果有)。这样 UI 就可以基于“已触碰”和“已提交”来决定显示什么,而不是对每次按键都做出反应。
struct FieldState {
var value: String = ""
var touched: Bool = false
var localError: String? = nil
var serverError: String? = nil
// One source of truth for what the UI displays
func displayedError(submitted: Bool) -> String? {
guard touched || submitted else { return nil }
return localError ?? serverError
}
}
struct FormState {
var submitted: Bool = false
var email = FieldState()
var password = FieldState()
}
几条小规则能让行为可预测:
- 将本地错误和服务端错误分开。本地规则(例如“必填”或“邮箱格式错误”)不应覆盖服务端信息,例如“邮箱已被占用”。
- 当用户再次编辑该字段时清除
serverError,避免他们一直盯着过时的消息。 - 只有在用户离开字段(或你决定他们已尝试交互)时才设置
touched = true,不要在输入第一个字符时就设为 true。
有了这些,视图可以自由绑定到 value。验证更新 localError,API 层设置 serverError,二者互不干扰。
引导而不是唠叨的焦点处理
良好的 SwiftUI 验证应该让系统键盘感觉像在帮助用户完成任务,而不是应用在训斥他们。焦点在其中起到很大作用。
一个简单的模式是使用 @FocusState 将焦点视为单一的事实来源。为字段定义一个 enum,把每个字段绑定到它,然后在用户点击键盘按钮时向前移动。
enum Field: Hashable { case email, password, confirm }
@FocusState private var focused: Field?
TextField("Email", text: $email)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.textInputAutocapitalization(.never)
.submitLabel(.next)
.focused($focused, equals: .email)
.onSubmit { focused = .password }
SecureField("Password", text: $password)
.submitLabel(.next)
.focused($focused, equals: .password)
.onSubmit { focused = .confirm }
让体验保持原生感的是克制。只有在明确的用户操作时才移动焦点:点 Next、Done 或主要按钮。提交时,将焦点设置到第一个无效字段(如有必要并滚动到那里)。不要在用户输入时抢走焦点,即便当前值无效。并且与键盘标签保持一致:中间字段用 Next,最后字段用 Done。
一个常见示例是注册。用户点击创建账户。你只做一次验证,展示错误,然后把焦点设在第一个不通过的字段(通常是 Email)。如果他们正在 Password 字段输入,不要在他们输入过程中把他们跳回 Email。这个小细节往往决定了“有打磨的 iOS 表单”和“烦人的表单”之间的差别。
在恰当时机出现的内联错误
内联错误应该像一个安静的提示,而不是训斥。使体验“原生”与“烦人”的最大差别就是你何时显示消息。
时机规则
如果在用户刚开始输入时就显示错误,会打断他们。更好的规则是:等用户有合理的机会完成字段后再显示。
显示内联错误的好时机:
- 字段失去焦点后
- 用户点击提交后
- 输入时短暂停顿后(仅用于明显的检查,如邮箱格式)
一个可靠的方法是仅在字段被触碰或尝试提交时显示消息。新表单保持安静,但用户一旦交互就能获得明确的指导。
布局与样式
最不 iOS 的感觉莫过于当错误出现时布局跳动。为消息预留空间,或者对其出现做动画,避免把下一个字段猛地往下挤。
保持错误文本简短且具体,每条消息只包含一个修复点。“密码必须至少 8 个字符”是可操作的。“输入无效”则不是。
在样式上,力求低调且一致。字段下方用小字体(如 footnote),统一的错误色和对字段的轻微高亮,通常比沉重的背景更好。值变为有效时立即清除消息。
现实的例子:在注册表单中,当用户仍在输入 name@ 时不要显示“邮箱无效”。在离开字段或短暂停顿后显示,并在地址变为有效时立即移除。
本地验证流程:输入、离开字段、提交
良好的本地流程有三种节奏:输入时的温和提示、离开字段时的更严格检查、提交时的全面规则。正是这种节奏让验证显得原生。
用户输入时,保持验证轻量和安静。想想“这是明显不可能吗?”而不是“这是完美的吗?”对于邮箱字段,你可能只检查是否包含 @ 且无空格。对于密码,可以在开始输入后显示“8+ 字符”之类的小提示,但避免在第一个按键就显示红色错误。
当用户离开字段时,运行更严格的单字段规则并在需要时显示内联错误。这里是“必填”和“格式无效”等错误出现的地方。这也是修剪空白和规范化输入(如将邮箱转为小写)让用户看到将被提交的内容的好时机。
提交时,再次验证所有内容,包括之前无法判断的跨字段规则。经典示例是密码与确认密码是否匹配。如果失败,将焦点移到需要修复的字段并在其附近展示一条明确的消息。
对提交按钮要谨慎。用户还在填写表单时保持它可点。只有在点击不会产生任何作用时才禁用(比如正在提交)。如果你为了无效输入而禁用它,仍要在附近展示该如何修复的提示。
提交期间展示清晰的加载状态。把按钮标签换成 ProgressView,防止重复点击,并保持表单可见,让用户理解发生了什么。如果请求超过一秒,像“正在创建账户...”这样的短标签能在不增加噪音的情况下减少焦虑。
在不让用户沮丧的情况下处理服务端验证
服务端检查是最终的权威,即使你的本地检查很严格。密码可能通过本地规则却因为过于常见被拒,或者邮箱可能已被占用。
最大的 UX 改进是区分“你的输入不可接受”和“我们无法连接到服务器”。如果请求超时或用户离线,不要把字段标为无效。展示一个冷静的横幅或提示,例如“无法连接。请重试。”并保持表单不变。
当服务器返回验证失败时,保留用户输入并指向具体字段。清空表单、清除密码或把焦点移走会让人觉得因为尝试而受到惩罚。
一个简单的模式是将结构化的错误响应解析为两类:字段错误和表单级错误。然后更新 UI 状态但不改变文本绑定。
struct ServerValidation: Decodable {
var fieldErrors: [String: String]
var formError: String?
}
// Map keys like "email" or "password" to your local field IDs.
通常感觉原生的做法:
- 将字段消息以内联形式放在字段下方,当服务端表述清晰时可直接使用该措辞。
- 只在提交后将焦点移到第一个有错误的字段,而不是在用户输入过程中突然跳转。
- 如果服务器返回多个问题,每个字段只显示第一条,以保持可读性。
- 如果你有字段的详细错误说明,不要退而求其次只显示“出了点问题”。
示例:用户提交注册表单,服务器返回“邮箱已被占用”。保留用户输入的邮箱,在邮箱下方显示该消息,并将焦点移到该字段。如果服务器宕机,展示单一的重试消息并保持所有字段不变。
在正确位置显示服务端消息
当服务端错误出现在随意的横幅中,会让人觉得“不公平”。尽可能将每条消息放在引起它的字段附近。只有在无法将错误绑定到单个输入时,才使用通用消息。
从把服务器的错误负载映射到你的 SwiftUI 字段标识符开始。后端可能返回诸如 email、password 或 profile.phone 的键,而你的 UI 使用像 Field.email 和 Field.password 的 enum。在响应后做一次映射,这样其余视图保持一致。
一种灵活的建模方法是保留 serverFieldErrors: [Field: [String]] 和 serverFormErrors: [String]。即便通常只显示一条消息,也把它们存为数组。当你显示内联错误时,选择最有帮助的第一条。例如同时出现“邮箱已被占用”和“邮箱无效”时,前者比后者更有用。
每字段多个错误很常见,但全部显示会很嘈杂。大多数情况下,只显示第一条内联错误,必要时将其余放到详情视图。
对于无法归属于某字段的错误(会话过期、速率限制、“请稍后再试”),将它们放在提交按钮附近,这样用户在操作时能看到。并确保在成功后清除旧错误,避免 UI 看起来“卡住”。
最后,当用户更改相关字段时清除服务端错误。实践中,对 email 的 onChange 处理器应移除 serverFieldErrors[.email],让 UI 立即反映“好,你正在修复它”。
无障碍与语气:让体验更像原生的一些小选择
良好的验证不仅仅是逻辑。它还关乎文字如何阅读、如何在动态字体、VoiceOver 以及不同语言下表现。
让错误易读(不要仅靠颜色)
假设文本会变大。使用支持 Dynamic Type 的样式(如 .font(.footnote) 或 .font(.caption),不要使用固定尺寸),并让错误标签换行。保持间距一致以免错误出现时布局跳动过大。
不要仅依靠红色文本。添加清晰的图标、前缀“错误:”或两者兼备。这有助于有色觉障碍的人,并加快扫描速度。
一组通常可靠的检查项:
- 使用随 Dynamic Type 缩放的可读文本样式。
- 允许换行,避免错误消息被截断。
- 加上图标或“错误:”这样的标签并配合颜色使用。
- 在浅色和深色模式下保持高对比度。
让 VoiceOver 读出正确的信息
当字段无效时,VoiceOver 应读出标签、当前值和错误。如果错误是字段下方的单独 Text,可能会被跳过或断章取义。
两种模式有帮助:
- 将字段和它的错误合并为一个可访问元素,这样当用户聚焦字段时错误会被朗读。
- 将错误消息包含在可访问提示或值中(例如“密码,必填,至少 8 个字符”)。
语气也很重要。写出清晰且易于本地化的消息。避免俚语、玩笑和模糊的“哎呀”之类表达。优先使用具体指引,如“缺少邮箱”或“密码必须包含数字”。
示例:同时包含本地和服务端规则的注册表单
想象一个包含三字段的注册表单:Email、Password 和 Confirm Password。目标是表单在用户输入时保持沉默,在用户想继续时变得有帮助。
焦点顺序(Return 键的行为)
使用 SwiftUI FocusState,每次按 Return 键都应感觉像一步自然的前进。
- Email 的 Return:将焦点移到 Password。
- Password 的 Return:将焦点移到 Confirm Password。
- Confirm Password 的 Return:收起键盘并尝试提交。
- 如果提交失败:将焦点移到第一个需要注意的字段。
最后一步很重要。如果邮箱无效,焦点应回到 Email,而不仅仅是在别处显示一条红色消息。
错误何时出现
一个简单的规则能保持 UI 平静:在字段被触碰(用户离开)或尝试提交后才显示消息。
- Email:离开字段或提交时显示“请输入有效的邮箱”。
- Password:离开字段或提交时显示规则(例如最短长度)。
- Confirm Password:离开字段或提交时显示“密码不匹配”。
现在看服务端。假设用户提交后 API 返回:
{
"errors": {
"email": "That email is already in use.",
"password": "Password is too weak. Try 10+ characters."
}
}
用户看到的:Email 在下面显示服务端消息,Password 也在其下方显示相应消息。除非 Confirm Password 也在本地校验失败,否则它保持安静。
接下来他们会怎么做:焦点落在 Email(第一个服务端错误),他们修改邮箱,按 Return 跳到 Password,调整密码,然后再次提交。由于消息是内联的且焦点随意图移动,表单显得合作而不是训斥。
常见导致验证“不像 iOS”的陷阱
表单在技术上可以是正确的,但仍然让人感觉不对。大多数“不像 iOS”的验证问题都归结于时机:你何时显示错误、何时移动焦点、以及如何响应服务端。
一个常见错误是太早发声。如果你在第一个按键就显示错误,人们会在输入时感到被训斥。等到字段被触碰(他们离开它,或尝试提交)通常能解决这一问题。
异步的服务端响应也会打乱流程。如果注册请求返回后你突然把焦点跳到另一个字段,会显得很随机。保持焦点在用户最后所在位置,只有在他们点了 Next 或在处理提交时才移动它。
另一个陷阱是在每次编辑时清空所有内容。只要任意字符变化就清除所有错误,会隐藏真正的问题,尤其是服务端消息。只清除被编辑字段的错误,其他的保留直到它们被真正修复。
避免“静默不可点击的”提交按钮。永久禁用提交而不解释需要修复什么,会迫使用户猜测。如果禁用它,请给出具体提示,或允许提交然后引导他们到第一个问题。
慢请求与重复点击很容易被忽视。如果你不展示进度并阻止重复提交,用户会连按两下,收到两个响应,最终得到混乱的错误。
快速自查清单:
- 错误延迟到失焦或提交,而不是第一个字符。
- 服务端响应后不要随意移动焦点,除非是用户发起的操作。
- 按字段清除错误,而不是一次性清空所有。
- 解释为何提交被阻止(或者允许提交并提供引导)。
- 在等待时展示加载并忽略额外点击。
示例:如果服务器返回“邮箱已被占用”(可能来自你用 AppMaster 构建的后端),在 Email 下方保留该消息,保持 Password 不变,让用户编辑 Email 而无需重启整个表单。
快速清单与下一步
原生感的验证体验主要关乎时机与克制。你可以有严格的规则,同时让界面保持冷静。
在发布前检查:
- 在正确的时机验证。除非确有帮助,否则不要在第一个按键就显示错误。
- 有目的地移动焦点。提交时跳到第一个无效字段并明确指出问题。
- 用简短具体的措辞。告诉用户下一步做什么,而不是泛泛地说“你做错了”。
- 尊重加载与重试。在发送时禁用提交按钮,并在请求失败时保留已输入的值。
- 尽可能把服务端错误当作字段反馈。将服务端代码映射到字段,并仅在确为全局问题时使用顶部消息。
然后像真实的人一样测试。用一只手的拇指操作手机并尝试完成表单。之后开启 VoiceOver,确保焦点顺序、错误朗读和按钮标签仍然合理。
为调试与支持记录服务端验证码(不是原始消息)以及屏幕和字段名很有帮助。当用户说“无法注册”时,你可以快速判断是 email_taken、weak_password 还是网络超时。
要在整个应用中保持一致,标准化你的字段模型(value、touched、local error、server error)、错误位置和焦点规则。如果你想在不为每个屏幕手工编码的情况下更快地构建原生 iOS 表单,AppMaster (appmaster.io) 可以同时生成 SwiftUI 应用和后端服务,帮助你更容易地保持客户端和服务端验证规则的一致性。


