Handling button touch based on the Observable value - swift

I wanted to know what is the best practice to handling button tap based on the Observable. I've got enum:
enum ButtonState {
case idle, valid, nonValid
}
and Observable which is combining two Observables:
var buttonStateObservable: Observable<ButtonState> {
return Observable.combineLatest(isAddingVariable.asObservable(), addItemName.asObservable(),
resultSelector: { isAdding, itemName in
if isAdding {
return !itemName.isEmpty ? .valid : .nonValid
}
return .idle
})
}
In my ViewController I'm subscribing to this observable and updating UIButton's UI. I would like to handle tap on this button and do certain action based on this observable. What is the best approach?

RxSwift already has ControlEvent that wrapped TouchUpInside event of UIButton. You can access it by .rx.tap:
button.rx.tap.subscribe(onNext: {
self.button.setTitle("\(arc4random_uniform(100))", for: .normal)
self.button.layer.borderColor = UIColor.random.cgColor // or your special color
})
.disposed(by: bag)
Result of 3 taps:
UPD.
You can check other observables by .withLatestFrom(). Improved top example:
let subject = BehaviorSubject<Bool>(value: true)
button.rx.tap.asDriver()
.withLatestFrom(subject.asDriver(onErrorJustReturn: false))
.drive(onNext: { value in
if value {
self.button.setTitle("\(arc4random_uniform(100))", for: .normal)
self.button.layer.borderColor = UIColor.random().cgColor
}
subject.onNext(!value)
})
.disposed(by: bag)
So, in your case you can put Observable.combineLatest(...) in .withLatestFrom and perform logic with these values.

Related

Is there a way to update UIButton's titleLabel text using Combine's .assign instead of .sink?

I have a #Published string in my view model. I want to receive updates on its value in my view controller so that I can update the UI.
I am able to successfully get the updates through use of the sink subscriber. This works fine:
viewModel.$buttonText.sink { [weak self] buttonText in
self?.buttonOutlet.setTitle(buttonText, for: .normal)
}.store(in: &cancellables)
But I am looking for a one line approach. Something more like what you are able to do with UILabels using the assign subscriber:
viewModel.$title.assign(to: \.text, on: titleLabel).store(in: &cancellables)
I've tried accessing the buttonOutlet.titleLabel directly, but this of course doesn't work since we can't directly update the text (UIButton.titleLabel is read-only). And it also introduces the issue of unwrapping the titleLabel property:
viewModel.$buttonText.assign(to: \.!.text, on: buttonOutlet.titleLabel).store(in: &cancellables)
I don't know if I'm just struggling to find the correct syntax or if this is simply a limitation of Combine for the time being.
You can just write an extension:
extension UIButton {
var normalTitleText: String? {
get {
title(for: .normal)
}
set {
setTitle(newValue, for: .normal)
}
}
}
Now you can get the keypath
viewModel.$title.assign(to: \.normalTitleText, on: someButton).store(in: &cancellables)
Another option:
extension UIButton {
func title(for state: UIControl.State) -> (String?) -> Void {
{ title in
self.setTitle(title, for: state)
}
}
}
So you don't have to write a different var for each control state. It can be used like this:
viewModel.$buttonText
.sink(receiveValue: buttonOutlet.title(for: .normal))
.store(in: &cancellables)
There's nothing wrong with the other answers, but if you just want the least code overall, you tighten up your code by capturing buttonOutlet directly and using $0 as the closure argument.
viewModel.$buttonText
.sink { [b = buttonOutlet] in b?.setTitle($0, for: .normal) }
.store(in: &cancellables)

Disabling a button based on value in a UITextField only works once (RxSwift)

