RxCocoa - prevent multiple view controller pushes when there's lag

1.7k views Asked by At

As is the case with both Reactive and non-Reactive iOS projects alike, if you have a UI element (e.g. a button or a table view cell that is being selected) that pushes a view controller onto the navigation stack, if there's lag for some reason (especially on older devices) repetitive taps can result in duplicate pushes and thus bad UX.

Normally you could disable the element after the first tap.

For example:

@IBAction func myButtonTap() { 
    button.isEnabled = false
    doTheRestOfTheAction()
}

I am relatively new to RxSwift. I am trying to figure out an appropriate Reactive way to implement this to fix a few bugs in my app where views get pushed repetitively.

Some thoughts:

Could use debounce or throttle but seems like a bandaid and won't necessarily fix every situation.

I'm currently thinking that the best way is to dispose of the subscription once the expected event has occurred.

let disposable = tableView.rx.itemSelected
    .subscribe(onNext: { [weak self] indexPath in 
        self?.performSegue(withIdentifier: "MySegueIdentifier", sender: self)
    })

...

func prepareForSegue() {
    myDisposable.dispose()
    finishPrepareForSegue()
}

Although if you want to unsubscribe inside the subscribe block, the compiler complains about using a variable inside its own initial value, which makes sense. I suppose there are workarounds but I wonder, is there a better way? Maybe a Reactive operator I'm missing?

Tried searching around for similar examples but the results were limited.

Thanks

EDIT: perhaps the takeUntil operator?

Possibly relevant SO question.

2

There are 2 answers

0
shim On BEST ANSWER

Not the only solution, but this seems to be working well at least for the case of pushing upon table view selection. It uses the takeUntil operator to stop the events

myTableView.rx.itemSelected
.takeUntil(self.rx.methodInvoked(#selector(viewWillDisappear)))
.subscribe(onNext: { [weak self] indexPath in
    self?.performSegue(withIdentifier: "MySegueIdentifier", sender: self)
)}
.dispose(by: self.myDisposeBag)

Although note that if you can return to view controller then you'd have to resubscribe, perhaps by moving the subscription to viewDidAppear. Maybe there's a more efficient method though that doesn't require re-subscription.

Another option is take(1) instead of takeUntil(…), but it would still require re-subscription upon returning to the view controller.

6
sargturner On

One thing that I see a lot at my company is the use of Rx's Variable that is something like loginInFlight which is a Variable<boolean>. This is defaulted to false and when the command is run to login we flip it to true. This boolean is also tied to the login button so once the user clicks login any subsequent clicks do not do anything. You could implement this wherever a user can click on something to change screens to make sure there isn't already a call / event in progress.

We follow MVVM so here's an example based on that. I tried to only show the barebones down below it so hopefully everything still makes sense below.

LoginViewController

class LoginViewController: UIViewController {
    @IBOutlet weak var signInButton: UIButton!

    override func viewDidLoad() {
        super.viewDidLoad()

        ...

        // This commandAvailable is what I was talking about above
        viewModel?
            .loginCommandAvailable
            .subscribe(onNext: {[unowned self] (available: Bool) in
                 self.signInButton.isEnabled = available
            })
            .addDisposableTo(disposeBag)

        signInButton.rx.tap
            .map {
                // Send Login Command
                return viewModel?.loginCommand()
            }.subscribe(onNext: { (result: LoginResult)
                // If result was successful we can send the user to the next screen
            }).addDisposableTo(disposeBag)
    }
}

LoginViewModel

enum LoginResult: Error {
    case success
    case failure
}

class LoginViewModel {
    private let loginInFlight = Variable<Bool>(false)

    private var emailAddressProperty = Variable<String>("")
    var emailAddress: Driver<String> {
        return emailAddressProperty
            .asObservable()
            .subscribeOn(ConcurrentDispatchQueueScheduler(queue: DispatchQueue.global()))
            .asDriver(onErrorJustReturn: "")
    }

    ...

    var loginCommandAvailable: Observable<Bool> {
        // We let the user login if login is not currently happening AND the user entered their email address 
        return Observable.combineLatest(emailAddressProperty.asObservable(), passwordProperty.asObservable(), loginInFlight.asObservable()) {
            (emailAddress: String, password: String, loginInFlight: Bool) in
                return !emailAddress.isEmpty && !password.isEmpty && !loginInFlight
        }
    }

    func loginCommand() -> Driver<LoginResult> {
        loginInFlight.value = true

        // Make call to login
        return authenticationService.login(email: emailAddressProperty.value, password: passwordProperty.value)
        .map { result -> LoginResult in
            loginInFlight.value = false
            return LoginResult.success
        }
    }
}

Edit For Toggling Command based on Availability

LoginViewController

class LoginViewController: UIViewController {
    @IBOutlet weak var signInButton: UIButton!

    override func viewDidLoad() {
        super.viewDidLoad()

        ...

        // This commandAvailable is what I was talking about above
        viewModel?
            .loginCommandAvailable
            .subscribe(onNext: {[unowned self] (available: Bool) in
                 self.signInButton.isEnabled = available
            })
            .addDisposableTo(disposeBag)

        signInButton.rx.tap
            .map {
                return viewModel?.loginCommandAvailable
            }.flatmap { (available: Bool) -> Observable<LoginResult>
                // Send Login Command if available
                if (available) {
                    return viewModel?.loginCommand()
                }
            }.subscribe(onNext: { (result: LoginResult)
                // If result was successful we can send the user to the next screen
            }).addDisposableTo(disposeBag)
    }
}