Iganinのブログ

日頃の開発で学んだ知見を中心に記事を書いています。

【iOS】アプリクライアント側での文字入力のValidation機構について(UITextField, UITextViewなど)

はじめに

サービスで使用するEmailアドレスやPasswordなどをアプリから入力する際に、アプリ側でValidationをかけることがあります。 そのような際に行う対応として直感的には以下のようにするかと思います。

extension ViewController: UITextFieldDelegate {
    func textFieldDidEndEditing(_ textField: UITextField) {
        if textField === textField {
            let text = textField.text
            
            if text?.isEmpty == true {
                errorLabel.text = "文字を入力してください。"
            } else if text?.contains("@") == false {
                errorLabel.text = "正しいメールアドレスの形式ではありません。"
            }
        }
    }
}

ただ、この方法では以下のような問題が発生します。

  • 同様なバリデーション処理が様々な箇所に分散してしまう。結果として修正漏れなどによる不具合が発生する
  • コードが冗長となり可読性が下がる

本記事では、Compositeパターンを適用することでValidation処理を構造的に作成し、 上記であげた問題に対処する方法を記載します。

環境設定

以下の環境を使用しています。

  • Xcode11.0.0
  • Swift 5.1.0

方法

Validator Protocolの作成

Validation用のエラーとValidation結果およびValidatorが準拠するべきProtocolを定義します。 ValidationResultはSwift.Resultを使用してもよかったのですが、Resultに指定した型Tの処理の部分が 冗長になるかと考えこの方法をとりました。

ValidationのErrorはLocalizedErrorに準拠することで errorDescription: String?を実装すると、 error.localizedDescriptionでエラーメッセージを表示させることができます。

Validatorはvalidate対象を引数とし、結果を返却するメソッドを持つProtocolです。

/// Validation結果
///
/// - valid: 有効
/// - invalid: 無効
enum ValidationResult {
    case valid
    case invalid(ValidationError)
}

/// Validation結果のエラーに使用
/// LocalizedErrorを使用することで .localizedDescriptionでエラーメッセージを表示可能
protocol ValidationErrorProtocol: LocalizedError { }

/// 文字列のValidationに関する責務
protocol Validator {
    func validate(_ value: String) -> ValidationResult
}

Validatorを複数持つCompositeValidator Protocolを作成します。 Composite ValidatorはvalidatorsとしてValidatorの配列を持ち、validateメソッド実行時に それぞれのValidatorのvalidateメソッドを順次実行、その返却値を元にcomposite validatorの validate実行結果を返却します。 UITextFieldのtextに対して適応するValidatorは基本的にComposite Validatorを使用します。

/// 文字列のValidationに関する責務。複数のValidatorを保持する
/// validatorsに格納されている順にValidationをかけていき、
/// 該当したエラーを返却する。
protocol CompositeValidator: Validator {
    var validators: [Validator] { get }
    func validate(_ value: String) -> ValidationResult
}

 extension CompositeValidator {
    
    func validate(_ value: String) -> [ValidationResult] {
        return validators.map { $0.validate(value) }
    }

     func validate(_ value: String) -> ValidationResult {
        let results: [ValidationResult] = validate(value)
        
        let errors = results.filter { result -> Bool in
            switch result {
            case .valid: return false
            case .invalid: return true
            }
        }
        return errors.first ?? .valid
    }
    
}

Validatorの実装の具体例

上記で記載したValidatorプロトコル、 Composite Validatorプロトコルを元に実際の実装をみていきます。 今回は試しに名前の入力値に対するValidatorを検討します。以下を仕様とします。

  • 文字入力必須
  • 1文字以上、8文字以下
  • 英語大文字小文字のみ入力可

それぞれのケースのエラーをまずは作成します。

enum ValidationError: ValidationErrorProtocol {

    case empty
    case length(min: Int, max: Int)
    case nameFormat
    
    var errorDescription: String? {
        switch self {
        case .empty: return "文字を入力してください"
        case .length(let min, let max): return "\(min)文字以上\(max)文字以下で入力してください。"
        case .nameFormat: return "英語大文字小文字のみで入力してください。"
        }
    }
}

次にそれぞれのケースのValidatorを作成します。

文字入力必須
struct EmptyValidator: Validator {
    
