The general solution as provided by the question and built upon in several of the answers, has a logic mistake that causes problems with short debounce thresholds.
Starting with the provided implementation:
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)
}
}
}
}
Testing with an interval of 30 milliseconds, we can create a relatively trivial example that demonstrates the weakness.
let oldDebouncerDebouncedFunction = debounce(interval: 30, queue: .main, action: exampleFunction)
DispatchQueue.global(qos: .background).async {
oldDebouncerDebouncedFunction("1")
oldDebouncerDebouncedFunction("2")
sleep(.seconds(2))
oldDebouncerDebouncedFunction("3")
}
This prints
called: 1
called: 2
called: 3
This is clearly incorrect, because the first call should be debounced. Using a longer debounce threshold (such as 300 milliseconds) will fix the problem. The root of the problem is a false expectation that the value of DispatchTime.now() will be equal to the deadline passed to asyncAfter(deadline: DispatchTime). The intention of the comparison now.rawValue >= when.rawValue is to actually compare the expected deadline to the "most recent" deadline. With small debounce thresholds, the latency of asyncAfter becomes a very important problem to think about.
It's easy to fix though, and the code can be made more concise on top of it. By carefully choosing when to call .now(), and ensuring the comparison of the actual deadline with most recently scheduled deadline, I arrived at this solution. Which is correct for all values of threshold. Pay special attention to #1 and #2 as they are the same syntactically, but will be different if multiple calls are made before the work is dispatched.
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)
}
}
}
}
Utilities
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
}
}
Hopefully, this answer will help someone else that has encountered unexpected behavior with the function currying solution.