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
Related
Have an issue with a Driver on RxSwift. Have a view model who is listening to an initTrigger in a ViewController as follow.
let initTrigger = rx.viewWillAppear
.mapToVoid()
.asDriverOnErrorJustComplete()
This initTrigger is used to bind to another Driver on the view model
let shoppingCart: Driver<ShoppingCart>
let shoppingCart = input.initTrigger
.flatMapLatest {
self.getShoppingCartUseCase
.execute()
.asDriver(onErrorJustReturn: ShoppingCart())
}
getShoppingCartUseCase.execute() returns Observable<ShoppingCart> and is using RxRealm lo listen to changes to a database.
back on the view controller, I have subscribed to that shoppingCart like this
output?.shoppingCart
.map {
print("Mapping")
return $0.lines.count == 0
}
.asObservable()
.bind(to: goToCartButton.rx.isHidden)
.disposed(by: bag)
I placed the print("Mapping") to realize that this last Driver is being triggered constantly after making an action that modifies my model and triggers the Observable<ShoppingCart> I mentioned before.
What I'm doing wrong here?
Thanks for your help.
First of all you can use .distincUntilChanged() to filter identical events.
second of all, check why .getShoppingCartUseCase keeps on emitting events, RxRealm will send updates whenever ShoppingCart is written to the db, so maybe you have some unnesessary writes. make sure when you write to realm you use .modified flag, not .all (which will override an item only if it has changed, and won't cause event if it hasn't)
If you sure you only need to an event once - you can always add .take(1)
Also you call it initTrigger, but send it on viewWillAppear - which can be called as many times as you getting back to the screen. If you need it once, put it on viewDidLoad
PS instead of .asObservable().bind(to:...) you can just write .drive(...) which is cleaner way to bind drivers to ui.
To stop subscription observer have to do one of the following:
Send error message
Send completed message
Dispose subscription (destroy disposeBag)
In your case nor rx.viewWillAppear neither shoppingCart not sending error or completed messages, cause they are Drivers
One way for you to stop subscription correctly is to destroy old disposeBag
bag = DisposeBag()
But don't forget to restore subscription on viewWillAppear
Other option would be to have some flag in VC like
var hasAppeared: Bool
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
...
hasAppeared = true
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisppear(animated)
...
hasAppeared = false
}
and just to add filtering
output?.shoppingCart
.filter({ [weak self] _ in self?.hasAppeared ?? false })
.map {
print("Mapping")
return $0.lines.count == 0
}
.asObservable()
.bind(to: goToCartButton.rx.isHidden)
.disposed(by: bag)
The third way is to stop sending from inside viewModel
let initTrigger = rx.viewWillAppear
.mapToVoid()
.asDriverOnErrorJustComplete()
let stopTrigger = rx.viewWillDisappear
.mapToVoid()
.asDriverOnErrorJustComplete()
let shoppingCart: Driver<ShoppingCart>
let shoppingCart = Observable.merge(input.initTrigger.map({ true }),
input.stopTrigger.map({ false }))
.flatMapLatest { isRunning in
guard isRunning else {
return .just(ShoppingCart())
}
return self.getShoppingCartUseCase
.execute()
.asDriver(onErrorJustReturn: ShoppingCart())
}
I was playing with Combine framework lately and was wondering if it is possible to create some smart extension to get text changes as Publisher.
Let's say I've got two UITextFields:
firstTextField.textPub.sink {
self.viewModel.first = $0
}
secondTextField.textPub.sink {
self.viewModel.second = $0
}
where first and second variable is just `#Published var first/second: String = ""
extension UITextField {
var textPub: AnyPublisher<String, Never> {
return NotificationCenter.default
.publisher(for: UITextField.textDidChangeNotification)
.map {
guard let textField = $0.object as? UITextField else { return "" }
return textField.text ?? ""
}
.eraseToAnyPublisher()
}
}
This doesn't work because I'm using shared instance of NotificationCenter so when I make any change to any of textFields it will propagate new value to both sink closures. Do you think is there any way to achieve something similar to rx.text available in RxSwift? I was thinking about using addTarget with closure but it would require using associated objects from Objective-C.
I figured this out. We can pass object using NotificationCenter and then filter all instances that are not matching our instance. It seems to work as I expected:
extension UITextField {
var textPublisher: AnyPublisher<String, Never> {
NotificationCenter.default
.publisher(for: UITextField.textDidChangeNotification, object: self)
.compactMap { $0.object as? UITextField }
.map { $0.text ?? "" }
.eraseToAnyPublisher()
}
}
I would suggest you add subscribers to the view modal, and connect them a text field publisher within the context of the view controller.
NotificationCenter is useful to dispatch events app-wide; there's no need to use it when connecting items that are fully owned by the View Controller. However, once you've updated the view modal it may make sense to publish a 'View Modal Did Change' event to NotificationCenter.
After some API calls, I have a check to either navigate to another screen or show an alert on the same screen. Currently, I am doing this by creating an observable that returns a UIViewController type but pushing a UIAlertController causes problem.
Any suggestions/ideas on how this should be done?
ViewModel.swift
let nextAction = Observable.combineLatest(appVersionOutput, serviceAvailabilityOutput, getLanguagePackOutput,
resultSelector:
{ appVersion, _, _ -> UIViewController in
if appVersion.currentAppVersion == "1.0.0" {
let appServiceAvailability = Availability.shared.getAppStatus()
if appServiceAvailability {
return LoginLandingViewController()
} else {
return ServiceMaintenanceViewController()
}
} else {
return UIAlertController()
}
})
ViewController.swift
viewModel.output.nextAction
.subscribe(onNext: { [weak self] screen in
self?.navigationController?.pushViewController(screen, animated: true)
}) // PROBLEM FACED: Pushing a UIAlertController
.disposed(by: disposeBag)
You can check the class using classForCoder
viewModel.output.nextAction
.subscribe(onNext: { [weak self] screen in
if String(describing: screen.classForCoder) == "UIAlertController" {
//present
self?.present(screen, animated: true, completion: nil)
} else {
//navigate
self?.navigationController?.pushViewController(screen, animated: true)
}
}) // PROBLEM FACED: Pushing a UIAlertController
.disposed(by: disposeBag)
There’re a few options.
The first option is the adoption of your current implementation.
You could pass an information in addition to ViewController in onNext event of nextAction observable, that will tell how to show the VC.
For instance, you can create an associated enum
// you can call it NextAction, Action etc
enum PresentationType {
case
push(UIViewController),
present(UIViewController)
}
and reuse it like this:
let nextAction = Observable.combineLatest(appVersionOutput, serviceAvailabilityOutput, getLanguagePackOutput,
resultSelector:
{ appVersion, _, _ -> PresentationType in
if appVersion.currentAppVersion == "1.0.0" {
let appServiceAvailability = Availability.shared.getAppStatus()
if appServiceAvailability {
return .push(LoginLandingViewController())
} else {
return .push(ServiceMaintenanceViewController()) // use .present if should present modally
}
} else {
return .present(UIAlertController())
}
})
// somewhere in viewController
viewModel.output.nextAction
.subscribe(onNext: { [weak self] action in
switch action {
case .push(let vc):
self?.navigationController?.pushViewController(vc, animated: true)
case .present(let vc):
self?.present(vc, animated: true, completion: nil)
})
.disposed(by: disposeBag)
The second (and more flexible and testable in some sense) option would be to create a separate Router class which is responsible for creating and showing next screens (with functions showLogin, showAlert etc). The router can be directly injected in ViewModel and you can call the Router to show next screens in, for example, do(onNext) events in your observables.
You can look at the is keyword which allows you to check the object type. More on is keyword. An alternative will be to use type(of: object) and compare to UIAlertViewController.self
Since you have to present a UIAlertViewController and not push it, use if and else with the above to present if type is UIAlertViewController and push if otherwise. NB: Unnecessary to check for UIViewController since they all are.
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.
Hi I am trying to make a UIRefreshControl work with RxSwift. Therefore I am using the Activity Indicator that is in the RxSwift Example.
In my viewModel I have the following function and variable to get my data.
// MARK: - Variables
var data = Variable<[Data]>([])
// MARK: - Public Interface
func getData() {
let request = Data.readAll()
_ = request.rxResult().subscribe(onNext: { response in
self.data.value = response.data
}, onError: { (Error) in
}, onCompleted: {
}, onDisposed: {
})
}
Then in my view controller I try to bind it to the UIRefreshcontrol and the collection view I have.
let refresher: UIRefreshControl = UIRefreshControl()
let indicator = ActivityIndicator()
indicator.asObservable()
.bindTo(refresher.rx.isRefreshing)
.addDisposableTo(disposeBag)
let resultObservable = viewModel.data.asObservable()
.trackActivity(indicator)
.bindTo(self.collectionView.rx.items(cellIdentifier: reuseCell, cellType: DataCollectionViewCell.self)) {
row, data, cell in
cell.configureCell(with: data)
}
resultObservable.addDisposableTo(disposeBag)
My question is, what am I missing to make this work? Right now if I start the app nothing happens except a black activity indicator that doesn't stop spinning.
I think you should prefer subscribing in ViewController and add that subscription in that view controller's dispose bag.
The following way I think is the correct way of using ActivityIndicator from RxExamples. following is a pseudo code.
/// ViewController.swift
import RxSwift
import RxCocoa
…
let refreshControl = UIRefreshControl()
refreshControl.rx.controlEvent(.valueChanged)
.bind(to:self.viewModel.inputs.loadPageTrigger)
.disposed(by: disposeBag)
self.viewModel.indicator
.bind(to:refreshControl.rx.isRefreshing)
.dispose(by:disposeBag)
…
/// ViewModel.swift
…
let loadTrigger = PublishSubject<Void>()
let indicator = ActivityIndicator().asDriver()
…
// Assuming rxResult returns Observable<Response>
let req = indicator.asObservable()
.sample(loadTrigger)
.flatMap { isLoading -> Observable<Response> in
if isLoading { return Observable.empty() }
return Data.readAll().rxResult()
}
.trackActivity(indicator)
.map { $0.data }
.do(onNext: { [unowned self] data in
self.data.value = data
})
…
refresher
.rx.controlEvent(UIControlEvents.valueChanged)
.subscribe(onNext: { [weak self] in
//Put your hide activity code here
self?.refresher.endRefreshing()
}, onCompleted: nil, onDisposed: nil)
.disposed(by: disposeBag)
Subscribe an event of refresher(UIRefreshControl)