Demo布景

表单验证也是一个频次很高的交互场景,一般套路是监测用户输入的邮箱格局是否正确,暗码是否有值,如果邮箱格局正确而且有输入暗码的情况下,登录按钮的状况才变成可交互状况,不然就一直是不可点击的状况。一般会用Notification监测文本框的输入,每次有值改动,就去调用相对应的验证办法,一切的办法都走一遍后输出一个按钮是否可点击的bool值,这么做能够,但总觉得不是很优雅,于是尝试用Combine来完成一版,详细的交互如下:

Combine 实现优雅的表单验证

Combine

Combine是iOS13上出来的一个sdk,江湖上号称是呼应式函数编程在iOS中的一种完成。有两个重要的人物:publisher和subscriber。publisher用来expose值,官方文档说是 for processing values over time。应该是说能够在整个时刻维度上随时抛出有变化的值,是一个流(stream)的概念。subscriber被用来接纳publisher抛出的值。

详细步骤

  1. UI层面简单用storyboard布局,email、password两个文本框,一个登录的button和一个默认躲藏用来显示登录状况的label
  2. 创建一个LoginViewModel,一致处理要监听的特点和Combine中一些api的调用以及一些逻辑代码(比方邮箱格局是否正确,登录按钮是否可用等)
  3. viewController中去observer,接纳特点值的变化,实时update UI

LoginViewModel

import Foundation
import Combine
class LoginViewModel: ObservableObject{
    // sec 1
    enum ViewState {
        case loading
        case success
        case failed
        case none
    }
    // sec 2
    @Published var email = ""
    @Published var password = ""
    @Published var state: ViewState = .none
    // sec 3
    var isValidUsernamePublisher: AnyPublisher<Bool, Never>{
        $email.map{ $0.isValidEmail}.eraseToAnyPublisher()
    }
    var isValidPasswordPublisher: AnyPublisher<Bool, Never>{
        $password.map{!$0.isEmpty}.eraseToAnyPublisher()
    }
    // sec 4
    var isSubmitEnabled: AnyPublisher<Bool, Never>{
        Publishers.CombineLatest(isValidUsernamePublisher, isValidPasswordPublisher).map{ $0 && $1 }.eraseToAnyPublisher()
    }
    // sec 5
    func submitLogin(){
        state = .loading
        DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)){[weak self] in
            guard let self = self else {return}
            if self.isCorrectLogin(){
                self.state = .success
            }else{
                self.state = .failed
            }
        }
    }
    // sec 6
    func isCorrectLogin() -> Bool{
        return email == "pgyjyl@163.com" && password == "123456"
    }
}
// sec 7
extension String{
    var isValidEmail: Bool{
        return NSPredicate(
            format: "SELF MATCHES %@", "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}"
        ).evaluate(with: self)
    }
}
  • sec1 定义ViewState,区别view的不同状况
  • sec2 LoginViewModel先要继承ObservableObject,才干有才能抛出值的改动,要监听改动的特点需要用特点包装器 @Published 润饰。这些被Published的特点只需产生变动就能实时驱动view状况的改动,达到updateUI的作用。
  • sec3 isValidUsernamePublisher和isValidPasswordPublisher便是对详细业务逻辑的处理了,详细逻辑在map回调里。
    • eraseToAnyPublisher:map的block里处理完业务逻辑,终究应该回来一个bool变量出来,eraseToAnyPublisher办法会包装真实的回来类型为一个通用的AnyPublisher<Bool, Never>类型,更好的被一切observe实时监听
  • sec4 CombineLatest:Combine sdk的信合api了,终究按钮是否可用的输出口是调用CombineLatest办法,传入若干个publisher,根据入参的不同回来不同的tuple,demo中会回来一个(string,string)的tuple,然后用map办法,把tuple转换成一个bool的表示
  • sec5 登录按钮的点击回调,更新state状况
  • sec6 匹配到写死的email和password 才算登录成功
  • sec7 String的extension办法,正则验证一个字符串是否符合email的规矩

ViewController的更新

class ViewController: UIViewController {
    var viewModel = LoginViewModel()
    var cancellables = Set<AnyCancellable>()
    @IBOutlet weak var passwordTextField: UITextField!
    @IBOutlet weak var emailTextField: UITextField!
    @IBOutlet weak var submitButton: UIButton!
    @IBOutlet weak var errorLabel: UILabel!
    override func viewDidLoad() {
        super.viewDidLoad()
        setupPublishers()
    }
    func setupPublishers(){
        NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: emailTextField)
            .map{($0.object as! UITextField).text ?? ""}
            .assign(to: \.email, on: viewModel)
            .store(in: &cancellables)
        NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: passwordTextField)
            .map{($0.object as! UITextField).text ?? ""}
            .assign(to: \.password, on: viewModel)
            .store(in: &cancellables)
        viewModel.isSubmitEnabled
            .assign(to: \.isEnabled, on: submitButton)
            .store(in: &cancellables)
        viewModel.$state.sink{[weak self] state in
            switch state{
            case .loading:
                self?.submitButton.isEnabled = false
                self?.submitButton.setTitle("Loading..", for: .normal)
                self?.hideError(true)
            case .success:
                self?.showResultScreen()
                self?.resetButton()
                self?.hideError(true)
            case .failed:
                self?.resetButton()
                self?.hideError(false)
            case .none:
                break
            }
        }.store(in: &cancellables)
    }
    @IBAction func onSubmit(_ sender: Any) {
        viewModel.submitLogin()
    }
    func resetButton(){
        submitButton.setTitle("Login", for: .normal)
        submitButton.isEnabled = true
    }
    func showResultScreen(){
        let resultVc = ResultViewController()
        navigationController?.pushViewController(resultVc, animated: true)
    }
    func hideError(_ isHidden: Bool){
        errorLabel.alpha = isHidden ? 0 : 1
    }
}
  • 首先声明一个LoginViewModel的特点 viewModel
  • AnyCancellable的set,让subscriber能够随时release一个publisher
  • setupPublishers:
    • 使用notification这个publisher监听email和password两个文本框的值改动,在assign中,文本框的值被更新到viewModel中要监听的特点上(@Published润饰的特点),而且observe被store在cancellables的set中,这是publisher的一个链式调用,本质上做了observe和publisher的绑定
  • isSubmitEnabled:将按钮是否可用的publisher assgin on到详细的button上
    • 其实内部是keypath assing subscription。是我们创建接纳publisher并设置状况到详细的UIKit上的subscription的一个办法,回来值是An AnyCancellable instance
  • sink:viewModel.$state.sink 回来值也是AnyCancellable,首要用来输出值的一个subscription,根据不同的输出值的状况更新view

引证

  • Apple’sCombinedocumentation
  • Form Validation in UIKit Made Easy With Combine
  • Getting Started With Combine