Search code examples
swiftrx-swiftreactivex

How to pull one event from an observable when another emits


I can't work out how to pull one event from an observable when another fires (at least, I think that's the essence of the problem but I'm so confused I might be wrong).

I have a ViewModel which is passed an array of objects on initialisation. The corresponding ViewController shows one of those objects at a time for the user to accept or decline (two buttons), along with an option to apply the response to all remaining objects (a checkbox). A side-effect of accepting the object (and possibly all remaining ones) is a database insertion. When there are no more objects the ViewController will be dismissed.

How can I model this reactively (using RxSwift/Cocoa)?

I'd also like to be able to show the user how many objects are remaining but that seems like an additional complexity.


Solution

  • This is an example implementation for the described behaviour.

    Remember though that stackoverflow is not meant to work like this. You should show the code that you wrote to try to solve your problem first.

    // This encodes a database action. You can subscribe to view model's `dbAction` to perform your desired side effect.
    enum DBAction<T> {
        case insert(object: T)
        case delete(object: T)
    }
    
    class SomeViewModel<Object> {
        struct Output {
            let currentObject: Observable<Object>
            let remainingObjectCount: Observable<Int>
            let dbAction: Observable<DBAction<Object>>
        }
    
        struct Input {
            let acceptOrDecline: Observable<(keepOrDrop: Bool, applyToAll: Bool)>
        }
    
        let totalObjectCount: Int
        let objects: Observable<Object>
    
        init(objects: [Object]) {
            self.totalObjectCount = objects.count
            self.objects = .from(objects) // 1
        }
    
        func transform(input: Input) -> Output {
            let applyToAll: Observable<Void> = input.acceptOrDecline.map { $0.applyToAll }.filter { $0 == true }.map { _ in }
            let acceptOrDecline = input.acceptOrDecline.map { $0.keepOrDrop }
    
            let currentObject = Observable.zip( // 2
                objects,
                acceptOrDecline.map { _ in }.startWith() // 3
            ) { object, _ in object }
                .takeUntil(applyToAll) // 4
                .share()
    
            // 5
            let actionForCurrent = input.acceptOrDecline.flatMap { tuple in
                tuple.applyToAll ? Observable.repeatElement(tuple.keepOrDrop, scheduler: MainScheduler.instance) : .just(tuple.keepOrDrop)
            }
    
            let dbAction = Observable.zip(
                objects,
                actionForCurrent
            ) { (object: Object, shouldKeep: Bool) -> DBAction<Object> in
                if shouldKeep {
                    return DBAction.insert(object: object)
                } else {
                    return DBAction.delete(object: object)
                }
            }
    
            let remainingObjectCount = currentObject.scan(totalObjectCount) { acc, _ in
                acc - 1
            }.concat(.just(0))
    
            return Output(
                currentObject: currentObject,
                remainingObjectCount: remainingObjectCount,
                dbAction: dbAction
            )
        }
    }
    
    1. Here I create an obversable that will emit each elements in the source array, on after the other.
    2. zip combines elements from two observables. The good thing with zip is that it will wait for a distinct element from each source. When subscribing to the result of zip, a new element will be emitted after input.acceptOrDecline emits. Hence, we'll receive a new object after each decision.
    3. startWith() will force a first emission, so that we receive the first object we want the user to take a decision on.
    4. takeUntil will make our observable complete when applyToAll emits. So that we do not receive a new element when the applyToAll checkbox is checked.
    5. repeatElement will indefinitely repeat an element. So when applyToAll is true, we'll repeat the decision indefinitely. Because we zip the result of flatMap to objects, it will repeat the decision for the number of remaning object in objects.

    To build the source observable for the view model, assuming you are using two UIButtons and a UISwitch

    let acceptButton: UIButton
    let dropButton: UIButton
    let applyToAll: UISwitch
    
    let accept = acceptButton.rx.tap.map { true }
    let drop = dropButton.rx.tap.map { false }
    let input = Input(
        acceptOrDecline: Observable.combineLatest(
            Observable.merge(accept, drop),
            applyToAll.rx.value
        ) { (keepOrDrop: $0, applyToAll: $1) }
    )
    

    Note that this is a proposed implementation that compiles but that I did not test. You'll find here leads to implement your desired behavior, but I cannot guaranty this is 100% correct.