I'm trying to get to grips with RxCocoa and have experienced an unusual bug relating to some dynamic UI behaviour I'm trying to implement.
I have a UITextField that's used for user input. The button which adds the input to a Realm database is bound to an RxSwift Action. This works absolutely fine.
Initially, I disabled the button until there was text of at least 1 character in length in the UITextField - the code of this works fine. The bug in my code arose when I then added a subscription to the Action's executionObservables parameter that should clear the UITextField after the button is pressed.
Expected behaviour:
No text (initial state) > button disabled
Text entered > button enabled
Text entered and button pressed > text field cleared and button disabled
Actual behaviour:
No text (initial state) > button disabled
Text entered > button enabled
Text entered and button pressed > text field cleared BUT button remains enabled
Adding debug() indicates that the binding to the UITextField that disables the button is disposed but I can't figure out why as the UIViewController and its associated view model should still be in scope. Can anyone point me in the right direction?
Code snippet:
func bindViewModel() {
// populate table
viewModel.output.sectionedObservations
.drive(tableView.rx.items(dataSource: dataSource))
.disposed(by: disposeBag)
// only allow enable button when there is text in the textfield
observationTextField.rx.text
.debug()
.map { $0!.count > 0 }
.bind(to: addObservationButton.rx.isEnabled)
.disposed(by: disposeBag)
// clear textfield once Action triggered by button press has completed
viewModel.addObservation.executionObservables
.subscribe({ [unowned self] _ in
self.observationTextField.rx.text.onNext("")
})
.disposed(by: disposeBag)
// add Observation to Realm using Action provided by the view model
addObservationButton.rx.tap
.withLatestFrom(observationTextField.rx.text.orEmpty)
.take(1)
.bind(to: viewModel.addObservation.inputs)
.disposed(by: disposeBag)
}
I think there is a little misunderstanding about how ControlProperty trait behaves. Let's take a look at specific behavior which is Programmatic value changes won't be reported
This Observable observationTextField.rx.text after subscription will not emit event for both:
self.observationTextField.rx.text.onNext("")
or
self.observationTextField.text = ""
I have 2 suggestion for your code:
1) Do the job manually:
viewModel.addObservation.executionObservables
.subscribe({ [unowned self] _ in
self.observationTextField = ""
self.addObservationButton.isEnabled = false
})
.disposed(by: disposeBag)
2) Add one more Observable and subscription:
//a
viewModel.addObservation.executionObservables
.map { _ in return "" }
.bind(to: observationTextField.rx.text)
.disposed(by: disposeBag)
viewModel.addObservation.executionObservables
.map { _ in return false }
.bind(to: addObservationButton.rx.isEnabled)
.disposed(by: disposeBag)
//b
let executionObservables = viewModel.addObservation
.executionObservables
.share()
executionObservables
.map { _ in return "" }
.bind(to: observationTextField.rx.text)
.disposed(by: disposeBag)
executionObservables
.map { _ in return false }
.bind(to: addObservationButton.rx.isEnabled)
.disposed(by: disposeBag)
Not sure how Action is implemented, to prevent job done twice maybe you have to share resources.

Rxswift hide button inside UITableViewCell after tapped

I have a addButton inside a UITableCieCwell. I want the addButton to be disappear after user click it, so I created a Action and bind it to the addButton.
However, all the addButton is disappeared although I just run my app.
I'm very new to RxSwift, please help me out.
Bind UI
viewModel.courses
.asObservable()
.bind(to: collectionView.rx.items(cellIdentifier: AddableCourseCell.reuseIdentifier, cellType: AddableCourseCell.self)) { (row, element, cell) in
let action = self.viewModel.actions.value[row]
action.enabled.asObservable()
.bind(to: cell.addButton.rx.isHidden)
.disposed(by: self.disposeBag)
cell.addButton.rx
.tap
.debounce(0.3, scheduler: MainScheduler.instance)
.subscribe({ (event) in
action.execute(element)
}).disposed(by: cell.disposeBag)
}.disposed(by: disposeBag)
ViewModel
class ViewModel {
var courses: Variable<[Course]> = Variable([])
var selectedCourses: Variable<[Course]> = Variable([])
var actions = Variable<[Action<Course, Bool>]>([])
private func generateAddActions(courses: [Course]) -> [Action<Course, Bool>] {
var actions: [Action<Course, Bool>] = []
for _ in courses {
let action = Action<Enrollment, Bool>(workFactory: { (input) -> Observable<Bool> in
let isAdded = selectedCourses.value.contains(input)
if !isAdded {
self.selectedCourses.value.append(input)
}
return Observable.just(isAdded)
})
actions.append(action)
}
return actions
}
}
Hi one tip I can give you is to add a .debug() in the action observable so you can see the values emitted. However, I believe what's causing you trouble is that the action observable's initial value is true which is bounded to the isHidden attribute of the addButton

Structuring a View Model Using RxSwift