    func validate(_ value: String) -> ValidationResult {
        if value.isEmpty == true {
            return .invalid(.empty)
        } else {
            return .valid
        }
    }
}
1文字以上、8文字以下
struct LengthValidator: Validator {
    let min: Int
    let max: Int
    
    func validate(_ value: String) -> ValidationResult {
        if value.count >= min && value.count <= max {
            return .valid
        } else {
            return .invalid(.length(min: min, max: max))
        }
    }
}
英語大文字小文字のみ入力可
struct NameFormatValidator: Validator {
    
    let regExpression = "^[a-zA-Z]+$"
    
    func validate(_ value: String) -> ValidationResult {
        let predicate = NSPredicate(format: "SELF MATCHES %@", regExpression)
        let result = predicate.evaluate(with: value)
        
        switch result {
        case true: return .valid
        case false: return .invalid(.nameFormat)
        }
    }
}

最後に今まで作成したValidatorを組み合わせたCompositeValidatorを作成します。 これで名前のTextFieldの入力値を検査するValidatorが作成できました。

struct NameValidator: CompositeValidator {
    var validators: [Validator] = [
        EmptyValidator(),
        LengthValidator(min: 1, max: 8),
        NameFormatValidator()
    ]
}

実際の使い方を見てみます。以下のようになります。 Validationの具体的な実装はNameValidator内部に閉じ込められ、同様の処理が拡散することを防いでいます。 Validationの内容を変えたい場合はNameValidatorを変更するか、より下層のここのValidatorを変更すれば良いため修正漏れも防げます。 また、今後新しいComposite Validatorを作成したい場合は今まで作成したValidatorを有効に利用することもできます。 if else などが連続してかかれることもないためコードの冗長性もずいぶん改善され可読性が上がりました。

extension ViewController: UITextFieldDelegate {
    
    func textFieldDidEndEditing(_ textField: UITextField) {
        if textField === textField {
            let nameValidator = NameValidator()
            let result: ValidationResult = nameValidator.validate(textField.text ?? "")
            
            switch result {
            case .valid: break
            case .invalid(let error): print(error.localizedDescription)
            }
        }
    }
}

UITextFieldのExtensionとして定義する

先ほどまでのやり方でかなり改善されましたが、さらに簡略化する方法を考えてみます。

一つの方法としてUITextFieldのExtensionメソッドとしてvalidateを作成する方法があります。 Validateの種別(名前、メールアドレス、パスワード)を定義して、その種別に応じたvalidateを実行するメソッドを作成することで実行時の実装はかなり簡略化できます。

extension UITextField {
    
    // Validateの種類を定義する。
    // name, email, passwordなど。
    // パスワードの確認など比較対象がある場合は、
    //case confirmEmail(origin: String)のように比較対象を入れられるようにすると良い
    // validatorにも比較対象を入れられるようにすることで、そのようなValidationも実装可能
    enum ValidateType {
        case name
    }
    
    // UITextFieldのValidationメソッド
    // Validationの結果をUI上に反映させたい場合は反映先のUILabelを引数として渡すとスッキリする(この実装が綺麗かどうかは要検討)
    @discardableResult
    func validate(type: ValidateType, errorMessageOn label: UILabel? = nil) -> ValidationResult {
        
        let result: ValidationResult
        
        // typeごとに使用するValidatorの初期化やvalidateの実行を行う
        switch type {
        case .name:
            result = NameValidator().validate(text ?? "")
        }
        
        // typeによらずErrorLabelへの反映は共通処理のためここで反映させる
        switch result {
        case .valid: label?.text = nil
        case .invalid(let error): label?.text = error.localizedDescription
        }
        
        // Validate結果を返す
        return result
    }
}

上記のExtensionメソッドを使用することで、使用側の実装は下記のようにかなりシンプルになりました。

extension ViewController: UITextFieldDelegate {
    func textFieldDidEndEditing(_ textField: UITextField) {
        if textField === textField {
            textField.validate(type: .name, errorMessageOn: errorLabel)
        }
    }
}

まとめ

ユーザーの文字入力をアプリ側でValidateする際に、それらの仕組みをCompositeパターンを使用して構造的に作成する方法を記載しました。本記事の方法を使用することで、修正漏れが少なく可読性が高い保守しやすいValidationの仕組みを作成することができるかと思います。

参考