Handling circular style events on observable sequence RxSwift

513 Views Asked by At

I would like to know the best possible way to handle the following situation, I have tried an approach as it will be described but I have encountered an issue of events calling each other repeatedly in a circular way hence it causes stackoverflow

I have 4 observables as follows: -

    let agreeToPrivacyPolicyObservable = BehaviorRelay<Bool>(value: false)
    let agreeToTermsObservable = BehaviorRelay<Bool>(value: false)
    let agreeToMarketingEmailObservable = BehaviorRelay<Bool>(value: false)
    let agreeToAllOptionsObservable = BehaviorRelay<Bool>(value: false)

Goal: Sync agree to all button with individual options. ie if agree to all is true/checked then force other options to be checked as well and vice-versa. Additionally if the previous state of all items were checked and either of them emit unchecked then remove a checkmark on Agree to all button.

The image below visualizes my goal above. Observables

What I have tried:

Observable.combineLatest(
agreeToPrivacyPolicyObservable,
agreeToTermsObservable,
agreeToMarketingEmailObservable,
agreeToAllOptionsObservable
, resultSelector:{(termsChecked,privacyChecked,marketingChecked,agreeToAllChecked) in 
  switch (termsChecked,privacyChecked,marketingChecked,agreeToAllChecked) {
        case (true, true, true,true):
         //All boxes are checked nothing to change.
             break
        case (false,false,false,false):
          //All boxes are unchecked nothing to change.
           break
       case (true,true,true,false):
     // I omitted the `triggeredByAgreeToAll` flag implementation details for clarity
         if triggeredByAgreeToAll {
              updateIndividualObservables(checked: false)
            }else {
               agreeToAllOptionsObservable.accept(true)
           }
    case (false,false,false,true):
         if triggeredByAgreeToAll {
             updateIndividualObservables(checked: true)
           }else {
               agreeToAllOptionsObservable.accept(false)
          }
  default:
     if triggeredByAgreeToAll && agreeToAllChecked {
        updateIndividualObservables(checked: true)               
       }else if triggeredByAgreeToAll && agreeToAllChecked == false {
            updateIndividualObservables(checked: false)
      } else if (termsChecked == false || privacyChecked == false || marketingChecked == false ) {
       agreeToAllOptionsObservable.accept(false)
        }
     }
                    
  }

})
.observeOn(MainScheduler.instance)
.subscribe()
.disposed(by: rx.disposeBag)
// Helper function
  func updateIndividualObservables(checked: Bool) {
      agreeToPrivacyPolicyObservable.accept(checked)
      agreeToTermsObservable.accept(checked)
      agreeToMarketingEmailObservable.accept(checked)
     }

Explanation: My attempt gives me Reentracy anomaly was detected error , which according to my observations is caused by events being triggered repeatedly. This seems to occurs in the default switch case (on my solution above). I think this solution is not good as I have to check which event triggered the function execution.

Is there any better approach or is it possible to refactor this solution into something easily manageable? Btw Feel free to ignore my implementation and suggest a different better approach if any. Thanks!

UPDATES (WORKING SOLUTION)

I successfully implemented a working solution by using @Rugmangathan idea (Found on the accepted answer). So I leave my solution here to help anyone in the future facing the same issue. Here is the working solution: -

import Foundation
import RxSwift
import RxRelay


/// This does all the magic of selecting checkboxes.
/// It is shared across any view which uses the license Agreement component.
class LicenseAgreemmentState {
    
      static let shared = LicenseAgreemmentState()
     let terms = BehaviorRelay<Bool>(value: false)
     let privacy = BehaviorRelay<Bool>(value: false)
     let marketing = BehaviorRelay<Bool>(value: false)
     let acceptAll = BehaviorRelay<Bool>(value: false)
    
    private let disposeBag = DisposeBag()
    
