上周写完 UILabel,这周切到 UITextField / UITextView。我以为这周会轻松一点——输入框 API 不是更难,只是更碎。实际跑下来发现 EditText 那套肌肉记忆迁移过来的"盲点"比 UILabel 那周还多:键盘类型枚举的命名对不上、密码显隐藏着字体 bug、字符限制得自己写、焦点链得自己拉、键盘监听得选对通知。下面按踩坑顺序记下来。
UITextField 默认长得跟一段文本一样,没边框没底色,直接放进页面里你分不清它是个按钮还是输入框。Android 的 EditText 也有点这意思(默认无下划线),但 Material 的 TextInputLayout 给现成的浮动 label + 边框。iOS 这边自己搭:
let nameField = UITextField()
nameField.borderStyle = .roundedRect // 默认是 .none,没边框
nameField.placeholder = "请输入用户名"
nameField.clearButtonMode = .whileEditing // 编辑时右侧出现 ×
nameField.returnKeyType = .next // 键盘右下角按钮
nameField.delegate = self
borderStyle 四个值(.none / .roundedRect / .line / .bezel)都是 Apple 早期风格,2025 年做新项目基本都自己用 UIView 垫底加 1pt layer.borderColor + cornerRadius,iOS 11 之后这个 API 一直停在半成品状态。要做现代化 UI(如错误态红框、聚焦蓝框),建议直接弃用 borderStyle。
键盘类型枚举叫 UIKeyboardType,跟 Android 的 InputType 命名完全不同,这是迁移人群第一道坎:
| iOS | Android | 场景 |
|---|---|---|
.default | text | 普通文本 |
.emailAddress | textEmailAddress | 邮箱 |
.URL | textUri | URL |
.numberPad | number / numberSigned | 整数 |
.decimalPad | numberDecimal | 小数 |
.phonePad | phone | 电话号(含 * #) |
.namePhonePad | textPersonName / phone | 姓名或电话 |
.asciiCapableNumberPad | 无直接对应 | 纯 ASCII 数字键盘 |
.webSearch | 无直接对应 | 搜索/URL 风格,键位靠空格附近 |
.alphabet(Deprecated) | 无直接对应 | 等同 .asciiCapable,Apple 已标弃用 |
.asciiCapable
在表格里没列,单独说一下:等同于传统 ASCII 字母键盘,开发者**用.asciiCapable而非.alphabet** 是 Apple 文档的明确建议(.alphabet` 后续可能移除)。
命名相似但不等价。.numberPad 没有小数点,金额输入必须用 .decimalPad。.numberPad / .decimalPad / .phonePad 三个没有 Return 按钮(数字键盘顶部没地方放),所以 returnKeyType = .done 在这几个上面压根不显示——想给金额输入框加"完成"按钮,得自己写 inputAccessoryView 工具栏。
关于
returnKeyType和keyboardType的联动:开发商社区普遍认为returnKeyType默认值会随keyboardType变化(.URL倾向.go、.emailAddress倾向.next之类的直觉),但 Apple 官方文档没有明确给出映射表——所以具体到.go还是.next、.search还是.send,别凭印象写代码;如果对返回键文案有要求,永远显式 setreturnKeyType,不要相信默认值。
字数限制是个隐藏的"非能力"。Android 那边 EditText 配 InputFilter.LengthFilter(max) 一行搞定,iOS 这边 UITextField 没有内建字符数限制,要在 UITextFieldDelegate 里写:
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange,
replacementString string: String) -> Bool {
// 用 NSString 算"用户实际想输入后的长度"
let current = textField.text ?? ""
guard let stringRange = Range(range, in: current) else { return false }
let updated = current.replacingCharacters(in: stringRange, with: string)
// 字符数限制(用 .count 是 Unicode 字符个数,不是 UTF-16 code units)
if updated.count > 20 { return false }
// 字符白名单(这里只允许数字)
if !string.isEmpty, !CharacterSet.decimalDigits.isSuperset(of: CharacterSet(charactersIn: string)) {
return false
}
return true
}
注意 shouldChangeCharactersIn 返回 false 就不会写入文本——这是 Android InputFilter 行为(拦截后字段不变)。但 replacementString 在用户按删除键时是空字符串,所以只对非空 string 做白名单校验。
还有一个坑:这个 delegate 方法在 iOS 26 起有了多范围签名 shouldChangeCharactersInRanges(数组,参数类型是 [NSValue] 包装 NSRange),用于一次替换多个不连续文本范围——典型场景是预测文本候选词分多个 bubble 同时插入。基础代码写完用旧签名没问题,但要兼容 iOS 26+ 的多 range 预测文本需要走新签名。Apple 在 iOS 26.0 引入这个方法时和旧签名并存,二者行为不一致(同一段 Zhuyin「ㄨㄟ」选「王」输入,旧签名拿到 range: {0, 2},新签名拿到 ranges: [{2, 0}]),接 iOS 26 的输入框如果不走新签名会出候选词不更新的 bug。这个我自己这周没踩,下周做输入体验优化专题时再展开。
字符数监听用 textDidChangeNotification:
NotificationCenter.default.addObserver(self, selector: #selector(textChanged),
name: UITextField.textDidChangeNotification, object: nameField)
@objc func textChanged() {
counterLabel.text = "(nameField.text?.count ?? 0)/20"
}
注意 delegate 方法在文本写入前触发,notification 在写入后触发——实时显示字数必须用 notification,用 delegate 拿到的是"还没写入"的状态。
密码显隐没有 passwordToggleEnabled 这类一行 API。要自己写:
let eyeButton = UIButton(type: .system)
eyeButton.setImage(UIImage(systemName: "eye.slash"), for: .normal)
eyeButton.tintColor = .secondaryLabel
eyeButton.frame = CGRect(x: 0, y: 0, width: 32, height: 32)
passwordField.rightView = eyeButton
passwordField.rightViewMode = .whileEditing
@objc func togglePassword() {
let wasFirstResponder = passwordField.isFirstResponder
// UIView.performWithoutAnimation 包一层,避免键盘弹/收动画导致屏幕抖动
UIView.performWithoutAnimation {
if wasFirstResponder { passwordField.resignFirstResponder() }
passwordField.isSecureTextEntry.toggle()
if wasFirstResponder { passwordField.becomeFirstResponder() }
}
}
isSecureTextEntry.toggle() 这一行藏着一个老 UIKit bug:当 field 已经是 first responder 时切换 isSecureTextEntry,font 会被静默重置成系统默认字体(不是 Times New Roman,是系统字体但是你之前自定义的那一份丢了)。Stack Overflow 上的解决方案都是 resignFirstResponder → toggle → becomeFirstResponder 包在 UIView.performWithoutAnimation 里,2016 年的 Alexander 那条回答至今还能用,说明 Apple 十多年没修。从安卓过来的人最容易踩,因为安卓 passwordToggleEnabled 一行搞定的事在 iOS 这边要绕一圈。
焦点链(按 Return 跳下一个输入框)也是自己写。安卓 imeOptions="actionNext" + EditorInfo IME_ACTION_NEXT + focusSearch 就完事。iOS 这边:
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
switch textField {
case nameField:
passwordField.becomeFirstResponder() // 跳下一个
case passwordField:
passwordField.resignFirstResponder() // 收起键盘
submitForm() // 提交
default:
textField.resignFirstResponder()
}
return true
}
textFieldShouldReturn 返回 true 才真正处理 Return。默认它什么都不做——点了键盘右下角的"完成"按钮不会自动收键盘,必须显式 resignFirstResponder()。
becomeFirstResponder() 会让焦点跳过去并自动弹键盘,resignFirstResponder() 收键盘。链的起点通常是页面进入时 DispatchQueue.main.async { firstField.becomeFirstResponder() },让第一个输入框自动获得焦点。
软键盘遮挡输入框这件事 iOS 的处理跟安卓完全是两个世界。
Android 现在的做法是 WindowInsetsAnimationCompat + ViewCompat.setOnApplyWindowInsetsListener,系统把 keyboard insets 推到 view tree,自己处理 ime() inset 加 padding。
iOS 这边还在用 NSNotificationCenter 监听键盘高度变化:
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillChangeFrame),
name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
@objc func keyboardWillChangeFrame(_ notification: Notification) {
guard let endFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
let keyboardHeight = view.frame.height - view.convert(endFrame, from: nil).origin.y
let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double ?? 0.25
UIView.animate(withDuration: duration) {
self.bottomConstraint.constant = keyboardHeight
self.view.layoutIfNeeded()
}
}
要点:
- 用
keyboardWillChangeFrameNotification,不是keyboardWillShowNotification。前者覆盖所有键盘高度变化(弹出、收起、切输入法、预测栏出现/消失),后者只在键盘首次出现时触发一次——切到 emoji 键盘或打开预测栏时后者不会触发,这是常见 bug。Apple 自 iOS 5 起就提供前者;社区里做高度相关 UI 普遍推荐前者(Apple 自己没有显式这么说)。有一条边界:iOS 11 在 emoji 键盘切换时偶发不发这个通知,iOS 11.2.6 修复,最新系统不会再触发这个问题。 UIKeyboardFrameEndUserInfoKey是结束时的 frame,不是开始时的。动画中间过程用 begin 帧,结束位置用 end 帧——做跟随动画用 end 帧的最终位置。- 动画 duration 在
UIKeyboardAnimationDurationUserInfoKey里,需要跟系统动画同步,否则会出现"内容跳一下再动"。
keyboardWillChangeFrame 触发时机还有一个边界:如果键盘从文本切到 emoji,不会重新发 Show 通知,但会发 keyboardWillChangeFrame,所以这是监听键盘"任何高度变化"的统一入口。
UITextView 是多行的,跟 UITextField 共用一个父类 UITextInput。差别:
UITextView默认就能多行换行,不需要numberOfLines = 0这种骚操作- 有
delegate: UITextViewDelegate(不是UITextFieldDelegate),方法名也不同(textView(_:shouldChangeTextIn:replacementText:),参数是NSRange不是range) - 没有 placeholder——这是最大的坑。要 placeholder 得自己 drawRect,或者加一个 UILabel 监听
textViewDidChange显隐 - 自带 UIScrollView,能滚动、能自适应高度
let textView = UITextView()
textView.font = .systemFont(ofSize: 16)
textView.delegate = self
textView.textContainerInset = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
textView.layer.borderColor = UIColor.separator.cgColor
textView.layer.borderWidth = 0.5
textView.layer.cornerRadius = 8
自适应高度的常见做法:监听 textViewDidChange,重新算 sizeThatFits(CGSize(width: width, height: .infinity)) 更新高度约束。
输入防抖 iOS 这边没有现成 RxJava/RxSwift 那样的 debounce 操作符。但 Swift 5.5 之后用 Task + 取消可以写得很轻:
private var debounceTask: Task<Void, Never>?
func textFieldDidChange(_ textField: UITextField) {
debounceTask?.cancel()
debounceTask = Task {
try? await Task.sleep(nanoseconds: 300_000_000) // 300ms
guard !Task.isCancelled else { return }
await MainActor.run {
searchAPI(textField.text ?? "")
}
}
}
每次文本变化取消上一个 Task,新 Task 等 300ms 后执行。300ms 内的连续输入都会被合并掉,只触发最后一次搜索。这比 DispatchWorkItem.cancel() 干净,Task 自带结构化并发和取消传播。Retrofit/OkHttp 那套有 debounce 操作符,iOS 这边走 Combine 的 .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) 也可以,但引入 Combine 库代价有点大,搜索框这种单点用 Task 就够。
实践
UIKeyboardType枚举值共 13 个(default/asciiCapable/numbersAndPunctuation/URL/numberPad/phonePad/namePhonePad/emailAddress/decimalPad/twitter/webSearch/asciiCapableNumberPad/alphabet),其中alphabet已 Deprecated — Apple Developer 官方文档枚举完整列表UIResponder.keyboardWillChangeFrameNotification触发条件覆盖键盘出现/收起/切输入法/预测栏/QuickType 栏变化 — Apple 官方文档 + Stack Overflow(26034997、25326869)多方一致textField(_:shouldChangeCharactersInRanges:replacementString:)是 iOS 26.0+ 新增的多范围签名,参数[NSValue]包装NSRange,用途是一并替换多段不连续文本 — Apple Developer 官方文档原文确认textDidChangeNotification(UITextFieldTextDidChangeNotification)在文本写入后触发 — Apple 官方文档"A notification that alerts observers when the text in a text field changes"(字面就含 "did",trigger 时机为写入后)+ 与 delegate 的"shouldChange"语义对照isSecureTextEntrytoggle 时字体被静默重置是 UIKit 已知 bug,需 resignFirstResponder → toggle → becomeFirstResponder 包在UIView.performWithoutAnimation中 — Stack Overflow 多年共识,未见 Apple 公开声明- iOS 26 多范围签名与旧签名行为不一致:同一段 Zhuyin「ㄨㄟ」选「王」输入,旧签名拿到
range: {0, 2},新签名拿到ranges: [{2, 0}]— Apple Developer Forum 上 Henry 2025-10 的实测复现
技术清零表
| 技术 | 它是什么 | 工程价值 | 常见坑 |
|---|---|---|---|
UITextField | 单行输入控件 | 表单、搜索、登录等基础输入 | borderStyle = .none 默认无边框;没有内建字符限制 |
UITextView | 多行可滚动输入控件 | 评论、笔记、长文本 | 没有 placeholder,要自己监听 textViewDidChange 显隐 label |
UIKeyboardType | 键盘类型枚举 | 引导用户正确输入 | 命名跟 Android InputType 不对应;.numberPad 没 Return 键 |
UITextFieldDelegate | 输入框事件代理协议 | 字符限制、Return 处理、焦点链 | shouldChangeCharactersIn 在写入前触发;iOS 26 起新增多范围签名 |
textFieldDidChangeNotification | 文本写入后通知 | 实时统计字数、触发搜索 | 跟 delegate 时机不同——del 写前、noti 写后 |
isSecureTextEntry | 密码隐藏显示开关 | 密码框 | toggle 时字体被重置(UIKit bug),必须 resign/toggle/become 三步走 |
returnKeyType + becomeFirstResponder / resignFirstResponder | 焦点链管理 | 按 Return 跳下个输入框 | textFieldShouldReturn 默认不收键盘,必须显式 resign |
keyboardWillChangeFrameNotification | 键盘高度变化通知(含弹出/收起/切换) | 软键盘遮挡输入框时跟随调整 | 用 keyboardFrameEndUserInfoKey 拿结束位置;用 animationDurationUserInfoKey 同步动画 |
UIView.performWithoutAnimation | 包裹代码块,禁用动画 | 避免 resignFirstResponder → setSecure → becomeFirstResponder 三步走时键盘抖动 | 不包会闪键盘,体感明显 |
Task + cancel | 结构化并发取消 | 输入防抖、轮询取消 | 必须 try? await Task.sleep,并检查 Task.isCancelled |
下周是 UIButton 全功能 + 点击优化,对照安卓第 3 周的按钮组件。安卓那边 MaterialButton 给了 OnClick/OnLongClick/水波纹现成方案,iOS 这边 UIControl 的 touchUpInside / touchDown 自己配,重复点击拦截、按钮热区扩大、热区与图形分离这些都得手写。第 1 周那张"默认值反着"表这周再加三条:keyboardType 与 InputType 命名不一致、字符数无内建限制、UITextView 没 placeholder。