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())
}
Related
I got a function such as scrollViewDidScroll that can trigger many times. And I need to call function loadMoreDataFromRemoteServerIfNeed only single time. How could I do this more elegantly without using any "flag" variables. Maybe I should use DispathGroup|DispatchWorkItem?
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let yOffset = scrollView.contentOffset.y
if yOffset > offset {
loadMoreDataFromRemoteServerIfNeed()
}
}
func loadMoreDataFromRemoteServerIfNeed() {
DispatchQueue.global(qos: .background).async {
sleep(2)
DispatchQueue.main.async {
// <Insert New Data>
}
}
}
The thing that you are trying to describe — "Do this, but only if you are not told to do it again any time in the next 2 seconds" — has a name. It's called debouncing. This is a well-solved problem in iOS programming, so now that you know its name, you can do a search and find some of the solutions.
While I'm here telling you about this, here's a solution you might not know about. Debouncing is now built in to iOS functionality! Starting in iOS 13, it's part of the Combine framework. I'm now using Combine all over the place: instead of notifications, instead of GCD, instead of Timer objects, etc. It's great!
Here's a Combine-based solution to this type of problem. Instead of a scroll view, suppose we have a button hooked up to an action handler, and we don't want the action handler to do its task unless 2 seconds has elapsed since the last time the user tapped the button:
var pipeline : AnyCancellable?
let pipelineStart = PassthroughSubject<Void,Never>()
#IBAction func doButton(_ sender: Any) {
if self.pipeline == nil {
self.pipeline = pipelineStart
.debounce(for: .seconds(2), scheduler: DispatchQueue.main)
.sink { [weak self] _ in self?.doSomething() }
}
self.pipelineStart.send()
}
func doSomething() {
print("I did it!")
}
I'm sure you can readily see how to adapt that to your own use case:
var pipeline : AnyCancellable?
let pipelineStart = PassthroughSubject<Void,Never>()
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let yOffset = scrollView.contentOffset.y
if yOffset > offset {
if self.pipeline == nil {
self.pipeline = pipelineStart
.debounce(for: .seconds(2), scheduler: DispatchQueue.main)
.sink { [weak self] _ in self?.loadMoreDataFromRemoteServerIfNeed()
}
self.pipelineStart.send()
}
}
func loadMoreDataFromRemoteServerIfNeed() {
// <Insert New Data>
}
You can create a flag from DispatchWorkItem to observe loading state e.g.:
var item: DispatchWorkItem?
func loadMoreDataFromRemoteServerIfNeed() {
assert(Thread.isMainThread)
guard item == nil else { return }
item = DispatchWorkItem {
print("loading items")
Thread.sleep(forTimeInterval: 2)
DispatchQueue.main.async {
item = nil
print("insert items")
}
}
DispatchQueue.global().async(execute: item!)
}
NOTE: to synchronise item var you must change its value on the same thread for instance the main thread.
Yes, you could use DispatchWorkItem, keep a reference to the old one, and cancel prior one if necessary. If you were going to do that, I might consider Operation, too, as that handles cancelation even more gracefully and has other advantages.
But that having been said, given that the work that you are dispatching is immediately sleeping for two seconds, this might suggest a completely different pattern, namely a Timer. You can schedule your timer, invalidating previously scheduled timers, if any:
weak var timer: Timer?
func loadMoreDataFromRemoteServerIfNeed() {
// cancel old timer if any
timer?.invalidate()
// schedule what you want to do in 2 seconds
timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in
// <Insert New Data>
}
}
FWIW, if you ever find yourself sleeping, you should general consider either timers or asyncAfter. This avoids tying up the global queue’s worker thread. Sleeping is an inefficient pattern.
In this case, keeping a weak reference to the prior timer (if any) is probably the best pattern.
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.
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
my problem is adding the duplicate data to the array
my program it works well before it can be refresh manually but duplicate added to list when manually refreshed
when I check the print, the data is added to the double list
print result
ARRAYLAR : ["EXAMPLE", "EXAMPLE"]
Watch the video for a better understanding of the problem
VİDEO
override func viewDidLoad() {
super.viewDidLoad()
tableView.delegate = self
tableView.dataSource = self
getData()
}
#objc func getData() {
self.konuAdiArray.removeAll(keepingCapacity: false)
self.konuHedefTarihArray.removeAll(keepingCapacity: false)
self.konuTestArray.removeAll(keepingCapacity: false)
self.konuIDArray.removeAll(keepingCapacity: false)
self.veriGirisArray.removeAll(keepingCapacity: false)
Database.database().reference().child("users").child((Auth.auth().currentUser?.uid)!).child("dersler").child(gelenDersID!).child("konular").observe(DataEventType.childAdded) { (snapshot) in
let values = snapshot.value! as! NSDictionary
self.konuAdiArray.append(values["konuAdi"]as! String)
self.konuHedefTarihArray.append(values["konuHedefTarihi"]as! String)
self.konuTestArray.append(values["konuTestHedefi"]as! String)
self.veriGirisArray.append(values["veriGirisSoru"]as! String)
self.konuIDArray.append(snapshot.key)
print("ARRAYLAR : \(self.konuAdiArray)")
self.tableView.reloadData()
}
}
refreshBarButton code
#IBAction func refreshBarButton(_ sender: Any) {
getData()
}
You need to make separate function for observing changes of your data base. When you call getData() you code calls twice of DB changes. So that you subscribe on changes one more time that's why I'd recommend you to make separate function like setDataBaseObserver() which you call only one time in viewDidLoad. If you are updating your data you should make network request or take them from different source (not from data base again). Hope you understand me right!
func setDataBaseObserver() {
Database.database().reference().child("users").child((Auth.auth().currentUser?.uid)!).child("dersler").child(gelenDersID!).child("konular").observe(DataEventType.childAdded) { (snapshot) in
let values = snapshot.value! as! NSDictionary
self.konuAdiArray.append(values["konuAdi"]as! String)
self.konuHedefTarihArray.append(values["konuHedefTarihi"]as! String)
self.konuTestArray.append(values["konuTestHedefi"]as! String)
self.veriGirisArray.append(values["veriGirisSoru"]as! String)
self.konuIDArray.append(snapshot.key)
print("ARRAYLAR : \(self.konuAdiArray)")
self.tableView.reloadData()
}
}
Hope it will help to you!
There's no reason for the manual refresh. You are observing childAdded on a Firebase database, which will continue to update in real time. Whenever you hit the manual refresh, your getData() is adding your controller as an observer again. Either remove the manual refresh control and just let Firebase do its thing (this is what it excels at - realtime updates without manual refresh), or change your childAdded observation to be a one-time data fetch.
I have an app which reloads data (by running two queries, appending the queried info to arrays, and the reloading tableview data). Both queries are set up as function which run on viewDidLoad() but are also linked to a refreshing function (which the user can manually activate with a pull to refresh). The queries work fine, however, an issue occasionally arises while the query functions are being run (but not yet completed) by the viewDidLoad() and the user tries to pull to refresh before it is complete... duplicates are added to the array and I would like to avoid that. The idea I had was to set a checking variable initially to false, change it to true after the viewDidLoad() had completed, and only allow the pull to refresh function to work after the variable had been changed to true. Here is the set up of the function:
func myQueryandAppend(completion: (() -> Void)?){
myCreatorArray.removeAll(keepingCapacity: true)
//repeated for all arrays
let myQuery = PFQuery(className: "Posts")
myQuery.whereKey("User", equalTo: currentUserId)
myQuery.findObjectsInBackground { (objects, error) in
if let objectss = objects{
for object in objectss {
//append the arrays
self.tableView.reloadData()
}
}
})
}
The other function is essentially identical to this one but pulls slightly different info and appends different arrays. (Side note... not entirely sure if the self.tableView.reloadData() is in the correct spot...)
This is the viewDidLoad():
var checkerVar = false
override func viewDidLoad() {
super.viewDidLoad()
print("It loaded")
myQueryandAppend(completion: {
self.tableView.reloadData()
print("myQuery was called in viewDidLoad()")//never called
checkerVar = true
})
refreshControl = UIRefreshControl()
refreshControl.attributedTitle = NSAttributedString(string: "Pull to refresh")
refreshControl.addTarget(self, action: #selector(MainPageVC.refresh(_:)), for: UIControlEvents.valueChanged)
tableView.addSubview(refreshControl)
self.navigationController?.navigationBar.isHidden = true
checkerVar = true //isn't changed on *completion* of the function
}
Then the refresh handler:
func refresh(_ sender: AnyObject){
if checkerVar == true{
myQueryandAppend(completion: {
self.tableView.reloadData()
self.refreshControl.endRefreshing()
})
self.refreshControl.endRefreshing()
}else{
self.refreshControl.endRefreshing()
}
}
The problem is, with the checkerVar, the instance within the completion in the viewDidLoad() closure is never called (the print statement above it is never logged), and I believe the second instance, at the bottom of viewDidLoad() is done immediately, without actually waiting for the function to complete. Also, no print statements within any of the (completion:{}) are ever logged.
So: 1. Why are the print statements within the completions not being called and (more importantly) 2. how do I get the checkerVar to only be changed to true after viewDidLoad() is complete?
(Side note... not entirely sure if the self.tableView.reloadData() is in the correct spot...)
It's not. You are calling reloadData multiple times, once for every object in objects. That's wasteful. You should write this:
if let objectss = objects{
for object in objectss {
//append the arrays
}
self.tableView.reloadData()
}
Also, I wouldn't count on this code running on the main thread — but you must make sure reloadData is called on the main thread. So now we have this:
if let objectss = objects{
for object in objectss {
//append the arrays
}
DispatchQueue.main.async {
self.tableView.reloadData()
}
}
Finally, as you've already been told, you are failing to call your own completion handler. But wait. You are also calling reloadData in your completion handler. So now we can delete all of that and just call completion:
if let objectss = objects{
for object in objectss {
//append the arrays
}
DispatchQueue.main.async {
completion()
}
}
Now, however, we run into a slight difficulty, as there are two cases to consider. What if the if let fails? We still need to call the completion handler, so we need to move the call to completion so that we are sure it takes place no matter what:
if let objectss = objects{
for object in objectss {
//append the arrays
}
}
DispatchQueue.main.async {
completion()
}
And with that final change, I think the code will do what you wanted it to do.
Your method "myQueryandAppend"'s completion closure was never called, so the completion closure never fired.
So after your for statement:
for object in objectss {
//append the arrays
self.tableView.reloadData()
}
add:
completion()
to trigger the closure.
I believe once you fix this issue, everything would fall in place easily as your check var would be updated as you expect (maybe you should name it to something like isQuerying though.