Reputation: 483
Here are the three points I want to achieve:
To put it more into context:
I want to toggle a state on tap from A to B, B to C and then back C to A. However, if the current state is B and there is no user input, I want to toggle the state to C after a random time interval. If the current state is B and the user taps the screen, I do not want to toggle the state to C anymore, with the exception if the user toggled often enough to be on B again, in which case I would want a new random interval to be used.
I have some (more or less) pseudo code to clarify my problem further.
class ViewModel {
var state: State
private var stateSubject: CurrentValueSubject<State, Never>
private var cancellables: Set<AnyCancellable> = []
init(initialState: State) {
state = initialState
stateSubject = CurrentValueSubject(state)
// Pipe to store new state
stateSubject
.sink { [unowned self] in
self.state = $0
}
.store(in: &cancellables)
// Logic to switch specific state after interval
// Approach 1
stateSubject
.debounce(for: .seconds(randomInterval), scheduler: RunLoop.main)
.filter { $0 == .B }
.sink { [unowned self] _ in
stateSubject.send(.C)
}
.store(in: &cancellables)
// --------------------------------
// Approach 2
stateSubject
.flatMap(maxPublishers: .max(1)) { state in
Future { [unowned self] promise in
DispatchQueue.main.asyncAfter(deadline: .now() + randomInterval) {
promise(.success(state))
}
}
}
.filter { $0 == .B }
.sink { [unowned self] _ in
stateSubject.send(.C)
}
.store(in: &cancellables)
}
var randomInterval: Double { Double.random(in: 1...4) }
// Called on tap
func toggleState() {
state = state.toggle()
}
}
extension ViewModel {
enum State {
case A, B, C
func toggle() -> Self {
switch self {
case .A:
return .B
case .B:
return .C
case .C:
return .A
}
}
}
}
Approach 1:
This seems reasonable, however, as the .debounce
operator is only created once and therefore only accesses randomInterval
once, resulting in always debouncing the same amount of time.
Approach 2: This works fairly well, except if the user is toggling the state a little too often. I could not find the cause of the issue, but found the state toggling even on State.A
Thank you for your help and let me know if you have any further questions.
Upvotes: 4
Views: 290
Reputation: 6300
Challenge accepted! :)
import Foundation
import Combine
// for the example sake,
// pretending that the user taps exactly every 3 seconds
// use the real UI taps publisher here
// it emits an integer "tap ID", you can use a tap timestamp here instead,
// it just has to be unique for each tap
let taps = (1...).publisher.flatMap(maxPublishers: .max(1)) {
Just($0).delay(for: .seconds(3), scheduler: RunLoop.main)
}
// each tap timeouts emitting its tap ID after a random delay
// except the first one which fires immediately with the tap
let timeouts = taps.map {
Just($0).delay(for: .seconds(($0 > 1) ? Double.random(in: 1...4) : 0), scheduler: RunLoop.main)
}.switchToLatest() // when a new tap arrives, cancel the previous timeout
// using 0, 1, 2 here instead of A, B, C for simplicity
func next(_ state: Int) -> Int {
(state + 1) % 3
}
// given the previous state and what happened - returns the new state
func update(_ prevState: Int, _ event: (Int, Int)) -> Int {
let (tapID, timeoutTapID) = event
let isTimeout = (tapID == timeoutTapID)
let isTap = !isTimeout
if isTap {
print("event: tap \(tapID)")
} else {
print("event: timeout \(timeoutTapID)")
}
if isTap || (prevState == 1) {
return next(prevState)
} else {
// ignore timeouts in other states
return prevState
}
}
let initialState = 0
let states = taps.combineLatest(timeouts)
.scan(initialState, update)
.removeDuplicates()
let sub = states.sink { value in
print("new state: \(value)")
}
Demo:
event: timeout 1
new state: 0
event: tap 2
new state: 1
event: timeout 2
new state: 2
event: tap 3
new state: 0
event: timeout 3
event: tap 4
new state: 1
event: timeout 4
new state: 2
event: tap 5
new state: 0
event: tap 6
new state: 1
event: tap 7
new state: 2
event: timeout 7
event: tap 8
new state: 0
event: tap 9
new state: 1
event: tap 10
new state: 2
event: timeout 10
event: tap 11
new state: 0
event: tap 12
new state: 1
event: tap 13
new state: 2
event: timeout 13
One downside is that a timeout timer is always running, even when the current state doesn't need it (we ignore such updates). Hopefully it is just a single timer at all times.
Making the timer availability state-dependant requires a feedback loop that I tried to avoid.
Upvotes: 3