How to implement simple form with UITextFields and RxSwift?

Viewed 370

I'm new to RxSwift and guess I am doing it very very wrong, so please enlighten me!

Simpified example:

There's a view with three UITextField and an UIButton, and two Swagger-API-calls for GET and PUT UserData (a struct with three Strings).

var firstName = UITextField()
var lastName = UITextField()
var email = UITextField()
var sendButton = UIButton()

ViewModel has a BehaviorSubject to hold the data:

var userData = BehaviorSubject<UserData?>(value: nil)

Binding incoming data to the BehaviorSubject works:

self.userData.onNext(response.userData)

And filling the UITextFields works:

viewModel.userData.bind { userData in
    firstName.text = userData?.firstName
    lastName.text = userData?.lastName
    email.text = userData?.email
}.disposed(by: disposeBag)

But typing in the UITextFields fails with this code:

// changing any of these fields should trigger an update of the whole object
BehaviorSubject.combineLatest(
    self.firstName.rx.text,
    self.lastName.rx.text,
    self.email.rx.text
)
.map {
    return UserData(
        firstName: $0,
        lastName: $1,
        email: $2
    )
}
.bind(to: viewModel.userData)
.disposed(by: disposeBag)

Most of the time the typed box stays, but all others get emptied or sometimes all get emptied or other weird stuff happens like values jumping around the fields.

For sending the data I use a simple tap handler, does not feel very Rx:

saveButton.onTapHandler = { [weak self] in
    containerView.endEditing(true)
        do {
            try self?.viewModel.saveUserData()
        } catch {
            self?.showAlert(error)
        }
    }
}

Any help is welcome, also not sure if BehaviorSubject is correct, if combineLatest does what I need, ...

Additional info: I know I can create a seperate BehaviorSubject for each subject, but I am trying to understand how I can bind whole objects.

1 Answers

The key here is to look at each effect as an individual thing and define what causes that effect.

Let's assume that you have methods for getting and saving user data. getUserData() -> Observable<UserData> (this get will emit one value and then complete) and save(_ userData: UserData). Further, for simplicity, I'll assume that these can't error.

So let's look at each effect in turn.

  1. Filling out the initial value for firstName:
getUserData()
    .map(\.firstName)
    .bind(to: firstName.rx.text)
    .disposed(by: disposeBag)

Well that was simple.

  1. Filling out the initial value for lastName. This is a bit tougher. We don't want to subscribe twice to getUserData() because that would cause two network requests... The share() operator helps here:
let userData = getUserData()
    .share()

userData
    .map(\.firstName)
    .bind(to: firstName.rx.text)
    .disposed(by: disposeBag)

userData
    .map(\.lastName)
    .bind(to: lastName.rx.text)
    .disposed(by: disposeBag)

or if you want to compress it a bit:

let userData = getUserData()
    .share()

disposeBag.insert(
    userData.map(\.firstName).bind(to: firstName.rx.text),
    userData.map(\.lastName).bind(to: lastName.rx.text)
)
  1. The email is the same:
let userData = getUserData()
    .share()

disposeBag.insert(
    userData.map(\.firstName).bind(to: firstName.rx.text),
    userData.map(\.lastName).bind(to: lastName.rx.text),
    userData.map(\.email).bind(to: email.rx.text)
)
  1. The next effect, the save sounds like the one you are having trouble with. I have to make some more assumptions here, but I'm going to guess that you need to send a fully formed, correct, user data object. If the user just changes their first name, you still have to send the old last name and email address with the new first name in the user data object.

So where do you get the original data from? That's pretty obvious I hope, the userData Observable above. Also, you need to collect anything the user types in any of the three fields.

Here's the twist... When you subscribe to a text field's text observable, it emits the base value which is an empty string. Because of this, you need to skip the first value emitted by the Observable and replace it with the output from your getUserData() function.

let latestUserData = Observable.combineLatest(
    Observable.merge(firstName.rx.text.orEmpty.skip(1), userData.map(\.firstName)),
    Observable.merge(lastName.rx.text.orEmpty.skip(1), userData.map(\.lastName)),
    Observable.merge(email.rx.text.orEmpty.skip(1), userData.map(\.email))
) { UserData(firstName: $0, lastName: $1, email: $2) }

The above is a lot of code, so let's dwell on it a bit... For each field, I'm grabbing whatever comes out of our getUserData() and merging it with whatever comes out of the appropriate text field. I'm ignoring the first value that comes out of the text field though, because I know the user didn't type that in.

But you only want to call the save function if the user taps the sendButton. So that's your trigger:

sendButton.rx.tap
    .withLatestFrom(latestUserData)
    .subscribe(onNext: { save($0) })
    .disposed(by: disposeBag)

Here's all the code in one place, including an observe(on:) which is necessary if your network getter emits its value on a background thread:

override func viewDidLoad() {
    super.viewDidLoad()

    let userData = getUserData()
        .observe(on: MainScheduler.instance)
        .share()

    let latestUserData = Observable.combineLatest(
        Observable.merge(firstName.rx.text.orEmpty.skip(1), userData.map(\.firstName)),
        Observable.merge(lastName.rx.text.orEmpty.skip(1), userData.map(\.lastName)),
        Observable.merge(email.rx.text.orEmpty.skip(1), userData.map(\.email))
    ) { UserData(firstName: $0, lastName: $1, email: $2) }

    sendButton.rx.tap
        .withLatestFrom(latestUserData)
        .subscribe(onNext: { save($0) })
        .disposed(by: disposeBag)

    disposeBag.insert(
        userData.map(\.firstName).bind(to: firstName.rx.text),
        userData.map(\.lastName).bind(to: lastName.rx.text),
        userData.map(\.email).bind(to: email.rx.text)
    )
}

If you want a view model, then here you go:

func saveViewModel(trigger: Observable<Void>, firstName: Observable<String?>, lastName: Observable<String?>, email: Observable<String?>, initial: Observable<UserData>) -> Observable<UserData> {
    let latestUserData = Observable.combineLatest(
        Observable.merge(firstName.compactMap { $0 }.skip(1), initial.map(\.firstName)),
        Observable.merge(lastName.compactMap { $0 }.skip(1), initial.map(\.lastName)),
        Observable.merge(email.compactMap { $0 }.skip(1), initial.map(\.email))
    ) { UserData(firstName: $0, lastName: $1, email: $2) }

    return trigger
        .withLatestFrom(latestUserData)
}

You can easily test the above with RxTest without bringing in anything from UIKit or your network stack. Bind its output to the save function like this:

saveViewModel(
    trigger: sendButton.rx.tap.asObservable(),
    firstName: firstName.rx.text.asObservable(),
    lastName: lastName.rx.text.asObservable(),
    email: email.rx.text.asObservable(),
    initial: userData
)
    .subscribe(onNext: { save($0) })
    .disposed(by: disposeBag)
Related