UITextField binding to ViewModel with RxSwift

29,995

Solution 1

@XFreire is right that orEmpty was the missing magic, but it might be instructive for you to see what your code would look like updated to the latest syntax and errors corrected:

First the view model...

  • Variable types should always be defined with let. You don't want to ever replace a Variable, you just want to push new data into one.
  • The way you have your isValid defined, a new one would be created every time you bind/subscribe to it. In this simple case that doesn't matter because you only bind to it once, but in general, this is not good practice. Better is to make the isValid observable just once in the constructor.

When using Rx fully, you will usually find that your view models consist of a bunch of let's and a single constructor. It's unusual to have any other methods or even computed properties.

struct LoginViewModel {

    let username = Variable<String>("")
    let password = Variable<String>("")

    let isValid: Observable<Bool>

    init() {
        isValid = Observable.combineLatest(self.username.asObservable(), self.password.asObservable())
        { (username, password) in
            return username.characters.count > 0
                && password.characters.count > 0
        }
    }
}

And the view controller. Again, use let when defining Rx elements.

  • addDisposableTo() has been deprecated in preference to using disposed(by:)
  • bindTo() has been deprecated in preference to using bind(to:)
  • You don't need the map in your viewModel.isValid chain.
  • You were missing the disposed(by:) in that chain as well.

In this case, you might actually want your viewModel to be a var if it is assigned by something outside the view controller before the latter is loaded.

class LoginViewController: UIViewController {
    var usernameTextField: UITextField!
    var passwordTextField: UITextField!
    var confirmButton: UIButton!

    let viewModel = LoginViewModel()
    let disposeBag = DisposeBag()

    override func viewDidLoad() {
        usernameTextField.rx.text
            .orEmpty
            .bind(to: viewModel.username)
            .disposed(by: disposeBag)

        passwordTextField.rx.text
            .orEmpty
            .bind(to: viewModel.password)
            .disposed(by: disposeBag)

        //from the viewModel
        viewModel.isValid
            .bind(to: confirmButton.rx.isEnabled)
            .disposed(by: disposeBag)
    }
}

Lastly, your view model could be replaced by a single function instead of a struct:

func confirmButtonValid(username: Observable<String>, password: Observable<String>) -> Observable<Bool> {
    return Observable.combineLatest(username, password)
    { (username, password) in
        return username.characters.count > 0
            && password.characters.count > 0
    }
}

Then your viewDidLoad would look like this:

override func viewDidLoad() {
    super.viewDidLoad()

    let username = usernameTextField.rx.text.orEmpty.asObservable()
    let password = passwordTextField.rx.text.orEmpty.asObservable()

    confirmButtonValid(username: username, password: password)
        .bind(to: confirmButton.rx.isEnabled)
        .disposed(by: disposeBag)
}

Using this style, the general rule is to consider each output in turn. Find all the inputs that influence that particular output and write a function that takes all the inputs as Observables and produces the output as an Observable.

Solution 2

You should add .orEmpty.

Try this:

usernameTextField.rx.text
    .orEmpty
    .bindTo(self.viewModel. username)
    .addDisposableTo(disposeBag)

... and the same for the rest of your UITextFields

The text property is a control property of type String?. Adding orEmpty you transform your String? control property into control property of type String.

Share:
29,995

Related videos on Youtube

satellink
Author by

satellink

Updated on September 05, 2020

Comments

  • satellink
    satellink over 3 years

    I am willing to use RxSwift for MVVM binding between model values & view controllers. I wanted to follow this realm.io tutorial, but the binding has apparently changed since then, and the sample code does not compile. Here is the sample code, where I think I've fixed the worst typos / missing things:

    LoginViewModel.swift

    import RxSwift
    
    struct LoginViewModel {
    
        var username = Variable<String>("")
        var password = Variable<String>("")
    
        var isValid : Observable<Bool>{
            return Observable.combineLatest(self.username.asObservable(), self.password.asObservable())
            { (username, password) in
                return username.characters.count > 0
                    && password.characters.count > 0
            }
        }
    } 
    

    LoginViewController.swift

    import RxSwift
    import RxCocoa
    import UIKit
    
    class LoginViewController: UIViewController {
        var usernameTextField: UITextField!
        var passwordTextField: UITextField!
        var confirmButton: UIButton!
    
        var viewModel = LoginViewModel()
    
        var disposeBag = DisposeBag()
    
        override func viewDidLoad() {
            usernameTextField.rx.text.bindTo(viewModel.username).addTo(disposeBag)
            passwordTextField.rx.text.bindTo(viewModel.password).addTo(disposeBag)
    
            //from the viewModel
            viewModel.rx.isValid.map { $0 }
                .bindTo(confirmButton.rx.isEnabled)
        }
    }
    

    The controller bindings do not compile. It is pretty close to impossible to track the correct way to do these, as the RxSwift documentation is pretty unhelpful, and the Xcode autocompletion does not suggest anything useful.

    The first issue is with this binding, which does not compile: usernameTextField.rx.text.bindTo(viewModel.username).addTo(disposeBag)

    The error:

    LoginViewController.swift:15:35: Cannot invoke 'bindTo' with an argument list of type '(Variable<String>)'

    I've tried the following without luck:

    1) usernameTextField.rx.text.bind(to: viewModel.username).addTo(disposeBag) - The error still persists: LoginViewController.swift:15:35: Cannot invoke 'bind' with an argument list of type '(to: Variable<String>)'

    2) let _ = viewModel.username.asObservable().bind(to: passwordTextField.rx.text)

    let _ = viewModel.username.asObservable()
                .map { $0 }
                .bind(to: usernameTextField.rx.text)
    

    This second one actually compiles, but does not work (ie. viewModel.username does not change)

    The main problem is here that I am shooting blind when passing parameters to the bind and bind(to: methods, since the autocompletion is not really helpful here.. I am using swift 3 and Xcode 8.3.2.

  • satellink
    satellink almost 7 years
    This was really educational, thanks! Next I'll have to write a function that submits the action, and returns the user.
  • Jan
    Jan about 6 years
    I was also watching this talk and Max Alexander explicitly mentions that we should not get rid of the view model ..
  • satellink
    satellink about 6 years
    I am not aware of the talk (a link would be useful!), but ViewModel's great benefit is testability; you don't have UIKit dependencies there, and eg. in this particular case you can have confirmButtonValid-method in ViewModel, and have nice unit tests for it.
  • Daniel T.
    Daniel T. almost 6 years
    The code above doesn't get rid of the view model. It turns the view model into a single function or collection of functions. Functions are easy to test.
  • Womble
    Womble almost 5 years
    What is the purpose of "isValid.map { $0 }"? Wouldn't the call to map be redundant, or am I missing something?
  • Daniel T.
    Daniel T. almost 5 years
    @Womble you are right. That was a carry over from the question and isn't needed.