morcutt
morcutt

Reputation: 3749

RxSwift MVVM Validate Form on Button Submit then Make API Request

I'm new to RxSwift and attempting to do as the title states with an MVVM input output approach.

I can't figure out the best approach to do the following.

  1. Validate the phoneNumberTextField values when submitButton is tapped
  2. Stop the Alamofire Request from being submitted if phoneNumberTextField is invalid and throw a client side error
  3. Show a display indicator when loading takes place. This is the least important right now

A few things to note.

Here is my view controller

import UIKit
import RxSwift
import RxCocoa

class SplashViewController: BaseViewController {

    // MARK: – View Variables

    @IBOutlet weak var phoneNumberTextField: UITextField!
    @IBOutlet weak var phoneNumberBackgroundView: UIView!
    @IBOutlet weak var submitButton: BaseButton!
    @IBOutlet weak var scrollView: UIScrollView!
    @IBOutlet weak var separatorView: UIView!
    @IBOutlet weak var countryCodeButton: UIButton!
    @IBOutlet weak var parentVerticalStackView: UIStackView!

    // MARK: – View Model & RxSwift Setup

    private let disposeBag = DisposeBag()
    private let viewModel: SplashMVVM = SplashMVVM()

    // MARK: – View lifecycle

    override func viewDidLoad() {
        super.viewDidLoad()

        // RxSwift handling
        setupViewModelBinding()
        setupCallbacks()

    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        navigationController?.setNavigationBarHidden(true, animated: true)
    }

    // MARK: – RxSwift Handling

    private func setupViewModelBinding() {

        submitButton.rx.controlEvent(.touchUpInside)
            .bind(to: viewModel.input.submit)
            .disposed(by: disposeBag)

    }

    private func setupCallbacks() {

        viewModel.output.success.asObservable()
            .filter { $0 != nil }
            .observeOn(MainScheduler())
            .subscribe({ _ in
                self.pushVerifyPhoneNumberViewController()
            })
            .disposed(by: disposeBag)

        viewModel.output.error.asObservable()
            .filter { $0 != nil }
            .observeOn(MainScheduler())
            .subscribe({ _ in
                SwiftMessages.show(.error, message: "There was an error. Please try again.")
            })
            .disposed(by: disposeBag)

    }

    // MARK: – Navigation

    func pushVerifyPhoneNumberViewController() {

        let viewController = VerifyPhoneNumberViewController.fromStoryboard("Authentication")

        self.navigationController?.pushViewController(viewController, animated: true)

    }

}

Here is my view model.

import Foundation
import RxSwift
import RxCocoa
import Alamofire

final class SplashMVVM: InputOutputModelType {


let input: SplashMVVM.Input
let output: SplashMVVM.Output

var submitSubject = PublishSubject<Void>()

struct Input {
    let submit: AnyObserver<Void>
}

struct Output {
    let success: Observable<VerifyMobilePhone?>
    let error: Observable<Error?>
}    

init() {

    input = Input(submit: submitSubject.asObserver())

    let request = Alamofire.request(VerifyMobileRouter.post("+16306996540")).responseDecodableRx(VerifyMobilePhone.self)

    let requestData = submitSubject.flatMapLatest {
        request
    }

    let success = requestData.map { $0.value ?? nil }

    let error = requestData.map { $0.error ?? nil }

    output = Output(
        success: success,
        error: error
    )

}

}

Here is what I came up with.

final class SplashMVVM: InputOutputModelType {

let input: SplashMVVM.Input
let output: SplashMVVM.Output

var submitSubject = PublishSubject<Void>()
var phoneNumberSubject = PublishSubject<String>()

struct Input {
    let phoneNumber: AnyObserver<String>
    let submit: AnyObserver<Void>
}

struct Output {
    let validationError: Observable<String>
    let success: Observable<VerifyMobilePhone>
    let error: Observable<Error>
}

init() {

    input = Input(phoneNumber: phoneNumberSubject.asObserver(), submit: submitSubject.asObserver())

    let request = submitSubject.asObservable().withLatestFrom(phoneNumberSubject.asObservable()).filter {
        $0.isValidPhoneNumber(region: "US")
    }.flatMap { number in
        Alamofire.request(VerifyMobileRouter.post(number)).responseDecodableRx(VerifyMobilePhone.self)
    }.share()

    let validationError = submitSubject.asObservable().withLatestFrom(phoneNumberSubject.asObservable()).filter {
        !$0.isValidPhoneNumber(region: "US")
    }.map { _ in
        "This phone number is invalid"
    }

    let success = request.filter { $0.isSuccess }.map { $0.value! }

    let error = request.filter { $0.isFailure }.map { $0.error! }

    output = Output(
        validationError: validationError,
        success: success,
        error: error
    )

}

}

View controller changes…

   private func setupViewModelBinding() {
        submitButton.rx.controlEvent(.touchUpInside).bind(to: viewModel.input.submit).disposed(by: disposeBag)
        phoneNumberTextField.rx.text.orEmpty.bind(to: viewModel.input.phoneNumber).disposed(by: disposeBag)
    }

    private func setupCallbacks() {

        viewModel.output.validationError.bind { string in
            SwiftMessages.show(.error, message: string)
        }.disposed(by: disposeBag)

        viewModel.output.success.bind { verifyMobilePhone in
            self.pushVerifyPhoneNumberViewController()
        }.disposed(by: disposeBag)

        viewModel.output.error.bind { error in
            SwiftMessages.show(.error, message: "There was an error. Please try again.")
        }.disposed(by: disposeBag)

    }

Upvotes: 1

Views: 2638

Answers (1)

Daniel T.
Daniel T.

Reputation: 33967

You are close, you're just missing the phone number text as input into your view model.

struct SplashInput {
    let phoneNumber: Observable<String>
    let submit: Observable<Void>
}

struct SplashOutput {
    let invalidInput: Observable<Void>
    let success: Observable<VerifyMobilePhone>
    let error: Observable<Error>
}

extension SplashOutput {
    init(_ input: SplashInput) {
        let request: Observable<Event<VerifyMobilePhone>> = input.submit.withLatestFrom(input.phoneNumber)
            .filter { $0.isValidPhoneNumber }
            .flatMap { number in
                Alamofire.request(VerifyMobileRouter.post(number)).responseDecodableRx(VerifyMobilePhone.self)
                    .materialize()
            }
            .share()

        invalidInput = input.submit.withLatestFrom(input.phoneNumber)
            .filter { $0.isValidPhoneNumber == false }

        success = request
            .map { $0.element }
            .filter { $0 != nil }
            .map { $0! }

        error = request
            .map { $0.error }
            .filter { $0 != nil }
            .map { $0! }
    }
}

Your SplashViewController would have:

override func viewDidLoad() {
    super.viewDidLoad()
    let input = SplashInput(
        phoneNumber: phoneNumberTextField.rx.text.orEmpty.asObservable(),
        submit: submitButton.rx.tap.asObservable()
    )
    let viewModel = SplashOutput(input)
    viewModel.invalidInput
        .bind {
            SwiftMessages.show(.invalid, message: "You entered an invalid number. Please try again.")
       }
        .disposed(by: bag)

    viewModel.success
        .bind { [unowned self] verifyMobilePhone in
            self.pushVerifyPhoneNumberViewController(verifyMobilePhone)
        }
        .disposed(by: bag)

    viewModel.error
        .bind { error in
            SwiftMessages.show(.error(error), message: "There was an error. Please try again.")
        }
 }

(I took some liberties with what you already have written, but the above should make sense.)

Upvotes: 1

Related Questions