     func update(termsChecked: Bool? = nil, privacyChecked: Bool? = nil, marketingChecked: Bool? = nil, acceptAllChecked: Bool? = nil) {
        if let acceptAllChecked = acceptAllChecked {
            // User toggled acceptAll button so change everything to it's value.
            acceptAll.accept(acceptAllChecked)
            updateIndividualObservables(termsChecked: acceptAllChecked, privacyChecked: acceptAllChecked, marketingChecked: acceptAllChecked)
        } else {
            // If either of the individual item is missing change acceptAll to false
            if termsChecked == nil || privacyChecked == nil || marketingChecked == nil {
                acceptAll.accept(false)
            }
            updateIndividualObservables(termsChecked: termsChecked, privacyChecked: privacyChecked, marketingChecked: marketingChecked)
        }
        
       // Deal with the case user triggered select All from individual items and vice-versa.
        Observable.combineLatest(terms, privacy, marketing,resultSelector: {(termsChecked,privacyChecked, marketingChecked) in
            switch (termsChecked,privacyChecked, marketingChecked) {
            case (true, true, true):
                self.acceptAll.accept(true)
            case (false,false,false):
                self.acceptAll.accept(false)
            default:
                break
            }
        })
            .observeOn(MainScheduler.instance)
            .subscribe()
            .disposed(by: disposeBag)
    }
    
    // MARK: - Helpers
    private func updateIndividualObservables(termsChecked: Bool?,privacyChecked: Bool?, marketingChecked:Bool?) {
        if let termsChecked = termsChecked {
            terms.accept(termsChecked)
        }
        if let privacyChecked = privacyChecked {
            privacy.accept(privacyChecked)
        }
        if let marketingChecked = marketingChecked {
            marketing.accept(marketingChecked)
        }
    }
}

2

There are 2 best solutions below

1
On BEST ANSWER

Your helper function updateIndividualObservables(:) triggers an event every time you update which in turn triggers the combineLatest you implemented above.

I would suggest you to keep a State object instead

struct TermsAndConditionState {
  var terms: Bool
  var privacy: Bool
  var marketing: Bool
  var acceptAll: Bool
}

In updateIndividualObservables method change this state and implement this state change with your respective checkboxes

func render(state: TermsAndConditionState) {
  if state.acceptAll {
    // TODO: update all checkboxes
  } else {
    // TODO: update individual checkboxes
  }
}
1
On

This is a simple state machine. State machines are implemented in Rx using the scan(_:accumulator:) or scan(into:accumulator:) operator like so:

struct Input {
    let agreeToPrivacyPolicy: Observable<Void>
    let agreeToTerms: Observable<Void>
    let agreeToMarketingEmail: Observable<Void>
    let agreeToAllOptions: Observable<Void>
}

struct Output {
    let agreeToPrivacyPolicy: Observable<Bool>
    let agreeToTerms: Observable<Bool>
    let agreeToMarketingEmail: Observable<Bool>
    let agreeToAllOptions: Observable<Bool>
}

func viewModel(input: Input) -> Output {
    enum Action {
        case togglePrivacyPolicy
        case toggleTerms
        case toggleMarketingEmail
        case toggleAllOptions
    }

    let action = Observable.merge(
        input.agreeToPrivacyPolicy.map { Action.togglePrivacyPolicy },
        input.agreeToTerms.map { Action.toggleTerms },
        input.agreeToMarketingEmail.map { Action.toggleMarketingEmail },
        input.agreeToAllOptions.map { Action.toggleAllOptions }
    )
    let state = action.scan(into: State()) { (current, action) in
        switch action {
        case .togglePrivacyPolicy:
            current.privacyPolicy = !current.privacyPolicy
        case .toggleTerms:
            current.terms = !current.terms
        case .toggleMarketingEmail:
            current.marketingEmail = !current.marketingEmail
        case .toggleAllOptions:
            if !current.allOptions {
                current.privacyPolicy = true
                current.terms = true
                current.marketingEmail = true
            }
        }
        current.allOptions = current.privacyPolicy && current.terms && current.marketingEmail
    }

    return Output(
        agreeToPrivacyPolicy: state.map { $0.privacyPolicy },
        agreeToTerms: state.map { $0.terms },
        agreeToMarketingEmail: state.map { $0.marketingEmail },
        agreeToAllOptions: state.map { $0.allOptions }
    )
}

struct State {
    var privacyPolicy: Bool = false
    var terms: Bool = false
    var marketingEmail: Bool = false
    var allOptions: Bool = false
}