95

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

Ahmad F
  • 30,560
  • 17
  • 97
  • 143
maggix
  • 3,268
  • 1
  • 22
  • 36

12 Answers12

144

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!

malhal
  • 26,330
  • 7
  • 115
  • 133
  • this works great... didn't know about the cancelPreviousPerformRequestsWithTarget method! – jesses.co.tt Apr 30 '15 at 17:05
  • you're welcome! It's a great pattern and can be used for all kinds of things. – malhal Sep 10 '15 at 14:42
  • So much useful! This is the real voodoo – Matteo Pacini Oct 30 '15 at 16:45
  • 2
    Regarding "reload"... I had to think about it for a couple extra seconds... That refers to the local method which will actually perform the stuff you want to do after the user stops typing for 0.5 seconds. The method could be called whatever you want, like searchExecute. Thanks! – blalond Aug 29 '16 at 17:28
  • this doesn't work for me... it continues to run "reload" function every time a letter is changed – Andrey Solera Sep 01 '19 at 23:37
  • I put this code in updateSearchResultsForSearchController but it going to crash ? why any solution for this ? thanks – Yogesh Patel Oct 29 '19 at 05:19
  • Point to note for this solution you're not able to pass the `searchText` as a parameter to your method. Use the `DispatchWorkItem` solution if you need this: https://stackoverflow.com/a/48666001/945247 – Leon Sep 02 '21 at 09:18
58

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")
}
Kyle Venn
  • 4,597
  • 27
  • 41
VivienG
  • 2,143
  • 3
  • 24
  • 43
  • 1
    Good answer! I just added a little improvement to it, you could [check it out](https://stackoverflow.com/a/47945314/5501940) :) – Ahmad F Dec 22 '17 at 17:58
  • Thanks @AhmadF , I was thinking of doing a SWIFT 4 update. You did it! :D – VivienG Dec 23 '17 at 10:01
  • 1
    For Swift 4, use `DispatchWorkItem` as first suggested above. It works elegantly than selectors. – Teffi Feb 06 '19 at 10:22
32

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.

Ahmad F
  • 30,560
  • 17
  • 97
  • 143
  • Its really helpful for me ,, parsing with the object of search bar in selector – Hari Narayanan Oct 23 '20 at 18:18
  • I just tried in OBJC - (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText { [NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(validateText:) object:searchBar]; [self performSelector:@selector(validateText:) withObject:searchBar afterDelay:0.5]; } – Hari Narayanan Oct 23 '20 at 18:18
18

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;

maggix
  • 3,268
  • 1
  • 22
  • 36
  • 1
    The link to the blog post appears dead to me – jeroen Mar 30 '15 at 21:22
  • 1
    @jeroen you're right: unfortunately it looks like the author removed the blog from his website. The repository on GitHub that referred to that blog is still up, so you may want to check the code here: https://github.com/SebastienThiebaud/dispatch_cancelable_block – maggix Mar 30 '15 at 21:38
  • the code inside of the searchBlock never executes. Is there more code necessary? – itinance May 24 '15 at 17:15
13

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.

duci9y
  • 4,128
  • 3
  • 26
  • 42
  • Looks like we had the same approach, and this one does not even require additional code. Although the `requestNewDataFromServer` method needs to be modified in order to get the parameter from the `userInfo` – maggix Jul 05 '14 at 16:09
  • Yep, modify it according to your needs. The concept is the same. – duci9y Jul 05 '14 at 16:16
  • 3
    since the timer is never fired in this approach, i figured out that one line is missing here: [[NSRunLoop mainRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode]; – itinance May 24 '15 at 17:25
  • @itinance What do you mean? The timer is already in the current run loop when you create it with the method in the code. – duci9y May 24 '15 at 19:06
  • This is a quick and neat solution. You can also use this in your other network requests, like in my situation, I fetch new data everytime the user drags his/her map. Just a note that in Swift, you will want to instantiate your timer object by calling the ```scheduledTimer...```. – Glenn Posadas Jun 29 '18 at 08:44
  • The `static` declaration can become a source of subtle bugs... – Cristik Feb 01 '23 at 20:09
5

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()
    }

}
GSnyder
  • 470
  • 4
  • 10
4

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)
}
William T.
  • 12,831
  • 4
  • 56
  • 53
3

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

Nirmit Dagly
  • 1,272
  • 1
  • 12
  • 25
  • Hi! I finally had time to have a look at your suggested control. It is definitely interesting and I have no doubt that many will benefit from it. However I think I found a shorter (and, in my opinion, cleaner) solution from this blog post, thanks to some inspiration from your link: http://sebastienthiebaud.us/blog/ios/gcd/block/2014/04/09/diggint-into-gcd-1-cancel-dispatch-after.html – maggix Jul 05 '14 at 15:18
  • @maggix the link you have given is expired now. Can you suggest any other link. – Nirmit Dagly Sep 15 '15 at 10:37
  • I am updating all the links in this thread. Use the one in my answer below (https://github.com/SebastienThiebaud/dispatch_cancelable_block) – maggix Sep 16 '15 at 11:02
  • Also look at this, if you are using Google Maps. This is compatible with iOS 8 and written in objective-c. https://github.com/hkellaway/HNKGooglePlacesAutocomplete – Nirmit Dagly Sep 16 '15 at 12:09
3

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

onmyway133
  • 45,645
  • 31
  • 257
  • 263
  • I find https://github.com/SebastienThiebaud/dispatch_cancelable_block/blob/master/dispatch_cancelable_block.h to be useful, too – onmyway133 Sep 21 '15 at 14:41
1

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

batuhankrbb
  • 598
  • 7
  • 10
0
  • 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

boraseoksoon
  • 2,164
  • 1
  • 20
  • 25
0

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
    }
}

MaatheusGois
  • 139
  • 5