RxSwift right way
Asked Answered
B

1

7

I'm trying to write a MVVM with RxSwift and comparing to what I was used to do in ReactiveCocoa for Objective-C it's been a little hard to write my service in the right way.

An exemple is a Login service.

With ReactiveCocoa (Objective-C) I code something like this:

// ViewController


// send textfield inputs to viewmodel 
RAC(self.viewModel, userNameValue) = self.fieldUser.rac_textSignal;
RAC(self.viewModel, userPassValue) = self.fieldPass.rac_textSignal;

// set button action
self.loginButton.rac_command = self.viewModel.loginCommand;

// subscribe to login signal
[[self.viewModel.loginResult deliverOnMainThread] subscribeNext:^(NSDictionary *result) {
    // implement
} error:^(NSError *error) {
    NSLog(@"error");
}];

and my viewModel should be like this:

// valid user name signal
self.isValidUserName = [[RACObserve(self, userNameValue)
                         map:^id(NSString *text) {
                             return @( text.length > 4 );
                         }] distinctUntilChanged];

// valid password signal
self.isValidPassword = [[RACObserve(self, userPassValue)
                         map:^id(NSString *text) {
                             return @( text.length > 3);
                         }] distinctUntilChanged];

// merge signal from user and pass
self.isValidForm = [RACSignal combineLatest:@[self.isValidUserName, self.isValidPassword]
                                           reduce:^id(NSNumber *user, NSNumber *pass){
                                               return @( [user boolValue] && [pass boolValue]);
                                           }];


// login button command
self.loginCommand = [[RACCommand alloc] initWithEnabled:self.isValidForm
                                            signalBlock:^RACSignal *(id input) {
                                                return [self executeLoginSignal];
                                            }];

now in RxSwift I've written the same as:

// ViewController

// initialize viewmodel with username and password bindings
    viewModel = LoginViewModel(withUserName: usernameTextfield.rx_text.asDriver(), password: passwordTextfield.rx_text.asDriver())

// subscribe to isCredentialsValid 'Signal' to assign button state
   viewModel.isCredentialsValid
        .driveNext { [weak self] valid in
            if let button = self?.signInButton {
                button.enabled = valid
            }
    }.addDisposableTo(disposeBag)

// signinbutton
    signInButton.rx_tap
        .withLatestFrom(viewModel.isCredentialsValid)
        .filter { $0 }
        .flatMapLatest { [unowned self] valid -> Observable<AutenticationStatus> in
            self.viewModel.login(self.usernameTextfield.text!, password: self.passwordTextfield.text!)
            .observeOn(SerialDispatchQueueScheduler(globalConcurrentQueueQOS: .Default))
        }
        .observeOn(MainScheduler.instance)
        .subscribeNext {
            print($0)
        }.addDisposableTo(disposeBag)

I'm changing the button state this way because I can't this to work:

viewModel.isCredentialsValid.drive(self.signInButton.rx_enabled).addDisposableTo(disposeBag)

and my viewModel

let isValidUser = username
    .distinctUntilChanged()
        .map { $0.characters.count > 3 }

    let isValidPass = password
    .distinctUntilChanged()
        .map { $0.characters.count > 2 }

    isCredentialsValid = Driver.combineLatest(isValidUser, isValidPass) { $0 && $1 }

and

func login(username: String, password: String) -> Observable<AutenticationStatus>
{
    return APIServer.sharedInstance.login(username, password: password)
}

I'm using Driver because it wrap some nice features like: catchErrorJustReturn(), but I really don't like the way I'm doing this:

1) I have to send username and password fields as a parameter to the viewModel (by the way, that's the easier to solve)

2 ) I don't like the way my viewController do all the work when login button is tapped, viewController doesn't need to know which service it should call to get login access, it's a viewModel job.

3 ) I can't access the stored value of username and password outside of a subscription.

Is there a different way to do this? how are you Rx'ers doing this kind of thing? Thanks a lot.

Brunelle answered 2/3, 2016 at 16:26 Comment(0)
O
8

I like to think about the View-Model in an Rx Application as a component that gets streams (Observables\Drivers) of input events (e.g. UI triggers such as button tap, table\collection view selection etc.), and dependencies such as APIService, Data Base service etc., to handle these events. In return it provides streams (Observables\Drivers) of values to be presented. ​ For example:

enum ServerResponse {
  case Failure(cause: String)
  case Success
}

protocol APIServerService {
  func authenticatedLogin(username username: String, password: String) -> Observable<ServerResponse>
}

