Ever since I've started using MVVM architecture with Reactive programming, I've been searching for a similar architecture that will fit my needs and more appealing. I found one and it's from Kickstarter, albeit I did not adapt it fully, just the concept of it.
Below is a sample of what I'm using right now. I've created a simple sign in form app that focuses on the validation of input data from the user.
A note by the way, I'm using RxSwift for the reactive part and SnapKit for constraints. Let's start!
// Enum for validity check
enum TextFieldStatus {
case valid, notValid
}
import RxCocoa
import RxSwift
protocol SigninViewModelInputs {
func didChange(email: String)
func didChange(password: String)
}
protocol SigninViewModelOutputs {
var isEmailValid: PublishRelay<TextFieldStatus> { get }
var isPasswordValid: PublishRelay<TextFieldStatus> { get }
var emailNotValidErr: PublishRelay<String> { get }
var passwordNotValidErr: PublishRelay<String> { get }
}
protocol SigninViewModelTypes {
var inputs: SigninViewModelInputs { get }
var outputs: SigninViewModelOutputs { get }
}
Breakdown:
As you can see I have three protocols:
- Inputs - mainly the actions from the view controller or wherever you need this. As you can see, isEmailValid and isPasswordValid are not boolean values but instead I created an enum to identify its validity. Why is that? You'll see later.
- Outputs - the values being exposed outside of the view model.
- Types - the wrapper for inputs and outputs. It brings sense of path and it's helpful with controlling accessibility from view model, you'll see later why we need this.
Next is the view model implementation
SigninViewModel.swift
class SigninViewModel: SigninViewModelTypes, SigninViewModelOutputs, SigninViewModelInputs {
var inputs: SigninViewModelInputs { return self }
var outputs: SigninViewModelOutputs { return self }
var isEmailValid: PublishRelay<TextFieldStatus> = PublishRelay()
var isPasswordValid: PublishRelay<TextFieldStatus> = PublishRelay()
var emailNotValidErr: PublishRelay<String> = PublishRelay()
var passwordNotValidErr: PublishRelay<String> = PublishRelay()
private var disposeBag: DisposeBag = DisposeBag()
private var didChangeEmailProperty = PublishSubject<String>()
func didChange(email: String) {
didChangeEmailProperty.onNext(email)
}
private var didChangePasswordProperty = PublishSubject<String>()
func didChange(password: String) {
didChangePasswordProperty.onNext(password)
}
init() {
didChangeEmailProperty.map(isValidEmail(_:)).bind(to: isEmailValid).disposed(by: disposeBag)
isEmailValid.filter { $0 == .notValid }
.map { _ in "Entered email is not valid." }
.bind(to: emailNotValidErr)
.disposed(by: disposeBag)
didChangePasswordProperty
.map { $0.count > 5 && $0.count < 21 ? .valid : .notValid }
.bind(to: isPasswordValid)
.disposed(by: disposeBag)
isPasswordValid.filter { $0 == .notValid }
.map { _ in "Password has to be from 6 to 20 characters long." }
.bind(to: passwordNotValidErr)
.disposed(by: disposeBag)
isEmailValid.filter { $0 == .valid }
.map { _ in "" }
.bind(to: emailNotValidErr)
.disposed(by: disposeBag)
isPasswordValid.filter { $0 == .valid }
.map { _ in "" }
.bind(to: passwordNotValidErr)
.disposed(by: disposeBag)
}
private func isValidEmail(_ email: String) -> TextFieldStatus {
let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
let emailPred = NSPredicate(format:"SELF MATCHES %@", emailRegEx)
return emailPred.evaluate(with: email) ? .valid : .notValid
}
}
Breakdown:
private var didChangeEmailProperty = PublishSubject<String>()
func didChange(email: String) {
didChangeEmailProperty.onNext(email)
}
private var didChangePasswordProperty = PublishSubject<String>()
func didChange(password: String) {
didChangePasswordProperty.onNext(password)
}
As you can see I created an internal property per input function, so that we can observe it in the init()
rather than directly validating it.
Let's now breakdown the bindings in the init()
Check input validity
didChangeEmailProperty.map(isValidEmail(_:)).bind(to: isEmailValid).disposed(by: disposeBag)
didChangePasswordProperty
.map { $0.count > 5 && $0.count < 21 ? .valid : .notValid }
.bind(to: isPasswordValid)
.disposed(by: disposeBag)
- This just checks the inputs for email and password and check if valid then binds it to isEmailValid and isPasswordValid
Return error message if not valid
isEmailValid.filter { $0 == .notValid }
.map { _ in "Entered email is not valid." }
.bind(to: emailNotValidErr)
.disposed(by: disposeBag)
isPasswordValid.filter { $0 == .notValid }
.map { _ in "Password has to be from 6 to 20 characters long." }
.bind(to: passwordNotValidErrMssg)
.disposed(by: disposeBag)
- Now that isEmailValid and isPasswordValid are triggered, each now has a value and I would like to return an error message if it is not valid.
Empty error message if it's valid
isEmailValid.filter { $0 == .valid }
.map { _ in "" }
.bind(to: emailNotValidErrMssg)
.disposed(by: disposeBag)
isPasswordValid.filter { $0 == .valid }
.map { _ in "" }
.bind(to: passwordNotValidErrMssg)
.disposed(by: disposeBag)
- Now we empty the error message if it's valid.
Now let's apply it to our view controller.
SigninViewController.swift
class SigninViewController: UIViewController {
var viewModel: SigninViewModelTypes
lazy var emailTextField: UITextField = UITextField()
lazy var emailErrLabel: UILabel = UILabel()
lazy var passwordTextField: UITextField = UITextField()
lazy var passwordErrLabel: UILabel = UILabel()
lazy var signinButton: UIButton = UIButton()
lazy var disposeBag = DisposeBag()
init(viewModel: SigninViewModelTypes) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func loadView() {
super.loadView()
view.backgroundColor = .white
setupScene()
}
override func viewDidLoad() {
super.viewDidLoad()
setupBindings()
}
private func setupBindings() {
emailTextField.rx.text.orEmpty.distinctUntilChanged()
.bind(onNext: viewModel.inputs.didChange(email:))
.disposed(by: disposeBag)
passwordTextField.rx.text.orEmpty.distinctUntilChanged()
.bind(onNext: viewModel.inputs.didChange(password:))
.disposed(by: disposeBag)
viewModel.outputs.isEmailValid.map { $0.borderColor }
.bind(to: self.emailTextField.rx.borderColor)
.disposed(by: disposeBag)
viewModel.outputs.isPasswordValid.map { $0.borderColor }
.bind(to: self.passwordTextField.rx.borderColor)
.disposed(by: disposeBag)
viewModel.outputs.emailNotValidErrMssg.bind(to: emailErrLabel.rx.text).disposed(by: disposeBag)
viewModel.outputs.passwordNotValidErrMssg.bind(to: passwordErrLabel.rx.text).disposed(by: disposeBag)
viewModel.outputs.emailNotValidErrMssg
.map { $0.isEmpty }
.bind(to: emailErrLabel.rx.isHidden)
.disposed(by: disposeBag)
viewModel.outputs.passwordNotValidErrMssg
.map { $0.isEmpty }
.bind(to: passwordErrLabel.rx.isHidden)
.disposed(by: disposeBag)
}
}
Now let's break that down.
- First I initialized the view model and the subviews of this view controller, including the DisposeBag.
- Now if you've noticed I didn't put
SigninViewModel
as the data type of my variableviewModel
, instead I used theSigninViewModelTypes
, why is that? If I've usedSigninViewModel
then I can access directly the variables within the class, which will bypass ourinputs
andoutputs
protocol which I want to use, so instead ofviewModel.inputs.someFunction()
I might accidentally useviewModel.someFunction()
which I want to avoid.
Let's skip the subview setup and focus on the bindings we have inside of setupBindings()
, let's now break that down.
Binding of from textField to viewModel input function
emailTextField.rx.text.orEmpty.distinctUntilChanged()
.bind(onNext: viewModel.inputs.didChange(email:))
.disposed(by: disposeBag)
passwordTextField.rx.text.orEmpty.distinctUntilChanged()
.bind(onNext: viewModel.inputs.didChange(password:))
.disposed(by: disposeBag)
Changing of textField's borderColor based of the entered email or password's validity
viewModel.outputs.isEmailValid.map { $0.borderColor }
.bind(to: emailTextField.rx.borderColor)
.disposed(by: disposeBag)
viewModel.outputs.isPasswordValid.map { $0.borderColor }
.bind(to: passwordTextField.rx.borderColor)
.disposed(by: disposeBag)
- Remember why I didn't use Bool and used an enum instead? This is why, I wanted to attach the borderColor to the state of textField's validity. Here's how I did it:
enum TextFieldStatus {
case valid, notValid
var borderColor: CGColor {
switch self {
case .valid: return UIColor.lightGray.cgColor
default: return UIColor.red.cgColor
}
}
}
- I've added a variable named
borderColor
and defined the cgColor based on the case. That's why we're able to map isPasswordValid to a cgColor as an example and bind it to the borderColor of the textField, but wait, if you're wondering how did I do that knowing thatborderColor
is not available as aBinder
in RxSwift. Well I created an extension and here's the code for it.
extension Reactive where Base: UITextField {
public var borderColor: Binder<CGColor> {
return Binder(base, binding: { textField, active in
textField.layer.borderColor = active
})
}
}
- Now I can directly bind the borderColor from the enum to the textField's borderColor.
Next I want to display the errors from the viewModel if ever the input data is not valid, here's how to do that:
viewModel.outputs.emailNotValidErrMssg.bind(to: emailErrLabel.rx.text).disposed(by: disposeBag)
viewModel.outputs.passwordNotValidErrMssg.bind(to: passwordErrLabel.rx.text).disposed(by: disposeBag)
Now our validation for the textField is done, here's how it looks like:
This kind of architecture helped me with separating mutations and accessible variables. Part 2 is in the making where I'll tackle how easy it is to do unit testing with this kind of approach. By the way here's the repository for this project.
Top comments (0)