• 如何对用户输入进行校验


    对用户输入进行校验是非常重要,我们无法预知用户行为。当你要求用户输入信息时,用户很可能输入了不正确的格式,而一旦我们将这些错误格式的数据发送到 API,往往会导致不可预期的后果,甚至程序崩溃。因此理论上只要是用户输入,我们都必须进行校验。

    但这不是一件简单的工作。我们不仅仅需要考虑正则校验,同时需要遵循响应式原则,尽早在用户输入的过程中发现一切错误,越早越好。仅仅在用户输入完成后才进行正则校验,不是一种良好体验。

    打开我们的示例工程,build& run,你可以真实地看一下一个良好体验的用户输入校验是什么样子:

    • 首先,它是响应式的,用户每输入一个字符都会立即得到校验并在 UI 上体现。
    • 其次,丰富的 UI 表现,给予用户良好的视觉反馈。无论检测到什么错误,都会以多种方式加以展现,比如文字颜色变化、下划线样式变化、按钮的失效/生效,以及多行的文字提示。
    • 禁止无效输入。当用户输入的字符达到最大长度限制,不会允许用户继续输入。

    接下来,我们演示这将如何实现。

    NameFieldValidator

    假设我们将对用户名进行校验,我们用一个单独的类实现这个逻辑。新建 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
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    该枚举用于代表校验结果。结果可能有几种:

    • error(Bool, Bool, Bool) 用户输入的内容中存在错误,这又可能包含 3 种情况:字符串太长,超出最大长度限制;字符串太短,小于最小长度限制;字符串中包含不被允许的字符。注意3 种情况可能存在1种,也可能兼而有之,我们用 3 个关联类型(Bool)表示它们。
    • emtpy,用户根本没有输入。
    • valid,用户输入有效。
    • badword,用户输入了禁止使用的词语(黑名单)。这需要后端校验。前端只是根据后端校验结果进行 UI 展示。
    • blank,用户输入的字符是不可见字符,比如一个或多个空格。

    接下来定义属性:

        var maxCharacters = 13
        var minCharacters = 2
        var textRed = UIColor.hexColor(hex: "d23939")
        var state: ValidationResult = .empty {
            didSet {
                updateUI(validationResult: state)
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    分别是:最大字符限制、最小字符限制、错误发生时 text field 的文字颜色(偏红色),校验结果。

    其中 state 带有一个属性监视器,一旦 state 被改变,将自动调用 updateUI 方法。

    以及几个弱引用属性:

        weak var bottomLine: UIView?
        weak var nextButton: UIButton?
        weak var errorDisplayLabel: UILabel?
        weak var nameTextField: UITextField?
    
    • 1
    • 2
    • 3
    • 4

    这些属性分别引用 View Controller 中的一些 UI 组件,因为我们会根据校验的结果刷新这些组件。它们分别是:

    • nameTextField,文本框,用户输入的地方。
    • bottomLine,一条黑细线,位于文本框的下方。
    • errorDisplayLabel,一个 Label,位于文本框下方,用于显示不同的错误信息。
    • nextButton,一个按钮,用于确认/提交用户输入的数据。

    然后是 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
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    同时将 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
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    这个方法拦截 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
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    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")
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37

    内容虽然有点多,但不难理解,就是根据对 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
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    首先对空串和空白字符进行检查,然后对最长字符限制和最短字符限制进行检查,最后进行正则校验,看有没有特殊字符。如果都没有,返回 .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)
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    就是正常的的正则校验而已,没有特别的地方。

    使用 NameFieldValidator

    我们通过故事板来使用 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!
    
    • 1
    • 2
    • 3
    • 4

    在 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
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    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
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    badword

    关于 badword,这需要 API 配合,出于演示目的,我们用延迟 1 秒来模拟一个 API 异步调用:

    fileprivate func checkBadword(name: String?, callback: @escaping (Bool)->()) {
            // simulate network request
            DispatchQueue.main.asyncAfter(deadline: .now()+1) {
                callback(name == "fool")
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    当 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
                }
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    我们检测 API 返回的 Bool 值,如果是 badword,我们将 NameValidator 状态改为 .badword。

    更多 init

    在一些简单场景中,我们的 UI 未必有多复杂,可能就一个 UITextField 和一个 UIButton 足矣,这种情况下 NameFieldValidator 仍然可以使用,你可以调用两个参数的 init 方法:

        public init(nameTextField:UITextField, nextButton: UIButton) {
            super.init()
            self.nameTextField = nameTextField
            self.nextButton = nextButton
            nameTextField.delegate = self
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    其它缺失的弱引用属性将自动置为 nil。这样当校验器的状态发生改变,只有 text field 和 nextButton 的 UI 会得到更新。最简单的init版本,是只有一个参数的版本:

        public init(nameTextField:UITextField) {
            super.init()
            self.nameTextField = nameTextField
            nameTextField.delegate = self
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    复用性

    NameFieldValidator 与 UI 绑定的特性,使得它的复用性非常一般。但通过对本教程的学习,你可以在此基础上轻易扩展出自己的输入校验类。

  • 相关阅读:
    vue项目created()被调用多次的坑
    排序算法—
    国庆活动征文 | 庆国庆,作几首打油诗在此
    【C++】C++11 ——— 可变参数模板
    HDLBits: 在线学习 SystemVerilog(六)-Problem 24-27
    ModelCheckpoint
    CentOS7服务器用U盘装centos7系统报错解决方案
    LLM学习笔记-4
    Spring Data JPA渐进式学习--如何自定义查询方法呢?
    数据可视化、BI和数字孪生软件:用途和特点对比
  • 原文地址:https://blog.csdn.net/kmyhy/article/details/126103757