对用户输入进行校验是非常重要,我们无法预知用户行为。当你要求用户输入信息时,用户很可能输入了不正确的格式,而一旦我们将这些错误格式的数据发送到 API,往往会导致不可预期的后果,甚至程序崩溃。因此理论上只要是用户输入,我们都必须进行校验。
但这不是一件简单的工作。我们不仅仅需要考虑正则校验,同时需要遵循响应式原则,尽早在用户输入的过程中发现一切错误,越早越好。仅仅在用户输入完成后才进行正则校验,不是一种良好体验。
打开我们的示例工程,build& run,你可以真实地看一下一个良好体验的用户输入校验是什么样子:
接下来,我们演示这将如何实现。
假设我们将对用户名进行校验,我们用一个单独的类实现这个逻辑。新建 NameFieldValidator.swift,首先定义一个枚举类型如下:
import UIKit
public enum ValidationResult: Equatable {
case error(Bool, Bool, Bool) // under characters limit, beyond characters limit, has special character
case empty
case valid
case badword
case blank
}
该枚举用于代表校验结果。结果可能有几种:
接下来定义属性:
var maxCharacters = 13
var minCharacters = 2
var textRed = UIColor.hexColor(hex: "d23939")
var state: ValidationResult = .empty {
didSet {
updateUI(validationResult: state)
}
}
分别是:最大字符限制、最小字符限制、错误发生时 text field 的文字颜色(偏红色),校验结果。
其中 state 带有一个属性监视器,一旦 state 被改变,将自动调用 updateUI 方法。
以及几个弱引用属性:
weak var bottomLine: UIView?
weak var nextButton: UIButton?
weak var errorDisplayLabel: UILabel?
weak var nameTextField: UITextField?
这些属性分别引用 View Controller 中的一些 UI 组件,因为我们会根据校验的结果刷新这些组件。它们分别是:
然后是 init 方法,用于将上面 4 个属性实例化:
public init(nameTextField:UITextField, bottomLine: UIView, errorDisplayLabel: UILabel, nextButton: UIButton) {
super.init()
self.nameTextField = nameTextField
nameTextField.text = nil // Instead of setting textfield's text property directly, we can use setTextInitially method to set textfield's text.
self.bottomLine = bottomLine
self.errorDisplayLabel = errorDisplayLabel
self.nextButton = nextButton
nameTextField.delegate = self
}
同时将 text field 的 delegate 指向自身,这样需要 NameFieldValidator 实现 UITextFieldDelegate 协议,也就是 textField(_, shouldChangeCharactersIn:, replacementString: )方法:
extension NameTextFieldValidator: UITextFieldDelegate {
public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
let text = textField.text ?? ""
var newText = text
if let range = Range(range, in: text) {
newText = text.replacingCharacters(in: range, with: string)
}
if string.isEmpty { // In swift 5.3, when the user deletes one or more characters, the replacement string is empty
nameTextField?.deleteBackward()
}else if newText.count <= maxCharacters {
textField.text = newText
}
testInputString(newText)
return false
}
}
这个方法拦截 text field 的输入事件。每输入一个都会调用此方法。首先这个方法我们只会返回 false,这导致无论用户输入什么,我们都不会采用用户的输入作为 text field 的最终值。这样的好处在于 text field 的输入将由我们完全掌控。
首先,我们计算出 newText,也就是用户这次输入将可能最终导致 text field 的 text 变成什么样子。但这只是有可能,因为最终是否采用要根据我们的校验而定。
然后,判断用户按键是否是 delete 键,这只需要判断 replacementString 是否为空即可,这是 swift 5.3 的新特性,不然要判断 delete 键在以前是一件相当麻烦的事情。如果是 delete 键,直接调用 text field 的 deleteBackward() 方法将当前光标回删一格,这是系统默认的行为,所以这里返回 true 也是可以的。
如果不是 delete 键,那么我们要判断 newText 的长度是否超过了最大限制,只有小于最大限制,我们才会改变 text field 的 text 值。
最后调用我们的特殊逻辑 testInputString 方法,对用户当前输入进行校验。注意,这里使用的是 newText,而非 text field 的 text。二者有何区别?
text field 的 text 是 text field 显示在屏幕上的文本内容,而 newText 是当前正在输入的值,它有可能等于 text field 的 text ——如果它没有长度限制,我们会直接将它赋值给 text field 的 text。也有可能不等于 text field 的 text ——如果超长的话,我们不会进行赋值,此时 text field 的值将保持上一次输入时的值,本次输入被舍弃。
接下来看一下 testInputString 方法的实现:
func testInputString(_ input: String) {
state = validate(string: input)
let realResult = validate(string: nameTextField?.text)
nextButton?.isEnabled = realResult == .valid
}
validate 方法验证一个字符串是否有效,返回结果时一个 ValidateResult 枚举。然后用 updateUI 去更新 UI(这里之前定义的弱引用属性中的 3 个将派上用场)。但是 nextButton 需要一些特别的处理,因为它的状态是根据 text field 的 text 值而定的,而非前面的 newText。所以实际上我们要校验两次,一次是 newText,更新除 nextButton 之外的 UI,一次是 text field 的 text,更新 nextButton 的 UI。
updateUI 负责更新除 nextButton 之外的 UI:
public func updateUI(validationResult: ValidationResult){
let red = textRed
switch validationResult {
case .error(let underMin, let exceedLen, let specialChar):
nameTextField?.textColor = red
bottomLine?.backgroundColor = red
var message = ""
if exceedLen {
message.append("exceeds max length limit")
message.append("\n")
}else if underMin {
message.append(String(format: "less than min lenghth limit: %@ characters", "\(minCharacters)"))
message.append("\n")
}
if specialChar{
message.append("exists unallowed characters")
}
errorDisplayLabel?.text = message
errorDisplayLabel?.isHidden = false
case .badword:
errorDisplayLabel?.text = "can not be special words"
errorDisplayLabel?.isHidden = false
bottomLine?.backgroundColor = red
nameTextField?.textColor = red
nameTextField?.text = nameTextField?.text
case .blank:
errorDisplayLabel?.text = "can not be blank"
errorDisplayLabel?.isHidden = false
bottomLine?.backgroundColor = red
default:
errorDisplayLabel?.text = nil
nameTextField?.textColor = .black
nameTextField?.text = nameTextField?.text
nextButton?.isEnabled = validationResult == .valid
bottomLine?.backgroundColor = UIColor.hexColor(hex: "c5c5c5")
}
}
内容虽然有点多,但不难理解,就是根据对 newText 的校验结果进行不同的展示。
核心的内容还是 validate 方法,它对字符串进行校验:
func validate(string input: String? ) -> ValidationResult {
guard let input = input, !input.isEmpty else{
return .empty
}
guard !input.isBlank else{
return .blank
}
let exceedMaxLength = input.count > maxCharacters
let underMinLength = input.count < minCharacters
var validatedString = input
if exceedMaxLength { // if input's length exceed limit, trim it to limit.
validatedString = String(input[...(maxCharacters-1)])
}
let hasSpecialCharacter = !validatedString.isValidFirstName
guard !exceedMaxLength && !hasSpecialCharacter && !underMinLength else {
return .error(underMinLength,exceedMaxLength, hasSpecialCharacter)
}
return .valid
}
首先对空串和空白字符进行检查,然后对最长字符限制和最短字符限制进行检查,最后进行正则校验,看有没有特殊字符。如果都没有,返回 .valid。
正则校验使用的是 String 扩展:
extension String {
// MARK: regex expression
// * any Letters and Numbers (in any language) are allowed
// * the following characters are allowed: ; !, ?, &, /, -, _, ', #, ., ,,
private static let firstName = "^[\\p{L}\\p{N}!\\?&\\/ _'#\\.,;-]+$"
// Determine if string is a valid first name.
var isValidFirstName: Bool {
let predicate = NSPredicate(format: "SELF MATCHES %@", type(of:self).firstName)
return predicate.evaluate(with: self)
}
}
就是正常的的正则校验而已,没有特别的地方。
我们通过故事板来使用 NameFieldValidator。打开 main.storyboard,你可以看到画布中包含了4 个 UI 组件:
它们分别链接到 ViewController.swift 中的几个 IBOutlet:
@IBOutlet weak var continueButton: UIButton!
@IBOutlet weak var bottomLine: UIView!
@IBOutlet weak var textField: UITextField!
@IBOutlet weak var errorDisplayLabel: UILabel!
在 viewDidLoad 方法中:
private var nameValidator: NameTextFieldValidator?
override func viewDidLoad() {
super.viewDidLoad()
nameValidator = NameTextFieldValidator(nameTextField: textField, bottomLine: bottomLine, errorDisplayLabel: errorDisplayLabel, nextButton: continueButton)
nameValidator?.setTextInitially("your name")
nameValidator?.maxCharacters = 10
nameValidator?.minCharacters = 4
}
setTextInitially 方法调用一句有点特别,因为我们需要考虑 textField.text 可能存在默认字符串值的情况,那样当 app 一启动时有可能 textField 中会存在非法的字符串,而我们的 NameFieldValidator 却没有任何错误提示。因此我们用 setTextInitially 方法替代直接设置 textField.text 的值,这样保证 text field 中出现的内容都是经过校验的:
func setTextInitially(_ string: String?) {
if let name = string {
nameTextField?.text = name
self.testInputString(name)
}else {
nextButton?.isEnabled = !string.isNilOrBlank
}
}
关于 badword,这需要 API 配合,出于演示目的,我们用延迟 1 秒来模拟一个 API 异步调用:
fileprivate func checkBadword(name: String?, callback: @escaping (Bool)->()) {
// simulate network request
DispatchQueue.main.asyncAfter(deadline: .now()+1) {
callback(name == "fool")
}
}
当 API 检测到提交的字符串等于 fool 时,返回 true,表示这是一个 badword,否则返回 false。然后在故事版中为 continueButton 创建一个 IBAction 链接并实现方法代码:
@IBAction func continueAction(_ sender: Any) {
checkBadword(name: textField.text) { [weak self] isBadword in
if isBadword {
self?.nameValidator?.state = .badword
}
}
}
我们检测 API 返回的 Bool 值,如果是 badword,我们将 NameValidator 状态改为 .badword。
在一些简单场景中,我们的 UI 未必有多复杂,可能就一个 UITextField 和一个 UIButton 足矣,这种情况下 NameFieldValidator 仍然可以使用,你可以调用两个参数的 init 方法:
public init(nameTextField:UITextField, nextButton: UIButton) {
super.init()
self.nameTextField = nameTextField
self.nextButton = nextButton
nameTextField.delegate = self
}
其它缺失的弱引用属性将自动置为 nil。这样当校验器的状态发生改变,只有 text field 和 nextButton 的 UI 会得到更新。最简单的init版本,是只有一个参数的版本:
public init(nameTextField:UITextField) {
super.init()
self.nameTextField = nameTextField
nameTextField.delegate = self
}
NameFieldValidator 与 UI 绑定的特性,使得它的复用性非常一般。但通过对本教程的学习,你可以在此基础上轻易扩展出自己的输入校验类。