1

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.

  • There is nothing tracking the phone number text at the moment
  • I do not want to disable the submit button until the form is valid as seen in examples all over.

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)

    }
morcutt
  • 3,739
  • 8
  • 30
  • 47

1 Answers1

1

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.)

Daniel T.
  • 32,821
  • 6
  • 50
  • 72
  • 2
    The whole `.filter { $0 != nil }.map { $0! }` dance is so common, that most people/libraries have written an operator for it. It is usually called something like `unwrap()` or `filterNil()`. – Daniel T. Jan 12 '19 at 20:03
  • Also, looking at my sample app might help you: https://github.com/dtartaglia/RxEarthquake – Daniel T. Jan 12 '19 at 20:07
  • Thanks for the help! I got something to work but not completely sure it is the best approach or not. It is updated in the question. – morcutt Jan 12 '19 at 23:55
  • I'm having a hard time seeing the major benefits of MVVM to a well organized MVC project. While this is a bit like comparing apples to oranges, use of PromiseKit, closures, etc w/ MVC goes a long way without confusing more jr devs that might join a project. There seem to be so many side effects with RxSwift. – morcutt Jan 12 '19 at 23:56
  • You are missing a critical part in your updated code. The `materialize()` operator on the network request. An error from the request will shut down the entire chain. Also, not critical but I think you have a bunch of redundant `.asObservable()`s in your code. Try removing them one at a time and see if your code still compiles. – Daniel T. Jan 13 '19 at 00:03
  • The major benefit of well structured MVVM code compared to MVC code is easier testability (you can test the logic without dealing with UIKit at all,) and easy reuse of view controllers in alternate contexts (just pass in a view model with different functionality.) You are loosing the latter benefit because of the way you have your code structured. – Daniel T. Jan 13 '19 at 00:04
  • 1
    Removed all of the `.asObservable()`! The network request returns an observable of https://github.com/Alamofire/Alamofire/blob/master/Source/Result.swift#L34 so it shouldn't ever throw an error. – morcutt Jan 13 '19 at 00:10
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/186611/discussion-between-morcutt-and-daniel-t). – morcutt Jan 13 '19 at 00:13