├── .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://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FTunous%2FDebouncedOnChange%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/Tunous/DebouncedOnChange) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FTunous%2FDebouncedOnChange%2Fbadge%3Ftype%3Dplatforms)](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 | --------------------------------------------------------------------------------