Search code examples
swiftconcurrencygrand-central-dispatch

Hide loading indicator on debounced search


I have a VC with a list of different options like sliders, buttons, switches and etc (basically it's a common Filter page). I have a serial queue and i run my api call once 0.5 seconds just to avoid calling it every time on each option change

private let searchQueue = DispatchQueue(label: "com.blablah.searchQueue")
private var searchWorkItem: DispatchWorkItem?

private func search() {
    bottomSheetView.showGameBtn.isLoading = true
    
    self.searchWorkItem?.cancel()

    let workItem = DispatchWorkItem { [weak self] in
        self?.presenter?.getSearchFilterResults()
    }

    self.searchQueue.asyncAfter(deadline: .now() + 0.5, execute: workItem)
    
    self.searchWorkItem = workItem
}

// Callback
func showSearchFilterResults() {
    bottomSheetView.showGameBtn.isLoading = false
}

In general it works as expected, but i have an issue with hiding/showing my showGame button.
For example:

  1. Change some of the filters
  2. Button indicator is showing immediately
  3. Wait 0.5 sec
  4. Api call starts executing (i.e it will be executing for 5 seconds)
  5. I change some other filters
  6. Wait 0.5 sec
  7. Another api request starts executing

The problem is when the first call will be finished, my button will stop loading, but in fact there will be a second request and i want it to stop loading after the second request is finished. Seems like a typical race condition, but i've tried different solutions with DispatchGroup but it seems like it didn't work for me or i tried them in a wrong way


Solution

  • The debounce mechanism you have implemented only debounces tasks that have not started yet. But once the task starts, your DispatchWorkItem cancelation mechanism won’t propagate the cancelation to the search routine. On top of that, the button “loading” state is Boolean.

    There are two broad approaches, and it depends upon whether getSearchFilterResults is cancelable or not.

    1. If it is cancelable, then you need to replace the DispatchWorkItem pattern with something that can actually cancel the search once it has begun:

      • The contemporary solution for that is to use Swift concurrency (i.e., Task with async-await) which offers a lot of elegant cancelation patterns. E.g., start the search in a Task and cancel that. Or use debounce from the Swift Async Algorithms package.

      • The legacy solution (which I even hesitate to mention because it is so convoluted) is to write a custom Operation subclass (with all the custom KVO notifications that that entails to properly wrap a task that is asynchronous). That handles cancelation far more gracefully (e.g., you can add a cancel implementation that will call the “cancel” mechanism provided by whatever API the search routine is using) than DispatchWorkItem (which, once the task starts, really only provides the potential for polling the cancelation state, which is fine for compute processes with a loop).

      In short DispatchWorkItem cancelation patterns are quite limited. Operation subclasses offer great features, but at the cost of some complexity. Swift concurrency is the best of both worlds.

    2. If getSearchFilterResults, itself, is not cancelable, then you have two options. (Go ahead and debounce within the first 0.5 seconds, but we’re talking about a new search call once the prior search has commenced but not yet finished.) In this case, the question now becomes whether the search supports concurrent requests:

      • If it does support concurrent requests, then go ahead and just kick off the next search concurrently. But, rather than a “is loading” boolean, you might have a counter, instead. Every time a search starts, increment the counter. Every time a search ends, decrement the counter. And have a routine that updates the UI according to that counter (if zero, not loading, if greater than zero, then loading).

        You may want to also keep track of which search is the most recent because the searches might not finish in the order in which they began, and you never want to replace the latest search results with some prior search results.

      • If the API underlying the search mechanism does not support concurrency, then obviously you must run the requests sequentially.

        Again, the devil is in the details. If the search mechanism runs synchronously, just add it to a serial queue and you are done. If the search runs asynchronously, there are a variety of approaches one might employ depending upon the nature of the implementation. Swift concurrency will call for one approach (awaiting tasks, AsyncChannel, etc.), whereas legacy asynchronous patterns will call for another (custom Operation subclass, futures, promises, etc.).

    Obviously, the devil is in the details and it is impossible to be any more specific without details about the API employed by getSearchFilterResults. I.e., namely, does it support cancelation and if not, does it support concurrent requests. Also, it depends upon the particular asynchronous pattern you employ (e.g., dispatch queues; operation queues; Combine; Swift concurrency; etc.).