记一次验证码输入框的实现

最近公司的项目开始做登录模块的页面改版,验证码/邀请码的输入框没有找到很合适的第三方控件去做,自定义的时候踩了一些坑,所以写了这篇文章记录一下。

UI 大概是这样子的:

验证码输入框

邀请码输入框

方案选择

显示的部分基本上必须自定义,没办法直接使用 UITextView / UITextField,实际动手之前我找了一下现有的方案:

  • 使用一个容器 View 去存放多个 UITextField / UITextField
    • 优点:UI 的显示非常容易实现
    • 缺点:与 TextField / TextView 的交互会很多,复杂度高
  • 使用 UIView + UIKeyInput / UITextInput 自定义控件
    • 优点:自由可控
    • 缺点:只使用 UIKeyInput 的话需要自己绘制输入光标,并且没有快捷填充等功能,而使用 UITextInput 的话需要实现的方法太多
  • 使用 UITextField 以及 NSAttributedString.Key.kern 控制字间距
    • 优点:可以轻松实现快速填充
    • 缺点:计算字间距太麻烦

最后的方案是继承 UITextField,因为 UITextField 本身提供了足够的方法能够去调整它的显示,所以这里直接隐藏掉它自身显示的内容,使用多个 Label 进行替代。

这是具体的实现以及最后的效果:

效果图

遇到的问题和解决方案

方案敲定之后,需要解决的事情就很明显了:

  1. 如何控制输入光标显示的位置?
  2. 如何控制插入点的位置?
  3. 如何控制复制/粘贴/剪切的行为?
  4. 如何限制输入的字数和字符集?
  5. 如何隐藏掉 TextField 原本的内容?
  6. 如何控制字符串的显示?

如何控制输入光标显示的位置?

只要重写 caretRect 方法即可,UITextField 会根据这个方法返回的 frame 去绘制输入光标:

override func caretRect(for position: UITextPosition) -> CGRect { ... }

如何控制插入点的位置?

每次点击 TextField 进入输入状态时,系统会自动根据点击的位置选择一个合适的插入点,但这里的输入框我们只要让它的插入点一直保持在最后即可,有这么几种情况会导致插入点的位置改变:

  1. 进入输入状态时
  2. 重按调整光标位置
  3. 输入或删除字符

原本我是打算在各个生命周期里去改 selectedTextRange 的,但后面我发现直接重写它的 setter 函数更加方便,在它每次改变时直接重置到文本的最后即可:

// 任何调整选择范围的行为都会直接把 insert point 调到最后
override var selectedTextRange: UITextRange? {
get { return super.selectedTextRange }
set { super.selectedTextRange = textRange(from: endOfDocument, to: endOfDocument) }
}

如何限制复制/粘贴/剪切的行为?

验证码的输入框不太好支持剪切 / 复制 / 选择等功能,所以这里我们限制 TextField 只处理粘贴,重写 canPerformAction 方法即可:

override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
return action == #selector(paste(_:))
}

如何限制输入的字数和字符集?

这里核心思路主要是通过代理方法去拦截字符的修改,UITextField 的 shouldChangeText(in:replacementText:) 处于未知原因没有被调用,所以我这里只能通过 Delegate 来拦截字符的修改:

func textField(
_ textField: UITextField,
shouldChangeCharactersIn range: NSRange,
replacementString string: String
) -> Bool {
let newText = text
.map { $0 as NSString }
.map { $0.replacingCharacters(in: range, with: string) }
.map(textPreprocess) ?? ""
let newTextCharacterSet = CharacterSet(charactersIn: newText)

let isValidLength = newText.count <= codeLength
let isUsingValidCharacterSet = validCharacterSet.isSuperset(of: newTextCharacterSet)

if isValidLength, isUsingValidCharacterSet {
textField.text = newText
sendActions(for: .editingChanged)
}
return false
}

这里的代码很简单,生成一个修改后的字符串,然后检验修改后的长度和字符集是否合法即可。

这里返回 false 是因为在第三方的输入法上,如果一次性输入多个字符的话只会在第一个字符插入时调用这个方法,所以这里只能返回 false,然后手动修改 text 属性,并且发送 editingChanged 的 action。

如何隐藏掉 TextField 原本的内容?

隐藏文字:

override func textRect(forBounds bounds: CGRect) -> CGRect {
return .zero
}

隐藏占位文字:

override func placeholderRect(forBounds bounds: CGRect) -> CGRect {
return .zero
}

隐藏边框:

override func borderRect(forBounds bounds: CGRect) -> CGRect {
return .zero
}

如何控制字符串的显示?

这里我做得相对比较简单粗暴,直接继承 UILabel 写一个 CharacterLabel,声明一个 update 方法去更新 Label 的状态,包括了字符,当前是否为编辑状态,正在编辑的字符 Label 是否为自己,外部只需要继承 CharacterLabel 就可以控制显示出来的 UI 了。

接着只要在 TextField 初始化时传入一个工厂方法去生成即可:

class CharacterLabel: UILabel {
var isEditing = false
var isFocusingCharacter = false

func update(character: Character?, isFocusingCharacter: Bool, isEditing: Bool) {
self.text = character.map { String($0) }
self.isEditing = isEditing
self.isFocusingCharacter = isFocusingCharacter
}
}

class CodeTextField: UITextField {
init(
characterLabelGenerator: () -> CharacterLabel,
...
) { ... }
}

最后再让 TextField 在合适的时机去更新这些 label:

init(...) {
...
addTarget(self, action: #selector(updateLabels), for: .editingChanged)
}

@objc
private func updateLabels() { ... }

override func becomeFirstResponder() -> Bool {
defer { updateLabels() }
return super.becomeFirstResponder()
}

override func resignFirstResponder() -> Bool {
defer { updateLabels() }
return super.resignFirstResponder()
}

override func deleteBackward() {
defer { sendActions(for: .editingChanged) }
super.deleteBackward()
}

最后

网上找的很多方案都做了很重的实现,有的甚至自己绘制了一个输入光标,但其实仔细看看文档的话,原生的控件就已经提供了充足的接口让我们自定义了。