RxSwift: Issue with chaining streams - swift

I have a login use case which involves a remote service call and a pin.
In my view model I have a behaviour relay for pin like so
let pin = BehaviorRelay(value: "")
Then I have this service:
protocol LoginService {
func login(pin: String) -> Single<User>
}
Also in the view model I have a publish relay (to back a submit button) and also a state stream. State must initially set to .inactive and once the submit relay fires I need the state to go .loading and eventually .active.
var state: Observable<State> {
return Observable.merge(
.just(.inactive),
submit.flatMap { [service, pin] in
service.login(pin: pin.value).asObservable().flatMap { user -> Observable<State> in
.just(.active)
}.catch { error in
return .just(.inactive)
}.startWith(.loading)
})
}
The problem is that should the pin change after submit (and my use case involves clearing the pin once submit button clicked), the service is called a second time with the new pin value (in this case empty string).
I want this stream to just take the value for pin and run the service once only and ignore any new value for pin unless the submit was fired again.

Hmm... The code shown only triggers when submit emits a next event, not when pin emits so either you have other code that you aren't showing that is causing the problem, or you are sending a .next event into your publish relay inappropriately.
In short, only send a .next event when the user taps the submit button and the code you posted will work fine. Also, clearing out the pin text field will not change the pin behavior relay unless you are doing something odd elsewhere so that shouldn't be an issue.
This is essentially the same as what you have, but uses the withLatestFrom operator:
class ViewModel {
let submit = PublishRelay<Void>()
let pin = BehaviorRelay(value: "")
let state: Observable<State>
init(service: LoginService) {
self.state = submit
.withLatestFrom(pin)
.flatMapLatest { [service] in
service.login(pin: $0)
.map { _ in State.active }
.catch { _ in .just(.inactive) }
.asObservable()
.startWith(.loading)
}
.startWith(.inactive)
}
}
I'm not a fan of all the relays though and I don't like that you are throwing away the User object. I would likely do something more like this:
class ViewModel {
let service: LoginService
init(service: LoginService) {
self.service = service
}
func bind(pin: Observable<String>, submit: Observable<Void>) -> (state: Observable<State>, user: Observable<User?>) {
let user = submit
.withLatestFrom(pin)
.flatMapLatest { [service] in
service.login(pin: $0)
.map(Optional.some)
.catchAndReturn(nil)
}
.share()
let state = Observable.merge(
submit.map { .loading },
user.map { user in user == nil ? .inactive : .active }
)
.startWith(State.inactive)
return (state: state, user: user)
}
}

I think you are trying too hard to chain things together :-).
Let's take your problem apart and see if that helps.
The important event for you is the button push. When the user pushes the "submit" button you want to make an attempt to log in.
So attach a subject to your pin entry field and let it capture the result of the user's typing. You want this to be a stream that holds the latest value of the pin:
// bound to a text input field. Has the latest pin entered
var pin = BehaviorSubject(value: "")
Then you can have an infinite stream that just gets sent a value when the button is pushed. The actual value sent is not as important as the fact that it emits a value when the user pushes the button.
var buttonPushes = PublishSubject<Bool>()
From that, we're going to create a stream that emits a value each time the button is pushed. We'll represent a login attempt as a struct, LoginInfo that contains all the stuff you need to try and log in.
struct LoginInfo {
let pin : String
/* Maybe other stuff like a username is needed here */
}
var loginAttempts = buttonPushes.map { _ in
LoginInfo(pin: try pin.value())
}
loginAttempts sees a button push and maps in into an attempt to log in.
As part of that mapping, it captures the latest value from the pin stream, but loginAttempts is not directly tied to the pin stream. The pin stream can go on changing forever and loginAttempts won't care until the user pushes the submit button.

Related

How can I launch the NSOpen dialog when my SwiftUI app starts on macOS?

