├── .gitignore
├── .swiftpm
└── xcode
│ ├── package.xcworkspace
│ └── xcshareddata
│ │ └── IDEWorkspaceChecks.plist
│ └── xcshareddata
│ └── xcschemes
│ └── Regulate.xcscheme
├── CHANGELOG.md
├── LICENSE
├── Package.swift
├── README.md
├── Regulate.gif
├── Sample
├── Sample.xcodeproj
│ ├── project.pbxproj
│ └── project.xcworkspace
│ │ ├── contents.xcworkspacedata
│ │ └── xcshareddata
│ │ └── IDEWorkspaceChecks.plist
└── Sample
│ ├── Assets.xcassets
│ ├── AccentColor.colorset
│ │ └── Contents.json
│ ├── AppIcon.appiconset
│ │ └── Contents.json
│ └── Contents.json
│ ├── ContentView.swift
│ ├── Preview Content
│ └── Preview Assets.xcassets
│ │ └── Contents.json
│ └── SampleApp.swift
├── Sources
├── Debouncer.swift
├── Regulator.swift
├── Supporting
│ └── DispatchTimeInterval+Nanoseconds.swift
├── SwiftUI
│ ├── Binding+Regulate.swift
│ ├── Button+Regulated.swift
│ └── RegulatedButtonStyle.swift
└── Throttler.swift
└── Tests
├── DebouncerTests.swift
├── Supporting
└── Spy.swift
└── ThrottlerTests.swift
/.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 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/Regulate.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
29 |
35 |
36 |
37 |
38 |
39 |
45 |
46 |
48 |
54 |
55 |
56 |
57 |
58 |
68 |
69 |
75 |
76 |
82 |
83 |
84 |
85 |
87 |
88 |
91 |
92 |
93 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | **v0.2.0:**
2 |
3 | - add static factory methods on `Task`
4 |
5 | **v0.1.0:**
6 |
7 | - Debouncer
8 | - Throttler
9 | - SwiftUI helpers for buttons and bindings
10 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 AsyncCommunity
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 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "Regulate",
8 | platforms: [
9 | .iOS(.v13),
10 | .macOS(.v10_15),
11 | .tvOS(.v13),
12 | .watchOS(.v6)
13 | ],
14 | products: [
15 | .library(
16 | name: "Regulate",
17 | targets: ["Regulate"]),
18 | ],
19 | dependencies: [],
20 | targets: [
21 | .target(
22 | name: "Regulate",
23 | dependencies: [],
24 | path: "Sources"
25 | ),
26 | .testTarget(
27 | name: "RegulateTests",
28 | dependencies: ["Regulate"],
29 | path: "Tests"
30 | ),
31 | ]
32 | )
33 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Regulate
2 |
3 | **Regulate** is a lightweight library that brings the following time-based regulation operations for things that can emit values over times (and are not using reactive programming or `AsyncSequence`).
4 |
5 | - [Debounce](./Sources/Debouncer.swift) (Outputs elements only after a specified time interval elapses between events)
6 | - [Throttle](./Sources/Throttler.swift) (Outputs either the most-recent or first element pushed by a producer in the specified time interval)
7 |
8 | **Regulate** is entirely backed by Swift concurrency and limits the number of created `Tasks` to the minimum.
9 |
10 | ```swift
11 | let regulator = Task.debounce(dueTime: .milliseconds(200)) { (value: Int) in
12 | print(value)
13 | }
14 |
15 | // the created `regulator` can be used across `Tasks` and each call to `regulator.push(x)`
16 | // will feed the regulation system
17 |
18 | // the execution of the provided closure will be debounced and executed 200ms after the last call to `push(x)`
19 | ```
20 |
21 | **Regulate** also provides SwiftUI helpers to regulate buttons and bindings out of the box.
22 | You can give a look at the [Sample app](./Sample).
23 |
24 | For a Button, it is as simple as:
25 |
26 | ```swift
27 | Button {
28 | print("I've been hit (throttled)!")
29 | } label: {
30 | Text("Hit me")
31 | }
32 | .throttle(dueTime: .seconds(1))
33 | ```
34 |
35 | For a Binding, there is a tiny bit of extra work:
36 |
37 | ```swift
38 | @State private var text = ""
39 | @StateObject private var debouncer = Debouncer(dueTime: .seconds(1))
40 | ...
41 | TextField(
42 | text: self
43 | .$text
44 | .perform(regulator: debouncer) { text in
45 | print("regulated text \(text)") // you can perform any side effect here!
46 | }
47 | ) {
48 | Text("prompt")
49 | }
50 | ```
51 |
52 | ## Demo
53 |
54 |
55 |
56 |
57 |
58 | ## Adding Regulate as a Dependency
59 |
60 | To use the `Regulate` library in a SwiftPM project,
61 | add the following line to the dependencies in your `Package.swift` file:
62 |
63 | ```swift
64 | .package(url: "https://github.com/sideeffect-io/Regulate"),
65 | ```
66 |
67 | Include `"Regulate"` as a dependency for your executable target:
68 |
69 | ```swift
70 | .target(name: "", dependencies: ["Regulate"]),
71 | ```
72 |
73 | Finally, add `import Regulate` to your source code.
74 |
--------------------------------------------------------------------------------
/Regulate.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sideeffect-io/Regulate/3dbaf34cf489792e9b944be5a3aaa31e20ff5b03/Regulate.gif
--------------------------------------------------------------------------------
/Sample/Sample.xcodeproj/project.pbxproj:
--------------------------------------------------------------------------------
1 | // !$*UTF8*$!
2 | {
3 | archiveVersion = 1;
4 | classes = {
5 | };
6 | objectVersion = 56;
7 | objects = {
8 |
9 | /* Begin PBXBuildFile section */
10 | 1A01D53928E73FC7002AD630 /* SampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A01D53828E73FC7002AD630 /* SampleApp.swift */; };
11 | 1A01D53B28E73FC7002AD630 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A01D53A28E73FC7002AD630 /* ContentView.swift */; };
12 | 1A01D53D28E73FC8002AD630 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1A01D53C28E73FC8002AD630 /* Assets.xcassets */; };
13 | 1A01D54028E73FC8002AD630 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1A01D53F28E73FC8002AD630 /* Preview Assets.xcassets */; };
14 | 1A01D54928E73FF9002AD630 /* Regulate in Frameworks */ = {isa = PBXBuildFile; productRef = 1A01D54828E73FF9002AD630 /* Regulate */; };
15 | /* End PBXBuildFile section */
16 |
17 | /* Begin PBXFileReference section */
18 | 1A01D53528E73FC7002AD630 /* Sample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sample.app; sourceTree = BUILT_PRODUCTS_DIR; };
19 | 1A01D53828E73FC7002AD630 /* SampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleApp.swift; sourceTree = ""; };
20 | 1A01D53A28E73FC7002AD630 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
21 | 1A01D53C28E73FC8002AD630 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
22 | 1A01D53F28E73FC8002AD630 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
23 | 1A01D54628E73FE1002AD630 /* Regulate */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Regulate; path = ..; sourceTree = ""; };
24 | /* End PBXFileReference section */
25 |
26 | /* Begin PBXFrameworksBuildPhase section */
27 | 1A01D53228E73FC7002AD630 /* Frameworks */ = {
28 | isa = PBXFrameworksBuildPhase;
29 | buildActionMask = 2147483647;
30 | files = (
31 | 1A01D54928E73FF9002AD630 /* Regulate in Frameworks */,
32 | );
33 | runOnlyForDeploymentPostprocessing = 0;
34 | };
35 | /* End PBXFrameworksBuildPhase section */
36 |
37 | /* Begin PBXGroup section */
38 | 1A01D52C28E73FC7002AD630 = {
39 | isa = PBXGroup;
40 | children = (
41 | 1A01D54628E73FE1002AD630 /* Regulate */,
42 | 1A01D53728E73FC7002AD630 /* Sample */,
43 | 1A01D53628E73FC7002AD630 /* Products */,
44 | 1A01D54728E73FF9002AD630 /* Frameworks */,
45 | );
46 | sourceTree = "";
47 | };
48 | 1A01D53628E73FC7002AD630 /* Products */ = {
49 | isa = PBXGroup;
50 | children = (
51 | 1A01D53528E73FC7002AD630 /* Sample.app */,
52 | );
53 | name = Products;
54 | sourceTree = "";
55 | };
56 | 1A01D53728E73FC7002AD630 /* Sample */ = {
57 | isa = PBXGroup;
58 | children = (
59 | 1A01D53828E73FC7002AD630 /* SampleApp.swift */,
60 | 1A01D53A28E73FC7002AD630 /* ContentView.swift */,
61 | 1A01D53C28E73FC8002AD630 /* Assets.xcassets */,
62 | 1A01D53E28E73FC8002AD630 /* Preview Content */,
63 | );
64 | path = Sample;
65 | sourceTree = "";
66 | };
67 | 1A01D53E28E73FC8002AD630 /* Preview Content */ = {
68 | isa = PBXGroup;
69 | children = (
70 | 1A01D53F28E73FC8002AD630 /* Preview Assets.xcassets */,
71 | );
72 | path = "Preview Content";
73 | sourceTree = "";
74 | };
75 | 1A01D54728E73FF9002AD630 /* Frameworks */ = {
76 | isa = PBXGroup;
77 | children = (
78 | );
79 | name = Frameworks;
80 | sourceTree = "";
81 | };
82 | /* End PBXGroup section */
83 |
84 | /* Begin PBXNativeTarget section */
85 | 1A01D53428E73FC7002AD630 /* Sample */ = {
86 | isa = PBXNativeTarget;
87 | buildConfigurationList = 1A01D54328E73FC8002AD630 /* Build configuration list for PBXNativeTarget "Sample" */;
88 | buildPhases = (
89 | 1A01D53128E73FC7002AD630 /* Sources */,
90 | 1A01D53228E73FC7002AD630 /* Frameworks */,
91 | 1A01D53328E73FC7002AD630 /* Resources */,
92 | );
93 | buildRules = (
94 | );
95 | dependencies = (
96 | );
97 | name = Sample;
98 | packageProductDependencies = (
99 | 1A01D54828E73FF9002AD630 /* Regulate */,
100 | );
101 | productName = Sample;
102 | productReference = 1A01D53528E73FC7002AD630 /* Sample.app */;
103 | productType = "com.apple.product-type.application";
104 | };
105 | /* End PBXNativeTarget section */
106 |
107 | /* Begin PBXProject section */
108 | 1A01D52D28E73FC7002AD630 /* Project object */ = {
109 | isa = PBXProject;
110 | attributes = {
111 | BuildIndependentTargetsInParallel = 1;
112 | LastSwiftUpdateCheck = 1400;
113 | LastUpgradeCheck = 1400;
114 | TargetAttributes = {
115 | 1A01D53428E73FC7002AD630 = {
116 | CreatedOnToolsVersion = 14.0.1;
117 | };
118 | };
119 | };
120 | buildConfigurationList = 1A01D53028E73FC7002AD630 /* Build configuration list for PBXProject "Sample" */;
121 | compatibilityVersion = "Xcode 14.0";
122 | developmentRegion = en;
123 | hasScannedForEncodings = 0;
124 | knownRegions = (
125 | en,
126 | Base,
127 | );
128 | mainGroup = 1A01D52C28E73FC7002AD630;
129 | productRefGroup = 1A01D53628E73FC7002AD630 /* Products */;
130 | projectDirPath = "";
131 | projectRoot = "";
132 | targets = (
133 | 1A01D53428E73FC7002AD630 /* Sample */,
134 | );
135 | };
136 | /* End PBXProject section */
137 |
138 | /* Begin PBXResourcesBuildPhase section */
139 | 1A01D53328E73FC7002AD630 /* Resources */ = {
140 | isa = PBXResourcesBuildPhase;
141 | buildActionMask = 2147483647;
142 | files = (
143 | 1A01D54028E73FC8002AD630 /* Preview Assets.xcassets in Resources */,
144 | 1A01D53D28E73FC8002AD630 /* Assets.xcassets in Resources */,
145 | );
146 | runOnlyForDeploymentPostprocessing = 0;
147 | };
148 | /* End PBXResourcesBuildPhase section */
149 |
150 | /* Begin PBXSourcesBuildPhase section */
151 | 1A01D53128E73FC7002AD630 /* Sources */ = {
152 | isa = PBXSourcesBuildPhase;
153 | buildActionMask = 2147483647;
154 | files = (
155 | 1A01D53B28E73FC7002AD630 /* ContentView.swift in Sources */,
156 | 1A01D53928E73FC7002AD630 /* SampleApp.swift in Sources */,
157 | );
158 | runOnlyForDeploymentPostprocessing = 0;
159 | };
160 | /* End PBXSourcesBuildPhase section */
161 |
162 | /* Begin XCBuildConfiguration section */
163 | 1A01D54128E73FC8002AD630 /* Debug */ = {
164 | isa = XCBuildConfiguration;
165 | buildSettings = {
166 | ALWAYS_SEARCH_USER_PATHS = NO;
167 | CLANG_ANALYZER_NONNULL = YES;
168 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
169 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
170 | CLANG_ENABLE_MODULES = YES;
171 | CLANG_ENABLE_OBJC_ARC = YES;
172 | CLANG_ENABLE_OBJC_WEAK = YES;
173 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
174 | CLANG_WARN_BOOL_CONVERSION = YES;
175 | CLANG_WARN_COMMA = YES;
176 | CLANG_WARN_CONSTANT_CONVERSION = YES;
177 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
178 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
179 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
180 | CLANG_WARN_EMPTY_BODY = YES;
181 | CLANG_WARN_ENUM_CONVERSION = YES;
182 | CLANG_WARN_INFINITE_RECURSION = YES;
183 | CLANG_WARN_INT_CONVERSION = YES;
184 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
185 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
186 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
187 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
188 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
189 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
190 | CLANG_WARN_STRICT_PROTOTYPES = YES;
191 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
192 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
193 | CLANG_WARN_UNREACHABLE_CODE = YES;
194 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
195 | COPY_PHASE_STRIP = NO;
196 | DEBUG_INFORMATION_FORMAT = dwarf;
197 | ENABLE_STRICT_OBJC_MSGSEND = YES;
198 | ENABLE_TESTABILITY = YES;
199 | GCC_C_LANGUAGE_STANDARD = gnu11;
200 | GCC_DYNAMIC_NO_PIC = NO;
201 | GCC_NO_COMMON_BLOCKS = YES;
202 | GCC_OPTIMIZATION_LEVEL = 0;
203 | GCC_PREPROCESSOR_DEFINITIONS = (
204 | "DEBUG=1",
205 | "$(inherited)",
206 | );
207 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
208 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
209 | GCC_WARN_UNDECLARED_SELECTOR = YES;
210 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
211 | GCC_WARN_UNUSED_FUNCTION = YES;
212 | GCC_WARN_UNUSED_VARIABLE = YES;
213 | IPHONEOS_DEPLOYMENT_TARGET = 16.0;
214 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
215 | MTL_FAST_MATH = YES;
216 | ONLY_ACTIVE_ARCH = YES;
217 | SDKROOT = iphoneos;
218 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
219 | SWIFT_OPTIMIZATION_LEVEL = "-Onone";
220 | };
221 | name = Debug;
222 | };
223 | 1A01D54228E73FC8002AD630 /* Release */ = {
224 | isa = XCBuildConfiguration;
225 | buildSettings = {
226 | ALWAYS_SEARCH_USER_PATHS = NO;
227 | CLANG_ANALYZER_NONNULL = YES;
228 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
229 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
230 | CLANG_ENABLE_MODULES = YES;
231 | CLANG_ENABLE_OBJC_ARC = YES;
232 | CLANG_ENABLE_OBJC_WEAK = YES;
233 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
234 | CLANG_WARN_BOOL_CONVERSION = YES;
235 | CLANG_WARN_COMMA = YES;
236 | CLANG_WARN_CONSTANT_CONVERSION = YES;
237 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
238 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
239 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
240 | CLANG_WARN_EMPTY_BODY = YES;
241 | CLANG_WARN_ENUM_CONVERSION = YES;
242 | CLANG_WARN_INFINITE_RECURSION = YES;
243 | CLANG_WARN_INT_CONVERSION = YES;
244 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
245 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
246 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
247 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
248 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
249 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
250 | CLANG_WARN_STRICT_PROTOTYPES = YES;
251 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
252 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
253 | CLANG_WARN_UNREACHABLE_CODE = YES;
254 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
255 | COPY_PHASE_STRIP = NO;
256 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
257 | ENABLE_NS_ASSERTIONS = NO;
258 | ENABLE_STRICT_OBJC_MSGSEND = YES;
259 | GCC_C_LANGUAGE_STANDARD = gnu11;
260 | GCC_NO_COMMON_BLOCKS = YES;
261 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
262 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
263 | GCC_WARN_UNDECLARED_SELECTOR = YES;
264 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
265 | GCC_WARN_UNUSED_FUNCTION = YES;
266 | GCC_WARN_UNUSED_VARIABLE = YES;
267 | IPHONEOS_DEPLOYMENT_TARGET = 16.0;
268 | MTL_ENABLE_DEBUG_INFO = NO;
269 | MTL_FAST_MATH = YES;
270 | SDKROOT = iphoneos;
271 | SWIFT_COMPILATION_MODE = wholemodule;
272 | SWIFT_OPTIMIZATION_LEVEL = "-O";
273 | VALIDATE_PRODUCT = YES;
274 | };
275 | name = Release;
276 | };
277 | 1A01D54428E73FC8002AD630 /* Debug */ = {
278 | isa = XCBuildConfiguration;
279 | buildSettings = {
280 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
281 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
282 | CODE_SIGN_STYLE = Automatic;
283 | CURRENT_PROJECT_VERSION = 1;
284 | DEVELOPMENT_ASSET_PATHS = "\"Sample/Preview Content\"";
285 | DEVELOPMENT_TEAM = 3V5265LQM9;
286 | ENABLE_PREVIEWS = YES;
287 | GENERATE_INFOPLIST_FILE = YES;
288 | INFOPLIST_KEY_CFBundleDisplayName = "Regulate Sample";
289 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
290 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
291 | INFOPLIST_KEY_UILaunchScreen_Generation = YES;
292 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
293 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
294 | IPHONEOS_DEPLOYMENT_TARGET = 15.0;
295 | LD_RUNPATH_SEARCH_PATHS = (
296 | "$(inherited)",
297 | "@executable_path/Frameworks",
298 | );
299 | MARKETING_VERSION = 1.0;
300 | PRODUCT_BUNDLE_IDENTIFIER = io.sideeffect.regulate.Sample;
301 | PRODUCT_NAME = "$(TARGET_NAME)";
302 | SWIFT_EMIT_LOC_STRINGS = YES;
303 | SWIFT_VERSION = 5.0;
304 | TARGETED_DEVICE_FAMILY = "1,2";
305 | };
306 | name = Debug;
307 | };
308 | 1A01D54528E73FC8002AD630 /* Release */ = {
309 | isa = XCBuildConfiguration;
310 | buildSettings = {
311 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
312 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
313 | CODE_SIGN_STYLE = Automatic;
314 | CURRENT_PROJECT_VERSION = 1;
315 | DEVELOPMENT_ASSET_PATHS = "\"Sample/Preview Content\"";
316 | DEVELOPMENT_TEAM = 3V5265LQM9;
317 | ENABLE_PREVIEWS = YES;
318 | GENERATE_INFOPLIST_FILE = YES;
319 | INFOPLIST_KEY_CFBundleDisplayName = "Regulate Sample";
320 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
321 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
322 | INFOPLIST_KEY_UILaunchScreen_Generation = YES;
323 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
324 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
325 | IPHONEOS_DEPLOYMENT_TARGET = 15.0;
326 | LD_RUNPATH_SEARCH_PATHS = (
327 | "$(inherited)",
328 | "@executable_path/Frameworks",
329 | );
330 | MARKETING_VERSION = 1.0;
331 | PRODUCT_BUNDLE_IDENTIFIER = io.sideeffect.regulate.Sample;
332 | PRODUCT_NAME = "$(TARGET_NAME)";
333 | SWIFT_EMIT_LOC_STRINGS = YES;
334 | SWIFT_VERSION = 5.0;
335 | TARGETED_DEVICE_FAMILY = "1,2";
336 | };
337 | name = Release;
338 | };
339 | /* End XCBuildConfiguration section */
340 |
341 | /* Begin XCConfigurationList section */
342 | 1A01D53028E73FC7002AD630 /* Build configuration list for PBXProject "Sample" */ = {
343 | isa = XCConfigurationList;
344 | buildConfigurations = (
345 | 1A01D54128E73FC8002AD630 /* Debug */,
346 | 1A01D54228E73FC8002AD630 /* Release */,
347 | );
348 | defaultConfigurationIsVisible = 0;
349 | defaultConfigurationName = Release;
350 | };
351 | 1A01D54328E73FC8002AD630 /* Build configuration list for PBXNativeTarget "Sample" */ = {
352 | isa = XCConfigurationList;
353 | buildConfigurations = (
354 | 1A01D54428E73FC8002AD630 /* Debug */,
355 | 1A01D54528E73FC8002AD630 /* Release */,
356 | );
357 | defaultConfigurationIsVisible = 0;
358 | defaultConfigurationName = Release;
359 | };
360 | /* End XCConfigurationList section */
361 |
362 | /* Begin XCSwiftPackageProductDependency section */
363 | 1A01D54828E73FF9002AD630 /* Regulate */ = {
364 | isa = XCSwiftPackageProductDependency;
365 | productName = Regulate;
366 | };
367 | /* End XCSwiftPackageProductDependency section */
368 | };
369 | rootObject = 1A01D52D28E73FC7002AD630 /* Project object */;
370 | }
371 |
--------------------------------------------------------------------------------
/Sample/Sample.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Sample/Sample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Sample/Sample/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Sample/Sample/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "platform" : "ios",
6 | "size" : "1024x1024"
7 | }
8 | ],
9 | "info" : {
10 | "author" : "xcode",
11 | "version" : 1
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/Sample/Sample/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Sample/Sample/ContentView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ContentView.swift
3 | // Sample
4 | //
5 | // Created by Thibault Wittemberg on 30/09/2022.
6 | //
7 |
8 | import SwiftUI
9 | import Regulate
10 |
11 | struct ContentView: View {
12 | @State var throttledCounter = 0
13 | @State var debouncedCounter = 0
14 | @State var text = ""
15 | @State var isOn = false
16 | @State var steps = 0
17 | @StateObject var textRegulator = Throttler(dueTime: .seconds(1))
18 | @StateObject var toggleRegulator = Debouncer(dueTime: .seconds(1))
19 | @StateObject var stepperRegulator = Debouncer(dueTime: .seconds(1))
20 |
21 | var body: some View {
22 | VStack {
23 | HStack {
24 | Button {
25 | print("I've been hit (throttled)!")
26 | self.throttledCounter += 1
27 | } label: {
28 | Text("Hit me (throttled)")
29 | }
30 | .throttle(dueTime: .seconds(1))
31 | .buttonStyle(BorderedProminentButtonStyle())
32 | .padding()
33 |
34 | Text("\(throttledCounter)")
35 | .padding()
36 | }
37 | .padding()
38 |
39 | HStack {
40 | Button {
41 | print("I've been hit (debounced)!")
42 | self.debouncedCounter += 1
43 | } label: {
44 | Text("Hit me (debounced)")
45 | }
46 | .debounce(dueTime: .seconds(1))
47 | .buttonStyle(BorderedProminentButtonStyle())
48 | .padding()
49 |
50 | Text("\(debouncedCounter)")
51 | .padding()
52 | }
53 |
54 | TextField(
55 | text: self
56 | .$text
57 | .perform(regulator: textRegulator) { text in
58 | print("regulated text \(text)")
59 | }
60 | ) {
61 | Text("prompt")
62 | }
63 | .textFieldStyle(RoundedBorderTextFieldStyle())
64 |
65 | Toggle(
66 | isOn: self
67 | .$isOn
68 | .perform(regulator: toggleRegulator) { value in
69 | print("regulated toggle \(value)")
70 | }
71 | ) {
72 | Text("Regulated toogle")
73 | }
74 |
75 | Stepper(
76 | "Regulated stepper \(self.steps)",
77 | value: self
78 | .$steps
79 | .perform(regulator: stepperRegulator) { value in
80 | print("regulated stepper \(value)")
81 | }
82 | )
83 | }
84 | .padding()
85 | }
86 | }
87 |
88 | struct ContentView_Previews: PreviewProvider {
89 | static var previews: some View {
90 | ContentView()
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/Sample/Sample/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Sample/Sample/SampleApp.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SampleApp.swift
3 | // Sample
4 | //
5 | // Created by Thibault Wittemberg on 30/09/2022.
6 | //
7 |
8 | import SwiftUI
9 |
10 | @main
11 | struct SampleApp: App {
12 | var body: some Scene {
13 | WindowGroup {
14 | ContentView()
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/Sources/Debouncer.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Debouncer.swift
3 | //
4 | //
5 | // Created by Thibault Wittemberg on 28/09/2022.
6 | //
7 |
8 | import Foundation
9 |
10 | public extension Task where Failure == Never {
11 | /// Creates a `Regulator` that executes an output only after a specified time interval elapses between events
12 | /// - Parameters:
13 | /// - dueTime: the time the Debouncer should wait before executing the output
14 | /// - output: the block to execute once the regulation is done
15 | /// - Returns: the debounced regulator
16 | static func debounce(
17 | dueTime: DispatchTimeInterval,
18 | output: @Sendable @escaping (Success) async -> Void
19 | ) -> some Regulator {
20 | Debouncer(dueTime: dueTime, output: output)
21 | }
22 | }
23 |
24 | /// Executes an output only after a specified time interval elapses between events
25 | ///
26 | /// ```swift
27 | /// let debouncer = Debouncer(dueTime: .seconds(2), output: { print($0) })
28 | ///
29 | /// for index in (0...99) {
30 | /// DispatchQueue.global().asyncAfter(deadline: .now().advanced(by: .milliseconds(100 * index))) {
31 | /// // pushes a value every 100 ms
32 | /// debouncer.push(index)
33 | /// }
34 | /// }
35 | ///
36 | /// // will only print "99" 2 seconds after the last call to `push(_:)`
37 | /// ```
38 | public final class Debouncer: @unchecked Sendable, ObservableObject, Regulator {
39 | struct DueValue {
40 | let value: Value
41 | let dueTime: DispatchTime
42 | }
43 |
44 | struct StateMachine {
45 | enum State {
46 | case idle
47 | case debouncing(value: DueValue, nextValue: DueValue?)
48 | }
49 |
50 | var state: State = .idle
51 |
52 | mutating func newValue(_ value: DueValue) -> Bool {
53 | switch self.state {
54 | case .idle:
55 | self.state = .debouncing(value: value, nextValue: nil)
56 | return true
57 | case .debouncing(let current, _):
58 | self.state = .debouncing(value: current, nextValue: value)
59 | return false
60 | }
61 | }
62 |
63 | enum HasDebouncedOutput {
64 | case continueDebouncing(DueValue)
65 | case finishDebouncing
66 | }
67 |
68 | mutating func hasDebouncedCurrentValue() -> HasDebouncedOutput {
69 | switch self.state {
70 | case .idle:
71 | fatalError("inconsistent state, a value was being debounced")
72 | case .debouncing(_, nextValue: .some(let nextValue)):
73 | state = .debouncing(value: nextValue, nextValue: nil)
74 | return .continueDebouncing(nextValue)
75 | case .debouncing(_, nextValue: .none):
76 | state = .idle
77 | return .finishDebouncing
78 | }
79 | }
80 | }
81 |
82 | public var output: (@Sendable (Value) async -> Void)?
83 | public var dueTime: DispatchTimeInterval
84 |
85 | private let lock: os_unfair_lock_t = UnsafeMutablePointer.allocate(capacity: 1)
86 | private var stateMachine = StateMachine()
87 | private var task: Task?
88 |
89 | public convenience init() {
90 | self.init(dueTime: .never, output: nil)
91 | }
92 |
93 | /// A Regulator that executes the output only after a specified time interval elapses between events
94 | /// - Parameters:
95 | /// - dueTime: the time the Debouncer should wait before executing the output
96 | /// - output: the block to execute once the regulation is done
97 | public init(
98 | dueTime: DispatchTimeInterval,
99 | output: (@Sendable (Value) async -> Void)? = nil
100 | ) {
101 | self.lock.initialize(to: os_unfair_lock())
102 | self.dueTime = dueTime
103 | self.output = output
104 | }
105 |
106 | public func push(_ value: Value) {
107 | let newValue = DueValue(value: value, dueTime: DispatchTime.now().advanced(by: dueTime))
108 | var shouldStartADebounce = false
109 |
110 | os_unfair_lock_lock(self.lock)
111 | shouldStartADebounce = self.stateMachine.newValue(newValue)
112 | os_unfair_lock_unlock(self.lock)
113 |
114 | if shouldStartADebounce {
115 | self.task = Task { [weak self] in
116 | guard let self = self else { return }
117 |
118 | var timeToSleep = self.dueTime.nanoseconds
119 | var currentValue = value
120 |
121 | loop: while true {
122 | try? await Task.sleep(nanoseconds: timeToSleep)
123 |
124 | var output: StateMachine.HasDebouncedOutput
125 | os_unfair_lock_lock(self.lock)
126 | output = self.stateMachine.hasDebouncedCurrentValue()
127 | os_unfair_lock_unlock(self.lock)
128 |
129 | switch output {
130 | case .finishDebouncing:
131 | break loop
132 | case .continueDebouncing(let value):
133 | timeToSleep = DispatchTime.now().distance(to: value.dueTime).nanoseconds
134 | currentValue = value.value
135 | continue loop
136 | }
137 | }
138 |
139 | await self.output?(currentValue)
140 | }
141 | }
142 | }
143 |
144 | public func cancel() {
145 | self.task?.cancel()
146 | }
147 |
148 | deinit {
149 | self.cancel()
150 | }
151 | }
152 |
--------------------------------------------------------------------------------
/Sources/Regulator.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Regulator.swift
3 | //
4 | //
5 | // Created by Thibault Wittemberg on 28/09/2022.
6 | //
7 |
8 | import Foundation
9 |
10 | public protocol Regulator: AnyObject, ObservableObject {
11 | associatedtype Value
12 | init()
13 | func push(_ value: Value)
14 | func cancel()
15 | var output: (@Sendable (Value) async -> Void)? { get set }
16 | var dueTime: DispatchTimeInterval { get set }
17 | }
18 |
--------------------------------------------------------------------------------
/Sources/Supporting/DispatchTimeInterval+Nanoseconds.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DispatchTimeInterval+Nanoseconds.swift
3 | // Debounce
4 | //
5 | // Created by Thibault Wittemberg on 28/09/2022.
6 | //
7 |
8 | import Foundation
9 |
10 | extension DispatchTimeInterval {
11 | var nanoseconds: UInt64 {
12 | switch self {
13 | case .nanoseconds(let value) where value >= 0: return UInt64(value)
14 | case .microseconds(let value) where value >= 0: return UInt64(value) * 1000
15 | case .milliseconds(let value) where value >= 0: return UInt64(value) * 1_000_000
16 | case .seconds(let value) where value >= 0: return UInt64(value) * 1_000_000_000
17 | case .never: return .zero
18 | default: return .zero
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Sources/SwiftUI/Binding+Regulate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Binding+Regulate.swift
3 | //
4 | //
5 | // Created by Thibault Wittemberg on 30/09/2022.
6 | //
7 |
8 | import Foundation
9 |
10 | #if canImport(SwiftUI)
11 | import SwiftUI
12 |
13 | public extension Binding {
14 | init(
15 | regulator: some Regulator,
16 | get: @escaping () -> Value,
17 | set: @Sendable @escaping (Value) async -> Void
18 | ) {
19 | regulator.output = set
20 | self.init(get: get) { value in
21 | regulator.push(value)
22 | }
23 | }
24 |
25 | /// Applies the specified regulator to the execution block
26 | /// every time the binding is set.
27 | /// - Parameters:
28 | /// - regulator: the regulator to apply to the binding input
29 | /// - block: the block to execute once the regulation has applied
30 | /// - Returns: the Binding wrapping the base binding
31 | func perform(
32 | regulator: some Regulator,
33 | _ block: @Sendable @escaping (Value) async -> Void
34 | ) -> Self {
35 | regulator.output = block
36 |
37 | return Binding {
38 | self.wrappedValue
39 | } set: { value in
40 | self.wrappedValue = value
41 | regulator.push(value)
42 | }
43 | }
44 | }
45 | #endif
46 |
--------------------------------------------------------------------------------
/Sources/SwiftUI/Button+Regulated.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Button+Regulated.swift
3 | //
4 | //
5 | // Created by Thibault Wittemberg on 30/09/2022.
6 | //
7 |
8 | #if canImport(SwiftUI)
9 | import SwiftUI
10 |
11 | public extension Button {
12 | /// Debounces the action of a Button
13 | /// - Parameter dueTime: the time the Debouncer should wait before executing the action
14 | /// - Returns: a debounced button
15 | func debounce(dueTime: DispatchTimeInterval) -> some View {
16 | return self.buttonStyle(RegulatedButtonStyle(dueTime: dueTime))
17 | }
18 |
19 | /// Throttles the action of a Button
20 | /// - Parameter dueTime: the interval at which to execute the action
21 | /// - Returns: a throttled button
22 | func throttle(dueTime: DispatchTimeInterval) -> some View {
23 | return self.buttonStyle(RegulatedButtonStyle(dueTime: dueTime))
24 | }
25 | }
26 | #endif
27 |
--------------------------------------------------------------------------------
/Sources/SwiftUI/RegulatedButtonStyle.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RegulatedButtonStyle.swift
3 | //
4 | //
5 | // Created by Thibault Wittemberg on 30/09/2022.
6 | //
7 |
8 | #if canImport(SwiftUI)
9 | import SwiftUI
10 |
11 | public struct RegulatedButtonStyle>: PrimitiveButtonStyle {
12 | @StateObject var regulator = R.init()
13 | let dueTime: DispatchTimeInterval
14 |
15 | init(dueTime: DispatchTimeInterval) {
16 | self.dueTime = dueTime
17 | }
18 |
19 | public func makeBody(configuration: Configuration) -> some View {
20 | regulator.dueTime = self.dueTime
21 | regulator.output = { _ in configuration.trigger() }
22 |
23 | if #available(iOS 15.0, macOS 12.0, *) {
24 | return Button(role: configuration.role) {
25 | regulator.push(())
26 | } label: {
27 | configuration.label
28 | }
29 | } else {
30 | return Button {
31 | regulator.push(())
32 | } label: {
33 | configuration.label
34 | }
35 | }
36 | }
37 | }
38 | #endif
39 |
--------------------------------------------------------------------------------
/Sources/Throttler.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Throttler.swift
3 | //
4 | //
5 | // Created by Thibault Wittemberg on 28/09/2022.
6 | //
7 |
8 | import Foundation
9 |
10 | public extension Task where Failure == Never {
11 | /// Creates a `Regulator` that executes the output with either the most-recent or first element
12 | /// pushed in the Throttler in the specified time interval
13 | /// - dueTime: the interval at which to find and emit either the most recent or the first element
14 | /// - latest: true if output should be called with the most-recent element, false otherwise
15 | /// - output: the block to execute once the regulation is done
16 | /// - Returns: the throttled regulator
17 | static func throttle(
18 | dueTime: DispatchTimeInterval,
19 | latest: Bool = true,
20 | output: @Sendable @escaping (Success) async -> Void
21 | ) -> some Regulator {
22 | Throttler(dueTime: dueTime, latest: latest, output: output)
23 | }
24 | }
25 |
26 | /// Executes the output with either the most-recent or first element pushed in the Throttler in the specified time interval
27 | ///
28 | /// ```swift
29 | /// let throttler = Throttler(dueTime: .seconds(2), latest: true, output: { print($0) })
30 | ///
31 | /// for index in (0...99) {
32 | /// DispatchQueue.global().asyncAfter(deadline: .now().advanced(by: .milliseconds(100 * index))) {
33 | /// // pushes a value every 100 ms
34 | /// throttler.push(index)
35 | /// }
36 | /// }
37 | ///
38 | /// // will only print an index once every 2 seconds (the latest received index before the `tick`)
39 | /// ```
40 | public final class Throttler: @unchecked Sendable, ObservableObject, Regulator {
41 | struct StateMachine {
42 | enum State {
43 | case idle
44 | case throttlingWithNoValues
45 | case throttlingWithFirst(first: Value)
46 | case throttlingWithFirstAndLast(first: Value, last: Value)
47 | }
48 |
49 | var state: State = .idle
50 |
51 | mutating func newValue(_ value: Value) -> Bool {
52 | switch self.state {
53 | case .idle:
54 | self.state = .throttlingWithFirst(first: value)
55 | return true
56 | case .throttlingWithFirst(let first), .throttlingWithFirstAndLast(let first, _):
57 | self.state = .throttlingWithFirstAndLast(first: first, last: value)
58 | return false
59 | case .throttlingWithNoValues:
60 | self.state = .throttlingWithFirst(first: value)
61 | return false
62 | }
63 | }
64 |
65 | enum HasTickedOutput {
66 | case finishThrottling
67 | case continueThrottling(first: Value, last: Value)
68 | }
69 |
70 | mutating func hasTicked() -> HasTickedOutput {
71 | switch state {
72 | case .idle:
73 | fatalError("inconsistent state, a value was being debounced")
74 | case .throttlingWithFirst(let first):
75 | self.state = .throttlingWithNoValues
76 | return .continueThrottling(first: first, last: first)
77 | case .throttlingWithFirstAndLast(let first, let last):
78 | self.state = .throttlingWithNoValues
79 | return .continueThrottling(first: first, last: last)
80 | case .throttlingWithNoValues:
81 | self.state = .idle
82 | return .finishThrottling
83 | }
84 | }
85 | }
86 |
87 | public var output: (@Sendable (Value) async -> Void)?
88 | public var dueTime: DispatchTimeInterval
89 |
90 | private let latest: Bool
91 | private let lock: os_unfair_lock_t = UnsafeMutablePointer.allocate(capacity: 1)
92 | private var stateMachine = StateMachine()
93 | private var task: Task?
94 |
95 | public convenience init() {
96 | self.init(dueTime: .never, latest: true, output: nil)
97 | }
98 |
99 | /// A Regulator that emits either the most-recent or first element received during the specified interval
100 | /// - Parameters:
101 | /// - dueTime: the interval at which to find and emit either the most recent or the first element
102 | /// - latest: true if output should be called with the most-recent element, false otherwise
103 | /// - output: the block to execute once the regulation is done
104 | public init(
105 | dueTime: DispatchTimeInterval,
106 | latest: Bool = true,
107 | output: (@Sendable (Value) async -> Void)? = nil
108 | ) {
109 | self.lock.initialize(to: os_unfair_lock())
110 | self.dueTime = dueTime
111 | self.latest = latest
112 | self.output = output
113 | }
114 |
115 | public func push(_ value: Value) {
116 | var shouldStartAThrottle = false
117 |
118 | os_unfair_lock_lock(self.lock)
119 | shouldStartAThrottle = self.stateMachine.newValue(value)
120 | os_unfair_lock_unlock(self.lock)
121 |
122 | if shouldStartAThrottle {
123 | self.task = Task { [weak self] in
124 | guard let self = self else { return }
125 |
126 | await withTaskGroup(of: Void.self) { group in
127 | loop: while true {
128 | try? await Task.sleep(nanoseconds: self.dueTime.nanoseconds)
129 |
130 | var hasTickedOutput: StateMachine.HasTickedOutput
131 |
132 | os_unfair_lock_lock(self.lock)
133 | hasTickedOutput = self.stateMachine.hasTicked()
134 | os_unfair_lock_unlock(self.lock)
135 |
136 | switch hasTickedOutput {
137 | case .finishThrottling:
138 | break loop
139 | case .continueThrottling(let first, let last):
140 | group.addTask {
141 | await self.output?(self.latest ? last : first)
142 | }
143 | continue loop
144 | }
145 | }
146 | }
147 | }
148 | }
149 | }
150 |
151 | public func cancel() {
152 | self.task?.cancel()
153 | }
154 |
155 | deinit {
156 | self.cancel()
157 | }
158 | }
159 |
--------------------------------------------------------------------------------
/Tests/DebouncerTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DebouncerTests.swift
3 | //
4 | //
5 | // Created by Thibault Wittemberg on 28/09/2022.
6 | //
7 |
8 | @testable import Regulate
9 | import XCTest
10 |
11 | final class DebouncerTests: XCTestCase {
12 | func test_debouncer_discards_intermediates_values_and_outputs_last_value() async {
13 | let hasDebounced = expectation(description: "Has debounced a value")
14 | let spy = Spy()
15 |
16 | let sut = Task.debounce(dueTime: .milliseconds(200)) { value in
17 | await spy.push(value)
18 | hasDebounced.fulfill()
19 | }
20 |
21 | for index in (0...4) {
22 | DispatchQueue.global().asyncAfter(deadline: .now().advanced(by: .milliseconds(100 * index))) {
23 | sut.push(index)
24 | }
25 | }
26 |
27 | wait(for: [hasDebounced], timeout: 5.0)
28 |
29 | await spy.assertEqual(expected: [4])
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/Tests/Supporting/Spy.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Spy.swift
3 | //
4 | //
5 | // Created by Thibault Wittemberg on 28/09/2022.
6 | //
7 |
8 | import XCTest
9 |
10 | actor Spy {
11 | var storage = [Value]()
12 |
13 | init() {}
14 |
15 | func push(_ value: Value) {
16 | self.storage.append(value)
17 | }
18 |
19 | func assertEqual(
20 | expected: [Value],
21 | file: StaticString = #filePath,
22 | line: UInt = #line
23 | ) where Value: Equatable {
24 | XCTAssertEqual(self.storage, expected, file: file, line: line)
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/Tests/ThrottlerTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ThrottlerTests.swift
3 | //
4 | //
5 | // Created by Thibault Wittemberg on 28/09/2022.
6 | //
7 |
8 | @testable import Regulate
9 | import XCTest
10 |
11 | final class ThrottlerTests: XCTestCase {
12 | func test_throttler_outputs_first_value_per_time_interval() async {
13 | let hasThrottledTwoValues = expectation(description: "Has throttled 2 values")
14 | hasThrottledTwoValues.expectedFulfillmentCount = 2
15 |
16 | let spy = Spy()
17 |
18 | let sut = Task.throttle(dueTime: .milliseconds(100), latest: false) { value in
19 | await spy.push(value)
20 | hasThrottledTwoValues.fulfill()
21 | }
22 |
23 | // T T
24 | // 0 -- 40 -- 80 -- 120 -- 160 ---------
25 | for index in (0...4) {
26 | DispatchQueue.global().asyncAfter(deadline: .now().advanced(by: .milliseconds(40 * index))) {
27 | sut.push(index)
28 | }
29 | }
30 |
31 | wait(for: [hasThrottledTwoValues], timeout: 5.0)
32 |
33 | await spy.assertEqual(expected: [0, 3])
34 | }
35 |
36 | func test_throttler_outputs_last_value_per_time_interval() async {
37 | let hasThrottledTwoValues = expectation(description: "Has throttled 2 values")
38 | hasThrottledTwoValues.expectedFulfillmentCount = 2
39 |
40 | let spy = Spy()
41 |
42 | let sut = Task.throttle(dueTime: .milliseconds(100), latest: true) { value in
43 | await spy.push(value)
44 | hasThrottledTwoValues.fulfill()
45 | }
46 |
47 | // T T
48 | // 0 -- 40 -- 80 -- 120 -- 160 ---------
49 | for index in (0...4) {
50 | DispatchQueue.global().asyncAfter(deadline: .now().advanced(by: .milliseconds(40 * index))) {
51 | sut.push(index)
52 | }
53 | }
54 |
55 | wait(for: [hasThrottledTwoValues], timeout: 5.0)
56 |
57 | await spy.assertEqual(expected: [2, 4])
58 | }
59 |
60 | func test_throttler_outputs_last_value_per_time_interval_when_no_last() async {
61 | let hasThrottledTwoValues = expectation(description: "Has throttled 2 values")
62 | hasThrottledTwoValues.expectedFulfillmentCount = 2
63 |
64 | let spy = Spy()
65 |
66 | let sut = Task.throttle(dueTime: .milliseconds(100), latest: true) { value in
67 | await spy.push(value)
68 | hasThrottledTwoValues.fulfill()
69 | }
70 |
71 | // T T
72 | // 0 -- 40 -- 80 -- 120 ----------------
73 | for index in (0...3) {
74 | DispatchQueue.global().asyncAfter(deadline: .now().advanced(by: .milliseconds(40 * index))) {
75 | sut.push(index)
76 | }
77 | }
78 |
79 | wait(for: [hasThrottledTwoValues], timeout: 5.0)
80 |
81 | await spy.assertEqual(expected: [2, 3])
82 | }
83 | }
84 |
--------------------------------------------------------------------------------