Demo背景
表单验证也是一个频次很高的交互场景,一般套路是监测用户输入的邮箱格式是否正确,密码是否有值,如果邮箱格式正确并且有输入密码的情况下,登录按钮的状态才变成可交互状态,否则就一直是不可点击的状态。通常会用Notification监测文本框的输入,每次有值改变,就去调用相对应的验证方法,所有的方法都走一遍后输出一个按钮是否可点击的bool值,这么做可以,但总觉得不是很优雅,于是尝试用Combine来实现一版,具体的交互如下:
Combine
Combine是iOS13上出来的一个sdk,江湖上号称是响应式函数编程在iOS中的一种实现。有两个重要的角色:publisher和subscriber。publisher用来expose值,官方文档说是 for processing values over time。应该是说可以在整个时间维度上随时抛出有变化的值,是一个流(stream)的概念。subscriber被用来接收publisher抛出的值。
具体步骤
- UI层面简单用storyboard布局,email、password两个文本框,一个登录的button和一个默认隐藏用来显示登录状态的label
- 创建一个LoginViewModel,统一处理要监听的属性和Combine中一些api的调用以及一些逻辑代码(比如邮箱格式是否正确,登录按钮是否可用等)
- 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
AnyCancellableinstance
- 其实内部是keypath assing subscription。是我们创建接收publisher并设置状态到具体的UIKit上的subscription的一个方法,返回值是An
- sink:viewModel.$state.sink 返回值也是AnyCancellable,主要用来输出值的一个subscription,根据不同的输出值的状态更新view
引用
- Apple’s Combine documentation
- Form Validation in UIKit Made Easy With Combine
- Getting Started With Combine