I have a tiny one-view SwiftUI app on macOS that acts as a front-end for hledger. I need it to prompt the user for a ledger file to use preferably with the minimum amount of extra code or user UI interactions (in that order of preference), and I would like for this to happen precisely once at launch ideally. My first idea was to do something like this:
func openHledgerFile() -> String {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
if panel.runModal() == .OK {
return panel.url?.path ?? ""
}
return ""
}
struct ExpenseControllerView: View {
private var ledgerFileName: String {
openHledgerFile() // Real code would make this condidional
}
var body: some View {
Text("Opened file \(ledgerFileName)")
}
}
however, that never shows the dialog at all.
Is there a way I can tell the declarative subsystem "if this value is not initialised then don't show the view, show the dialog"? I would like to avoid having an enitre view with just a "do thing" button.

Chaining observables (that are network requests)

I have a network request called login that returns an Observable<UserInfo>. I need to make another API call from that result based on whether the data returned from login has a count > 1, otherwise, I just need to go to a different view controller. I’m trying to use flatMapLatest to do the check for the first request login and make the next network call jobStates (which returns an Observable<JobState>, but I don’t think I’m arranging them correctly. Any ideas? Is there a better / easier way to do this?
Here's what it looks like:
I would expect to see something like this:
func login() {
let loginResult = networkService
.login(login: usernameTextField.text!, password: passwordTextField.text!)
.share()
loginResult
.filter { $0.count > 1 }
.subscribe(onNext: { userInfo in
// stop here and go to a different view with userInfo data
})
.disposed(by: disposeBag)
let networkService = self.networkService // so you don't have to capture self below
loginResult
.filter { $0.count <= 1 }
.flatMapLatest { networkService.jobStates(locationId: $0.locationId) }
.subscribe(
onNext: { data in
// do whatever with data from second request
},
onError: { error in
// if either request errors, you will end up here.
})
.disposed(by: disposeBag)
}
When you have two different possible outcomes, you need two different subscribes.

Networking start loading issue

I'm using the next states to handle my API state
enum RequestState<T: Decodable> {
case loading
case loaded(T)
case error(Error)
}
and the following code to change these states:
let response = request
.flatMapLatest {
provider.rx.request($0)
.map(T.self).map { RequestState.loaded($0) }
.asDriver(onErrorRecover: { error in
return Driver.just(.error(error))
})
.startWith(.loading)
}
all works awesome but except one issue. case loading works only when all fine with network and request in progress.
Connection
Tap - Loading spinner - Response
No connection
Tap - network thining delay - No connection response
I would like to start loading on tap always. Maybe use the new state case startedLoading. And get this state when a new request pushed to the sequence (ex. after tap refresh button).
Currently, .loading is emitted only after the first request observable emits a next value, as startWith is applied to the inner observable.
Moving .startWith to the outer observable will ensure you always get a .loading event before any other.
let response = request
.flatMapLatest {
provider.rx.request($0)
.map(T.self).map { RequestState.loaded($0) }
.asDriver(onErrorRecover: { error in
return Driver.just(.error(error))
})
}
.startWith(.loading)

Given a list of timers, how to output if one of them completed, while also being able to reset the list?

I have an output signal that should output when one of a given set of timers time out, complete or when the entire list is reset.
enum DeviceActionStatus {
case pending
case completed
case failed
}
struct DeviceAction {
let start: Date
let status: DeviceActionStatus
func isTimedOut() -> Bool // if start is over 30 seconds ago
let id: String
}
Output signal:
let pendingActionUpdated: Signal<[DeviceAction], NoError>
Inputs:
let completeAction: Signal<String, NoError>
let tick: Signal<Void, NoError> // runs every 1 second and should iterate to see if any DeviceAction is timed out
let addAction: Signal<DeviceAction, NoError>
let resetAllActions: Signal<Void, NoError>
It should output an array of all running device actions.
let output = Signal.combineLatest(
addAction,
resetAllActions,
tick,
Signal.merge(
completeAction,
tick.take(first: 1).map { _ in "InvalidActionId" }
)) // make sure the combinelatest can fire initially
I've tried sending this to a .scan to cumulate every time the addAction is fired, and resetting every time the resetAllActions is fired, but since there is no way of knowing which of those fired, i can't get the logic to work. How can I both cumulate a growing list while also being able to run through it and being able to reset it when I want?
This looks like a job for the merge/enum pattern. I'm more an RxSwift guy myself, but if you map each of your Signals into an enum and merge them, then you can receive them properly into your scan...
enum ActionEvent {
case complete(String)
case tick
case add(DeviceAction)
case reset
}
merge(
completeAction.map { ActionEvent.complete($0) },
tick.map { ActionEvent.tick },
addAction.map { ActionEvent.add($0) },
resetAllActions.map { ActionEvent.reset }
).scan([DeviceAction]()) { actions, event in
switch event {
case let .complete(id):
return actions.filter { $0.id != id }
case .tick:
return actions.filter { $0.isTimedOut() == false }
case let .add(action):
return actions + [action]
case .reset:
let resetDate = Date()
return actions.map { $0.start = resetDate }
// or
return []
// depending on what "reset" means.
}
It's a bit difficult to see the full use case here, so I will just describe how I would distinguish between addAction and resultAllActions being fired, leaving the rest of the design alone.
You can merge those two into one signal prior to the Signal.combineLatest. In order to do that, you will need to map them to the same type. An enum is perfect for this:
enum Action {
case add(DeviceAction)
case resetAll
}
Now you can map each signal and merge them into a single signal:
let action = Signal.merge(
addAction.map { Action.add($0) },
resetAllActions.map { _ in Action.resetAll })
Now you can switch on the value in your scan and determine whether it's a new action being added or a reset.

How to properly combine multiple Drivers with RxSwift?

I'm combining a viewDidAppear and filter Drivers with RxSwift. And they work great. But when I introduce a third Driver, it stops calling flatMapLatest on the latest combine.
In my View Controller, I have these Drivers:
let filter: Driver<String>
let viewDidAppear: Driver<Void>
let refresh: Driver<Void>
And in my view model:
// On viewDidAppear, I download a list of portfolios
let viewDidAppearPortfolios = viewDidAppear
.flatMapLatest({ (_) -> Driver<Result<[PortfolioModel]>> in
return networkService.request(Router.portfolios)!
.responseCollections()
.trackActivity(fetching)
.asDriver(onErrorJustReturn: .failure(NSError()))
})
.flatMapLatest({ (result: Result<[PortfolioModel]>) -> Driver<[PortfolioModel]> in
switch result {
case .success(let value): return Driver.just(value)
case .failure(_): return Driver.just([])
}
})
// Then I combine with a filter from my search bar.
self.portfolios = Driver.combineLatest(viewDidAppearPortfolios, filter)
.flatMapLatest { (portfolios: [PortfolioModel], filter: String) -> Driver<[PortfolioModel]> in
if filter.isEmpty {
return Driver.just(portfolios)
}
return Driver.just(portfolios.filter({ (portfolio) -> Bool in
portfolio.portfolio.localizedCaseInsensitiveContains(filter)
}))
}
The above works!
The network requests a list of portfolios, and I'm able to filter those results as I type, client side.
However, I'd like for the user to pull to refresh, and trigger the network request again! And so, I combine with my refresh driver.
And this:
Driver.combineLatest(viewDidAppearPortfolios, filter)
Becomes this:
Driver.combineLatest(viewDidAppearPortfolios, filter, refresh)
Problem!
After combining with refresh the flatMapLatest is no longer called on viewDidAppear! Only if I manually pullToRefresh.
Driver.combineLatest(viewDidAppearPortfolios, filter, refresh).flatMapLatest { _,_,_ in
// No longer get's called on viewDidAppear after combining with refresh
}
The viewDidAppearPortfolios still executes, so the network request
is getting called!
Only if I manually pull to refresh do I get the
list of portfolios that I previously requested...
Any idea why?
Thank you!
It looks like your refresh didn't emit a single event yet and so the combineLatest is not computed.
I tried this code to test:
let one = Driver.just(1)
let two = Driver.just(2)
let three: Driver<Int> = .just(3)
let result = Driver.combineLatest(one, two, three)
.flatMapLatest {
return Driver.just($0 + $1 + $2)
}
result.drive(onNext: {
print($0)
})
This prints 6 but if you use let three: Driver<Int> = .empty() this is not printing anything. So I guess you need a way to set an initial value to refresh stream.