A debounce operation can be implemented by waiting inside a task(id:). Change the id parameter in onTapGesture to start a new task. If another tap occurs during the Task.sleep call, the task will be cancelled and so Task.sleep will throw an error.
struct DebounceTapModifier: ViewModifier {
private let action: () -> Void
private let debounce: CGFloat
@State private var taskTrigger: Bool?
init(action: @escaping () -> Void, debounce: CGFloat) {
self.action = action
self.debounce = debounce
}
func body(content: Content) -> some View {
content
.onTapGesture {
taskTrigger = !(taskTrigger ?? false)
}
.task(id: taskTrigger) {
guard taskTrigger != nil { return } // the user has not tapped yet
do {
try await Task.sleep(for: .seconds(debounce))
// if Task.sleep throws an error, action will not be called
action()
} catch {
// another tap occurred during the debounce duration
}
}
}
}
As for your Combine approach, the lifetime of DebounceViewModel is not being managed by SwiftUI. I would make it an ObservableObject and put it in a @StateObject, so that its lifetime is the same as the view modifier.
class Debouncer: ObservableObject {
var cancellable: (any Cancellable)?
let subject = PassthroughSubject()
deinit {
print("deinit")
}
}
struct DebounceTapModifier: ViewModifier {
private let action: () -> Void
private let debounce: CGFloat
private var debouncer = Debouncer()
init(action: @escaping () -> Void, debounce: CGFloat) {
self.action = action
self.debounce = debounce
}
func body(content: Content) -> some View {
content
.onTapGesture {
debouncer.subject.send(0)
}
.onChange(of: debounce) {
// redo the publisher subscription if the debounce time has changed
// if debounce is always the same then you can just do this in onAppear
setupActions()
// *do not* call setupActions in the initialiser. The StateObject has not yet been "installed" at that time.
}
}
private func setupActions() {
debouncer.cancellable = debouncer.subject
.debounce(for: .seconds(debounce), scheduler: DispatchQueue.main)
.sink { _ in
action()
}
}
}