How to throttle search (based on typing speed) in iOS UISearchBar?

IosObjective CSwiftSearch

Ios Problem Overview


I have a UISearchBar part of a UISearchDisplayController that is used to display search results from both local CoreData and remote API. What I want to achieve is the "delaying" of the search on the remote API. Currently, for each character typed by the user, a request is sent. But if the user types particularly fast, it does not make sense to send many requests: it would help to wait until he has stopped typing. Is there a way to achieve that?

Reading the documentation suggests to wait until the users explicitly taps on search, but I don't find it ideal in my case.

> Performance issues. If search operations can be carried out very > rapidly, it is possible to update the search results as the user is > typing by implementing the searchBar:textDidChange: method on the > delegate object. However, if a search operation takes more time, you > should wait until the user taps the Search button before beginning the > search in the searchBarSearchButtonClicked: method. Always perform > search operations a background thread to avoid blocking the main > thread. This keeps your app responsive to the user while the search is > running and provides a better user experience.

Sending many requests to the API is not a problem of local performance but only of avoiding too high request rate on the remote server.

Thanks

Ios Solutions


Solution 1 - Ios

Try this magic:

- (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText{
    // to limit network activity, reload half a second after last key press.
    [NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(reload) object:nil];
    [self performSelector:@selector(reload) withObject:nil afterDelay:0.5];
}

Swift version:

 func searchBar(searchBar: UISearchBar, textDidChange searchText: String) {
    // to limit network activity, reload half a second after last key press.
      NSObject.cancelPreviousPerformRequestsWithTarget(self, selector: "reload", object: nil)
      self.performSelector("reload", withObject: nil, afterDelay: 0.5)
 }

Note this example calls a method called reload but you can make it call whatever method you like!

Solution 2 - Ios

For people who need this in Swift 4 onwards:

Keep it simple with a DispatchWorkItem like here.


or use the old Obj-C way:

func searchBar(searchBar: UISearchBar, textDidChange searchText: String) {
    // to limit network activity, reload half a second after last key press.
    NSObject.cancelPreviousPerformRequestsWithTarget(self, selector: "reload", object: nil)
    self.performSelector("reload", withObject: nil, afterDelay: 0.5)
}

EDIT: SWIFT 3 Version

func searchBar(searchBar: UISearchBar, textDidChange searchText: String) {
    // to limit network activity, reload half a second after last key press.
    NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(self.reload), object: nil)
    self.perform(#selector(self.reload), with: nil, afterDelay: 0.5)
}
@objc func reload() {
    print("Doing things")
}

Solution 3 - Ios

Improved Swift 4+:

Assuming that you are already conforming to UISearchBarDelegate, this is an improved Swift 4 version of VivienG's answer:

func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
    NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(self.reload(_:)), object: searchBar)
    perform(#selector(self.reload(_:)), with: searchBar, afterDelay: 0.75)
}

@objc func reload(_ searchBar: UISearchBar) {
    guard let query = searchBar.text, query.trimmingCharacters(in: .whitespaces) != "" else {
        print("nothing to search")
        return
    }
    
    print(query)
}

The purpose of implementing cancelPreviousPerformRequests(withTarget:) is to prevent the continuous calling to the reload() for each change to the search bar (without adding it, if you typed "abc", reload() will be called three times based on the number of the added characters).

The improvement is: in reload() method has the sender parameter which is the search bar; Thus accessing its text -or any of its method/properties- would be accessible with declaring it as a global property in the class.

Solution 4 - Ios

Thanks to this link, I found a very quick and clean approach. Compared to Nirmit's answer it lacks the "loading indicator", however it wins in terms of number of lines of code and does not require additional controls. I first added the dispatch_cancelable_block.h file to my project (from this repo), then defined the following class variable: __block dispatch_cancelable_block_t searchBlock;.

My search code now looks like this:

- (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText
{
    if (searchBlock != nil) {
        //We cancel the currently scheduled block
        cancel_block(searchBlock);
    }
    searchBlock = dispatch_after_delay(searchBlockDelay, ^{
        //We "enqueue" this block with a certain delay. It will be canceled if the user types faster than the delay, otherwise it will be executed after the specified delay
        [self loadPlacesAutocompleteForInput:searchText]; 
    });
}

Notes:

  • The loadPlacesAutocompleteForInput is part of the LPGoogleFunctions library

  • searchBlockDelay is defined as follows outside of the @implementation:

    static CGFloat searchBlockDelay = 0.2;

Solution 5 - Ios

A quick hack would be like so:

- (void)textViewDidChange:(UITextView *)textView
{
    static NSTimer *timer;
    [timer invalidate];
    timer = [NSTimer timerWithTimeInterval:1.0 target:self selector:@selector(requestNewDataFromServer) userInfo:nil repeats:NO];
}

Every time the text view changes, the timer is invalidated, causing it not to fire. A new timer is created and set to fire after 1 second. The search is only updated after the user stops typing for 1 second.

Solution 6 - Ios

Swift 4 solution, plus some general comments:

These are all reasonable approaches, but if you want exemplary autosearch behavior, you really need two separate timers or dispatches.

The ideal behavior is that 1) autosearch is triggered periodically, but 2) not too frequently (because of server load, cellular bandwidth, and the potential to cause UI stutters), and 3) it triggers rapidly as soon as there is a pause in the user's typing.

You can achieve this behavior with one longer-term timer that triggers as soon as editing begins (I suggest 2 seconds) and is allowed to run regardless of later activity, plus one short-term timer (~0.75 seconds) that is reset on every change. The expiration of either timer triggers autosearch and resets both timers.

The net effect is that continuous typing yields autosearches every long-period seconds, but a pause is guaranteed to trigger an autosearch within short-period seconds.

You can implement this behavior very simply with the AutosearchTimer class below. Here's how to use it:

// The closure specifies how to actually do the autosearch
lazy var timer = AutosearchTimer { [weak self] in self?.performSearch() }

// Just call activate() after all user activity
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
    timer.activate()
}

func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
    performSearch()
}

func performSearch() {
    timer.cancel()
    // Actual search procedure goes here...
}

The AutosearchTimer handles its own cleanup when freed, so there's no need to worry about that in your own code. But don't give the timer a strong reference to self or you'll create a reference cycle.

The implementation below uses timers, but you can recast it in terms of dispatch operations if you prefer.

// Manage two timers to implement a standard autosearch in the background.
// Firing happens after the short interval if there are no further activations.
// If there is an ongoing stream of activations, firing happens at least
// every long interval.

class AutosearchTimer {
    
    let shortInterval: TimeInterval
    let longInterval: TimeInterval
    let callback: () -> Void
    
    var shortTimer: Timer?
    var longTimer: Timer?
    
    enum Const {
        // Auto-search at least this frequently while typing
        static let longAutosearchDelay: TimeInterval = 2.0
        // Trigger automatically after a pause of this length
        static let shortAutosearchDelay: TimeInterval = 0.75
    }
    
    init(short: TimeInterval = Const.shortAutosearchDelay,
         long: TimeInterval = Const.longAutosearchDelay,
         callback: @escaping () -> Void)
    {
        shortInterval = short
        longInterval = long
        self.callback = callback
    }
    
    func activate() {
        shortTimer?.invalidate()
        shortTimer = Timer.scheduledTimer(withTimeInterval: shortInterval, repeats: false)
            { [weak self] _ in self?.fire() }
        if longTimer == nil {
            longTimer = Timer.scheduledTimer(withTimeInterval: longInterval, repeats: false)
                { [weak self] _ in self?.fire() }
        }
    }
    
    func cancel() {
        shortTimer?.invalidate()
        longTimer?.invalidate()
        shortTimer = nil; longTimer = nil
    }
    
    private func fire() {
        cancel()
        callback()
    }
    
}

Solution 7 - Ios

Please see the following code which i've found on cocoa controls. They are sending request asynchronously to fetch the data. May be they are getting data from local but you can try it with the remote API. Send async request on remote API in background thread. Follow below link:

https://www.cocoacontrols.com/controls/jcautocompletingsearch

Solution 8 - Ios

We can use dispatch_source

+ (void)runBlock:(void (^)())block withIdentifier:(NSString *)identifier throttle:(CFTimeInterval)bufferTime {
    if (block == NULL || identifier == nil) {
        NSAssert(NO, @"Block or identifier must not be nil");
    }

    dispatch_source_t source = self.mappingsDictionary[identifier];
    if (source != nil) {
        dispatch_source_cancel(source);
    }

    source = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_main_queue());
    dispatch_source_set_timer(source, dispatch_time(DISPATCH_TIME_NOW, bufferTime * NSEC_PER_SEC), DISPATCH_TIME_FOREVER, 0);
    dispatch_source_set_event_handler(source, ^{
        block();
        dispatch_source_cancel(source);
        [self.mappingsDictionary removeObjectForKey:identifier];
    });
    dispatch_resume(source);

    self.mappingsDictionary[identifier] = source;
}

More on Throttling a block execution using GCD

If you're using ReactiveCocoa, consider throttle method on RACSignal

Here is ThrottleHandler in Swift in you're interested

Solution 9 - Ios

Swift 2.0 version of the NSTimer solution:

private var searchTimer: NSTimer?

func doMyFilter() {
    //perform filter here
}

func searchBar(searchBar: UISearchBar, textDidChange searchText: String) {
    if let searchTimer = searchTimer {
        searchTimer.invalidate()
    }
    searchTimer = NSTimer.scheduledTimerWithTimeInterval(0.5, target: self, selector: #selector(MySearchViewController.doMyFilter), userInfo: nil, repeats: false)
}

Solution 10 - Ios

You can use DispatchWorkItem with Swift 4.0 or above. It's a lot easier and makes sense.

We can execute the API call when the user hasn't typed for 0.25 second.

class SearchViewController: UIViewController, UISearchBarDelegate {
// We keep track of the pending work item as a property
private var pendingRequestWorkItem: DispatchWorkItem?

func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
    // Cancel the currently pending item
    pendingRequestWorkItem?.cancel()

    // Wrap our request in a work item
    let requestWorkItem = DispatchWorkItem { [weak self] in
        self?.resultsLoader.loadResults(forQuery: searchText)
    }

    // Save the new work item and execute it after 250 ms
    pendingRequestWorkItem = requestWorkItem
    DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250),
                                  execute: requestWorkItem)
}
}

You can read the full article about it from here

Solution 11 - Ios

  • Disclamer: I am the author.

If you need vanilla Foundation based throttling feature,
If you want just one liner API without going into reactive, combine, timer, NSObject cancel and anything complex,

Throttler can be the right tool to get your job done.

You can use throttling without going reactive as below:

import Throttler

for i in 1...1000 {
    Throttler.go {
        print("throttle! > \(i)")
    }
}

// throttle! > 1000

import UIKit

import Throttler

class ViewController: UIViewController {
    @IBOutlet var button: UIButton!
    
    var index = 0
    
    /********
    Assuming your users will tap the button, and 
    request asyncronous network call 10 times(maybe more?) in a row within very short time nonstop.
    *********/
    
    @IBAction func click(_ sender: Any) {
        print("click1!")
        
        Throttler.go {
        
            // Imaging this is a time-consuming and resource-heavy task that takes an unknown amount of time!
            
            let url = URL(string: "https://jsonplaceholder.typicode.com/todos/1")!
            let task = URLSession.shared.dataTask(with: url) {(data, response, error) in
                guard let data = data else { return }
                self.index += 1
                print("click1 : \(self.index) :  \(String(data: data, encoding: .utf8)!)")
            }
        }
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
    }
}
click1!
click1!
click1!
click1!
click1!
click1!
click1!
click1!
click1!
click1!
2021-02-20 23:16:50.255273-0500 iOSThrottleTest[24776:813744] 
click1 : 1 :  {
  "userId": 1,
  "id": 1,
  "title": "delectus aut autem",
  "completed": false
}

if you want some specific delay seconds:


import Throttler

for i in 1...1000 {
    Throttler.go(delay:1.5) {
        print("throttle! > \(i)")
    }
}

// throttle! > 1000

Solution 12 - Ios

Swift 5.0

Based on GSnyder response

//
//  AutoSearchManager.swift
//  BTGBankingCommons
//
//  Created by Matheus Gois on 01/10/21.
//

import Foundation


/// Manage two timers to implement a standard auto search in the background.
/// Firing happens after the short interval if there are no further activations.
/// If there is an ongoing stream of activations, firing happens at least every long interval.
public class AutoSearchManager {

    // MARK: - Properties

    private let shortInterval: TimeInterval
    private let longInterval: TimeInterval
    private let callback: (Any?) -> Void

    private var shortTimer: Timer?
    private var longTimer: Timer?

    // MARK: - Lifecycle

    public init(
        short: TimeInterval = Constants.shortAutoSearchDelay,
        long: TimeInterval = Constants.longAutoSearchDelay,
        callback: @escaping (Any?) -> Void
    ) {
        shortInterval = short
        longInterval = long
        self.callback = callback
    }

    // MARK: - Methods

    public func activate(_ object: Any? = nil) {
        shortTimer?.invalidate()
        shortTimer = Timer.scheduledTimer(
            withTimeInterval: shortInterval,
            repeats: false
        ) { [weak self] _ in self?.fire(object) }

        if longTimer == nil {
            longTimer = Timer.scheduledTimer(
                withTimeInterval: longInterval,
                repeats: false
            ) { [weak self] _ in self?.fire(object) }
        }
    }

    public func cancel() {
        shortTimer?.invalidate()
        longTimer?.invalidate()
        shortTimer = nil
        longTimer = nil
    }

    // MARK: - Private methods

    private func fire(_ object: Any? = nil) {
        cancel()
        callback(object)
    }
}

// MARK: - Constants

extension AutoSearchManager {
    public enum Constants {
        /// Auto-search at least this frequently while typing
        public static let longAutoSearchDelay: TimeInterval = 2.0
        /// Trigger automatically after a pause of this length
        public static let shortAutoSearchDelay: TimeInterval = 0.75
    }
}

Attributions

All content for this solution is sourced from the original question on Stackoverflow.

The content on this page is licensed under the Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license.

Content TypeOriginal AuthorOriginal Content on Stackoverflow
QuestionmaggixView Question on Stackoverflow
Solution 1 - IosmalhalView Answer on Stackoverflow
Solution 2 - IosVivienGView Answer on Stackoverflow
Solution 3 - IosAhmad FView Answer on Stackoverflow
Solution 4 - IosmaggixView Answer on Stackoverflow
Solution 5 - Iosduci9yView Answer on Stackoverflow
Solution 6 - IosGSnyderView Answer on Stackoverflow
Solution 7 - IosNirmit DaglyView Answer on Stackoverflow
Solution 8 - Iosonmyway133View Answer on Stackoverflow
Solution 9 - IosWilliam T.View Answer on Stackoverflow
Solution 10 - IosbatuhankrbbView Answer on Stackoverflow
Solution 11 - IosboraseoksoonView Answer on Stackoverflow
Solution 12 - IosMaatheusGoisView Answer on Stackoverflow