diff --git a/README.md b/README.md index 151cdb3..7edb6e2 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ struct ExampleView: View { var body: some View { TextField("Text", text: $text) - .onChange(of: text, debounceTime: .seconds(2)) { newValue in + .onChange(of: text, debounceTime: .seconds(2)) { oldValue, newValue in // Action executed each time 2 seconds pass since change of text property } .task(id: text, debounceTime: .milliseconds(250)) { diff --git a/Sources/DebouncedOnChange/DebouncedChangeViewModifier.swift b/Sources/DebouncedOnChange/DebouncedChangeViewModifier.swift index 710fa83..f2da53b 100644 --- a/Sources/DebouncedOnChange/DebouncedChangeViewModifier.swift +++ b/Sources/DebouncedOnChange/DebouncedChangeViewModifier.swift @@ -6,24 +6,99 @@ extension View { /// changes. /// /// Each time the value changes before `debounceTime` passes, the previous action will be cancelled and the next - /// action will be scheduled to run after that time passes again. This mean that the action will only execute + /// action will be scheduled to run after that time passes again. This means that the action will only execute /// after changes to the value stay unmodified for the specified `debounceTime`. /// /// - Parameters: /// - value: The value to check against when determining whether to run the closure. - /// - duration: The time to wait after each value change before running `action` 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. + /// - 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, *) + @available(macOS 14.0, *) + @available(tvOS 17.0, *) + @available(watchOS 10.0, *) + @available(visionOS 1.0, *) + public func onChange( + of value: Value, + initial: Bool = false, + debounceTime: Duration, + _ action: @escaping () -> Void + ) -> some View where Value: Equatable { + self.modifier( + DebouncedChangeNoParamViewModifier( + trigger: value, + initial: initial, + action: action, + debounceDuration: debounceTime + ) + ) + } + + /// Adds a modifier for this view that fires an action only when a specified `debounceTime` elapses between value + /// changes. + /// + /// Each time the value changes before `debounceTime` passes, the previous action will be cancelled and the next + /// action will be scheduled to run after that time passes again. This means that the action will only execute + /// after changes to the value stay unmodified for the specified `debounceTime`. + /// + /// - Parameters: + /// - 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. + /// - action: A closure to run when the value changes. + /// - oldValue: The old value that failed the comparison check (or the + /// initial value when requested). + /// - newValue: The new value that failed the comparison check. + /// - Returns: A view that fires an action after debounced time when the specified value changes. + @available(iOS 17, *) + @available(macOS 14.0, *) + @available(tvOS 17.0, *) + @available(watchOS 10.0, *) + @available(visionOS 1.0, *) + public func onChange( + of value: Value, + initial: Bool = false, + debounceTime: Duration, + _ action: @escaping (_ oldValue: Value, _ newValue: Value) -> Void + ) -> some View where Value: Equatable { + self.modifier( + DebouncedChange2ParamViewModifier( + trigger: value, + initial: initial, + action: action, + debounceDuration: debounceTime + ) + ) + } + + /// Adds a modifier for this view that fires an action only when a specified `debounceTime` elapses between value + /// changes. + /// + /// Each time the value changes before `debounceTime` passes, the previous action will be cancelled and the next + /// action will be scheduled to run after that time passes again. This means that the action will only execute + /// after changes to the value stay unmodified for the specified `debounceTime`. + /// + /// - 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. /// - 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, *) @available(macOS 13.0, *) @available(tvOS 16.0, *) @available(watchOS 9.0, *) + @available(iOS, deprecated: 17.0, message: "Use `onChange` with a two or zero parameter action closure instead") + @available(macOS, deprecated: 14.0, message: "Use `onChange` with a two or zero parameter action closure instead") + @available(tvOS, deprecated: 17.0, message: "Use `onChange` with a two or zero parameter action closure instead") + @available(watchOS, deprecated: 10.0, message: "Use `onChange` with a two or zero parameter action closure instead") public func onChange( of value: Value, debounceTime: Duration, perform action: @escaping (_ newValue: Value) -> Void ) -> some View where Value: Equatable { - self.modifier(DebouncedChangeViewModifier(trigger: value, action: action) { + self.modifier(DebouncedChange1ParamViewModifier(trigger: value, action: action) { try await Task.sleep(for: debounceTime) }) } @@ -32,7 +107,7 @@ extension View { /// `debounceTime` elapses between value changes. /// /// Each time the value changes before `debounceTime` passes, the previous action will be cancelled and the next - /// action will be scheduled to run after that time passes again. This mean that the action will only execute + /// action will be scheduled to run after that time passes again. This means that the action will only execute /// after changes to the value stay unmodified for the specified `debounceTime` in seconds. /// /// - Parameters: @@ -49,13 +124,13 @@ extension View { debounceTime: TimeInterval, perform action: @escaping (_ newValue: Value) -> Void ) -> some View where Value: Equatable { - self.modifier(DebouncedChangeViewModifier(trigger: value, action: action) { + self.modifier(DebouncedChange1ParamViewModifier(trigger: value, action: action) { try await Task.sleep(seconds: debounceTime) }) } } -private struct DebouncedChangeViewModifier: ViewModifier where Value: Equatable { +private struct DebouncedChange1ParamViewModifier: ViewModifier where Value: Equatable { let trigger: Value let action: (Value) -> Void let sleep: @Sendable () async throws -> Void @@ -72,3 +147,47 @@ private struct DebouncedChangeViewModifier: ViewModifier where Value: Equ } } } + +@available(iOS 17, *) +@available(macOS 14.0, *) +@available(tvOS 17.0, *) +@available(watchOS 10.0, *) +@available(visionOS 1.0, *) +private struct DebouncedChangeNoParamViewModifier: ViewModifier where Value: Equatable { + let trigger: Value + let initial: Bool + let action: () -> Void + let debounceDuration: Duration + + @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) + } + } +} + +@available(iOS 17, *) +@available(macOS 14.0, *) +@available(tvOS 17.0, *) +@available(watchOS 10.0, *) +@available(visionOS 1.0, *) +private struct DebouncedChange2ParamViewModifier: ViewModifier where Value: Equatable { + let trigger: Value + let initial: Bool + let action: (Value, Value) -> Void + let debounceDuration: Duration + + @State private var debouncedTask: Task? + + func body(content: Content) -> some View { + content.onChange(of: trigger, initial: initial) { lhs, rhs in + debouncedTask?.cancel() + debouncedTask = Task.delayed(duration: debounceDuration) { + action(lhs, rhs) + } + } + } +} diff --git a/Sources/DebouncedOnChange/Task+Delayed.swift b/Sources/DebouncedOnChange/Task+Delayed.swift index 7e6c78b..cd169e6 100644 --- a/Sources/DebouncedOnChange/Task+Delayed.swift +++ b/Sources/DebouncedOnChange/Task+Delayed.swift @@ -27,4 +27,21 @@ extension Task { static func sleep(seconds: TimeInterval) async throws where Success == Never, Failure == Never { try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) } + + @available(iOS 17, *) + @available(macOS 14.0, *) + @available(tvOS 17.0, *) + @available(watchOS 10.0, *) + @available(visionOS 1.0, *) + static func delayed( + duration: Duration, + operation: @escaping () async -> Void + ) -> Self where Success == Void, Failure == Never { + Self { + do { + try await Task.sleep(for: duration) + await operation() + } catch {} + } + } }