DEV Community

loading...

View model's I/O approach applying Reactive Programming

Jaime Jazareno III
・5 min read

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
}
Enter fullscreen mode Exit fullscreen mode
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 }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
  • 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)
Enter fullscreen mode Exit fullscreen mode
  • 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)
Enter fullscreen mode Exit fullscreen mode
  • 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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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 variable viewModel, instead I used the SigninViewModelTypes, why is that? If I've used SigninViewModel then I can access directly the variables within the class, which will bypass our inputs and outputs protocol which I want to use, so instead of viewModel.inputs.someFunction() I might accidentally use viewModel.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)
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode
  • 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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 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 that borderColor is not available as a Binder 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
        })
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 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)
Enter fullscreen mode Exit fullscreen mode

Now our validation for the textField is done, here's how it looks like:

Sign in validation

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.

Discussion (0)