protocol ValidationService {
  func validUsername(username: String) -> Bool
  func validPassword(password: String) -> Bool
}


struct LoginViewModel {

  private let disposeBag = DisposeBag()

  let isCredentialsValid: Driver<Bool>
  let loginResponse: Driver<ServerResponse>


  init(
    dependencies:(
      APIprovider: APIServerService,
      validator: ValidationService),
    input:(
      username:Driver<String>,
      password: Driver<String>,
      loginRequest: Driver<Void>)) {


    isCredentialsValid = Driver.combineLatest(input.username, input.password) { dependencies.validator.validUsername($0) && dependencies.validator.validPassword($1) }

    let usernameAndPassword = Driver.combineLatest(input.username, input.password) { ($0, $1) }

    loginResponse = input.loginRequest.withLatestFrom(usernameAndPassword).flatMapLatest { (username, password) in

      return dependencies.APIprovider.authenticatedLogin(username: username, password: password)
        .asDriver(onErrorJustReturn: ServerResponse.Failure(cause: "Network Error"))
    }
  }
}

And now your ViewController and Dependencies look something like this:

struct Validation: ValidationService {
  func validUsername(username: String) -> Bool {
    return username.characters.count > 4
  }

  func validPassword(password: String) -> Bool {
    return password.characters.count > 3
  }
}


struct APIServer: APIServerService {
  func authenticatedLogin(username username: String, password: String) -> Observable<ServerResponse> {
    return Observable.just(ServerResponse.Success)
  }
}

class LoginMVVMViewController: UIViewController {

  @IBOutlet weak var usernameTextField: UITextField!
  @IBOutlet weak var passwordTextField: UITextField!
  @IBOutlet weak var loginButton: UIButton!

  let loginRequestPublishSubject = PublishSubject<Void>()

  lazy var viewModel: LoginViewModel = {
    LoginViewModel(
      dependencies: (
        APIprovider: APIServer(),
        validator: Validation()
      ),
      input: (
        username: self.usernameTextField.rx_text.asDriver(),
        password: self.passwordTextField.rx_text.asDriver(),
        loginRequest: self.loginButton.rx_tap.asDriver()
      )
    )
  }()

  let disposeBag = DisposeBag()

  override func viewDidLoad() {
    super.viewDidLoad()

    viewModel.isCredentialsValid.drive(loginButton.rx_enabled).addDisposableTo(disposeBag)

    viewModel.loginResponse.driveNext { loginResponse in

      print(loginResponse)

    }.addDisposableTo(disposeBag)
  }
}

For your specific questions:

1.I have to send username and password fields as a parameter to the viewModel (by the way, that's the easier to solve)

Acuatlly you don't pass username and password fields as a parameter to the view model, you pass Observables\Drivers as an input parameter. So now the business and presentation logic are not tightly coupled to the UI logic. You give the View-Model inputs from any source, not necessarily UI, e.g. in unit tests when you send mock data. This means you can change your UI without concerning the business logic, and vice versa.

​In other words, don’t import UIKit in your View-Models and you’ll be fine.

2.I don't like the way my viewController do all the work when login button is tapped, viewController doesn't need to know which service it should call to get login access, it's a viewModel job. ​

Yes, you are right, this is business logic, and in MVVM pattern the view controller should not be responsible for that. All the business logic should be implemented in the View-Model. And You can see in my example that all this logic takes place in the View-Model and the ViewController is almost empty. AS a side note, the ViewController can contain many lines of code, the point is seaparation of concerns, the ViewController should only handle UI logic (e.g. color changes when login is disabled), and presentation and business logic are habdled by the View-Model.

  1. I can't access the stored value of username and password outside of a subscription.

You should access those values in an Rx way. e.g. let the View-Model provide a Variable that gives you those values, maybe after some processing, or a Driver that gives you relevant events (e.g. show an alert view that asks "Is (userName) your user name?" before sending the login request). This way you avoid state, and synchronization problems (e.g. I got the stored value and presented it on a label but a second later it got updated and another label is presenting the updated value)

MVVM Diagram from Microsoft

enter image description here

Hope you will find this information helpful :)

Related articles:

Model-View-ViewModel for iOS By Ash Furrow http://www.teehanlax.com/blog/model-view-viewmodel-for-ios/

ViewModel in RxSwift world By Serg Dort https://medium.com/@SergDort/viewmodel-in-rxswift-world-13d39faa2cf5#.wuthixtp9

Overriding answered 6/7, 2016 at 13:15 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.