├── .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 | Demo Application 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 | --------------------------------------------------------------------------------