My view models are fundamentally flawed because those that use a driver will complete when an error is returned and resubscribing cannot be automated.
An example is my PickerViewModel, the interface of which is:
// MARK: Picker View Modelling
/**
Configures a picker view.
*/
public protocol PickerViewModelling {
/// The titles of the items to be displayed in the picker view.
var titles: Driver<[String]> { get }
/// The currently selected item.
var selectedItem: Driver<String?> { get }
/**
Allows for the fetching of the specific item at the given index.
- Parameter index: The index at which the desired item can be found.
- Returns: The item at the given index. `nil` if the index is invalid.
*/
func item(atIndex index: Int) -> String?
/**
To be called when the user selects an item.
- Parameter index: The index of the selected item.
*/
func selectItem(at index: Int)
}
An example of the Driver issue can be found within my CountryPickerViewModel:
init(client: APIClient, location: LocationService) {
selectedItem = selectedItemVariable.asDriver().map { $0?.name }
let isLoadingVariable = Variable(false)
let countryFetch = location.user
.startWith(nil)
.do(onNext: { _ in isLoadingVariable.value = true })
.flatMap { coordinate -> Observable<ItemsResponse<Country>> in
let url = try client.url(for: RootFetchEndpoint.countries(coordinate))
return Country.fetch(with: url, apiClient: client)
}
.do(onNext: { _ in isLoadingVariable.value = false },
onError: { _ in isLoadingVariable.value = false })
isEmpty = countryFetch.catchError { _ in countryFetch }.map { $0.items.count == 0 }.asDriver(onErrorJustReturn: true)
isLoading = isLoadingVariable.asDriver()
titles = countryFetch
.map { [weak self] response -> [String] in
guard let `self` = self else { return [] }
self.countries = response.items
return response.items.map { $0.name }
}
.asDriver(onErrorJustReturn: [])
}
}
The titles drive the UIPickerView, but when the countryFetch fails with an error, the subscription completes and the fetch cannot be retried manually.
If I attempt to catchError, it is unclear what observable I could return which could be retried later when the user has restored their internet connection.
Any justReturn error handling (asDriver(onErrorJustReturn:), catchError(justReturn:)) will obviously complete as soon as they return a value, and are useless for this issue.
I need to be able to attempt the fetch, fail, and then display a Retry button which will call refresh() on the view model and try again. How do I keep the subscription open?
If the answer requires a restructure of my view model because what I am trying to do is not possible or clean, I would be willing to hear the better solution.
Regarding ViewModel structuring when using RxSwift, during intensive work on a quite big project I've figured out 2 rules that help keeping solution scalable and maintainable:
Avoid any UI-related code in your viewModel. It includes RxCocoa extensions and drivers. ViewModel should focus specifically on business logic. Drivers are meant to be used to drive UI, so leave them for ViewControllers :)
Try to avoid Variables and Subjects if possible. AKA try to make everything "flowing". Function into function, into function and so on and, eventually, in UI. Of course, sometimes you need to convert non-rx events into rx ones (like user input) - for such situations subjects are OK. But be afraid of subjects overuse - otherwise your project will become hard to maintain and scale in no time.
Regarding your particular problem. So it is always a bit tricky when you want retry functionality. Here is a good discussion with RxSwift author on this topic.
First way. In your example, you setup your observables on init, I also like to do so. In this case, you need to accept the fact that you DO NOT expect a sequence that can fail because of error. You DO expect sequence that can emit either result-with-titles or result-with-error. For this, in RxSwift we have .materialize() combinator.
In ViewModel:
// in init
titles = _reloadTitlesSubject.asObservable() // _reloadTitlesSubject is a BehaviorSubject<Void>
.flatMap { _ in
return countryFetch
.map { [weak self] response -> [String] in
guard let `self` = self else { return [] }
self.countries = response.items
return response.items.map { $0.name }
}
.materialize() // it IS important to be inside flatMap
}
// outside init
func reloadTitles() {
_reloadTitlesSubject.onNext(())
}
In ViewController:
viewModel.titles
.asDriver(onErrorDriveWith: .empty())
.drive(onNext: [weak self] { titlesEvent in
if let titles = titlesEvent.element {
// update UI with
}
else if let error = titlesEvent.error {
// handle error
}
})
.disposed(by: bag)
retryButton.rx.tap.asDriver()
.drive(onNext: { [weak self] in
self?.viewModel.reloadTitles()
})
.disposed(by: bag)
Second way is basically what CloackedEddy suggests in his answer. But can be simplified even more to avoid Variables. In this approach you should NOT setup your observable sequence in viewModel's init, but rather return it anew each time:
// in ViewController
yourButton.rx.tap.asDriver()
.startWith(())
.flatMap { [weak self] _ in
guard let `self` = self else { return .empty() }
return self.viewModel.fetchRequest()
.asDriver(onErrorRecover: { error -> Driver<[String]> in
// Handle error.
return .empty()
})
}
.drive(onNext: { [weak self] in
// update UI
})
.disposed(by: disposeBag)
I would shift some responsibilities to the view controller.
One approach would be to have the view model produce an Observable which as a side effect updates the view model properties. In the following code example, the view controller remains in charge of the view bindings, as well as triggering the refresh in viewDidLoad() and via a button tap.
class ViewModel {
let results: Variable<[String]> = Variable([])
let lastFetchError: Variable<Error?> = Variable(nil)
func fetchRequest() -> Observable<[String]> {
return yourNetworkRequest
.do(onNext: { self.results.value = $0 },
onError: { self.lastFetchError.value = $0 })
}
}
class ViewController: UIViewController {
let viewModel = ViewModel()
let disposeBag = DisposeBag()
override func viewDidLoad() {
super.viewDidLoad()
viewModel.results
.asDriver()
.drive(onNext: { yourLabel.text = $0 /* .reduce(...) */ })
.disposed(by: disposeBag)
viewModel.lastFetchError
.asDriver()
.drive(onNext: { yourButton.isHidden = $0 == nil })
.disposed(by: disposeBag)
yourButton.rx.tap
.subscribe(onNext: { [weak self] in
self?.refresh()
})
.disposed(by: disposeBag)
// initial attempt
refresh()
}
func refresh() {
// trigger the request
viewModel.fetchRequest()
.subscribe()
.disposed(by: disposeBag)
}
}
All answers are good, but i want to mentioned about CleanArchitectureRxSwift. This framework really help me to find the way how rx can be applied to my code. The part about "backend" mobile programming (request, parsers, etc) can be omitted, but work with viewModel/viewController has really interesting things.

