simnik
simnik

Reputation: 483

Emit value in random intervals if condition applies

Here are the three points I want to achieve:

  1. I want to emit a specific value in a random interval
  2. I want to emit only if upstream has a specific value
  3. I want to cancel the random time interval and re-start it if there is a new value upstream

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

Answers (1)

battlmonstr
battlmonstr

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

Related Questions