第 2 周:UITextField 不是 EditText 的换皮兄弟

0 阅读9分钟

上周写完 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 命名完全不同,这是迁移人群第一道坎:

iOSAndroid场景
.defaulttext普通文本
.emailAddresstextEmailAddress邮箱
.URLtextUriURL
.numberPadnumber / numberSigned整数
.decimalPadnumberDecimal小数
.phonePadphone电话号(含 * #)
.namePhonePadtextPersonName / 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,别凭印象写代码;如果对返回键文案有要求,永远显式 set returnKeyType,不要相信默认值。


字数限制是个隐藏的"非能力"。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"语义对照
  • isSecureTextEntry toggle 时字体被静默重置是 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。