From 3d5d0002cb26f0bdad1f8ba58a196faef193cc38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Rutkowski?= Date: Sat, 13 Jul 2024 17:42:05 +0200 Subject: [PATCH] Add debouncer with function to manually cancel debounced tasks (#4) --- .../DebouncedChangeViewModifier.swift | 30 ++++++++-- Sources/DebouncedOnChange/Debouncer.swift | 55 +++++++++++++++++++ 2 files changed, 80 insertions(+), 5 deletions(-) create mode 100644 Sources/DebouncedOnChange/Debouncer.swift diff --git a/Sources/DebouncedOnChange/DebouncedChangeViewModifier.swift b/Sources/DebouncedOnChange/DebouncedChangeViewModifier.swift index f2da53b..c7c54e9 100644 --- a/Sources/DebouncedOnChange/DebouncedChangeViewModifier.swift +++ b/Sources/DebouncedOnChange/DebouncedChangeViewModifier.swift @@ -13,6 +13,7 @@ extension View { /// - value: The value to check against when determining whether to run the closure. /// - initial: Whether the action should be run (after debounce time) when this view initially appears. /// - debounceTime: The time to wait after each value change before running `action` closure. + /// - debouncer: Object used to manually cancel debounced actions. /// - action: A closure to run when the value changes. /// - Returns: A view that fires an action after debounced time when the specified value changes. @available(iOS 17, *) @@ -24,6 +25,7 @@ extension View { of value: Value, initial: Bool = false, debounceTime: Duration, + debouncer: Binding? = nil, _ action: @escaping () -> Void ) -> some View where Value: Equatable { self.modifier( @@ -31,7 +33,8 @@ extension View { trigger: value, initial: initial, action: action, - debounceDuration: debounceTime + debounceDuration: debounceTime, + debouncer: debouncer ) ) } @@ -47,6 +50,7 @@ extension View { /// - value: The value to check against when determining whether to run the closure. /// - initial: Whether the action should be run (after debounce time) when this view initially appears. /// - debounceTime: The time to wait after each value change before running `action` closure. + /// - debouncer: Object used to manually cancel debounced actions. /// - action: A closure to run when the value changes. /// - oldValue: The old value that failed the comparison check (or the /// initial value when requested). @@ -61,6 +65,7 @@ extension View { of value: Value, initial: Bool = false, debounceTime: Duration, + debouncer: Binding? = nil, _ action: @escaping (_ oldValue: Value, _ newValue: Value) -> Void ) -> some View where Value: Equatable { self.modifier( @@ -68,7 +73,8 @@ extension View { trigger: value, initial: initial, action: action, - debounceDuration: debounceTime + debounceDuration: debounceTime, + debouncer: debouncer ) ) } @@ -83,6 +89,7 @@ extension View { /// - Parameters: /// - value: The value to check against when determining whether to run the closure. /// - debounceTime: The time to wait after each value change before running `action` closure. + /// - debouncer: Object used to manually cancel debounced actions. /// - action: A closure to run when the value changes. /// - Returns: A view that fires an action after debounced time when the specified value changes. @available(iOS 16.0, *) @@ -96,9 +103,10 @@ extension View { public func onChange( of value: Value, debounceTime: Duration, + debouncer: Binding? = nil, perform action: @escaping (_ newValue: Value) -> Void ) -> some View where Value: Equatable { - self.modifier(DebouncedChange1ParamViewModifier(trigger: value, action: action) { + self.modifier(DebouncedChange1ParamViewModifier(trigger: value, action: action, debouncer: debouncer) { try await Task.sleep(for: debounceTime) }) } @@ -113,6 +121,7 @@ extension View { /// - Parameters: /// - value: The value to check against when determining whether to run the closure. /// - debounceTime: The time in seconds to wait after each value change before running `action` closure. + /// - debouncer: Object used to manually cancel debounced actions. /// - action: A closure to run when the value changes. /// - Returns: A view that fires an action after debounced time when the specified value changes. @available(iOS, deprecated: 16.0, message: "Use version of this method accepting Duration type as debounceTime") @@ -122,17 +131,21 @@ extension View { public func onChange( of value: Value, debounceTime: TimeInterval, + debouncer: Binding? = nil, perform action: @escaping (_ newValue: Value) -> Void ) -> some View where Value: Equatable { - self.modifier(DebouncedChange1ParamViewModifier(trigger: value, action: action) { + self.modifier(DebouncedChange1ParamViewModifier(trigger: value, action: action, debouncer: debouncer) { try await Task.sleep(seconds: debounceTime) }) } } +// MARK: Implementation + private struct DebouncedChange1ParamViewModifier: ViewModifier where Value: Equatable { let trigger: Value let action: (Value) -> Void + let debouncer: Binding? let sleep: @Sendable () async throws -> Void @State private var debouncedTask: Task? @@ -144,6 +157,7 @@ private struct DebouncedChange1ParamViewModifier: ViewModifier where Valu do { try await sleep() } catch { return } action(value) } + debouncer?.wrappedValue.task = debouncedTask } } } @@ -158,13 +172,17 @@ private struct DebouncedChangeNoParamViewModifier: ViewModifier where Val let initial: Bool let action: () -> Void let debounceDuration: Duration + let debouncer: Binding? @State private var debouncedTask: Task? func body(content: Content) -> some View { content.onChange(of: trigger, initial: initial) { debouncedTask?.cancel() - debouncedTask = Task.delayed(duration: debounceDuration, operation: action) + debouncedTask = Task.delayed(duration: debounceDuration) { + action() + } + debouncer?.wrappedValue.task = debouncedTask } } } @@ -179,6 +197,7 @@ private struct DebouncedChange2ParamViewModifier: ViewModifier where Valu let initial: Bool let action: (Value, Value) -> Void let debounceDuration: Duration + let debouncer: Binding? @State private var debouncedTask: Task? @@ -188,6 +207,7 @@ private struct DebouncedChange2ParamViewModifier: ViewModifier where Valu debouncedTask = Task.delayed(duration: debounceDuration) { action(lhs, rhs) } + debouncer?.wrappedValue.task = debouncedTask } } } diff --git a/Sources/DebouncedOnChange/Debouncer.swift b/Sources/DebouncedOnChange/Debouncer.swift new file mode 100644 index 0000000..e724fb0 --- /dev/null +++ b/Sources/DebouncedOnChange/Debouncer.swift @@ -0,0 +1,55 @@ +// +// Debouncer.swift +// +// +// Created by Ɓukasz Rutkowski on 13/07/2024. +// + +import Foundation + +/// Object which allows manual control of debounced operations. +/// +/// You can create an instance of this object and pass it to one of debounced `onChange` functions to be able to +/// manually cancel their execution. +/// +/// - Note: A single debouncer should be passed to only one onChange function. If you want to control multiple +/// operations create a separate debouncer for each of them. +/// +/// # Example +/// +/// Here a debouncer is used to cancel debounced api call and instead trigger it immediately when keyboard return +/// key is pressed. +/// +/// ```swift +/// struct Sample: View { +/// @State private var debouncer = Debouncer() +/// @State private var query = "" +/// +/// var body: some View { +/// TextField("Query", text: $query) +/// .onKeyPress(.return) { +/// debouncer.cancel() +/// callApi() +/// return .handled +/// } +/// .onChange(of: query, debounceTime: .seconds(1), debouncer: $debouncer) { +/// callApi() +/// } +/// } +/// +/// private func callApi() { +/// print("Sending query \(query)") +/// } +/// } +/// ``` +public struct Debouncer { + var task: Task? + + /// Creates a new debouncer. + public init() {} + + /// Cancels an operation that is currently being debounced. + public func cancel() { + task?.cancel() + } +}