J'essaie d'utiliser un UISearchView
pour interroger Google Adresses. Ce faisant, lors des changements de texte pour mes UISearchBar
, je fais une demande à google places. Le problème est que je préfère ne pas utiliser cet appel pour ne demander qu'une fois par 250 ms afin d'éviter un trafic réseau inutile. Je préfère ne pas écrire cette fonctionnalité moi-même, mais je le ferai si nécessaire.
J'ai trouvé: https://Gist.github.com/ShamylZakariya/54ee03228d955f458389 , mais je ne sais pas trop comment l'utiliser:
func debounce( delay:NSTimeInterval, #queue:dispatch_queue_t, action: (()->()) ) -> ()->() {
var lastFireTime:dispatch_time_t = 0
let dispatchDelay = Int64(delay * Double(NSEC_PER_SEC))
return {
lastFireTime = dispatch_time(DISPATCH_TIME_NOW,0)
dispatch_after(
dispatch_time(
DISPATCH_TIME_NOW,
dispatchDelay
),
queue) {
let now = dispatch_time(DISPATCH_TIME_NOW,0)
let when = dispatch_time(lastFireTime, dispatchDelay)
if now >= when {
action()
}
}
}
}
Voici une chose que j'ai essayé d'utiliser le code ci-dessus:
let searchDebounceInterval: NSTimeInterval = NSTimeInterval(0.25)
func findPlaces() {
// ...
}
func searchBar(searchBar: UISearchBar!, textDidChange searchText: String!) {
debounce(
searchDebounceInterval,
dispatch_get_main_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT),
self.findPlaces
)
}
L'erreur résultante est Cannot invoke function with an argument list of type '(NSTimeInterval, $T5, () -> ())
Comment utiliser cette méthode, ou existe-t-il une meilleure façon de le faire dans iOS/Swift.
Mettez cela au niveau supérieur de votre fichier afin de ne pas vous confondre avec les règles de nom de paramètre drôles de Swift. Notez que j'ai supprimé le #
pour qu'aucun des paramètres ne porte de nom:
func debounce( delay:NSTimeInterval, queue:dispatch_queue_t, action: (()->()) ) -> ()->() {
var lastFireTime:dispatch_time_t = 0
let dispatchDelay = Int64(delay * Double(NSEC_PER_SEC))
return {
lastFireTime = dispatch_time(DISPATCH_TIME_NOW,0)
dispatch_after(
dispatch_time(
DISPATCH_TIME_NOW,
dispatchDelay
),
queue) {
let now = dispatch_time(DISPATCH_TIME_NOW,0)
let when = dispatch_time(lastFireTime, dispatchDelay)
if now >= when {
action()
}
}
}
}
Maintenant, dans votre classe actuelle, votre code ressemblera à ceci:
let searchDebounceInterval: NSTimeInterval = NSTimeInterval(0.25)
let q = dispatch_get_main_queue()
func findPlaces() {
// ...
}
let debouncedFindPlaces = debounce(
searchDebounceInterval,
q,
findPlaces
)
Maintenant, debouncedFindPlaces
est une fonction que vous pouvez appeler, et votre findPlaces
ne sera pas exécuté à moins que delay
ne soit passé depuis la dernière fois que vous l'avez appelée.
func debounce(interval: Int, queue: DispatchQueue, action: @escaping (() -> Void)) -> () -> Void {
var lastFireTime = DispatchTime.now()
let dispatchDelay = DispatchTimeInterval.milliseconds(interval)
return {
lastFireTime = DispatchTime.now()
let dispatchTime: DispatchTime = DispatchTime.now() + dispatchDelay
queue.asyncAfter(deadline: dispatchTime) {
let when: DispatchTime = lastFireTime + dispatchDelay
let now = DispatchTime.now()
if now.rawValue >= when.rawValue {
action()
}
}
}
}
Parfois, il est utile que la fonction anti-rebond prenne un paramètre.
typealias Debounce<T> = (_ : T) -> Void
func debounce<T>(interval: Int, queue: DispatchQueue, action: @escaping Debounce<T>) -> Debounce<T> {
var lastFireTime = DispatchTime.now()
let dispatchDelay = DispatchTimeInterval.milliseconds(interval)
return { param in
lastFireTime = DispatchTime.now()
let dispatchTime: DispatchTime = DispatchTime.now() + dispatchDelay
queue.asyncAfter(deadline: dispatchTime) {
let when: DispatchTime = lastFireTime + dispatchDelay
let now = DispatchTime.now()
if now.rawValue >= when.rawValue {
action(param)
}
}
}
}
Dans l'exemple suivant, vous pouvez voir comment fonctionne la fonction anti-rebond, en utilisant un paramètre de chaîne pour identifier les appels.
let debouncedFunction = debounce(interval: 200, queue: DispatchQueue.main, action: { (identifier: String) in
print("called: \(identifier)")
})
DispatchQueue.global(qos: .background).async {
debouncedFunction("1")
usleep(100 * 1000)
debouncedFunction("2")
usleep(100 * 1000)
debouncedFunction("3")
usleep(100 * 1000)
debouncedFunction("4")
usleep(300 * 1000) // waiting a bit longer than the interval
debouncedFunction("5")
usleep(100 * 1000)
debouncedFunction("6")
usleep(100 * 1000)
debouncedFunction("7")
usleep(300 * 1000) // waiting a bit longer than the interval
debouncedFunction("8")
usleep(100 * 1000)
debouncedFunction("9")
usleep(100 * 1000)
debouncedFunction("10")
usleep(100 * 1000)
debouncedFunction("11")
usleep(100 * 1000)
debouncedFunction("12")
}
Remarque: la fonction usleep()
est uniquement utilisée à des fins de démonstration et peut ne pas être la solution la plus élégante pour une application réelle.
Vous obtenez toujours un rappel, lorsqu'il y a un intervalle d'au moins 200 ms depuis le dernier appel.
appelé: 4
appelé: 7
appelé: 12
Si vous aimez garder les choses propres, voici une solution basée sur GCD qui peut faire ce dont vous avez besoin en utilisant une syntaxe basée sur GCD familière: https://Gist.github.com/staminajim/b5e89c6611eef81910502db2a01f1a8
DispatchQueue.main.asyncDeduped(target: self, after: 0.25) { [weak self] in
self?.findPlaces()
}
findPlaces () ne sera appelé qu'une seule fois , 0,25 seconde après le dernier appel à asyncDuped.
Créez d'abord une classe générique Debouncer:
//
// Debouncer.Swift
//
// Created by Frédéric Adda
import UIKit
import Foundation
class Debouncer {
// MARK: - Properties
private let queue = DispatchQueue.main
private var workItem = DispatchWorkItem(block: {})
private var interval: TimeInterval
// MARK: - Initializer
init(seconds: TimeInterval) {
self.interval = seconds
}
// MARK: - Debouncing function
func debounce(action: @escaping (() -> Void)) {
workItem.cancel()
workItem = DispatchWorkItem(block: { action() })
queue.asyncAfter(deadline: .now() + interval, execute: workItem)
}
}
Créez ensuite une sous-classe de UISearchBar qui utilise le mécanisme anti-rebond:
//
// DebounceSearchBar.Swift
//
// Created by Frédéric ADDA on 28/06/2018.
//
import UIKit
/// Subclass of UISearchBar with a debouncer on text edit
class DebounceSearchBar: UISearchBar, UISearchBarDelegate {
// MARK: - Properties
/// Debounce engine
private var debouncer: Debouncer?
/// Debounce interval
var debounceInterval: TimeInterval = 0 {
didSet {
guard debounceInterval > 0 else {
self.debouncer = nil
return
}
self.debouncer = Debouncer(seconds: debounceInterval)
}
}
/// Event received when the search textField began editing
var onSearchTextDidBeginEditing: (() -> Void)?
/// Event received when the search textField content changes
var onSearchTextUpdate: ((String) -> Void)?
/// Event received when the search button is clicked
var onSearchClicked: (() -> Void)?
/// Event received when cancel is pressed
var onCancel: (() -> Void)?
// MARK: - Initializers
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
delegate = self
}
override init(frame: CGRect) {
super.init(frame: frame)
delegate = self
}
override func awakeFromNib() {
super.awakeFromNib()
delegate = self
}
// MARK: - UISearchBarDelegate
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
onCancel?()
}
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
onSearchClicked?()
}
func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
onSearchTextDidBeginEditing?()
}
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
guard let debouncer = self.debouncer else {
onSearchTextUpdate?(searchText)
return
}
debouncer.debounce {
DispatchQueue.main.async {
self.onSearchTextUpdate?(self.text ?? "")
}
}
}
}
Notez que cette classe est définie comme UISearchBarDelegate. Les actions seront transmises à cette classe en tant que fermetures.
Enfin, vous pouvez l'utiliser comme ceci:
class MyViewController: UIViewController {
// Create the searchBar as a DebounceSearchBar
// in code or as an IBOutlet
private var searchBar: DebounceSearchBar?
override func viewDidLoad() {
super.viewDidLoad()
self.searchBar = createSearchBar()
}
private func createSearchBar() -> DebounceSearchBar {
let searchFrame = CGRect(x: 0, y: 0, width: 375, height: 44)
let searchBar = DebounceSearchBar(frame: searchFrame)
searchBar.debounceInterval = 0.5
searchBar.onSearchTextUpdate = { [weak self] searchText in
// call a function to look for contacts, like:
// searchContacts(with: searchText)
}
searchBar.placeholder = "Enter name or email"
return searchBar
}
}
Notez que dans ce cas, DebounceSearchBar est déjà le délégué searchBar. Vous devez PAS définir cette sous-classe UIViewController comme délégué searchBar! N'utilisez pas non plus les fonctions de délégué. Utilisez plutôt les fermetures fournies!
Ce qui suit fonctionne pour moi:
Ajoutez ce qui suit à un fichier de votre projet (je gère un fichier 'SwiftExtensions.Swift' pour des choses comme ça):
// Encapsulate a callback in a way that we can use it with NSTimer.
class Callback {
let handler:()->()
init(_ handler:()->()) {
self.handler = handler
}
@objc func go() {
handler()
}
}
// Return a function which debounces a callback,
// to be called at most once within `delay` seconds.
// If called again within that time, cancels the original call and reschedules.
func debounce(delay:NSTimeInterval, action:()->()) -> ()->() {
let callback = Callback(action)
var timer: NSTimer?
return {
// if calling again, invalidate the last timer
if let timer = timer {
timer.invalidate()
}
timer = NSTimer(timeInterval: delay, target: callback, selector: "go", userInfo: nil, repeats: false)
NSRunLoop.currentRunLoop().addTimer(timer!, forMode: NSDefaultRunLoopMode)
}
}
Ensuite, installez-le dans vos cours:
class SomeClass {
...
// set up the debounced save method
private var lazy debouncedSave: () -> () = debounce(1, self.save)
private func save() {
// ... actual save code here ...
}
...
func doSomething() {
...
debouncedSave()
}
}
Vous pouvez maintenant appeler someClass.doSomething()
à plusieurs reprises et il ne sauvera qu'une fois par seconde.
Voici une option pour ceux qui ne veulent pas créer de classes/extensions:
Quelque part dans votre code:
var debounce_timer:Timer?
Et dans les endroits où vous voulez faire le debounce:
debounce_timer?.invalidate()
debounce_timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { _ in
print ("Debounce this...")
}
La solution générale telle que fournie par la question et développée dans plusieurs des réponses, a une erreur logique qui provoque des problèmes avec des seuils de rebond courts.
En commençant par l'implémentation fournie:
typealias Debounce<T> = (T) -> Void
func debounce<T>(interval: Int, queue: DispatchQueue, action: @escaping (T) -> Void) -> Debounce<T> {
var lastFireTime = DispatchTime.now()
let dispatchDelay = DispatchTimeInterval.milliseconds(interval)
return { param in
lastFireTime = DispatchTime.now()
let dispatchTime: DispatchTime = DispatchTime.now() + dispatchDelay
queue.asyncAfter(deadline: dispatchTime) {
let when: DispatchTime = lastFireTime + dispatchDelay
let now = DispatchTime.now()
if now.rawValue >= when.rawValue {
action(param)
}
}
}
}
En testant avec un intervalle de 30 millisecondes, nous pouvons créer un exemple relativement trivial qui démontre la faiblesse.
let oldDebouncerDebouncedFunction = debounce(interval: 30, queue: .main, action: exampleFunction)
DispatchQueue.global(qos: .background).async {
oldDebouncerDebouncedFunction("1")
oldDebouncerDebouncedFunction("2")
sleep(.seconds(2))
oldDebouncerDebouncedFunction("3")
}
Cela imprime
appelé: 1
appelé: 2
appelé: 3
Ceci est clairement incorrect, car le premier appel doit être rejeté. L'utilisation d'un seuil anti-rebond plus long (comme 300 millisecondes) résoudra le problème. La racine du problème est une fausse attente selon laquelle la valeur de DispatchTime.now()
sera égale à deadline
passée à asyncAfter(deadline: DispatchTime)
. L'intention de la comparaison now.rawValue >= when.rawValue
Est de comparer réellement l'échéance attendue à l'échéance "la plus récente". Avec de petits seuils anti-rebond, la latence de asyncAfter
devient un problème très important à considérer.
Il est cependant facile à corriger et le code peut être rendu plus concis par-dessus. En choisissant soigneusement le moment d'appeler .now()
, et en assurant la comparaison de l'échéance réelle avec l'échéance la plus récente, je suis arrivé à cette solution. Ce qui est correct pour toutes les valeurs de threshold
. Portez une attention particulière aux points # 1 et # 2, car ils sont identiques sur le plan syntaxique, mais ils seront différents si plusieurs appels sont effectués avant l'envoi du travail.
typealias DebouncedFunction<T> = (T) -> Void
func makeDebouncedFunction<T>(threshold: DispatchTimeInterval = .milliseconds(30), queue: DispatchQueue = .main, action: @escaping (T) -> Void) -> DebouncedFunction<T> {
// Debounced function's state, initial value doesn't matter
// By declaring it outside of the returned function, it becomes state that persists across
// calls to the returned function
var lastCallTime: DispatchTime = .distantFuture
return { param in
lastCallTime = .now()
let scheduledDeadline = lastCallTime + threshold // 1
queue.asyncAfter(deadline: scheduledDeadline) {
let latestDeadline = lastCallTime + threshold // 2
// If there have been no other calls, these will be equal
if scheduledDeadline == latestDeadline {
action(param)
}
}
}
}
func exampleFunction(identifier: String) {
print("called: \(identifier)")
}
func sleep(_ dispatchTimeInterval: DispatchTimeInterval) {
switch dispatchTimeInterval {
case .seconds(let seconds):
Foundation.sleep(UInt32(seconds))
case .milliseconds(let milliseconds):
usleep(useconds_t(milliseconds * 1000))
case .microseconds(let microseconds):
usleep(useconds_t(microseconds))
case .nanoseconds(let nanoseconds):
let (sec, nsec) = nanoseconds.quotientAndRemainder(dividingBy: 1_000_000_000)
var timeSpec = timespec(tv_sec: sec, tv_nsec: nsec)
withUnsafePointer(to: &timeSpec) {
_ = nanosleep($0, nil)
}
case .never:
return
}
}
Espérons que cette réponse aidera quelqu'un d'autre qui a rencontré un comportement inattendu avec la solution de curry de fonction.
Malgré plusieurs bonnes réponses ici, j'ai pensé partager mon approche préférée (pure Swift) pour éliminer les recherches entrées par l'utilisateur ...
1) Ajoutez cette classe simple ( Debounce.Swift ):
import Dispatch
class Debounce<T: Equatable> {
private init() {}
static func input(_ input: T,
comparedAgainst current: @escaping @autoclosure () -> (T),
perform: @escaping (T) -> ()) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
if input == current() { perform(input) }
}
}
}
2) Incluez éventuellement ce test unitaire ( DebounceTests.Swift ):
import XCTest
class DebounceTests: XCTestCase {
func test_entering_text_delays_processing_until_settled() {
let expect = expectation(description: "processing completed")
var finalString: String = ""
var timesCalled: Int = 0
let process: (String) -> () = {
finalString = $0
timesCalled += 1
expect.fulfill()
}
Debounce<String>.input("A", comparedAgainst: "AB", perform: process)
Debounce<String>.input("AB", comparedAgainst: "ABCD", perform: process)
Debounce<String>.input("ABCD", comparedAgainst: "ABC", perform: process)
Debounce<String>.input("ABC", comparedAgainst: "ABC", perform: process)
wait(for: [expect], timeout: 2.0)
XCTAssertEqual(finalString, "ABC")
XCTAssertEqual(timesCalled, 1)
}
}
3) Utilisez-le où vous voulez retarder le traitement (par exemple UISearchBarDelegate ):
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
Debounce<String>.input(searchText, comparedAgainst: searchBar.text ?? "") {
self.filterResults($0)
}
}
Le principe de base est que nous retardons simplement le traitement du texte saisi de 0,5 seconde. À ce moment-là, nous comparons la chaîne que nous avons obtenue de l'événement avec la valeur actuelle de la barre de recherche. S'ils correspondent, nous supposons que l'utilisateur a interrompu la saisie du texte et nous procédons au filtrage.
Comme il est générique, il fonctionne avec tout type de valeur équivalente.
Étant donné que le module Dispatch a été inclus dans la bibliothèque principale Swift depuis la version 3, cette classe peut également être utilisée avec des plates-formes non Apple.
J'ai utilisé cette bonne vieille méthode inspirée d'Objective-C:
override func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
// Debounce: wait until the user stops typing to send search requests
NSObject.cancelPreviousPerformRequests(withTarget: self)
perform(#selector(updateSearch(with:)), with: searchText, afterDelay: 0.5)
}
Notez que la méthode appelée updateSearch
doit être marquée @objc!
@objc private func updateSearch(with text: String) {
// Do stuff here
}
Le gros avantage de cette méthode est que je peux passer des paramètres (ici: la chaîne de recherche). Avec la plupart des Debouncers présentés ici, ce n'est pas le cas ...
Une autre implémentation anti-rebond utilisant la classe, vous pouvez trouver utile: https://github.com/webadnan/Swift-debouncer
Voici une implémentation anti-rebond pour Swift 3.
https://Gist.github.com/bradfol/541c010a6540404eca0f4a5da009c761
import Foundation
class Debouncer {
// Callback to be debounced
// Perform the work you would like to be debounced in this callback.
var callback: (() -> Void)?
private let interval: TimeInterval // Time interval of the debounce window
init(interval: TimeInterval) {
self.interval = interval
}
private var timer: Timer?
// Indicate that the callback should be called. Begins the debounce window.
func call() {
// Invalidate existing timer if there is one
timer?.invalidate()
// Begin a new timer from now
timer = Timer.scheduledTimer(timeInterval: interval, target: self, selector: #selector(handleTimer), userInfo: nil, repeats: false)
}
@objc private func handleTimer(_ timer: Timer) {
if callback == nil {
NSLog("Debouncer timer fired, but callback was nil")
} else {
NSLog("Debouncer timer fired")
}
callback?()
callback = nil
}
}
Scénario: L'utilisateur appuie sur le bouton en continu, mais seule la dernière est acceptée et toute demande précédente est annulée. Pour rester simple, fetchMethod () imprime la valeur du compteur.
1: Utilisation du sélecteur Perform Après un délai:
exemple de travail Swift 5
import UIKit
class ViewController: UIViewController {
var stepper = 1
override func viewDidLoad() {
super.viewDidLoad()
}
@IBAction func StepperBtnTapped() {
stepper = stepper + 1
NSObject.cancelPreviousPerformRequests(withTarget: self)
perform(#selector(updateRecord), with: self, afterDelay: 0.5)
}
@objc func updateRecord() {
print("final Count \(stepper)")
}
}
2: Utilisation de DispatchWorkItem:
class ViewController: UIViewController {
private var pendingRequestWorkItem: DispatchWorkItem?
override func viewDidLoad() {
super.viewDidLoad()
}
@IBAction func tapButton(sender: UIButton) {
counter += 1
pendingRequestWorkItem?.cancel()
let requestWorkItem = DispatchWorkItem { [weak self] in self?.fetchMethod()
}
pendingRequestWorkItem = requestWorkItem
DispatchQueue.main.asyncAfter(deadline: .now() +.milliseconds(250),execute: requestWorkItem)
}
func fetchMethod() {
print("fetchMethod:\(counter)")
}
}
//Output:
fetchMethod:1 //clicked once
fetchMethod:4 //clicked 4 times ,
//but previous triggers are cancelled by
// pendingRequestWorkItem?.cancel()
Quelques améliorations subtiles sur quickthyme 's excellente réponse :
delay
, peut-être avec une valeur par défaut.Debounce
un enum
au lieu d'un class
, afin que vous puissiez éviter d'avoir à déclarer un private init
.enum Debounce<T: Equatable> {
static func input(_ input: T, delay: TimeInterval = 0.3, current: @escaping @autoclosure () -> T, perform: @escaping (T) -> Void) {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
guard input == current() else { return }
perform(input)
}
}
}
Il n'est également pas nécessaire de déclarer explicitement le type générique sur le site d'appel - il peut être déduit. Par exemple, si vous souhaitez utiliser Debounce
avec un UISearchController
, dans updateSearchResults(for:)
(méthode requise de UISearchResultsUpdating
), vous feriez ceci:
func updateSearchResults(for searchController: UISearchController) {
guard let text = searchController.searchBar.text else { return }
Debounce.input(text, current: searchController.searchBar.text ?? "") {
// ...
}
}
la solution d'owenoak fonctionne pour moi. Je l'ai un peu modifié pour l'adapter à mon projet:
J'ai créé un fichier Swift Dispatcher.Swift
:
import Cocoa
// Encapsulate an action so that we can use it with NSTimer.
class Handler {
let action: ()->()
init(_ action: ()->()) {
self.action = action
}
@objc func handle() {
action()
}
}
// Creates and returns a new debounced version of the passed function
// which will postpone its execution until after delay seconds have elapsed
// since the last time it was invoked.
func debounce(delay: NSTimeInterval, action: ()->()) -> ()->() {
let handler = Handler(action)
var timer: NSTimer?
return {
if let timer = timer {
timer.invalidate() // if calling again, invalidate the last timer
}
timer = NSTimer(timeInterval: delay, target: handler, selector: "handle", userInfo: nil, repeats: false)
NSRunLoop.currentRunLoop().addTimer(timer!, forMode: NSDefaultRunLoopMode)
NSRunLoop.currentRunLoop().addTimer(timer!, forMode: NSEventTrackingRunLoopMode)
}
}
J'ai ensuite ajouté ce qui suit dans ma classe d'interface utilisateur:
class func changed() {
print("changed")
}
let debouncedChanged = debounce(0.5, action: MainWindowController.changed)
La principale différence avec la réponse d'owenoak est cette ligne:
NSRunLoop.currentRunLoop().addTimer(timer!, forMode: NSEventTrackingRunLoopMode)
Sans cette ligne, le temporisateur ne se déclenche jamais si l'interface utilisateur perd le focus.