Reputation: 10733
I'm new to RxSwift and trying to learn by creating a simple signup form. I want to implement it using a UITableView
(as an exercise, plus it will become more complicated in the future) so I'm currently using two types of cells:
TextInputTableViewCell
with just a UITextField
ButtonTableViewCell
with just a UIButton
In order to represent each cell, I created an enum which looks like that:
enum FormElement {
case textInput(placeholder: String, text: String?)
case button(title: String, enabled: Bool)
}
and use it in a Variable
to feed the tableview:
formElementsVariable = Variable<[FormElement]>([
.textInput(placeholder: "username", text: nil),
.textInput(placeholder: "password", text: nil),
.textInput(placeholder: "password, again", text: nil),
.button(title: "create account", enabled: false)
])
by binding like that:
formElementsVariable.asObservable()
.bind(to: tableView.rx.items) {
(tableView: UITableView, index: Int, element: FormElement) in
let indexPath = IndexPath(row: index, section: 0)
switch element {
case .textInput(let placeholder, let defaultText):
let cell = tableView.dequeueReusableCell(withIdentifier: "TextInputTableViewCell", for: indexPath) as! TextInputTableViewCell
cell.textField.placeholder = placeholder
cell.textField.text = defaultText
return cell
case .button(let title, let enabled):
let cell = tableView.dequeueReusableCell(withIdentifier: "ButtonTableViewCell", for: indexPath) as! ButtonTableViewCell
cell.button.setTitle(title, for: .normal)
cell.button.isEnabled = enabled
return cell
}
}.disposed(by: disposeBag)
So far, so good - this is how my form looks like:
Now, the actual problem I'm facing here, is how am I supposed to enable the create account button when all the 3 text inputs are not empty and the password is the same in both password textfields? In other words, what is the right way to apply changes to a cell, based on events happening on one or more of the other cells?
Should my goal be to change this formElementsVariable
through the ViewModel or is there any better way to achieve what I want?
Upvotes: 6
Views: 3386
Reputation: 21154
I suggest that you change your ViewModel a bit such that you can have more control over the changes in the textfields. If you create streams from your input fields such as username, password and confirmation, you can subscribe for the changes and react to it in any way you want.
Here is how I restructured your code a bit for working with changes in text fields.
internal enum FormElement {
case textInput(placeholder: String, variable: Variable<String>)
case button(title: String)
}
ViewModel.
internal class ViewModel {
let username = Variable("")
let password = Variable("")
let confirmation = Variable("")
lazy var formElementsVariable: Driver<[FormElement]> = {
return Observable<[FormElement]>.of([.textInput(placeholder: "username",
variable: username),
.textInput(placeholder: "password",
variable: password),
.textInput(placeholder: "password, again",
variable: confirmation),
.button(title: "create account")])
.asDriver(onErrorJustReturn: [])
}()
lazy var isFormValid: Driver<Bool> = {
let usernameObservable = username.asObservable()
let passwordObservable = password.asObservable()
let confirmationObservable = confirmation.asObservable()
return Observable.combineLatest(usernameObservable,
passwordObservable,
confirmationObservable) { [unowned self] username, password, confirmation in
return self.validateFields(username: username,
password: password,
confirmation: confirmation)
}.asDriver(onErrorJustReturn: false)
}()
fileprivate func validateFields(username: String,
password: String,
confirmation: String) -> Bool {
guard username.count > 0,
password.count > 0,
password == confirmation else {
return false
}
// do other validations here
return true
}
}
ViewController,
internal class ViewController: UIViewController {
@IBOutlet var tableView: UITableView!
fileprivate var viewModel = ViewModel()
fileprivate let disposeBag = DisposeBag()
override func viewDidLoad() {
super.viewDidLoad()
viewModel.formElementsVariable.drive(tableView.rx.items) { [unowned self] (tableView: UITableView, index: Int, element: FormElement) in
let indexPath = IndexPath(row: index, section: 0)
switch element {
case .textInput(let placeholder, let variable):
let cell = self.createTextInputCell(at: indexPath,
placeholder: placeholder)
cell.textField.text = variable.value
cell.textField.rx.text.orEmpty
.bind(to: variable)
.disposed(by: cell.disposeBag)
return cell
case .button(let title):
let cell = self.createButtonCell(at: indexPath,
title: title)
self.viewModel.isFormValid.drive(cell.button.rx.isEnabled)
.disposed(by: cell.disposeBag)
return cell
}
}.disposed(by: disposeBag)
}
fileprivate func createTextInputCell(at indexPath:IndexPath,
placeholder: String) -> TextInputTableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "TextInputTableViewCell",
for: indexPath) as! TextInputTableViewCell
cell.textField.placeholder = placeholder
return cell
}
fileprivate func createButtonCell(at indexPath:IndexPath,
title: String) -> ButtonInputTableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "ButtonInputTableViewCell",
for: indexPath) as! ButtonInputTableViewCell
cell.button.setTitle(title, for: .normal)
return cell
}
}
We have three different variables based on which we enable disable button, you can see the power of stream and rx operators here.
I think it is always good to convert plain properties to Rx when they change a lot like username, password and passwordField in our case. You can see that formElementsVariable do not change much, it has no real added value of Rx except the magical tableview binding for creating cell.
Upvotes: 13
Reputation: 59
I think that you are the missing the appropriate rx
properties inside the FormElement
that will enable you to bind UI events to the validations you want to perform within the ViewModel.
To begin with the FormElement
, the textInput
should expose a text Variable
and the button
an enabled Driver
. I made this distinction to showcase that in the first case you want to consume UI events while in the second you just want to update the UI.
enum FormElement {
case textInput(placeholder: String, text: Variable<String?>)
case button(title: String, enabled:Driver<Bool>, tapped:PublishRelay<Void>)
}
I took the liberty of adding a tapped event that will enable you to perform your business logic when the button if finally enabled!
Moving on to the ViewModel
, I exposed only what the View
needs to know but internally I applied all the necessary operators:
class FormViewModel {
// what ViewModel exposes to view
let formElementsVariable: Variable<[FormElement]>
let registerObservable: Observable<Bool>
init() {
// form element variables, the middle step that was missing...
let username = Variable<String?>(nil) // docs says that Variable will deprecated and you should use BehaviorRelay...
let password = Variable<String?>(nil)
let passwordConfirmation = Variable<String?>(nil)
let enabled: Driver<Bool> // no need for Variable as you only need to emit events (could also be an observable)
let tapped = PublishRelay<Void>.init() // No need for Variable as there is no need for a default value
// field validations
let usernameValidObservable = username
.asObservable()
.map { text -> Bool in !(text?.isEmpty ?? true) }
let passwordValidObservable = password
.asObservable()
.map { text -> Bool in text != nil && !text!.isEmpty && text!.count > 5 }
let passwordConfirmationValidObservable = passwordConfirmation
.asObservable()
.map { text -> Bool in text != nil && !text!.isEmpty && text!.count > 5 }
let passwordsMatchObservable = Observable.combineLatest(password.asObservable(), passwordConfirmation.asObservable())
.map({ (password, passwordConfirmation) -> Bool in
password == passwordConfirmation
})
// enable based on validations
enabled = Observable.combineLatest(usernameValidObservable, passwordValidObservable, passwordConfirmationValidObservable, passwordsMatchObservable)
.map({ (usernameValid, passwordValid, passwordConfirmationValid, passwordsMatch) -> Bool in
usernameValid && passwordValid && passwordConfirmationValid && passwordsMatch // return true if all validations are true
})
.asDriver(onErrorJustReturn: false)
// now that everything is in place, generate the form elements providing the ViewModel variables
formElementsVariable = Variable<[FormElement]>([
.textInput(placeholder: "username", text: username),
.textInput(placeholder: "password", text: password),
.textInput(placeholder: "password, again", text: passwordConfirmation),
.button(title: "create account", enabled: enabled, tapped: tapped)
])
// somehow you need to subscribe to register to handle for button clicks...
// I think it's better to do it from ViewController because of the disposeBag and because you probably want to show a loading or something
registerObservable = tapped
.asObservable()
.flatMap({ value -> Observable<Bool> in
// Business login here!!!
NSLog("Create account!!")
return Observable.just(true)
})
}
}
Finally, on your View
:
class ViewController: UIViewController {
@IBOutlet weak var tableView: UITableView!
private let disposeBag = DisposeBag()
var formViewModel: FormViewModel = FormViewModel()
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(UINib(nibName: "TextInputTableViewCell", bundle: nil), forCellReuseIdentifier: "TextInputTableViewCell")
tableView.register(UINib(nibName: "ButtonTableViewCell", bundle: nil), forCellReuseIdentifier: "ButtonTableViewCell")
// view subscribes to ViewModel observables...
formViewModel.registerObservable.subscribe().disposed(by: disposeBag)
formViewModel.formElementsVariable.asObservable()
.bind(to: tableView.rx.items) {
(tableView: UITableView, index: Int, element: FormElement) in
let indexPath = IndexPath(row: index, section: 0)
switch element {
case .textInput(let placeholder, let defaultText):
let cell = tableView.dequeueReusableCell(withIdentifier: "TextInputTableViewCell", for: indexPath) as! TextInputTableViewCell
cell.textField.placeholder = placeholder
cell.textField.text = defaultText.value
// listen to text changes and pass them to viewmodel variable
cell.textField.rx.text.asObservable().bind(to: defaultText).disposed(by: self.disposeBag)
return cell
case .button(let title, let enabled, let tapped):
let cell = tableView.dequeueReusableCell(withIdentifier: "ButtonTableViewCell", for: indexPath) as! ButtonTableViewCell
cell.button.setTitle(title, for: .normal)
// listen to viewmodel variable changes and pass them to button
enabled.drive(cell.button.rx.isEnabled).disposed(by: self.disposeBag)
// listen to button clicks and pass them to the viewmodel
cell.button.rx.tap.asObservable().bind(to: tapped).disposed(by: self.disposeBag)
return cell
}
}.disposed(by: disposeBag)
}
}
}
Hope I helped!
PS. I am mainly an Android developer but I found your question (and bounty) intriguing so please forgive any rough edges with (rx)swift
Upvotes: 5
Reputation: 2321
You would do better to emit table data all at once rather than one row at a time because otherwise you can't really distinguish between a) is this next event a new row or b) is this next event a refresh of a row I already showed.
Given that here's one way to do it. This would go in the ViewModel and present the table data as an observable. You can then bind the text fields for the username/password to the properties (behavior relays) though probably nicer to not expose them as such to the UI (hide behind properties)
var userName = BehaviorRelay<String>(value: "")
var password1 = BehaviorRelay<String>(value: "")
var password2 = BehaviorRelay<String>(value: "")
struct LoginTableValues {
let username: String
let password1: String
let password2: String
let createEnabled: Bool
}
func tableData() -> Observable<LoginTableValues> {
let createEnabled = Observable.combineLatest(userName.asObservable(), password1.asObservable(), password2.asObservable())
.map { (username: String, password1: String, password2: String) -> Bool in
return !username.isEmpty &&
!password1.isEmpty &&
password1 == password2
}
return Observable.combineLatest(userName.asObservable(), password1.asObservable(), password2.asObservable(), createEnabled)
.map { (arg: (String, String, String, Bool)) -> LoginTableValues in
let (username, password1, password2, createEnabled) = arg
return LoginTableValues(username: username, password1: password1, password2: password2, createEnabled: createEnabled)
}
}
Upvotes: 3
Reputation: 773
Firstly, you may want to try RxDataSources
which is an RxSwift wrapper for TableViews. Secondly, to answer your question, I would have done the change through the ViewModel- that is, provide a ViewModel for the cell and then in the ViewModel set an observable that will handle the validation. When all of that is setup, do a combineLatest
on all the cell's validation observables.
Upvotes: 2