Skip to content

Instantly share code, notes, and snippets.

@LidorFadida
Created April 16, 2025 20:01
Show Gist options
  • Select an option

  • Save LidorFadida/b12351d845b1c7095d741503af0bbe7d to your computer and use it in GitHub Desktop.

Select an option

Save LidorFadida/b12351d845b1c7095d741503af0bbe7d to your computer and use it in GitHub Desktop.
import SwiftUI
import Combine
// MARK: - The Handler
final class DebounceHandler<V: Equatable, S: Scheduler>: ObservableObject {
private let debounceTime: S.SchedulerTimeType.Stride
private let scheduler: S
private let action: (V, V) -> Void
private var previousValue: V
private var currentValue: V
private var cancellable: AnyCancellable?
init(
initialValue: V,
debounceTime: S.SchedulerTimeType.Stride,
scheduler: S = RunLoop.main,
action: @escaping (V, V) -> Void
) {
self.previousValue = initialValue
self.currentValue = initialValue
self.debounceTime = debounceTime
self.scheduler = scheduler
self.action = action
}
func updateValue(oldValue: V? = nil, newValue: V) {
cancellable?.cancel()
cancellable = Just((oldValue ?? previousValue, newValue))
.delay(for: debounceTime, scheduler: scheduler)
.sink { [weak self] pair in
self?.action(pair.0, pair.1)
}
}
}
// MARK: - The Modifer
fileprivate struct DebounceModifier<V: Equatable, S: Scheduler>: ViewModifier {
let value: V
let debounceTime: S.SchedulerTimeType.Stride
let scheduler: S
let action: (V, V) -> Void
@StateObject private var handler: DebounceHandler<V, S>
init(value: V, debounceTime: S.SchedulerTimeType.Stride, scheduler: S, action: @escaping (V, V) -> Void) {
self.value = value
self.debounceTime = debounceTime
self.scheduler = scheduler
self.action = action
_handler = StateObject(wrappedValue: DebounceHandler(initialValue: value, debounceTime: debounceTime, scheduler: scheduler, action: action))
}
func body(content: Content) -> some View {
content
.onChange(of: value, handler.updateValue(oldValue:newValue:))
}
}
// Mark: View Using ⬇
public extension View {
func onChange<V, S>(
of value: V,
debounce: S.SchedulerTimeType.Stride,
scheduler: S = RunLoop.main,
_ action: @escaping (V, V) -> Void
) -> some View where V: Equatable, S: Scheduler {
modifier(DebounceModifier(value: value, debounceTime: debounce, scheduler: scheduler, action: action))
}
}
//MARK: - Unit On Handler
@testable import <#Module#>
import XCTest
final class DebounceHandlerTests: XCTestCase {
func testDebouncedValueTriggersOnce() {
let expectation = self.expectation(description: "debounced action triggered")
var received: (Int, Int)?
let handler = DebounceHandler(
initialValue: 0,
debounceTime: .milliseconds(200),
action: { old, new in
received = (old, new)
expectation.fulfill()
}
)
let updates = (0...10)
updates.forEach {
handler.updateValue(newValue: $0)
}
wait(for: [expectation], timeout: 1.0)
XCTAssertEqual(received?.0, 0)
XCTAssertEqual(received?.1, 10)
}
func testDebouncedValueTriggersMultipleTimes() {
let expectation = self.expectation(description: "debounced action triggered")
var received: (Int, Int)?
let handler = DebounceHandler(
initialValue: 0,
debounceTime: .milliseconds(200),
action: { old, new in
received = (old, new)
expectation.fulfill()
}
)
let updates = [0, 2, 5, 10]
updates.enumerated().forEach { index, current in
let previous = index == 0 ? 0 : updates[index - 1]
let delay = DispatchTime.now() + .milliseconds(50 * index)
DispatchQueue.main.asyncAfter(deadline: delay) {
handler.updateValue(oldValue: previous, newValue: current)
}
}
wait(for: [expectation], timeout: 2.0)
XCTAssertEqual(received?.0, 5)
XCTAssertEqual(received?.1, 10)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment