Skip to content

Commit

Permalink
Add debouncer with function to manually cancel debounced tasks (#4)
Browse files Browse the repository at this point in the history
  • Loading branch information
Tunous authored Jul 13, 2024
1 parent eca7af4 commit 3d5d000
Show file tree
Hide file tree
Showing 2 changed files with 80 additions and 5 deletions.
30 changes: 25 additions & 5 deletions Sources/DebouncedOnChange/DebouncedChangeViewModifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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, *)
Expand All @@ -24,14 +25,16 @@ extension View {
of value: Value,
initial: Bool = false,
debounceTime: Duration,
debouncer: Binding<Debouncer>? = nil,
_ action: @escaping () -> Void
) -> some View where Value: Equatable {
self.modifier(
DebouncedChangeNoParamViewModifier(
trigger: value,
initial: initial,
action: action,
debounceDuration: debounceTime
debounceDuration: debounceTime,
debouncer: debouncer
)
)
}
Expand All @@ -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).
Expand All @@ -61,14 +65,16 @@ extension View {
of value: Value,
initial: Bool = false,
debounceTime: Duration,
debouncer: Binding<Debouncer>? = nil,
_ action: @escaping (_ oldValue: Value, _ newValue: Value) -> Void
) -> some View where Value: Equatable {
self.modifier(
DebouncedChange2ParamViewModifier(
trigger: value,
initial: initial,
action: action,
debounceDuration: debounceTime
debounceDuration: debounceTime,
debouncer: debouncer
)
)
}
Expand All @@ -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, *)
Expand All @@ -96,9 +103,10 @@ extension View {
public func onChange<Value>(
of value: Value,
debounceTime: Duration,
debouncer: Binding<Debouncer>? = 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)
})
}
Expand All @@ -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")
Expand All @@ -122,17 +131,21 @@ extension View {
public func onChange<Value>(
of value: Value,
debounceTime: TimeInterval,
debouncer: Binding<Debouncer>? = 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<Value>: ViewModifier where Value: Equatable {
let trigger: Value
let action: (Value) -> Void
let debouncer: Binding<Debouncer>?
let sleep: @Sendable () async throws -> Void

@State private var debouncedTask: Task<Void, Never>?
Expand All @@ -144,6 +157,7 @@ private struct DebouncedChange1ParamViewModifier<Value>: ViewModifier where Valu
do { try await sleep() } catch { return }
action(value)
}
debouncer?.wrappedValue.task = debouncedTask
}
}
}
Expand All @@ -158,13 +172,17 @@ private struct DebouncedChangeNoParamViewModifier<Value>: ViewModifier where Val
let initial: Bool
let action: () -> Void
let debounceDuration: Duration
let debouncer: Binding<Debouncer>?

@State private var debouncedTask: Task<Void, Never>?

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
}
}
}
Expand All @@ -179,6 +197,7 @@ private struct DebouncedChange2ParamViewModifier<Value>: ViewModifier where Valu
let initial: Bool
let action: (Value, Value) -> Void
let debounceDuration: Duration
let debouncer: Binding<Debouncer>?

@State private var debouncedTask: Task<Void, Never>?

Expand All @@ -188,6 +207,7 @@ private struct DebouncedChange2ParamViewModifier<Value>: ViewModifier where Valu
debouncedTask = Task.delayed(duration: debounceDuration) {
action(lhs, rhs)
}
debouncer?.wrappedValue.task = debouncedTask
}
}
}
55 changes: 55 additions & 0 deletions Sources/DebouncedOnChange/Debouncer.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Never>?

Check warning on line 47 in Sources/DebouncedOnChange/Debouncer.swift

View workflow job for this annotation

GitHub Actions / lint

Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace)
/// Creates a new debouncer.
public init() {}

/// Cancels an operation that is currently being debounced.
public func cancel() {
task?.cancel()
}
}

0 comments on commit 3d5d000

Please sign in to comment.