RxSwift: How to correctly subscribe to a variable change

I have two screens:
NewControlTableViewController: contains a textfield for selecting a client from the other view
ClientsTableViewController: The second view contains a list clients that can be selected
The two screens share a viewmodel.
So here is my code:
import RxSwift
import RxCocoa
struct NewControlViewModel {
var selectedClient = Variable<Client>(Client())
// other stuff
}
// NewControlTableViewController : viewDidLoad
viewModel.selectedClient.asObservable().subscribe { event in
debugPrint(event)
}
// ClientsTableViewController: viewDidLoad
/*tableView.rx.itemSelected.subscribe(onNext: { indexPath in
let client = self.clients[indexPath.row]
debugPrint(client)
self.viewModel.selectedClient.value = client
self.navigationController?.popToRootViewController(animated: true)
}).disposed(by: self.disposeBag)*/
// new code
tableView.rx
.modelSelected(Client.self)
.debug("client selected", trimOutput: true)
.do(onNext: { _ in
self.navigationController?.popViewController(animated: true)
})
.subscribe(onNext: { client in
debugPrint(client)
self.viewModel.selectedClient.value = client
}, onError: { error in
debugPrint(error)
}).disposed(by: disposeBag)
The event is fired(with empty values) whenever I view the first screen, then, after selecting a client from the second screen, for some reason, no event is being fired.
First of all, Variables are deprecated.
You should use a PublishRelay which doesn't need an initial value, that way it won't be fired upon subscription in your first screen.
The advantage of Relays is they don't error out or complete.
struct NewControlViewModel {
let selectedClient = PublishRelay<Client>()
}
// NewControlTableViewController : viewDidLoad
choisirMandat.rx.tap.subscribe(onNext: { [unowned self] in
let viewController = /* instantiate vc */
// Make sure to use the same viewModel
viewController.viewModel = self.viewModel
self.present(viewController, animated: true)
}).disposed(by: disposeBag)
self.viewModel.selectedClient.debug().subscribe().disposed(by: self.disposeBag)
// ClientsTableViewController: viewDidLoad
tableView.rx.itemSelected.map { [unowned self] indexPath in
return self.clients[indexPath.row]
}
.debug("client selected", trimOutput: true)
.do(onNext: { [unowned self] _ in
self.navigationController?.popToRootViewController(animated: true)
})
.bind(to: self.viewModel.selectedClient)
.disposed(by: self.disposeBag)
On a side note, clients should probably also come from the Rx world if you want to be fully functional.
Second side note, you can use the debug() operator to print events.
bind(to:) was added in RxSwift 4.1 so make sure to be up to date
I think I have found your issue here. You are doing is
// NewControlTableViewController : viewDidLoad
viewModel.selectedClient.asObservable().subscribe { event in
debugPrint(event)
}
// ClientsTableViewController: viewDidLoad
tableView.rx.itemSelected.subscribe(onNext: { indexPath in
let client = self.clients[indexPath.row]
debugPrint(client)
self.viewModel.selectedClient.value = client
self.navigationController?.popToRootViewController(animated: true)
}).disposed(by: self.disposeBag)
You need to change this line "self.viewModel.selectedClient.value = client". You need to use previous controller instance or NewControlTableViewController instance like self.newControlTableVC.selectedClient.value = client
I think you are trying to use viewmodel by sending there value from NewControlTableViewController to ClientsTableViewController.
Moreover, I did the same scenario which you did and it worked very well in my case. Just try my scenario if it could not work there must be some minor issue.
I hope it may help