├── .github
└── workflows
│ └── main.yml
├── .gitignore
├── .spi.yml
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── LICENSE
├── Package.swift
├── README.md
├── Sources
└── DebouncedOnChange
│ ├── DebouncedChangeViewModifier.swift
│ ├── DebouncedTaskViewModifier.swift
│ ├── Debouncer.swift
│ ├── Documentation.docc
│ └── Documentation.md
│ └── Task+Delayed.swift
└── Tests
└── DebouncedOnChangeTests
└── DelayedTaskTests.swift
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | branches: [ main ]
8 |
9 | jobs:
10 | test:
11 | runs-on: macos-latest
12 | steps:
13 | - uses: actions/checkout@v3
14 | - run: swift test
15 |
16 | lint:
17 | runs-on: ubuntu-latest
18 | steps:
19 | - uses: actions/checkout@v3
20 | - uses: norio-nomura/action-swiftlint@3.2.1
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 | DerivedData/
7 | .swiftpm/config/registries.json
8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
9 | .netrc
10 |
--------------------------------------------------------------------------------
/.spi.yml:
--------------------------------------------------------------------------------
1 | version: 1
2 | builder:
3 | configs:
4 | - documentation_targets: [DebouncedOnChange]
5 | custom_documentation_parameters: [--include-extended-types]
6 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Łukasz Rutkowski
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.5
2 |
3 | import PackageDescription
4 |
5 | let package = Package(
6 | name: "DebouncedOnChange",
7 | platforms: [.iOS(.v14), .macOS(.v11), .tvOS(.v14), .watchOS(.v7)],
8 | products: [
9 | .library(
10 | name: "DebouncedOnChange",
11 | targets: ["DebouncedOnChange"])
12 | ],
13 | dependencies: [
14 | ],
15 | targets: [
16 | .target(
17 | name: "DebouncedOnChange",
18 | dependencies: []),
19 | .testTarget(
20 | name: "DebouncedOnChangeTests",
21 | dependencies: ["DebouncedOnChange"])
22 | ]
23 | )
24 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # DebouncedOnChange
2 |
3 | [](https://swiftpackageindex.com/Tunous/DebouncedOnChange) [](https://swiftpackageindex.com/Tunous/DebouncedOnChange)
4 |
5 | A SwiftUI onChange and task view modifiers with additional debounce time.
6 |
7 | [Documentation](https://swiftpackageindex.com/Tunous/DebouncedOnChange/main/documentation/debouncedonchange)
8 |
9 | ## Usage
10 |
11 | ### Basics
12 |
13 | ```swift
14 | import SwiftUI
15 | import DebouncedOnChange
16 |
17 | struct ExampleView: View {
18 | @State private var text = ""
19 |
20 | var body: some View {
21 | TextField("Text", text: $text)
22 | .onChange(of: text, debounceTime: .seconds(2)) { oldValue, newValue in
23 | // Action executed each time 2 seconds pass since change of text property
24 | }
25 | .task(id: text, debounceTime: .milliseconds(250)) {
26 | // Asynchronous action executed each time 250 milliseconds pass since change of text property
27 | }
28 | }
29 | }
30 | ```
31 |
32 | ### Manually cancelling debounced actions
33 |
34 | ```swift
35 | struct Sample: View {
36 | @State private var debouncer = Debouncer() // 1. Store debouncer to control actions
37 | @State private var query = ""
38 |
39 | var body: some View {
40 | TextField("Query", text: $query)
41 | .onChange(of: query, debounceTime: .seconds(1), debouncer: $debouncer) { // 2. Pass debouncer to onChange
42 | callApi()
43 | }
44 | .onKeyPress(.return) {
45 | debouncer.cancel() // 3. Call cancel to prevent debounced action from running
46 | callApi()
47 | return .handled
48 | }
49 | }
50 |
51 | private func callApi() {
52 | print("Sending query \(query)")
53 | }
54 | }
55 | ```
56 |
57 | ## Installation
58 |
59 | ### Swift Package Manager
60 |
61 | Add the following to the dependencies array in your "Package.swift" file:
62 |
63 | ```swift
64 | .package(url: "https://github.com/Tunous/DebouncedOnChange.git", .upToNextMajor(from: "1.1.0"))
65 | ```
66 |
67 | Or add https://github.com/Tunous/DebouncedOnChange.git, to the list of Swift packages for any project in Xcode.
68 |
--------------------------------------------------------------------------------
/Sources/DebouncedOnChange/DebouncedChangeViewModifier.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | extension View {
4 |
5 | /// Adds a modifier for this view that fires an action only when a specified `debounceTime` elapses between value
6 | /// changes.
7 | ///
8 | /// Each time the value changes before `debounceTime` passes, the previous action will be cancelled and the next
9 | /// action will be scheduled to run after that time passes again. This means that the action will only execute
10 | /// after changes to the value stay unmodified for the specified `debounceTime`.
11 | ///
12 | /// - Parameters:
13 | /// - value: The value to check against when determining whether to run the closure.
14 | /// - initial: Whether the action should be run (after debounce time) when this view initially appears.
15 | /// - debounceTime: The time to wait after each value change before running `action` closure.
16 | /// - debouncer: Object used to manually cancel debounced actions.
17 | /// - action: A closure to run when the value changes.
18 | /// - Returns: A view that fires an action after debounced time when the specified value changes.
19 | @available(iOS 17, *)
20 | @available(macOS 14.0, *)
21 | @available(tvOS 17.0, *)
22 | @available(watchOS 10.0, *)
23 | @available(visionOS 1.0, *)
24 | public func onChange(
25 | of value: Value,
26 | initial: Bool = false,
27 | debounceTime: Duration,
28 | debouncer: Binding? = nil,
29 | _ action: @escaping () -> Void
30 | ) -> some View where Value: Equatable {
31 | self.modifier(
32 | DebouncedChangeNoParamViewModifier(
33 | trigger: value,
34 | initial: initial,
35 | action: action,
36 | debounceDuration: debounceTime,
37 | debouncer: debouncer
38 | )
39 | )
40 | }
41 |
42 | /// Adds a modifier for this view that fires an action only when a specified `debounceTime` elapses between value
43 | /// changes.
44 | ///
45 | /// Each time the value changes before `debounceTime` passes, the previous action will be cancelled and the next
46 | /// action will be scheduled to run after that time passes again. This means that the action will only execute
47 | /// after changes to the value stay unmodified for the specified `debounceTime`.
48 | ///
49 | /// - Parameters:
50 | /// - value: The value to check against when determining whether to run the closure.
51 | /// - initial: Whether the action should be run (after debounce time) when this view initially appears.
52 | /// - debounceTime: The time to wait after each value change before running `action` closure.
53 | /// - debouncer: Object used to manually cancel debounced actions.
54 | /// - action: A closure to run when the value changes.
55 | /// - oldValue: The old value that failed the comparison check (or the
56 | /// initial value when requested).
57 | /// - newValue: The new value that failed the comparison check.
58 | /// - Returns: A view that fires an action after debounced time when the specified value changes.
59 | @available(iOS 17, *)
60 | @available(macOS 14.0, *)
61 | @available(tvOS 17.0, *)
62 | @available(watchOS 10.0, *)
63 | @available(visionOS 1.0, *)
64 | public func onChange(
65 | of value: Value,
66 | initial: Bool = false,
67 | debounceTime: Duration,
68 | debouncer: Binding? = nil,
69 | _ action: @escaping (_ oldValue: Value, _ newValue: Value) -> Void
70 | ) -> some View where Value: Equatable {
71 | self.modifier(
72 | DebouncedChange2ParamViewModifier(
73 | trigger: value,
74 | initial: initial,
75 | action: action,
76 | debounceDuration: debounceTime,
77 | debouncer: debouncer
78 | )
79 | )
80 | }
81 |
82 | /// Adds a modifier for this view that fires an action only when a specified `debounceTime` elapses between value
83 | /// changes.
84 | ///
85 | /// Each time the value changes before `debounceTime` passes, the previous action will be cancelled and the next
86 | /// action will be scheduled to run after that time passes again. This means that the action will only execute
87 | /// after changes to the value stay unmodified for the specified `debounceTime`.
88 | ///
89 | /// - Parameters:
90 | /// - value: The value to check against when determining whether to run the closure.
91 | /// - debounceTime: The time to wait after each value change before running `action` closure.
92 | /// - debouncer: Object used to manually cancel debounced actions.
93 | /// - action: A closure to run when the value changes.
94 | /// - Returns: A view that fires an action after debounced time when the specified value changes.
95 | @available(iOS 16.0, *)
96 | @available(macOS 13.0, *)
97 | @available(tvOS 16.0, *)
98 | @available(watchOS 9.0, *)
99 | @available(iOS, deprecated: 17.0, message: "Use `onChange` with a two or zero parameter action closure instead")
100 | @available(macOS, deprecated: 14.0, message: "Use `onChange` with a two or zero parameter action closure instead")
101 | @available(tvOS, deprecated: 17.0, message: "Use `onChange` with a two or zero parameter action closure instead")
102 | @available(watchOS, deprecated: 10.0, message: "Use `onChange` with a two or zero parameter action closure instead")
103 | public func onChange(
104 | of value: Value,
105 | debounceTime: Duration,
106 | debouncer: Binding? = nil,
107 | perform action: @escaping (_ newValue: Value) -> Void
108 | ) -> some View where Value: Equatable {
109 | self.modifier(DebouncedChange1ParamViewModifier(trigger: value, action: action, debouncer: debouncer) {
110 | try await Task.sleep(for: debounceTime)
111 | })
112 | }
113 |
114 | /// Adds a modifier for this view that fires an action only when a time interval in seconds represented by
115 | /// `debounceTime` elapses between value changes.
116 | ///
117 | /// Each time the value changes before `debounceTime` passes, the previous action will be cancelled and the next
118 | /// action will be scheduled to run after that time passes again. This means that the action will only execute
119 | /// after changes to the value stay unmodified for the specified `debounceTime` in seconds.
120 | ///
121 | /// - Parameters:
122 | /// - value: The value to check against when determining whether to run the closure.
123 | /// - debounceTime: The time in seconds to wait after each value change before running `action` closure.
124 | /// - debouncer: Object used to manually cancel debounced actions.
125 | /// - action: A closure to run when the value changes.
126 | /// - Returns: A view that fires an action after debounced time when the specified value changes.
127 | @available(iOS, deprecated: 16.0, message: "Use version of this method accepting Duration type as debounceTime")
128 | @available(macOS, deprecated: 13.0, message: "Use version of this method accepting Duration type as debounceTime")
129 | @available(tvOS, deprecated: 16.0, message: "Use version of this method accepting Duration type as debounceTime")
130 | @available(watchOS, deprecated: 9.0, message: "Use version of this method accepting Duration type as debounceTime")
131 | public func onChange(
132 | of value: Value,
133 | debounceTime: TimeInterval,
134 | debouncer: Binding? = nil,
135 | perform action: @escaping (_ newValue: Value) -> Void
136 | ) -> some View where Value: Equatable {
137 | self.modifier(DebouncedChange1ParamViewModifier(trigger: value, action: action, debouncer: debouncer) {
138 | try await Task.sleep(seconds: debounceTime)
139 | })
140 | }
141 | }
142 |
143 | // MARK: Implementation
144 |
145 | private struct DebouncedChange1ParamViewModifier: ViewModifier where Value: Equatable {
146 | let trigger: Value
147 | let action: (Value) -> Void
148 | let debouncer: Binding?
149 | let sleep: @Sendable () async throws -> Void
150 |
151 | @State private var debouncedTask: Task?
152 |
153 | func body(content: Content) -> some View {
154 | content.onChange(of: trigger) { value in
155 | debouncedTask?.cancel()
156 | debouncedTask = Task {
157 | do { try await sleep() } catch { return }
158 | action(value)
159 | }
160 | debouncer?.wrappedValue.task = debouncedTask
161 | }
162 | }
163 | }
164 |
165 | @available(iOS 17, *)
166 | @available(macOS 14.0, *)
167 | @available(tvOS 17.0, *)
168 | @available(watchOS 10.0, *)
169 | @available(visionOS 1.0, *)
170 | private struct DebouncedChangeNoParamViewModifier: ViewModifier where Value: Equatable {
171 | let trigger: Value
172 | let initial: Bool
173 | let action: () -> Void
174 | let debounceDuration: Duration
175 | let debouncer: Binding?
176 |
177 | @State private var debouncedTask: Task?
178 |
179 | func body(content: Content) -> some View {
180 | content.onChange(of: trigger, initial: initial) {
181 | debouncedTask?.cancel()
182 | debouncedTask = Task.delayed(duration: debounceDuration) {
183 | action()
184 | }
185 | debouncer?.wrappedValue.task = debouncedTask
186 | }
187 | }
188 | }
189 |
190 | @available(iOS 17, *)
191 | @available(macOS 14.0, *)
192 | @available(tvOS 17.0, *)
193 | @available(watchOS 10.0, *)
194 | @available(visionOS 1.0, *)
195 | private struct DebouncedChange2ParamViewModifier: ViewModifier where Value: Equatable {
196 | let trigger: Value
197 | let initial: Bool
198 | let action: (Value, Value) -> Void
199 | let debounceDuration: Duration
200 | let debouncer: Binding?
201 |
202 | @State private var debouncedTask: Task?
203 |
204 | func body(content: Content) -> some View {
205 | content.onChange(of: trigger, initial: initial) { lhs, rhs in
206 | debouncedTask?.cancel()
207 | debouncedTask = Task.delayed(duration: debounceDuration) {
208 | action(lhs, rhs)
209 | }
210 | debouncer?.wrappedValue.task = debouncedTask
211 | }
212 | }
213 | }
214 |
--------------------------------------------------------------------------------
/Sources/DebouncedOnChange/DebouncedTaskViewModifier.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | extension View {
4 |
5 | /// Adds a task to perform before this view appears or when a specified value changes and a specified
6 | /// `debounceTime` elapses between value changes.
7 | ///
8 | /// Each time the value changes before `debounceTime` passes, the previous action will be cancelled and the next
9 | /// action will be scheduled to run after that time passes again. This mean that the action will only execute
10 | /// after changes to the value stay unmodified for the specified `debounceTime`.
11 | ///
12 | /// - Parameters:
13 | /// - value: The value to observe for changes. The value must conform to the `Equatable` protocol.
14 | /// - duration: The time to wait after each value change before running `action` closure.
15 | /// - action: A closure called after debounce time as an asynchronous task before the view appears. SwiftUI can
16 | /// automatically cancel the task after the view disappears before the action completes. If the id value
17 | /// changes, SwiftUI cancels and restarts the task.
18 | /// - Returns: A view that runs the specified action asynchronously before the view appears, or restarts the task
19 | /// with the id value changes after debounced time.
20 | @available(iOS 16.0, *)
21 | @available(macOS 13.0, *)
22 | @available(tvOS 16.0, *)
23 | @available(watchOS 9.0, *)
24 | public func task(
25 | id value: T,
26 | priority: TaskPriority = .userInitiated,
27 | debounceTime: Duration,
28 | _ action: @escaping () async -> Void
29 | ) -> some View where T: Equatable {
30 | self.task(id: value, priority: priority) {
31 | do { try await Task.sleep(for: debounceTime) } catch { return }
32 | await action()
33 | }
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/Sources/DebouncedOnChange/Debouncer.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Debouncer.swift
3 | //
4 | //
5 | // Created by Łukasz Rutkowski on 13/07/2024.
6 | //
7 |
8 | import Foundation
9 |
10 | /// Object which allows manual control of debounced operations.
11 | ///
12 | /// You can create an instance of this object and pass it to one of debounced `onChange` functions to be able to
13 | /// manually cancel their execution.
14 | ///
15 | /// - Note: A single debouncer should be passed to only one onChange function. If you want to control multiple
16 | /// operations create a separate debouncer for each of them.
17 | ///
18 | /// # Example
19 | ///
20 | /// Here a debouncer is used to cancel debounced api call and instead trigger it immediately when keyboard return
21 | /// key is pressed.
22 | ///
23 | /// ```swift
24 | /// struct Sample: View {
25 | /// @State private var debouncer = Debouncer()
26 | /// @State private var query = ""
27 | ///
28 | /// var body: some View {
29 | /// TextField("Query", text: $query)
30 | /// .onKeyPress(.return) {
31 | /// debouncer.cancel()
32 | /// callApi()
33 | /// return .handled
34 | /// }
35 | /// .onChange(of: query, debounceTime: .seconds(1), debouncer: $debouncer) {
36 | /// callApi()
37 | /// }
38 | /// }
39 | ///
40 | /// private func callApi() {
41 | /// print("Sending query \(query)")
42 | /// }
43 | /// }
44 | /// ```
45 | public struct Debouncer {
46 | var task: Task?
47 |
48 | /// Creates a new debouncer.
49 | public init() {}
50 |
51 | /// Cancels an operation that is currently being debounced.
52 | public func cancel() {
53 | task?.cancel()
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/Sources/DebouncedOnChange/Documentation.docc/Documentation.md:
--------------------------------------------------------------------------------
1 | # ``DebouncedOnChange``
2 |
3 | A SwiftUI onChange and task view modifiers with additional debounce time.
4 |
5 | ## Topics
6 |
7 | ### View extensions
8 |
9 | - ``SwiftUICore/View/onChange(of:initial:debounceTime:debouncer:_:)-9kwdz``
10 | - ``SwiftUICore/View/onChange(of:initial:debounceTime:debouncer:_:)-113nr``
11 | - ``SwiftUICore/View/task(id:priority:debounceTime:_:)``
12 |
13 | ### Cancelling actions
14 |
15 | - ``Debouncer``
16 |
17 | ### Depreciated View extensions
18 |
19 | - ``SwiftUICore/View/onChange(of:debounceTime:debouncer:perform:)-89k8t``
20 | - ``SwiftUICore/View/onChange(of:debounceTime:debouncer:perform:)-2p8uc``
21 |
--------------------------------------------------------------------------------
/Sources/DebouncedOnChange/Task+Delayed.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | extension Task {
4 |
5 | /// Asynchronously runs the given `operation` in its own task after the specified number of `seconds`.
6 | ///
7 | /// The operation will be executed after specified number of `seconds` passes. You can cancel the task earlier
8 | /// for the operation to be skipped.
9 | ///
10 | /// - Parameters:
11 | /// - time: Delay time in seconds.
12 | /// - operation: The operation to execute.
13 | /// - Returns: Handle to the task which can be cancelled.
14 | @discardableResult
15 | public static func delayed(
16 | seconds: TimeInterval,
17 | operation: @escaping @Sendable () async -> Void
18 | ) -> Self where Success == Void, Failure == Never {
19 | Self {
20 | do {
21 | try await Task.sleep(seconds: seconds)
22 | await operation()
23 | } catch {}
24 | }
25 | }
26 |
27 | static func sleep(seconds: TimeInterval) async throws where Success == Never, Failure == Never {
28 | try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
29 | }
30 |
31 | @available(iOS 17, *)
32 | @available(macOS 14.0, *)
33 | @available(tvOS 17.0, *)
34 | @available(watchOS 10.0, *)
35 | @available(visionOS 1.0, *)
36 | static func delayed(
37 | duration: Duration,
38 | operation: @escaping () async -> Void
39 | ) -> Self where Success == Void, Failure == Never {
40 | Self {
41 | do {
42 | try await Task.sleep(for: duration)
43 | await operation()
44 | } catch {}
45 | }
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/Tests/DebouncedOnChangeTests/DelayedTaskTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import DebouncedOnChange
3 |
4 | final class DelayedTaskTest: XCTestCase {
5 | func testTaskExecutesAfterDelay() throws {
6 | let positiveExpectation = expectation(description: "Delayed task finished in time")
7 | let negativeExpectation = expectation(description: "Delayed task finished too early")
8 | negativeExpectation.isInverted = true
9 |
10 | Task.delayed(seconds: 0.2) {
11 | positiveExpectation.fulfill()
12 | negativeExpectation.fulfill()
13 | }
14 |
15 | wait(for: [negativeExpectation], timeout: 0.1)
16 | wait(for: [positiveExpectation], timeout: 0.15)
17 | }
18 | }
19 |
--------------------------------------------------------------------------------