├── .github ├── CODEOWNERS └── FUNDING.yml ├── .gitignore ├── Demo ├── .gitignore ├── ButtonKitDemo.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ │ └── xcschemes │ │ └── ButtonKitDemo.xcscheme └── ButtonKitDemo │ ├── Advanced │ └── AppStoreButtonDemo.swift │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json │ ├── ButtonKitDemo.entitlements │ ├── Buttons │ ├── AsyncButtonDemo.swift │ └── ThrowableButtonDemo.swift │ ├── ContentView.swift │ ├── DemoApp.swift │ ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json │ ├── Progress │ ├── DiscreteProgressDemo.swift │ └── EstimatedProgressDemo.swift │ └── Trigger │ └── TriggerDemo.swift ├── LICENSE ├── Package.swift ├── Package@swift-5.9.swift ├── Preview ├── determinant-bar.gif ├── determinant-leading.gif ├── determinant-percent.gif ├── determinant-trailing.gif ├── leading.gif ├── overlay.gif ├── pulse.gif ├── shake.gif └── trailing.gif ├── README.md └── Sources └── ButtonKit ├── Button+AppIntent.swift ├── Button+Async.swift ├── Internal ├── BarProgressView.swift ├── CircularProgressView.swift └── IndeterminateProgressView.swift ├── Modifiers ├── Button+AsyncDisabled.swift ├── Button+AsyncError.swift └── Button+AsyncTask.swift ├── Progress ├── Progress+Discrete.swift ├── Progress+Estimated.swift ├── Progress+Indeterminate.swift ├── Progress+NSProgress.swift └── Progress.swift ├── Style ├── Async │ ├── AsyncStyle+Leading.swift │ ├── AsyncStyle+None.swift │ ├── AsyncStyle+Overlay.swift │ ├── AsyncStyle+Pulse.swift │ └── AsyncStyle+Trailing.swift ├── Button+AsyncStyle.swift ├── Button+ThrowableStyle.swift └── Throwable │ ├── ThrowableStyle+None.swift │ └── ThrowableStyle+Shake.swift └── Trigger └── Trigger+Environment.swift /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @Dean151 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [Dean151] 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Demo/.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | -------------------------------------------------------------------------------- /Demo/ButtonKitDemo.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | E937B5A22BAF30A500B1B880 /* ButtonKit in Frameworks */ = {isa = PBXBuildFile; productRef = E937B5A12BAF30A500B1B880 /* ButtonKit */; }; 11 | E937B5AD2BAF8A1000B1B880 /* DiscreteProgressDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E937B5AC2BAF8A1000B1B880 /* DiscreteProgressDemo.swift */; }; 12 | E937B5AF2BAF8B8100B1B880 /* AsyncButtonDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E937B5AE2BAF8B8100B1B880 /* AsyncButtonDemo.swift */; }; 13 | E937B5B12BAF8C4500B1B880 /* EstimatedProgressDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E937B5B02BAF8C4500B1B880 /* EstimatedProgressDemo.swift */; }; 14 | E937B5B42BAF8D7F00B1B880 /* AppStoreButtonDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E937B5B32BAF8D7F00B1B880 /* AppStoreButtonDemo.swift */; }; 15 | E9447BCD2C62BA6D0056DC4D /* TriggerDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9447BCB2C62BA6D0056DC4D /* TriggerDemo.swift */; }; 16 | E9D33A662BAF2D8600C500FD /* DemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D33A652BAF2D8600C500FD /* DemoApp.swift */; }; 17 | E9D33A682BAF2D8600C500FD /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D33A672BAF2D8600C500FD /* ContentView.swift */; }; 18 | E9D33A6A2BAF2D8700C500FD /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E9D33A692BAF2D8700C500FD /* Assets.xcassets */; }; 19 | E9D33A6E2BAF2D8700C500FD /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E9D33A6D2BAF2D8700C500FD /* Preview Assets.xcassets */; }; 20 | E9D33A792BAF303F00C500FD /* ThrowableButtonDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D33A782BAF303F00C500FD /* ThrowableButtonDemo.swift */; }; 21 | /* End PBXBuildFile section */ 22 | 23 | /* Begin PBXFileReference section */ 24 | E937B5AC2BAF8A1000B1B880 /* DiscreteProgressDemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscreteProgressDemo.swift; sourceTree = ""; }; 25 | E937B5AE2BAF8B8100B1B880 /* AsyncButtonDemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncButtonDemo.swift; sourceTree = ""; }; 26 | E937B5B02BAF8C4500B1B880 /* EstimatedProgressDemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EstimatedProgressDemo.swift; sourceTree = ""; }; 27 | E937B5B32BAF8D7F00B1B880 /* AppStoreButtonDemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreButtonDemo.swift; sourceTree = ""; }; 28 | E9447BCB2C62BA6D0056DC4D /* TriggerDemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerDemo.swift; sourceTree = ""; }; 29 | E9D33A622BAF2D8600C500FD /* ButtonKitDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ButtonKitDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; 30 | E9D33A652BAF2D8600C500FD /* DemoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoApp.swift; sourceTree = ""; }; 31 | E9D33A672BAF2D8600C500FD /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 32 | E9D33A692BAF2D8700C500FD /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 33 | E9D33A6B2BAF2D8700C500FD /* ButtonKitDemo.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ButtonKitDemo.entitlements; sourceTree = ""; }; 34 | E9D33A6D2BAF2D8700C500FD /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 35 | E9D33A742BAF2EC700C500FD /* ButtonKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = ButtonKit; path = ..; sourceTree = ""; }; 36 | E9D33A782BAF303F00C500FD /* ThrowableButtonDemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThrowableButtonDemo.swift; sourceTree = ""; }; 37 | /* End PBXFileReference section */ 38 | 39 | /* Begin PBXFrameworksBuildPhase section */ 40 | E9D33A5F2BAF2D8600C500FD /* Frameworks */ = { 41 | isa = PBXFrameworksBuildPhase; 42 | buildActionMask = 2147483647; 43 | files = ( 44 | E937B5A22BAF30A500B1B880 /* ButtonKit in Frameworks */, 45 | ); 46 | runOnlyForDeploymentPostprocessing = 0; 47 | }; 48 | /* End PBXFrameworksBuildPhase section */ 49 | 50 | /* Begin PBXGroup section */ 51 | E937B5A02BAF30A500B1B880 /* Frameworks */ = { 52 | isa = PBXGroup; 53 | children = ( 54 | ); 55 | name = Frameworks; 56 | sourceTree = ""; 57 | }; 58 | E9447BCC2C62BA6D0056DC4D /* Trigger */ = { 59 | isa = PBXGroup; 60 | children = ( 61 | E9447BCB2C62BA6D0056DC4D /* TriggerDemo.swift */, 62 | ); 63 | path = Trigger; 64 | sourceTree = ""; 65 | }; 66 | E9D33A592BAF2D8600C500FD = { 67 | isa = PBXGroup; 68 | children = ( 69 | E9D33A742BAF2EC700C500FD /* ButtonKit */, 70 | E9D33A642BAF2D8600C500FD /* ButtonKitDemo */, 71 | E9D33A632BAF2D8600C500FD /* Products */, 72 | E937B5A02BAF30A500B1B880 /* Frameworks */, 73 | ); 74 | sourceTree = ""; 75 | }; 76 | E9D33A632BAF2D8600C500FD /* Products */ = { 77 | isa = PBXGroup; 78 | children = ( 79 | E9D33A622BAF2D8600C500FD /* ButtonKitDemo.app */, 80 | ); 81 | name = Products; 82 | sourceTree = ""; 83 | }; 84 | E9D33A642BAF2D8600C500FD /* ButtonKitDemo */ = { 85 | isa = PBXGroup; 86 | children = ( 87 | E9D33A652BAF2D8600C500FD /* DemoApp.swift */, 88 | E9D33A672BAF2D8600C500FD /* ContentView.swift */, 89 | E9D33A752BAF2FFE00C500FD /* Buttons */, 90 | E9D33A762BAF300E00C500FD /* Progress */, 91 | E9447BCC2C62BA6D0056DC4D /* Trigger */, 92 | E9D33A772BAF301700C500FD /* Advanced */, 93 | E9D33A692BAF2D8700C500FD /* Assets.xcassets */, 94 | E9D33A6B2BAF2D8700C500FD /* ButtonKitDemo.entitlements */, 95 | E9D33A6C2BAF2D8700C500FD /* Preview Content */, 96 | ); 97 | path = ButtonKitDemo; 98 | sourceTree = ""; 99 | }; 100 | E9D33A6C2BAF2D8700C500FD /* Preview Content */ = { 101 | isa = PBXGroup; 102 | children = ( 103 | E9D33A6D2BAF2D8700C500FD /* Preview Assets.xcassets */, 104 | ); 105 | path = "Preview Content"; 106 | sourceTree = ""; 107 | }; 108 | E9D33A752BAF2FFE00C500FD /* Buttons */ = { 109 | isa = PBXGroup; 110 | children = ( 111 | E937B5AE2BAF8B8100B1B880 /* AsyncButtonDemo.swift */, 112 | E9D33A782BAF303F00C500FD /* ThrowableButtonDemo.swift */, 113 | ); 114 | path = Buttons; 115 | sourceTree = ""; 116 | }; 117 | E9D33A762BAF300E00C500FD /* Progress */ = { 118 | isa = PBXGroup; 119 | children = ( 120 | E937B5AC2BAF8A1000B1B880 /* DiscreteProgressDemo.swift */, 121 | E937B5B02BAF8C4500B1B880 /* EstimatedProgressDemo.swift */, 122 | ); 123 | path = Progress; 124 | sourceTree = ""; 125 | }; 126 | E9D33A772BAF301700C500FD /* Advanced */ = { 127 | isa = PBXGroup; 128 | children = ( 129 | E937B5B32BAF8D7F00B1B880 /* AppStoreButtonDemo.swift */, 130 | ); 131 | path = Advanced; 132 | sourceTree = ""; 133 | }; 134 | /* End PBXGroup section */ 135 | 136 | /* Begin PBXNativeTarget section */ 137 | E9D33A612BAF2D8600C500FD /* ButtonKitDemo */ = { 138 | isa = PBXNativeTarget; 139 | buildConfigurationList = E9D33A712BAF2D8700C500FD /* Build configuration list for PBXNativeTarget "ButtonKitDemo" */; 140 | buildPhases = ( 141 | E9D33A5E2BAF2D8600C500FD /* Sources */, 142 | E9D33A5F2BAF2D8600C500FD /* Frameworks */, 143 | E9D33A602BAF2D8600C500FD /* Resources */, 144 | ); 145 | buildRules = ( 146 | ); 147 | dependencies = ( 148 | ); 149 | name = ButtonKitDemo; 150 | packageProductDependencies = ( 151 | E937B5A12BAF30A500B1B880 /* ButtonKit */, 152 | ); 153 | productName = ButtonKitDemo; 154 | productReference = E9D33A622BAF2D8600C500FD /* ButtonKitDemo.app */; 155 | productType = "com.apple.product-type.application"; 156 | }; 157 | /* End PBXNativeTarget section */ 158 | 159 | /* Begin PBXProject section */ 160 | E9D33A5A2BAF2D8600C500FD /* Project object */ = { 161 | isa = PBXProject; 162 | attributes = { 163 | BuildIndependentTargetsInParallel = 1; 164 | LastSwiftUpdateCheck = 1530; 165 | LastUpgradeCheck = 1600; 166 | TargetAttributes = { 167 | E9D33A612BAF2D8600C500FD = { 168 | CreatedOnToolsVersion = 15.3; 169 | }; 170 | }; 171 | }; 172 | buildConfigurationList = E9D33A5D2BAF2D8600C500FD /* Build configuration list for PBXProject "ButtonKitDemo" */; 173 | compatibilityVersion = "Xcode 14.0"; 174 | developmentRegion = en; 175 | hasScannedForEncodings = 0; 176 | knownRegions = ( 177 | en, 178 | Base, 179 | ); 180 | mainGroup = E9D33A592BAF2D8600C500FD; 181 | productRefGroup = E9D33A632BAF2D8600C500FD /* Products */; 182 | projectDirPath = ""; 183 | projectRoot = ""; 184 | targets = ( 185 | E9D33A612BAF2D8600C500FD /* ButtonKitDemo */, 186 | ); 187 | }; 188 | /* End PBXProject section */ 189 | 190 | /* Begin PBXResourcesBuildPhase section */ 191 | E9D33A602BAF2D8600C500FD /* Resources */ = { 192 | isa = PBXResourcesBuildPhase; 193 | buildActionMask = 2147483647; 194 | files = ( 195 | E9D33A6E2BAF2D8700C500FD /* Preview Assets.xcassets in Resources */, 196 | E9D33A6A2BAF2D8700C500FD /* Assets.xcassets in Resources */, 197 | ); 198 | runOnlyForDeploymentPostprocessing = 0; 199 | }; 200 | /* End PBXResourcesBuildPhase section */ 201 | 202 | /* Begin PBXSourcesBuildPhase section */ 203 | E9D33A5E2BAF2D8600C500FD /* Sources */ = { 204 | isa = PBXSourcesBuildPhase; 205 | buildActionMask = 2147483647; 206 | files = ( 207 | E937B5AD2BAF8A1000B1B880 /* DiscreteProgressDemo.swift in Sources */, 208 | E937B5B42BAF8D7F00B1B880 /* AppStoreButtonDemo.swift in Sources */, 209 | E9D33A792BAF303F00C500FD /* ThrowableButtonDemo.swift in Sources */, 210 | E937B5B12BAF8C4500B1B880 /* EstimatedProgressDemo.swift in Sources */, 211 | E9D33A682BAF2D8600C500FD /* ContentView.swift in Sources */, 212 | E9447BCD2C62BA6D0056DC4D /* TriggerDemo.swift in Sources */, 213 | E9D33A662BAF2D8600C500FD /* DemoApp.swift in Sources */, 214 | E937B5AF2BAF8B8100B1B880 /* AsyncButtonDemo.swift in Sources */, 215 | ); 216 | runOnlyForDeploymentPostprocessing = 0; 217 | }; 218 | /* End PBXSourcesBuildPhase section */ 219 | 220 | /* Begin XCBuildConfiguration section */ 221 | E9D33A6F2BAF2D8700C500FD /* Debug */ = { 222 | isa = XCBuildConfiguration; 223 | buildSettings = { 224 | ALWAYS_SEARCH_USER_PATHS = NO; 225 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 226 | CLANG_ANALYZER_NONNULL = YES; 227 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 228 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 229 | CLANG_ENABLE_MODULES = YES; 230 | CLANG_ENABLE_OBJC_ARC = YES; 231 | CLANG_ENABLE_OBJC_WEAK = YES; 232 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 233 | CLANG_WARN_BOOL_CONVERSION = YES; 234 | CLANG_WARN_COMMA = YES; 235 | CLANG_WARN_CONSTANT_CONVERSION = YES; 236 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 237 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 238 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 239 | CLANG_WARN_EMPTY_BODY = YES; 240 | CLANG_WARN_ENUM_CONVERSION = YES; 241 | CLANG_WARN_INFINITE_RECURSION = YES; 242 | CLANG_WARN_INT_CONVERSION = YES; 243 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 244 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 245 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 246 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 247 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 248 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 249 | CLANG_WARN_STRICT_PROTOTYPES = YES; 250 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 251 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 252 | CLANG_WARN_UNREACHABLE_CODE = YES; 253 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 254 | COPY_PHASE_STRIP = NO; 255 | DEAD_CODE_STRIPPING = YES; 256 | DEBUG_INFORMATION_FORMAT = dwarf; 257 | ENABLE_STRICT_OBJC_MSGSEND = YES; 258 | ENABLE_TESTABILITY = YES; 259 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 260 | GCC_C_LANGUAGE_STANDARD = gnu17; 261 | GCC_DYNAMIC_NO_PIC = NO; 262 | GCC_NO_COMMON_BLOCKS = YES; 263 | GCC_OPTIMIZATION_LEVEL = 0; 264 | GCC_PREPROCESSOR_DEFINITIONS = ( 265 | "DEBUG=1", 266 | "$(inherited)", 267 | ); 268 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 269 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 270 | GCC_WARN_UNDECLARED_SELECTOR = YES; 271 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 272 | GCC_WARN_UNUSED_FUNCTION = YES; 273 | GCC_WARN_UNUSED_VARIABLE = YES; 274 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 275 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 276 | MTL_FAST_MATH = YES; 277 | ONLY_ACTIVE_ARCH = YES; 278 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 279 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 280 | }; 281 | name = Debug; 282 | }; 283 | E9D33A702BAF2D8700C500FD /* Release */ = { 284 | isa = XCBuildConfiguration; 285 | buildSettings = { 286 | ALWAYS_SEARCH_USER_PATHS = NO; 287 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 288 | CLANG_ANALYZER_NONNULL = YES; 289 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 290 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 291 | CLANG_ENABLE_MODULES = YES; 292 | CLANG_ENABLE_OBJC_ARC = YES; 293 | CLANG_ENABLE_OBJC_WEAK = YES; 294 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 295 | CLANG_WARN_BOOL_CONVERSION = YES; 296 | CLANG_WARN_COMMA = YES; 297 | CLANG_WARN_CONSTANT_CONVERSION = YES; 298 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 299 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 300 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 301 | CLANG_WARN_EMPTY_BODY = YES; 302 | CLANG_WARN_ENUM_CONVERSION = YES; 303 | CLANG_WARN_INFINITE_RECURSION = YES; 304 | CLANG_WARN_INT_CONVERSION = YES; 305 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 306 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 307 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 308 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 309 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 310 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 311 | CLANG_WARN_STRICT_PROTOTYPES = YES; 312 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 313 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 314 | CLANG_WARN_UNREACHABLE_CODE = YES; 315 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 316 | COPY_PHASE_STRIP = NO; 317 | DEAD_CODE_STRIPPING = YES; 318 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 319 | ENABLE_NS_ASSERTIONS = NO; 320 | ENABLE_STRICT_OBJC_MSGSEND = YES; 321 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 322 | GCC_C_LANGUAGE_STANDARD = gnu17; 323 | GCC_NO_COMMON_BLOCKS = YES; 324 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 325 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 326 | GCC_WARN_UNDECLARED_SELECTOR = YES; 327 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 328 | GCC_WARN_UNUSED_FUNCTION = YES; 329 | GCC_WARN_UNUSED_VARIABLE = YES; 330 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 331 | MTL_ENABLE_DEBUG_INFO = NO; 332 | MTL_FAST_MATH = YES; 333 | SWIFT_COMPILATION_MODE = wholemodule; 334 | }; 335 | name = Release; 336 | }; 337 | E9D33A722BAF2D8700C500FD /* Debug */ = { 338 | isa = XCBuildConfiguration; 339 | buildSettings = { 340 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 341 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 342 | CODE_SIGN_ENTITLEMENTS = ButtonKitDemo/ButtonKitDemo.entitlements; 343 | CODE_SIGN_STYLE = Automatic; 344 | CURRENT_PROJECT_VERSION = 1; 345 | DEAD_CODE_STRIPPING = YES; 346 | DEVELOPMENT_ASSET_PATHS = "\"ButtonKitDemo/Preview Content\""; 347 | DEVELOPMENT_TEAM = 4TJN3NGJ9J; 348 | ENABLE_HARDENED_RUNTIME = YES; 349 | ENABLE_PREVIEWS = YES; 350 | GENERATE_INFOPLIST_FILE = YES; 351 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; 352 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; 353 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 354 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; 355 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; 356 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; 357 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; 358 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 359 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 360 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 361 | IPHONEOS_DEPLOYMENT_TARGET = 15.0; 362 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 363 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 364 | MACOSX_DEPLOYMENT_TARGET = 12.0; 365 | MARKETING_VERSION = 1.0; 366 | PRODUCT_BUNDLE_IDENTIFIER = fr.thomasdurand.ButtonKitDemo; 367 | PRODUCT_NAME = "$(TARGET_NAME)"; 368 | SDKROOT = auto; 369 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; 370 | SWIFT_EMIT_LOC_STRINGS = YES; 371 | SWIFT_VERSION = 6.0; 372 | TARGETED_DEVICE_FAMILY = "1,2"; 373 | }; 374 | name = Debug; 375 | }; 376 | E9D33A732BAF2D8700C500FD /* Release */ = { 377 | isa = XCBuildConfiguration; 378 | buildSettings = { 379 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 380 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 381 | CODE_SIGN_ENTITLEMENTS = ButtonKitDemo/ButtonKitDemo.entitlements; 382 | CODE_SIGN_STYLE = Automatic; 383 | CURRENT_PROJECT_VERSION = 1; 384 | DEAD_CODE_STRIPPING = YES; 385 | DEVELOPMENT_ASSET_PATHS = "\"ButtonKitDemo/Preview Content\""; 386 | DEVELOPMENT_TEAM = 4TJN3NGJ9J; 387 | ENABLE_HARDENED_RUNTIME = YES; 388 | ENABLE_PREVIEWS = YES; 389 | GENERATE_INFOPLIST_FILE = YES; 390 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; 391 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; 392 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 393 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; 394 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; 395 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; 396 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; 397 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 398 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 399 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 400 | IPHONEOS_DEPLOYMENT_TARGET = 15.0; 401 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 402 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 403 | MACOSX_DEPLOYMENT_TARGET = 12.0; 404 | MARKETING_VERSION = 1.0; 405 | PRODUCT_BUNDLE_IDENTIFIER = fr.thomasdurand.ButtonKitDemo; 406 | PRODUCT_NAME = "$(TARGET_NAME)"; 407 | SDKROOT = auto; 408 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; 409 | SWIFT_EMIT_LOC_STRINGS = YES; 410 | SWIFT_VERSION = 6.0; 411 | TARGETED_DEVICE_FAMILY = "1,2"; 412 | }; 413 | name = Release; 414 | }; 415 | /* End XCBuildConfiguration section */ 416 | 417 | /* Begin XCConfigurationList section */ 418 | E9D33A5D2BAF2D8600C500FD /* Build configuration list for PBXProject "ButtonKitDemo" */ = { 419 | isa = XCConfigurationList; 420 | buildConfigurations = ( 421 | E9D33A6F2BAF2D8700C500FD /* Debug */, 422 | E9D33A702BAF2D8700C500FD /* Release */, 423 | ); 424 | defaultConfigurationIsVisible = 0; 425 | defaultConfigurationName = Release; 426 | }; 427 | E9D33A712BAF2D8700C500FD /* Build configuration list for PBXNativeTarget "ButtonKitDemo" */ = { 428 | isa = XCConfigurationList; 429 | buildConfigurations = ( 430 | E9D33A722BAF2D8700C500FD /* Debug */, 431 | E9D33A732BAF2D8700C500FD /* Release */, 432 | ); 433 | defaultConfigurationIsVisible = 0; 434 | defaultConfigurationName = Release; 435 | }; 436 | /* End XCConfigurationList section */ 437 | 438 | /* Begin XCSwiftPackageProductDependency section */ 439 | E937B5A12BAF30A500B1B880 /* ButtonKit */ = { 440 | isa = XCSwiftPackageProductDependency; 441 | productName = ButtonKit; 442 | }; 443 | /* End XCSwiftPackageProductDependency section */ 444 | }; 445 | rootObject = E9D33A5A2BAF2D8600C500FD /* Project object */; 446 | } 447 | -------------------------------------------------------------------------------- /Demo/ButtonKitDemo.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Demo/ButtonKitDemo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Demo/ButtonKitDemo.xcodeproj/xcshareddata/xcschemes/ButtonKitDemo.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /Demo/ButtonKitDemo/Advanced/AppStoreButtonDemo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppStoreButtonDemo.swift 3 | // ButtonKit 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2025 Thomas Durand 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import ButtonKit 29 | import SwiftUI 30 | 31 | @available(iOS 17, macOS 14, *) 32 | struct AppStoreButtonDemo: View { 33 | @State private var downloaded = false 34 | 35 | var body: some View { 36 | AsyncButton(progress: .download) { progress in 37 | guard !downloaded else { 38 | downloaded = false 39 | return 40 | } 41 | // Indeterminate loading 42 | try? await Task.sleep(for: .seconds(2)) 43 | progress.bytesToDownload = 100 // Fake 44 | // Download started! 45 | for _ in 1...100 { 46 | try? await Task.sleep(for: .seconds(0.02)) 47 | progress.bytesDownloaded += 1 48 | } 49 | // Installation 50 | try? await Task.sleep(for: .seconds(0.5)) 51 | if !Task.isCancelled { 52 | downloaded = true 53 | } 54 | } label: { 55 | Text(downloaded ? "Open" : "Get") 56 | } 57 | .asyncButtonStyle(.appStore) 58 | // Otherwise, cancellation is impossible 59 | .allowsHitTestingWhenLoading(true) 60 | } 61 | } 62 | 63 | @available(iOS 17, macOS 14, *) 64 | #Preview { 65 | AppStoreButtonDemo() 66 | } 67 | 68 | // MARK: - Custom Progress 69 | 70 | @MainActor 71 | final class DownloadProgress: TaskProgress { 72 | @Published var bytesToDownload = 0 73 | @Published var bytesDownloaded = 0 74 | 75 | var fractionCompleted: Double? { 76 | guard bytesToDownload > 0 else { 77 | return nil 78 | } 79 | return (Double(bytesDownloaded) / Double(bytesToDownload)) * 0.77 80 | } 81 | 82 | nonisolated init() {} 83 | 84 | func reset() { 85 | bytesToDownload = 0 86 | bytesDownloaded = 0 87 | } 88 | } 89 | 90 | extension TaskProgress where Self == DownloadProgress { 91 | static var download: DownloadProgress { 92 | DownloadProgress() 93 | } 94 | } 95 | 96 | // MARK: - Custom Style 97 | 98 | @available(iOS 17, macOS 14, *) 99 | struct AppStoreButtonStyle: AsyncButtonStyle { 100 | @Namespace private var namespace 101 | 102 | func makeLabel(configuration: LabelConfiguration) -> some View { 103 | configuration.label 104 | .foregroundStyle(.white.opacity(configuration.isLoading ? 0 : 1)) 105 | .aspectRatio(configuration.isLoading ? nil : 1, contentMode: .fill) 106 | .padding(.horizontal, configuration.isLoading ? 8 : 16) 107 | .padding(.vertical, 8) 108 | .bold() 109 | } 110 | 111 | func makeButton(configuration: ButtonConfiguration) -> some View { 112 | configuration.button 113 | .background { 114 | Capsule() 115 | .fill(.tint) 116 | .opacity(configuration.isLoading ? 0 : 1) 117 | 118 | if configuration.isLoading { 119 | if let progress = configuration.fractionCompleted { 120 | AppStoreProgressView(progress: progress) 121 | } else { 122 | AppStoreLoadingView() 123 | } 124 | } 125 | } 126 | .buttonBorderShape(configuration.isLoading ? .circle : .capsule) 127 | .overlay { 128 | if configuration.isLoading { 129 | if configuration.fractionCompleted != nil { 130 | Image(systemName: "stop.fill") 131 | .imageScale(.small) 132 | .foregroundStyle(.tint) 133 | } 134 | } 135 | } 136 | .animation(.default, value: configuration.isLoading) 137 | .onTapGesture { 138 | if configuration.isLoading { 139 | configuration.cancel() 140 | } 141 | } 142 | } 143 | } 144 | 145 | struct AppStoreProgressView: View { 146 | let progress: Double 147 | 148 | var body: some View { 149 | ZStack { 150 | Circle() 151 | .stroke(lineWidth: 2) 152 | .fill(.quaternary) 153 | 154 | 155 | Circle() 156 | .trim(from: 0, to: progress) 157 | .stroke(lineWidth: 2) 158 | .fill(.tint) 159 | .rotationEffect(.degrees(-90)) 160 | } 161 | } 162 | } 163 | 164 | struct AppStoreLoadingView: View { 165 | @State private var rotation: Double = 0 166 | 167 | var body: some View { 168 | Circle() 169 | .trim(from: 0, to: 0.75) 170 | .stroke(lineWidth: 2) 171 | .fill(.quaternary) 172 | .rotationEffect(.degrees(rotation - 135)) 173 | .onAppear { 174 | withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) { 175 | rotation = 360 176 | } 177 | } 178 | } 179 | } 180 | 181 | @available(iOS 17, macOS 14, *) 182 | extension AsyncButtonStyle where Self == AppStoreButtonStyle { 183 | static var appStore: AppStoreButtonStyle { 184 | AppStoreButtonStyle() 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /Demo/ButtonKitDemo/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 | -------------------------------------------------------------------------------- /Demo/ButtonKitDemo/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "1x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "2x", 16 | "size" : "16x16" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "1x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "2x", 26 | "size" : "32x32" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "2x", 36 | "size" : "128x128" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "1x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "2x", 46 | "size" : "256x256" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "scale" : "1x", 51 | "size" : "512x512" 52 | }, 53 | { 54 | "idiom" : "mac", 55 | "scale" : "2x", 56 | "size" : "512x512" 57 | } 58 | ], 59 | "info" : { 60 | "author" : "xcode", 61 | "version" : 1 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Demo/ButtonKitDemo/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/ButtonKitDemo/ButtonKitDemo.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Demo/ButtonKitDemo/Buttons/AsyncButtonDemo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncButtonDemo.swift 3 | // ButtonKit 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2025 Thomas Durand 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import ButtonKit 29 | import SwiftUI 30 | 31 | struct AsyncButtonDemo: View { 32 | var body: some View { 33 | VStack(spacing: 24) { 34 | Group { 35 | AsyncButton { 36 | // Here you have a throwable & async closure! 37 | try await Task.sleep(nanoseconds: 2_000_000_000) 38 | } label: { 39 | Text("Overlay style") 40 | } 41 | .asyncButtonStyle(.overlay) 42 | 43 | AsyncButton { 44 | try await Task.sleep(nanoseconds: 2_000_000_000) 45 | } label: { 46 | Text("Leading style") 47 | } 48 | .asyncButtonStyle(.leading) 49 | 50 | AsyncButton { 51 | try await Task.sleep(nanoseconds: 2_000_000_000) 52 | } label: { 53 | Text("Trailing style") 54 | } 55 | .asyncButtonStyle(.trailing) 56 | 57 | AsyncButton { 58 | try await Task.sleep(nanoseconds: 2_000_000_000) 59 | } label: { 60 | Text("Pulse style") 61 | } 62 | .asyncButtonStyle(.pulse) 63 | 64 | AsyncButton { 65 | try await Task.sleep(nanoseconds: 2_000_000_000) 66 | } label: { 67 | Text("No style") 68 | } 69 | .asyncButtonStyle(.none) 70 | } 71 | .asyncButtonTaskStarted { _ in 72 | print("task started") 73 | } 74 | .asyncButtonTaskEnded { 75 | print("task ended") 76 | } 77 | } 78 | .buttonStyle(.borderedProminent) 79 | } 80 | } 81 | 82 | #Preview { 83 | AsyncButtonDemo() 84 | } 85 | -------------------------------------------------------------------------------- /Demo/ButtonKitDemo/Buttons/ThrowableButtonDemo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThrowableButtonDemo.swift 3 | // ButtonKit 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2025 Thomas Durand 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import ButtonKit 29 | import SwiftUI 30 | 31 | struct CustomError: Error {} 32 | 33 | struct ThrowableButtonDemo: View { 34 | var body: some View { 35 | VStack(spacing: 24) { 36 | AsyncButton { 37 | // Here you have a throwable closure! 38 | throw CustomError() 39 | } label: { 40 | Text("Shake throwable style") 41 | } 42 | .throwableButtonStyle(.shake) 43 | 44 | AsyncButton { 45 | throw CustomError() 46 | } label: { 47 | Text("No throwable style") 48 | } 49 | .throwableButtonStyle(.none) 50 | } 51 | .buttonStyle(.borderedProminent) 52 | .onButtonError { error in 53 | // Do something with the error 54 | print(error) 55 | } 56 | } 57 | } 58 | 59 | #Preview { 60 | ThrowableButtonDemo() 61 | } 62 | -------------------------------------------------------------------------------- /Demo/ButtonKitDemo/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // ButtonKit 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2025 Thomas Durand 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import SwiftUI 29 | 30 | struct ContentView: View { 31 | var body: some View { 32 | NavigationView { 33 | List { 34 | Section { 35 | NavigationLink { 36 | ThrowableButtonDemo() 37 | } label: { 38 | Text("Throwable Button") 39 | } 40 | 41 | NavigationLink { 42 | AsyncButtonDemo() 43 | } label: { 44 | Text("Async Button") 45 | } 46 | } header: { 47 | Text("Basics") 48 | } 49 | 50 | Section { 51 | NavigationLink { 52 | TriggerButtonDemo() 53 | } label: { 54 | Text("Programmatic Trigger") 55 | } 56 | } header: { 57 | Text("Triggers") 58 | } 59 | 60 | Section { 61 | NavigationLink { 62 | DiscreteProgressDemo() 63 | } label: { 64 | Text("Discrete Progress") 65 | } 66 | 67 | NavigationLink { 68 | EstimatedProgressDemo() 69 | } label: { 70 | Text("Estimated Progress") 71 | } 72 | } header: { 73 | Text("Determinate progress") 74 | } 75 | 76 | if #available(iOS 17, macOS 14, *) { 77 | Section { 78 | NavigationLink { 79 | AppStoreButtonDemo() 80 | } label: { 81 | Text("App Store Download") 82 | } 83 | } header: { 84 | Text("Customization") 85 | } 86 | } 87 | } 88 | .navigationTitle("ButtonKit") 89 | } 90 | } 91 | } 92 | 93 | #Preview { 94 | ContentView() 95 | } 96 | -------------------------------------------------------------------------------- /Demo/ButtonKitDemo/DemoApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ButtonKitDemoApp.swift 3 | // ButtonKit 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2025 Thomas Durand 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import SwiftUI 29 | 30 | @main 31 | struct ButtonKitDemoApp: App { 32 | var body: some Scene { 33 | WindowGroup { 34 | ContentView() 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Demo/ButtonKitDemo/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/ButtonKitDemo/Progress/DiscreteProgressDemo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DiscreteProgressDemo.swift 3 | // ButtonKit 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2025 Thomas Durand 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import ButtonKit 29 | import SwiftUI 30 | 31 | struct DiscreteProgressDemo: View { 32 | var body: some View { 33 | VStack(spacing: 24) { 34 | AsyncButton(progress: .discrete(totalUnitCount: 100)) { progress in 35 | for _ in 1...100 { 36 | try await Task.sleep(nanoseconds: 20_000_000) 37 | progress.completedUnitCount += 1 38 | } 39 | } label: { 40 | Text("Overlay style") 41 | } 42 | .asyncButtonStyle(.overlay) 43 | 44 | AsyncButton(progress: .discrete(totalUnitCount: 100)) { progress in 45 | for _ in 1...100 { 46 | try await Task.sleep(nanoseconds: 20_000_000) 47 | progress.completedUnitCount += 1 48 | } 49 | } label: { 50 | Text("Percent overlay style") 51 | } 52 | .asyncButtonStyle(.overlay(style: .percent)) 53 | 54 | AsyncButton(progress: .discrete(totalUnitCount: 100)) { progress in 55 | for _ in 1...100 { 56 | try await Task.sleep(nanoseconds: 20_000_000) 57 | progress.completedUnitCount += 1 58 | } 59 | } label: { 60 | Text("Leading style") 61 | } 62 | .asyncButtonStyle(.leading) 63 | 64 | AsyncButton(progress: .discrete(totalUnitCount: 100)) { progress in 65 | for _ in 1...100 { 66 | try await Task.sleep(nanoseconds: 20_000_000) 67 | progress.completedUnitCount += 1 68 | } 69 | } label: { 70 | Text("Trailing style") 71 | } 72 | .asyncButtonStyle(.trailing) 73 | } 74 | .buttonStyle(.borderedProminent) 75 | } 76 | } 77 | 78 | #Preview { 79 | DiscreteProgressDemo() 80 | } 81 | -------------------------------------------------------------------------------- /Demo/ButtonKitDemo/Progress/EstimatedProgressDemo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EstimatedProgressDemo.swift 3 | // ButtonKit 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2025 Thomas Durand 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import ButtonKit 29 | import SwiftUI 30 | 31 | struct EstimatedProgressDemo: View { 32 | var body: some View { 33 | VStack(spacing: 24) { 34 | AsyncButton(progress: .estimated(nanoseconds: 1_000_000_000)) { _ in 35 | try await Task.sleep(nanoseconds: 2_000_000_000) 36 | } label: { 37 | Text("Overlay style") 38 | } 39 | .asyncButtonStyle(.overlay) 40 | 41 | AsyncButton(progress: .estimated(nanoseconds: 1_000_000_000)) { _ in 42 | try await Task.sleep(nanoseconds: 2_000_000_000) 43 | } label: { 44 | Text("Percent overlay style") 45 | } 46 | .asyncButtonStyle(.overlay(style: .percent)) 47 | 48 | AsyncButton(progress: .estimated(nanoseconds: 1_000_000_000)) { _ in 49 | try await Task.sleep(nanoseconds: 2_000_000_000) 50 | } label: { 51 | Text("Leading style") 52 | } 53 | .asyncButtonStyle(.leading) 54 | 55 | AsyncButton(progress: .estimated(nanoseconds: 1_000_000_000)) { _ in 56 | try await Task.sleep(nanoseconds: 2_000_000_000) 57 | } label: { 58 | Text("Trailing style") 59 | } 60 | .asyncButtonStyle(.trailing) 61 | } 62 | .buttonStyle(.borderedProminent) 63 | } 64 | } 65 | 66 | #Preview { 67 | EstimatedProgressDemo() 68 | } 69 | -------------------------------------------------------------------------------- /Demo/ButtonKitDemo/Trigger/TriggerDemo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TriggerExample.swift 3 | // ButtonKit 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2025 Thomas Durand 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import ButtonKit 29 | import SwiftUI 30 | 31 | enum FieldName { 32 | case username 33 | case password 34 | } 35 | 36 | enum FormButton: Hashable { 37 | case login 38 | case cancel 39 | } 40 | 41 | struct TriggerButtonDemo: View { 42 | @Environment(\.triggerButton) 43 | private var triggerButton 44 | 45 | @FocusState private var focus: FieldName? 46 | @State private var username = "Dean" 47 | @State private var password = "" 48 | 49 | @State private var success = false 50 | 51 | var body: some View { 52 | Form { 53 | Section { 54 | TextField("Username", text: $username) 55 | .focused($focus, equals: .username) 56 | .submitLabel(.continue) 57 | .onSubmit { 58 | focus = .password 59 | } 60 | 61 | SecureField("Password", text: $password) 62 | .focused($focus, equals: .password) 63 | .submitLabel(.send) 64 | .onSubmit { 65 | triggerButton(id: FormButton.login) 66 | } 67 | } header: { 68 | Text("You need to login") 69 | } footer: { 70 | Text("Press send when the username and password are filled to trigger the Login button") 71 | } 72 | 73 | Section { 74 | AsyncButton(id: FormButton.login) { 75 | focus = nil 76 | try await Task.sleep(nanoseconds: 1_000_000_000) 77 | success = true 78 | } label: { 79 | Text("Login") 80 | .frame(maxWidth: .infinity, alignment: .center) 81 | } 82 | .disabled(username.isEmpty || password.isEmpty) 83 | } 84 | 85 | Section { 86 | AsyncButton(role: .destructive, id: FormButton.cancel) { 87 | focus = nil 88 | username = "" 89 | password = "" 90 | } label: { 91 | Text("Reset") 92 | .frame(maxWidth: .infinity, alignment: .center) 93 | .listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0)) 94 | } 95 | .disabled(username.isEmpty && password.isEmpty) 96 | } 97 | } 98 | .alert(isPresented: $success) { 99 | Alert(title: Text("Logged in!"), dismissButton: .default(Text("OK"))) 100 | } 101 | .onChange(of: success) { _ in 102 | // After a login, reset the fields 103 | triggerButton(id: FormButton.cancel) 104 | } 105 | } 106 | } 107 | 108 | #Preview { 109 | NavigationView { 110 | TriggerButtonDemo() 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Thomas Durand 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: 6.0 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: "ButtonKit", 8 | platforms: [.iOS(.v15), .tvOS(.v15), .watchOS(.v8), .macOS(.v12), .visionOS(.v1)], 9 | products: [ 10 | .library(name: "ButtonKit", targets: ["ButtonKit"]), 11 | ], 12 | targets: [ 13 | .target(name: "ButtonKit"), 14 | ], 15 | swiftLanguageModes: [.v6] 16 | ) 17 | -------------------------------------------------------------------------------- /Package@swift-5.9.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 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: "ButtonKit", 8 | platforms: [.iOS(.v15), .tvOS(.v15), .watchOS(.v8), .macOS(.v12), .visionOS(.v1)], 9 | products: [ 10 | .library(name: "ButtonKit", targets: ["ButtonKit"]), 11 | ], 12 | targets: [ 13 | .target(name: "ButtonKit", swiftSettings: [.strictConcurrency]), 14 | ] 15 | ) 16 | 17 | extension SwiftSetting { 18 | static let strictConcurrency = enableExperimentalFeature("StrictConcurrency") 19 | } 20 | -------------------------------------------------------------------------------- /Preview/determinant-bar.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dean151/ButtonKit/52a58f78ac78b487604e5e2f776978724e1ed74d/Preview/determinant-bar.gif -------------------------------------------------------------------------------- /Preview/determinant-leading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dean151/ButtonKit/52a58f78ac78b487604e5e2f776978724e1ed74d/Preview/determinant-leading.gif -------------------------------------------------------------------------------- /Preview/determinant-percent.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dean151/ButtonKit/52a58f78ac78b487604e5e2f776978724e1ed74d/Preview/determinant-percent.gif -------------------------------------------------------------------------------- /Preview/determinant-trailing.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dean151/ButtonKit/52a58f78ac78b487604e5e2f776978724e1ed74d/Preview/determinant-trailing.gif -------------------------------------------------------------------------------- /Preview/leading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dean151/ButtonKit/52a58f78ac78b487604e5e2f776978724e1ed74d/Preview/leading.gif -------------------------------------------------------------------------------- /Preview/overlay.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dean151/ButtonKit/52a58f78ac78b487604e5e2f776978724e1ed74d/Preview/overlay.gif -------------------------------------------------------------------------------- /Preview/pulse.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dean151/ButtonKit/52a58f78ac78b487604e5e2f776978724e1ed74d/Preview/pulse.gif -------------------------------------------------------------------------------- /Preview/shake.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dean151/ButtonKit/52a58f78ac78b487604e5e2f776978724e1ed74d/Preview/shake.gif -------------------------------------------------------------------------------- /Preview/trailing.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dean151/ButtonKit/52a58f78ac78b487604e5e2f776978724e1ed74d/Preview/trailing.gif -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ButtonKit 2 | 3 | ButtonKit provides a new a SwiftUI Button replacement to deal with throwable and asynchronous actions. 4 | By default, SwiftUI Button only accept a closure. 5 | 6 | With ButtonKit, you'll have access to an `AsyncButton` view, accepting a `() async throws -> Void` closure. 7 | 8 | ## Requirements 9 | 10 | - Swift 5.9+ (Xcode 15+) 11 | - iOS 15+, iPadOS 15+, tvOS 15+, watchOS 8+, macOS 12+, visionOS 1+ 12 | 13 | ## Installation 14 | 15 | Install using Swift Package Manager 16 | 17 | ``` 18 | dependencies: [ 19 | .package(url: "https://github.com/Dean151/ButtonKit.git", from: "0.3.0"), 20 | ], 21 | targets: [ 22 | .target(name: "MyTarget", dependencies: [ 23 | .product(name: "ButtonKit", package: "ButtonKit"), 24 | ]), 25 | ] 26 | ``` 27 | 28 | And import it: 29 | ```swift 30 | import ButtonKit 31 | ``` 32 | 33 | ## Usage 34 | 35 | ### Throwable 36 | 37 | Use it as any SwiftUI button, but throw if you want in the closure: 38 | 39 | ```swift 40 | AsyncButton { 41 | try doSomethingThatCanFail() 42 | } label { 43 | Text("Do something") 44 | } 45 | ``` 46 | 47 | When the button closure throws, the button will shake by default 48 | 49 | For now, only this shake behavior is built-in: 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 |
.throwableButtonStyle(.shake)
59 | 60 | You can still disable it by passing `.none` to throwableButtonStyle: 61 | 62 | ```swift 63 | AsyncButton { 64 | try doSomethingThatCanFail() 65 | } label { 66 | Text("Do something") 67 | } 68 | .throwableButtonStyle(.none) 69 | ``` 70 | 71 | You can also bring your own behavior using the `ThrowableButtonStyle` protocol. 72 | 73 | In ThrowableButtonStyle, you can implement `makeLabel`, `makeButton` or both to alter the button look and behavior. 74 | 75 | ```swift 76 | public struct TryAgainThrowableButtonStyle: ThrowableButtonStyle { 77 | public init() {} 78 | 79 | public func makeLabel(configuration: LabelConfiguration) -> some View { 80 | if configuration.errorCount > 0 { 81 | Text("Try again!") 82 | } else { 83 | configuration.label 84 | } 85 | } 86 | } 87 | 88 | extension ThrowableButtonStyle where Self == TryAgainThrowableButtonStyle { 89 | public static var tryAgain: TryAgainThrowableButtonStyle { 90 | TryAgainThrowableButtonStyle() 91 | } 92 | } 93 | ``` 94 | 95 | Then, use it: 96 | ```swift 97 | AsyncButton { 98 | try doSomethingThatCanFail() 99 | } label { 100 | Text("Do something") 101 | } 102 | .throwableButtonStyle(.tryAgain) 103 | ``` 104 | 105 | ### Asynchronous 106 | 107 | Use it as any SwiftUI button, but the closure will support both try and await. 108 | 109 | ```swift 110 | AsyncButton { 111 | try await doSomethingThatTakeTime() 112 | } label { 113 | Text("Do something") 114 | } 115 | ``` 116 | 117 | When the process is in progress, another button press will not result in a new Task being issued. But the button is still enabled and hittable. 118 | You can disable the button on loading using `disabledWhenLoading` modifier. 119 | ```swift 120 | AsyncButton { 121 | ... 122 | } 123 | .disabledWhenLoading() 124 | ``` 125 | 126 | You can also disable hitTesting when loading with `allowsHitTestingWhenLoading` modifier. 127 | ```swift 128 | AsyncButton { 129 | ... 130 | } 131 | .allowsHitTestingWhenLoading(false) 132 | ``` 133 | 134 | Access and react to the underlying task using `asyncButtonTaskStarted` or `asyncButtonTaskEnded` modifier. 135 | ```swift 136 | AsyncButton { 137 | ... 138 | } 139 | .asyncButtonTaskStarted { task in 140 | // Task started 141 | } 142 | .asyncButtonTaskEnded { 143 | // Task ended or was cancelled 144 | } 145 | ``` 146 | 147 | You can summarize both using `asyncButtonTaskChanged` modifier. 148 | ```swift 149 | AsyncButton { 150 | ... 151 | } 152 | .asyncButtonTaskChanged { task in 153 | if let task { 154 | // Task started 155 | } else { 156 | // Task ended or was cancelled 157 | } 158 | } 159 | ``` 160 | 161 | While the progress is loading, the button will animate, defaulting by replacing the label of the button with a `ProgressView`. 162 | All sort of styles are built-in: 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 |
.asyncButtonStyle(.overlay).asyncButtonStyle(.pulse)
.asyncButtonStyle(.leading).asyncButtonStyle(.trailing)
182 | 183 | You can disable this behavior by passing `.none` to `asyncButtonStyle` 184 | ```swift 185 | AsyncButton { 186 | try await doSomethingThatTakeTime() 187 | } label { 188 | Text("Do something") 189 | } 190 | .asyncButtonStyle(.none) 191 | ``` 192 | 193 | You can also build your own customization by implementing `AsyncButtonStyle` protocol. 194 | 195 | Just like `ThrowableButtonStyle`, `AsyncButtonStyle` allows you to implement either `makeLabel`, `makeButton` or both to alter the button look and behavior while loading is in progress. 196 | 197 | ### External triggering 198 | 199 | You might need to trigger the behavior behind a button with specific user actions, like when pressing the "Send" key on the virtual keyboard. 200 | 201 | Therefore, to get free animated progress and errors behavior on your button, you can't just start the action of the button by yourself. You need the button to start it. 202 | 203 | To do so, you need to set a unique identifier to your button: 204 | 205 | ```swift 206 | enum LoginViewButton: Hashable { 207 | case login 208 | } 209 | 210 | struct ContentView: View { 211 | var body: some View { 212 | AsyncButton(id: LoginViewButton.login) { 213 | try await login() 214 | } label: { 215 | Text("Login") 216 | } 217 | } 218 | } 219 | ``` 220 | 221 | And from any view, access the triggerButton environment: 222 | 223 | ```swift 224 | struct ContentView: View { 225 | @Environment(\.triggerButton) 226 | private var triggerButton 227 | 228 | ... 229 | 230 | func performLogin() { 231 | triggerButton(LoginViewButton.login) 232 | } 233 | } 234 | ``` 235 | 236 | Note that: 237 | - The button **Must be on screen** to trigger it using this method. 238 | - If the triggered button is disabled, calling triggerButton will have no effect 239 | - If a task has already started on the triggered button, calling triggerButton will have no effect 240 | 241 | ### Deterministic progress 242 | 243 | AsyncButton supports progress reporting: 244 | 245 | ```swift 246 | AsyncButton(progress: .discrete(totalUnitCount: files.count)) { progress in 247 | for file in files { 248 | try await file.doExpensiveComputation() 249 | progress.completedUnitCount += 1 250 | } 251 | } label: { 252 | Text("Process") 253 | } 254 | .buttonStyle(.borderedProminent) 255 | .buttonBorderShape(.roundedRectangle) 256 | ``` 257 | 258 | `AsyncButtonStyle` now also supports determinate progress as well, responding to `configuration.fractionCompleted: Double?` property: 259 | 260 | ```swift 261 | AsyncButton(progress: .discrete(totalUnitCount: files.count)) { progress in 262 | for file in files { 263 | try await file.doExpensiveComputation() 264 | progress.completedUnitCount += 1 265 | } 266 | } label: { 267 | Text("Process") 268 | } 269 | .buttonStyle(.borderedProminent) 270 | .buttonBorderShape(.roundedRectangle) 271 | .asyncButtonStyle(.trailing) 272 | ``` 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 |
.asyncButtonStyle(.overlay).asyncButtonStyle(.overlay(style: .percent))
.asyncButtonStyle(.leading).asyncButtonStyle(.trailing)
292 | 293 | You can also create your own progression logic by implementing the `TaskProgress` protocol. 294 | This would allow you to build logarithmic based progress, or a first step that is indeterminate, before moving to a deterministic state (like the App Store download button) 295 | 296 | Available TaskProgress implementation are: 297 | - Indeterminate, default non-determinant progress with `.indeterminate` 298 | - Discrete linear (completed / total) with `.discrete(totalUnitsCount: Int)` 299 | - Estimated progress that fill the bar in the provided time interval, stopping at 85% to simulate determinant loading with `.estimated(for: Duration)` 300 | - (NS)Progress bridge with `.progress` 301 | 302 | ## Contribute 303 | 304 | You are encouraged to contribute to this repository, by opening issues, or pull requests for bug fixes, improvement requests, or support. Suggestions for contributions: 305 | 306 | - Improving documentation 307 | - Adding some automated tests 😜 308 | - Helping me out to remove/improve all the type erasure stuff if possible? 309 | - Adding some new built-in styles, options or properties for more use cases 310 | -------------------------------------------------------------------------------- /Sources/ButtonKit/Button+AppIntent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Button+AppIntent.swift 3 | // ButtonKit 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2025 Thomas Durand 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import AppIntents 29 | import SwiftUI 30 | 31 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) 32 | extension AsyncButton where P == IndeterminateProgress { 33 | public init( 34 | role: ButtonRole? = nil, 35 | id: AnyHashable? = nil, 36 | intent: some AppIntent, 37 | @ViewBuilder label: @escaping () -> S 38 | ) { 39 | self.init(role: role, id: id, action: { _ = try await intent.perform() }, label: label) 40 | } 41 | } 42 | 43 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) 44 | extension AsyncButton where P == IndeterminateProgress, S == Text { 45 | public init( 46 | _ titleKey: LocalizedStringKey, 47 | role: ButtonRole? = nil, 48 | id: AnyHashable? = nil, 49 | intent: some AppIntent 50 | ) { 51 | self.init(titleKey, role: role, id: id, action: { _ = try await intent.perform() }) 52 | } 53 | 54 | @_disfavoredOverload 55 | public init( 56 | _ title: some StringProtocol, 57 | role: ButtonRole? = nil, 58 | id: AnyHashable? = nil, 59 | intent: some AppIntent 60 | ) { 61 | self.init(title, role: role, id: id, action: { _ = try await intent.perform() }) 62 | } 63 | } 64 | 65 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) 66 | extension AsyncButton where P == IndeterminateProgress, S == Label { 67 | public init( 68 | _ titleKey: LocalizedStringKey, 69 | image name: String, 70 | role: ButtonRole? = nil, 71 | id: AnyHashable? = nil, 72 | intent: some AppIntent 73 | ) { 74 | self.init(titleKey, image: name, role: role, id: id, action: { _ = try await intent.perform() }) 75 | } 76 | 77 | @_disfavoredOverload 78 | public init( 79 | _ title: some StringProtocol, 80 | image name: String, 81 | role: ButtonRole? = nil, 82 | id: AnyHashable? = nil, 83 | intent: some AppIntent 84 | ) { 85 | self.init(title, image: name, role: role, id: id, action: { _ = try await intent.perform() }) 86 | } 87 | 88 | @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) 89 | public init( 90 | _ titleKey: LocalizedStringKey, 91 | image: ImageResource, 92 | role: ButtonRole? = nil, 93 | id: AnyHashable? = nil, 94 | intent: some AppIntent 95 | ) { 96 | self.init(titleKey, image: image, role: role, id: id, action: { _ = try await intent.perform() }) 97 | } 98 | 99 | @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) 100 | @_disfavoredOverload 101 | public init( 102 | _ title: some StringProtocol, 103 | image: ImageResource, 104 | role: ButtonRole? = nil, 105 | id: AnyHashable? = nil, 106 | intent: some AppIntent 107 | ) { 108 | self.init(title, image: image, role: role, id: id, action: { _ = try await intent.perform() }) 109 | } 110 | 111 | public init( 112 | _ titleKey: LocalizedStringKey, 113 | systemImage: String, 114 | role: ButtonRole? = nil, 115 | id: AnyHashable? = nil, 116 | intent: some AppIntent 117 | ) { 118 | self.init(titleKey, systemImage: systemImage, role: role, id: id, action: { _ = try await intent.perform() }) 119 | } 120 | 121 | @_disfavoredOverload 122 | public init( 123 | _ title: some StringProtocol, 124 | systemImage: String, 125 | role: ButtonRole? = nil, 126 | id: AnyHashable? = nil, 127 | intent: some AppIntent 128 | ) { 129 | self.init(title, systemImage: systemImage, role: role, id: id, action: { _ = try await intent.perform() }) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /Sources/ButtonKit/Button+Async.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Button+Async.swift 3 | // ButtonKit 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2025 Thomas Durand 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import SwiftUI 29 | 30 | @available(*, deprecated, renamed: "AsyncButton") 31 | public typealias ThrowableButton = AsyncButton 32 | 33 | enum AsyncButtonState: Equatable { 34 | case idle 35 | case started(Task) 36 | case ended 37 | 38 | var isLoading: Bool { 39 | switch self { 40 | case .started: 41 | return true 42 | case .idle, .ended: 43 | return false 44 | } 45 | } 46 | 47 | mutating func cancel() { 48 | switch self { 49 | case .idle: 50 | self = .ended 51 | case .started(let task): 52 | task.cancel() 53 | case .ended: 54 | break 55 | } 56 | } 57 | } 58 | 59 | public struct AsyncButton: View { 60 | @Environment(\.asyncButtonStyle) 61 | private var asyncButtonStyle 62 | @Environment(\.allowsHitTestingWhenLoading) 63 | private var allowsHitTestingWhenLoading 64 | @Environment(\.disabledWhenLoading) 65 | private var disabledWhenLoading 66 | @Environment(\.isEnabled) 67 | private var isEnabled 68 | @Environment(\.throwableButtonStyle) 69 | private var throwableButtonStyle 70 | @Environment(\.triggerButton) 71 | private var triggerButton 72 | 73 | private let role: ButtonRole? 74 | private let id: AnyHashable? 75 | private let action: @MainActor (P) async throws -> Void 76 | private let label: S 77 | 78 | // Environmnent lies when called from triggerButton 79 | // Let's copy it in our own State :) 80 | @State private var isDisabled = false 81 | @State private var state: AsyncButtonState = .idle 82 | @ObservedObject private var progress: P 83 | @State private var errorCount = 0 84 | @State private var lastError: Error? 85 | 86 | public var body: some View { 87 | let throwableLabelConfiguration = ThrowableButtonStyleLabelConfiguration( 88 | label: AnyView(label), 89 | errorCount: errorCount 90 | ) 91 | let label: AnyView 92 | let asyncLabelConfiguration = AsyncButtonStyleLabelConfiguration( 93 | label: AnyView(throwableButtonStyle.makeLabel(configuration: throwableLabelConfiguration)), 94 | isLoading: state.isLoading, 95 | fractionCompleted: progress.fractionCompleted, 96 | cancel: cancel 97 | ) 98 | label = asyncButtonStyle.makeLabel(configuration: asyncLabelConfiguration) 99 | let button = Button(role: role, action: perform) { 100 | label 101 | } 102 | let throwableConfiguration = ThrowableButtonStyleButtonConfiguration( 103 | button: AnyView(button), 104 | errorCount: errorCount 105 | ) 106 | let asyncConfiguration = AsyncButtonStyleButtonConfiguration( 107 | button: AnyView(throwableButtonStyle.makeButton(configuration: throwableConfiguration)), 108 | isLoading: state.isLoading, 109 | fractionCompleted: progress.fractionCompleted, 110 | cancel: cancel 111 | ) 112 | return asyncButtonStyle 113 | .makeButton(configuration: asyncConfiguration) 114 | .allowsHitTesting(allowsHitTestingWhenLoading || !state.isLoading) 115 | .disabled(disabledWhenLoading && state.isLoading) 116 | .preference(key: AsyncButtonTaskPreferenceKey.self, value: state) 117 | .preference(key: AsyncButtonErrorPreferenceKey.self, value: lastError.flatMap { .init(increment: errorCount, error: $0) }) 118 | .onAppear { 119 | isDisabled = !isEnabled 120 | guard let id else { 121 | return 122 | } 123 | triggerButton.register(id: id, action: perform) 124 | } 125 | .onDisappear { 126 | guard let id else { 127 | return 128 | } 129 | triggerButton.unregister(id: id) 130 | } 131 | .onChange(of: isEnabled) { newValue in 132 | isDisabled = !newValue 133 | } 134 | } 135 | 136 | public init( 137 | role: ButtonRole? = nil, 138 | id: AnyHashable? = nil, 139 | progress: P, 140 | action: @MainActor @escaping (P) async throws -> Void, 141 | @ViewBuilder label: @escaping () -> S 142 | ) { 143 | self.role = role 144 | self.id = id 145 | self._progress = .init(initialValue: progress) 146 | self.action = action 147 | self.label = label() 148 | } 149 | 150 | private func perform() { 151 | guard !state.isLoading, !isDisabled else { 152 | return 153 | } 154 | state = .started(Task { 155 | // Initialize progress 156 | progress.reset() 157 | await progress.started() 158 | do { 159 | try await action(progress) 160 | } catch { 161 | errorCount += 1 162 | lastError = error 163 | } 164 | // Reset progress 165 | await progress.ended() 166 | state = .ended 167 | }) 168 | } 169 | 170 | private func cancel() { 171 | state.cancel() 172 | } 173 | } 174 | 175 | extension AsyncButton where S == Text { 176 | public init( 177 | _ titleKey: LocalizedStringKey, 178 | role: ButtonRole? = nil, 179 | id: AnyHashable? = nil, 180 | progress: P, 181 | action: @MainActor @escaping (P) async throws -> Void 182 | ) { 183 | self.role = role 184 | self.id = id 185 | self._progress = .init(initialValue: progress) 186 | self.action = action 187 | self.label = Text(titleKey) 188 | } 189 | 190 | @_disfavoredOverload 191 | public init( 192 | _ title: some StringProtocol, 193 | role: ButtonRole? = nil, 194 | id: AnyHashable? = nil, 195 | progress: P, 196 | action: @MainActor @escaping (P) async throws -> Void 197 | ) { 198 | self.role = role 199 | self.id = id 200 | self._progress = .init(initialValue: progress) 201 | self.action = action 202 | self.label = Text(title) 203 | } 204 | } 205 | 206 | extension AsyncButton where S == Label { 207 | public init( 208 | _ titleKey: LocalizedStringKey, 209 | image name: String, 210 | role: ButtonRole? = nil, 211 | id: AnyHashable? = nil, 212 | progress: P, 213 | action: @MainActor @escaping (P) async throws -> Void 214 | ) { 215 | self.role = role 216 | self.id = id 217 | self._progress = .init(initialValue: progress) 218 | self.action = action 219 | self.label = Label(titleKey, image: name) 220 | } 221 | 222 | @_disfavoredOverload 223 | public init( 224 | _ title: some StringProtocol, 225 | image name: String, 226 | role: ButtonRole? = nil, 227 | id: AnyHashable? = nil, 228 | progress: P, 229 | action: @MainActor @escaping (P) async throws -> Void 230 | ) { 231 | self.role = role 232 | self.id = id 233 | self._progress = .init(initialValue: progress) 234 | self.action = action 235 | self.label = Label(title, image: name) 236 | } 237 | 238 | @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) 239 | public init( 240 | _ titleKey: LocalizedStringKey, 241 | image: ImageResource, 242 | role: ButtonRole? = nil, 243 | id: AnyHashable? = nil, 244 | progress: P, 245 | action: @MainActor @escaping (P) async throws -> Void 246 | ) { 247 | self.role = role 248 | self.id = id 249 | self._progress = .init(initialValue: progress) 250 | self.action = action 251 | self.label = Label(titleKey, image: image) 252 | } 253 | 254 | @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) 255 | @_disfavoredOverload 256 | public init( 257 | _ title: some StringProtocol, 258 | image: ImageResource, 259 | role: ButtonRole? = nil, 260 | id: AnyHashable? = nil, 261 | progress: P, 262 | action: @MainActor @escaping (P) async throws -> Void 263 | ) { 264 | self.role = role 265 | self.id = id 266 | self._progress = .init(initialValue: progress) 267 | self.action = action 268 | self.label = Label(title, image: image) 269 | } 270 | 271 | public init( 272 | _ titleKey: LocalizedStringKey, 273 | systemImage: String, 274 | role: ButtonRole? = nil, 275 | id: AnyHashable? = nil, 276 | progress: P, 277 | action: @MainActor @escaping (P) async throws -> Void 278 | ) { 279 | self.role = role 280 | self.id = id 281 | self._progress = .init(initialValue: progress) 282 | self.action = action 283 | self.label = Label(titleKey, systemImage: systemImage) 284 | } 285 | 286 | @_disfavoredOverload 287 | public init( 288 | _ title: some StringProtocol, 289 | systemImage: String, 290 | role: ButtonRole? = nil, 291 | id: AnyHashable? = nil, 292 | progress: P, 293 | action: @MainActor @escaping (P) async throws -> Void 294 | ) { 295 | self.role = role 296 | self.id = id 297 | self._progress = .init(initialValue: progress) 298 | self.action = action 299 | self.label = Label(title, systemImage: systemImage) 300 | } 301 | } 302 | 303 | extension AsyncButton where P == IndeterminateProgress { 304 | public init( 305 | role: ButtonRole? = nil, 306 | id: AnyHashable? = nil, 307 | action: @escaping () async throws -> Void, 308 | @ViewBuilder label: @escaping () -> S 309 | ) { 310 | self.role = role 311 | self.id = id 312 | self._progress = .init(initialValue: .indeterminate) 313 | self.action = { _ in try await action()} 314 | self.label = label() 315 | } 316 | } 317 | 318 | extension AsyncButton where P == IndeterminateProgress, S == Text { 319 | public init( 320 | _ titleKey: LocalizedStringKey, 321 | role: ButtonRole? = nil, 322 | id: AnyHashable? = nil, 323 | action: @escaping () async throws -> Void 324 | ) { 325 | self.role = role 326 | self.id = id 327 | self._progress = .init(initialValue: .indeterminate) 328 | self.action = { _ in try await action()} 329 | self.label = Text(titleKey) 330 | } 331 | 332 | @_disfavoredOverload 333 | public init( 334 | _ title: some StringProtocol, 335 | role: ButtonRole? = nil, 336 | id: AnyHashable? = nil, 337 | action: @escaping () async throws -> Void 338 | ) { 339 | self.role = role 340 | self.id = id 341 | self._progress = .init(initialValue: .indeterminate) 342 | self.action = { _ in try await action()} 343 | self.label = Text(title) 344 | } 345 | } 346 | 347 | extension AsyncButton where P == IndeterminateProgress, S == Label { 348 | public init( 349 | _ titleKey: LocalizedStringKey, 350 | image name: String, 351 | role: ButtonRole? = nil, 352 | id: AnyHashable? = nil, 353 | action: @escaping () async throws -> Void 354 | ) { 355 | self.role = role 356 | self.id = id 357 | self._progress = .init(initialValue: .indeterminate) 358 | self.action = { _ in try await action()} 359 | self.label = Label(titleKey, image: name) 360 | } 361 | 362 | @_disfavoredOverload 363 | public init( 364 | _ title: some StringProtocol, 365 | image name: String, 366 | role: ButtonRole? = nil, 367 | id: AnyHashable? = nil, 368 | action: @escaping () async throws -> Void 369 | ) { 370 | self.role = role 371 | self.id = id 372 | self._progress = .init(initialValue: .indeterminate) 373 | self.action = { _ in try await action()} 374 | self.label = Label(title, image: name) 375 | } 376 | 377 | @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) 378 | public init( 379 | _ titleKey: LocalizedStringKey, 380 | image: ImageResource, 381 | role: ButtonRole? = nil, 382 | id: AnyHashable? = nil, 383 | action: @escaping () async throws -> Void 384 | ) { 385 | self.role = role 386 | self.id = id 387 | self._progress = .init(initialValue: .indeterminate) 388 | self.action = { _ in try await action()} 389 | self.label = Label(titleKey, image: image) 390 | } 391 | 392 | @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) 393 | @_disfavoredOverload 394 | public init( 395 | _ title: some StringProtocol, 396 | image: ImageResource, 397 | role: ButtonRole? = nil, 398 | id: AnyHashable? = nil, 399 | action: @escaping () async throws -> Void 400 | ) { 401 | self.role = role 402 | self.id = id 403 | self._progress = .init(initialValue: .indeterminate) 404 | self.action = { _ in try await action()} 405 | self.label = Label(title, image: image) 406 | } 407 | 408 | public init( 409 | _ titleKey: LocalizedStringKey, 410 | systemImage: String, 411 | role: ButtonRole? = nil, 412 | id: AnyHashable? = nil, 413 | action: @escaping () async throws -> Void 414 | ) { 415 | self.role = role 416 | self.id = id 417 | self._progress = .init(initialValue: .indeterminate) 418 | self.action = { _ in try await action()} 419 | self.label = Label(titleKey, systemImage: systemImage) 420 | } 421 | 422 | @_disfavoredOverload 423 | public init( 424 | _ title: some StringProtocol, 425 | systemImage: String, 426 | role: ButtonRole? = nil, 427 | id: AnyHashable? = nil, 428 | action: @escaping () async throws -> Void 429 | ) { 430 | self.role = role 431 | self.id = id 432 | self._progress = .init(initialValue: .indeterminate) 433 | self.action = { _ in try await action()} 434 | self.label = Label(title, systemImage: systemImage) 435 | } 436 | } 437 | 438 | #Preview("Indeterminate") { 439 | AsyncButton { 440 | try? await Task.sleep(nanoseconds: 1_000_000_000) 441 | } label: { 442 | Text("Process") 443 | } 444 | .buttonStyle(.borderedProminent) 445 | .buttonBorderShape(.roundedRectangle) 446 | } 447 | 448 | #Preview("Determinate") { 449 | AsyncButton(progress: .discrete(totalUnitCount: 100)) { progress in 450 | for _ in 1...100 { 451 | try await Task.sleep(nanoseconds: 20_000_000) 452 | progress.completedUnitCount += 1 453 | } 454 | } label: { 455 | Text("Process") 456 | } 457 | .buttonStyle(.borderedProminent) 458 | .buttonBorderShape(.roundedRectangle) 459 | } 460 | 461 | #Preview("Indeterminate error") { 462 | AsyncButton { 463 | try await Task.sleep(nanoseconds: 1_000_000_000) 464 | throw NSError() as Error 465 | } label: { 466 | Text("Process") 467 | } 468 | .buttonStyle(.borderedProminent) 469 | .buttonBorderShape(.roundedRectangle) 470 | .asyncButtonStyle(.overlay) 471 | .throwableButtonStyle(.shake) 472 | } 473 | 474 | #Preview("Determinate error") { 475 | AsyncButton(progress: .discrete(totalUnitCount: 100)) { progress in 476 | for _ in 1...42 { 477 | try await Task.sleep(nanoseconds: 20_000_000) 478 | progress.completedUnitCount += 1 479 | } 480 | throw NSError() as Error 481 | } label: { 482 | Text("Process") 483 | } 484 | .buttonStyle(.borderedProminent) 485 | .buttonBorderShape(.roundedRectangle) 486 | } 487 | -------------------------------------------------------------------------------- /Sources/ButtonKit/Internal/BarProgressView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BarProgressView.swift 3 | // ButtonKit 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2025 Thomas Durand 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import SwiftUI 29 | 30 | struct BarProgressView: View { 31 | let value: Double 32 | let total: Double 33 | 34 | var body: some View { 35 | progress 36 | .opacity(0) 37 | .overlay { 38 | Rectangle() 39 | .fill(.primary) 40 | .mask { progress } 41 | } 42 | #if os(macOS) 43 | .controlSize(.small) 44 | #endif 45 | .animation(value == 0 ? nil : .default, value: value) 46 | .compositingGroup() 47 | } 48 | 49 | @ViewBuilder 50 | var progress: some View { 51 | ProgressView(value: value, total: total) 52 | .progressViewStyle(.linear) 53 | } 54 | 55 | init(value: V, total: V = 1.0) { 56 | self.value = Double(value) 57 | self.total = Double(total) 58 | } 59 | } 60 | 61 | #Preview { 62 | BarProgressView(value: 0.42) 63 | .foregroundStyle(.linearGradient( 64 | colors: [.blue, .red], 65 | startPoint: .topLeading, 66 | endPoint: .bottomTrailing) 67 | ) 68 | } 69 | -------------------------------------------------------------------------------- /Sources/ButtonKit/Internal/CircularProgressView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CircularProgressView.swift 3 | // ButtonKit 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2025 Thomas Durand 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import SwiftUI 29 | 30 | struct CircularProgressView: View { 31 | let value: Double 32 | let total: Double 33 | 34 | var body: some View { 35 | // Use ProgressView to set the view size 36 | ProgressView() 37 | #if os(macOS) 38 | .controlSize(.small) 39 | #endif 40 | .opacity(0) 41 | .overlay { 42 | Rectangle() 43 | .fill(.primary) 44 | .mask { 45 | Group { 46 | Circle() 47 | .stroke(.black.opacity(0.33), lineWidth: 4) 48 | 49 | Circle() 50 | .trim(from: 0, to: value / total) 51 | .stroke(.black, style: .init(lineWidth: 4, lineCap: .round)) 52 | .rotationEffect(.degrees(-90)) 53 | } 54 | .padding(2) 55 | } 56 | } 57 | .animation(value == 0 ? nil : .default, value: value) 58 | .compositingGroup() 59 | } 60 | 61 | init(value: V, total: V = 1.0) { 62 | self.value = Double(value) 63 | self.total = Double(total) 64 | } 65 | } 66 | 67 | #Preview("Determinate") { 68 | CircularProgressView(value: 0.42) 69 | .foregroundStyle(.linearGradient( 70 | colors: [.blue, .red], 71 | startPoint: .topLeading, 72 | endPoint: .bottomTrailing) 73 | ) 74 | } 75 | -------------------------------------------------------------------------------- /Sources/ButtonKit/Internal/IndeterminateProgressView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HierarchicalProgressView.swift 3 | // ButtonKit 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2025 Thomas Durand 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import SwiftUI 29 | 30 | struct IndeterminateProgressView: View { 31 | var body: some View { 32 | progress 33 | .opacity(0) 34 | .overlay { 35 | Rectangle() 36 | .fill(.primary) 37 | .mask { progress } 38 | } 39 | #if os(macOS) 40 | .controlSize(.small) 41 | #endif 42 | .compositingGroup() 43 | } 44 | 45 | @ViewBuilder 46 | var progress: some View { 47 | ProgressView() 48 | } 49 | 50 | init() {} 51 | } 52 | 53 | #Preview { 54 | IndeterminateProgressView() 55 | .foregroundStyle(.linearGradient( 56 | colors: [.blue, .red], 57 | startPoint: .topLeading, 58 | endPoint: .bottomTrailing) 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /Sources/ButtonKit/Modifiers/Button+AsyncDisabled.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Button+AsyncDisabled.swift 3 | // ButtonKit 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2025 Thomas Durand 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import Foundation 29 | 30 | import SwiftUI 31 | 32 | // MARK: Public protocol 33 | 34 | extension View { 35 | public func allowsHitTestingWhenLoading(_ enabled: Bool) -> some View { 36 | environment(\.allowsHitTestingWhenLoading, enabled) 37 | } 38 | 39 | public func disabledWhenLoading(_ disabled: Bool = true) -> some View { 40 | environment(\.disabledWhenLoading, disabled) 41 | } 42 | } 43 | 44 | // MARK: SwiftUI Environment 45 | 46 | struct AllowsHitTestingWhenLoadingKey: EnvironmentKey { 47 | static let defaultValue: Bool = false 48 | } 49 | 50 | struct DisabledWhenLoadingKey: EnvironmentKey { 51 | static let defaultValue: Bool = false 52 | } 53 | 54 | extension EnvironmentValues { 55 | var allowsHitTestingWhenLoading: Bool { 56 | get { 57 | return self[AllowsHitTestingWhenLoadingKey.self] 58 | } 59 | set { 60 | self[AllowsHitTestingWhenLoadingKey.self] = newValue 61 | } 62 | } 63 | 64 | var disabledWhenLoading: Bool { 65 | get { 66 | return self[DisabledWhenLoadingKey.self] 67 | } 68 | set { 69 | self[DisabledWhenLoadingKey.self] = newValue 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Sources/ButtonKit/Modifiers/Button+AsyncError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Button+AsyncError.swift 3 | // ButtonKit 4 | // 5 | // Created by Thomas Durand on 12/01/2025. 6 | // 7 | 8 | import SwiftUI 9 | 10 | // MARK: Public protocol 11 | 12 | public typealias AsyncButtonErrorHandler = @MainActor @Sendable (Error) -> Void 13 | 14 | extension View { 15 | public func onButtonError(_ handler: @escaping AsyncButtonErrorHandler) -> some View { 16 | modifier(OnAsyncButtonErrorChangeModifier(handler: { error in 17 | handler(error) 18 | })) 19 | } 20 | } 21 | 22 | // MARK: - Internal implementation 23 | 24 | struct AsyncButtonErrorPreferenceKey: PreferenceKey { 25 | static let defaultValue: ErrorHolder? = nil 26 | 27 | static func reduce(value: inout ErrorHolder?, nextValue: () -> ErrorHolder?) { 28 | guard let newValue = nextValue() else { 29 | return 30 | } 31 | value = .init(increment: (value?.increment ?? 0) + newValue.increment, error: newValue.error) 32 | } 33 | } 34 | 35 | struct ErrorHolder: Equatable { 36 | let increment: Int 37 | let error: Error 38 | 39 | static func == (lhs: ErrorHolder, rhs: ErrorHolder) -> Bool { 40 | lhs.increment == rhs.increment 41 | } 42 | } 43 | 44 | struct OnAsyncButtonErrorChangeModifier: ViewModifier { 45 | let handler: AsyncButtonErrorHandler 46 | 47 | init(handler: @escaping AsyncButtonErrorHandler) { 48 | self.handler = handler 49 | } 50 | 51 | func body(content: Content) -> some View { 52 | content 53 | .onPreferenceChange(AsyncButtonErrorPreferenceKey.self) { value in 54 | guard let error = value?.error else { 55 | return 56 | } 57 | #if swift(>=5.10) 58 | MainActor.assumeIsolated { 59 | onError(error) 60 | } 61 | #else 62 | onError(error) 63 | #endif 64 | } 65 | } 66 | 67 | @MainActor 68 | func onError(_ error: Error) { 69 | handler(error) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/ButtonKit/Modifiers/Button+AsyncTask.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Button+AsyncTask.swift 3 | // ButtonKit 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2025 Thomas Durand 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import SwiftUI 29 | 30 | // MARK: Public protocol 31 | 32 | public typealias AsyncButtonTaskStartedHandler = @MainActor @Sendable (Task) -> Void 33 | public typealias AsyncButtonTaskChangedHandler = @MainActor @Sendable (Task?) -> Void 34 | public typealias AsyncButtonTaskEndedHandler = @MainActor @Sendable () -> Void 35 | 36 | extension View { 37 | public func asyncButtonTaskStarted(_ handler: @escaping AsyncButtonTaskStartedHandler) -> some View { 38 | modifier(OnAsyncButtonTaskChangeModifier { task in 39 | if let task { 40 | handler(task) 41 | } 42 | }) 43 | } 44 | 45 | public func asyncButtonTaskChanged(_ handler: @escaping AsyncButtonTaskChangedHandler) -> some View { 46 | modifier(OnAsyncButtonTaskChangeModifier { task in 47 | handler(task) 48 | }) 49 | } 50 | 51 | public func asyncButtonTaskEnded(_ handler: @escaping AsyncButtonTaskEndedHandler) -> some View { 52 | modifier(OnAsyncButtonTaskChangeModifier { task in 53 | if task == nil { 54 | handler() 55 | } 56 | }) 57 | } 58 | } 59 | 60 | // MARK: - Internal implementation 61 | 62 | struct AsyncButtonTaskPreferenceKey: PreferenceKey { 63 | static let defaultValue: AsyncButtonState = .idle 64 | 65 | static func reduce(value: inout AsyncButtonState, nextValue: () -> AsyncButtonState) { 66 | value = nextValue() 67 | } 68 | } 69 | 70 | struct OnAsyncButtonTaskChangeModifier: ViewModifier { 71 | let handler: AsyncButtonTaskChangedHandler 72 | 73 | init(handler: @escaping AsyncButtonTaskChangedHandler) { 74 | self.handler = handler 75 | } 76 | 77 | func body(content: Content) -> some View { 78 | content 79 | .onPreferenceChange(AsyncButtonTaskPreferenceKey.self) { state in 80 | #if swift(>=5.10) 81 | MainActor.assumeIsolated { 82 | onTaskChanged(state) 83 | } 84 | #else 85 | onTaskChanged(state) 86 | #endif 87 | } 88 | } 89 | 90 | @MainActor 91 | func onTaskChanged(_ state: AsyncButtonState) { 92 | switch state { 93 | case .started(let task): 94 | handler(task) 95 | case .ended: 96 | handler(nil) 97 | case .idle: 98 | break 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Sources/ButtonKit/Progress/Progress+Discrete.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Progress+Discrete.swift 3 | // ButtonKit 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2025 Thomas Durand 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import Combine 29 | 30 | /// Represents a discrete and linear progress 31 | @MainActor 32 | public final class DiscreteProgress: TaskProgress { 33 | public let totalUnitCount: Int 34 | @Published public var completedUnitCount = 0 { 35 | willSet { 36 | assert(newValue >= 0 && newValue <= totalUnitCount, "Discrete progression requires completedUnitCount to be in 0...\(totalUnitCount)") 37 | } 38 | } 39 | 40 | public func reset() { 41 | completedUnitCount = 0 42 | } 43 | 44 | public var fractionCompleted: Double? { 45 | Double(completedUnitCount) / Double(totalUnitCount) 46 | } 47 | 48 | nonisolated init(totalUnitCount: Int) { 49 | self.totalUnitCount = totalUnitCount 50 | } 51 | } 52 | 53 | extension TaskProgress where Self == DiscreteProgress { 54 | public static func discrete(totalUnitCount: Int) -> DiscreteProgress { 55 | assert(totalUnitCount > 0, "Discrete progression requires totalUnitCount to be positive") 56 | return DiscreteProgress(totalUnitCount: totalUnitCount) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Sources/ButtonKit/Progress/Progress+Estimated.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Progress+Estimated.swift 3 | // ButtonKit 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2025 Thomas Durand 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import SwiftUI 29 | 30 | /// Represents a progress where we estimate the time required to complete it 31 | @MainActor 32 | public class EstimatedProgress: TaskProgress { 33 | let sleeper: Sleeper 34 | let stop = 0.85 35 | @Published public private(set) var fractionCompleted: Double? = 0 36 | private var task: Task? 37 | 38 | nonisolated init(nanoseconds duration: UInt64) { 39 | self.sleeper = NanosecondsSleeper(nanoseconds: duration) 40 | } 41 | 42 | @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, visionOS 1.0, *) 43 | nonisolated init(estimation: Duration) { 44 | self.sleeper = DurationSleeper(duration: estimation) 45 | } 46 | 47 | public func reset() { 48 | fractionCompleted = 0 49 | } 50 | 51 | public func started() async { 52 | task = Task { 53 | for _ in 1...100 { 54 | try? await sleeper.sleep(fraction: stop / 100) 55 | fractionCompleted! += stop / 100 56 | } 57 | } 58 | } 59 | 60 | public func ended() async { 61 | task?.cancel() 62 | fractionCompleted = 1 63 | try? await Task.sleep(nanoseconds: 100_000_000) 64 | } 65 | } 66 | 67 | extension TaskProgress where Self == EstimatedProgress { 68 | public static func estimated(nanoseconds duration: UInt64) -> EstimatedProgress { 69 | EstimatedProgress(nanoseconds: duration) 70 | } 71 | 72 | @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, visionOS 1.0, *) 73 | public static func estimated(for duration: Duration) -> EstimatedProgress { 74 | EstimatedProgress(estimation: duration) 75 | } 76 | 77 | /// This one is only to make SwiftUI preview happy 78 | /// Turns out SiwftUI preview does not like "literal UInt" to be present 79 | @_disfavoredOverload 80 | public static func estimated(nanoseconds duration: Int) -> EstimatedProgress { 81 | assert(duration >= 0, "duration must be positive!") 82 | return .estimated(nanoseconds: UInt64(duration)) 83 | } 84 | } 85 | 86 | protocol Sleeper: Sendable { 87 | func sleep(fraction: Double) async throws 88 | } 89 | 90 | struct NanosecondsSleeper: Sleeper { 91 | let duration: UInt64 92 | 93 | init(nanoseconds duration: UInt64) { 94 | self.duration = duration 95 | } 96 | 97 | func sleep(fraction: Double) async throws { 98 | try await Task.sleep(nanoseconds: UInt64(Double(duration) * fraction)) 99 | } 100 | } 101 | 102 | @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, visionOS 1.0, *) 103 | struct DurationSleeper: Sleeper { 104 | let duration: Duration 105 | 106 | func sleep(fraction: Double) async throws { 107 | try await Task.sleep(for: duration * fraction) 108 | } 109 | } 110 | 111 | #Preview("Nanoseconds signature") { 112 | AsyncButton(progress: .estimated(nanoseconds: 1_000_000_000)) { progress in 113 | try await Task.sleep(nanoseconds: 2_000_000_000) 114 | } label: { 115 | Text("Estimated duration") 116 | } 117 | .buttonStyle(.borderedProminent) 118 | .asyncButtonStyle(.overlay) 119 | } 120 | 121 | @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, visionOS 1.0, *) 122 | #Preview("Duration signature") { 123 | AsyncButton(progress: .estimated(for: .seconds(1))) { progress in 124 | try await Task.sleep(for: .seconds(2)) 125 | } label: { 126 | Text("Estimated duration") 127 | } 128 | .buttonStyle(.borderedProminent) 129 | .asyncButtonStyle(.overlay) 130 | } 131 | -------------------------------------------------------------------------------- /Sources/ButtonKit/Progress/Progress+Indeterminate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Progress+Indeterminate.swift 3 | // ButtonKit 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2025 Thomas Durand 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | /// Indeterminate progress is the default progress mode, where the progress is always indeterminate 29 | public final class IndeterminateProgress: TaskProgress { 30 | public let fractionCompleted: Double? = nil 31 | public func reset() {} 32 | } 33 | 34 | extension TaskProgress where Self == IndeterminateProgress { 35 | public static var indeterminate: IndeterminateProgress { 36 | IndeterminateProgress() 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/ButtonKit/Progress/Progress+NSProgress.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Progress+NSProgress.swift 3 | // ButtonKit 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2025 Thomas Durand 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import SwiftUI 29 | 30 | /// Monitor and reflect a (NS)Progress object 31 | @MainActor 32 | public final class NSProgressBridge: TaskProgress { 33 | @Published public private(set) var fractionCompleted: Double? = nil 34 | 35 | public var nsProgress: Progress? { 36 | didSet { 37 | observations.forEach { $0.invalidate() } 38 | observations.removeAll() 39 | 40 | if let nsProgress { 41 | observations.insert(nsProgress.observe(\.fractionCompleted, options: [.initial, .new], changeHandler: { [weak self] progress, _ in 42 | DispatchQueue.main.async { [weak self] in 43 | self?.update(with: progress) 44 | } 45 | })) 46 | observations.insert(nsProgress.observe(\.isIndeterminate, options: [.initial, .new], changeHandler: { [weak self] progress, _ in 47 | DispatchQueue.main.async { [weak self] in 48 | self?.update(with: progress) 49 | } 50 | })) 51 | } 52 | } 53 | } 54 | private var observations: Set = [] 55 | 56 | nonisolated init() {} 57 | 58 | private func update(with progress: Progress) { 59 | fractionCompleted = progress.isIndeterminate ? nil : progress.fractionCompleted 60 | } 61 | 62 | public func reset() { 63 | nsProgress = nil 64 | fractionCompleted = nil 65 | } 66 | } 67 | 68 | extension TaskProgress where Self == NSProgressBridge { 69 | public static var progress: NSProgressBridge { 70 | NSProgressBridge() 71 | } 72 | } 73 | 74 | #Preview { 75 | AsyncButton(progress: .progress) { progress in 76 | let nsProgress = Progress(totalUnitCount: 100) 77 | progress.nsProgress = nsProgress 78 | for _ in 1...100 { 79 | try await Task.sleep(nanoseconds: 20_000_000) 80 | nsProgress.completedUnitCount += 1 81 | } 82 | } label: { 83 | Text("NSProgress") 84 | } 85 | .buttonStyle(.borderedProminent) 86 | .asyncButtonStyle(.overlay) 87 | } 88 | -------------------------------------------------------------------------------- /Sources/ButtonKit/Progress/Progress.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Progress.swift 3 | // ButtonKit 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2025 Thomas Durand 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import SwiftUI 29 | 30 | public protocol TaskProgress: Sendable, ObservableObject { 31 | /// Report nil when the progress is indeterminate, and report a Double between 0 and 1 when the progress is determinate 32 | /// A progress can alternate from determinate to intedeterminate if necessary, and vice versa 33 | @MainActor var fractionCompleted: Double? { get } 34 | 35 | /// Should reset the progres to it's initial value 36 | @MainActor func reset() 37 | 38 | @MainActor 39 | func started() async 40 | @MainActor 41 | func ended() async 42 | } 43 | 44 | extension TaskProgress { 45 | public func started() async {} 46 | public func ended() async {} 47 | } 48 | -------------------------------------------------------------------------------- /Sources/ButtonKit/Style/Async/AsyncStyle+Leading.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncStyle+Leading.swift 3 | // ButtonKit 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2025 Thomas Durand 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import SwiftUI 29 | 30 | public struct LeadingAsyncButtonStyle: AsyncButtonStyle { 31 | public init() {} 32 | 33 | public func makeLabel(configuration: LabelConfiguration) -> some View { 34 | HStack(spacing: 8) { 35 | if configuration.isLoading { 36 | if let fractionCompleted = configuration.fractionCompleted { 37 | CircularProgressView(value: fractionCompleted) 38 | } else { 39 | IndeterminateProgressView() 40 | } 41 | } 42 | configuration.label 43 | } 44 | .animation(.default, value: configuration.isLoading) 45 | .animation(.default, value: configuration.fractionCompleted) 46 | } 47 | } 48 | 49 | extension AsyncButtonStyle where Self == LeadingAsyncButtonStyle { 50 | public static var leading: LeadingAsyncButtonStyle { 51 | LeadingAsyncButtonStyle() 52 | } 53 | } 54 | 55 | #Preview("Indeterminate") { 56 | AsyncButton { 57 | try await Task.sleep(nanoseconds: 30_000_000_000) 58 | } label: { 59 | Text("Leading") 60 | } 61 | .buttonStyle(.borderedProminent) 62 | .asyncButtonStyle(.leading) 63 | } 64 | 65 | #Preview("Determinate") { 66 | AsyncButton(progress: .discrete(totalUnitCount: 100)) { progress in 67 | for _ in 1...100 { 68 | try await Task.sleep(nanoseconds: 10_000_000) 69 | progress.completedUnitCount += 1 70 | } 71 | } label: { 72 | Text("Leading") 73 | } 74 | .buttonStyle(.borderedProminent) 75 | .asyncButtonStyle(.leading) 76 | } 77 | -------------------------------------------------------------------------------- /Sources/ButtonKit/Style/Async/AsyncStyle+None.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncStyle+None.swift 3 | // ButtonKit 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2025 Thomas Durand 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import SwiftUI 29 | 30 | public struct NoStyleAsyncButtonStyle: AsyncButtonStyle { 31 | public init() {} 32 | } 33 | 34 | extension AsyncButtonStyle where Self == NoStyleAsyncButtonStyle { 35 | public static var none: NoStyleAsyncButtonStyle { 36 | NoStyleAsyncButtonStyle() 37 | } 38 | } 39 | 40 | #Preview("Indeterminate") { 41 | AsyncButton { 42 | try await Task.sleep(nanoseconds: 30_000_000_000) 43 | } label: { 44 | Text("None") 45 | } 46 | .buttonStyle(.borderedProminent) 47 | .asyncButtonStyle(.none) 48 | } 49 | 50 | #Preview("Determinate") { 51 | AsyncButton(progress: .discrete(totalUnitCount: 100)) { progress in 52 | for _ in 1...100 { 53 | try await Task.sleep(nanoseconds: 10_000_000) 54 | progress.completedUnitCount += 1 55 | } 56 | } label: { 57 | Text("Progress bar") 58 | } 59 | .buttonStyle(.borderedProminent) 60 | .asyncButtonStyle(.none) 61 | } 62 | -------------------------------------------------------------------------------- /Sources/ButtonKit/Style/Async/AsyncStyle+Overlay.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncStyle+Overlay.swift 3 | // ButtonKit 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2025 Thomas Durand 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import SwiftUI 29 | 30 | public struct OverlayAsyncButtonStyle: AsyncButtonStyle { 31 | public enum ProgressStyle: Sendable { 32 | case bar 33 | case percent 34 | } 35 | 36 | private let style: ProgressStyle 37 | public init(style: ProgressStyle = .bar) { 38 | self.style = style 39 | } 40 | 41 | public func makeLabel(configuration: LabelConfiguration) -> some View { 42 | configuration.label 43 | .opacity(configuration.isLoading ? 0 : 1) 44 | .overlay { 45 | if configuration.isLoading { 46 | if let fractionCompleted = configuration.fractionCompleted { 47 | switch style { 48 | case .bar: 49 | BarProgressView(value: fractionCompleted) 50 | case .percent: 51 | Text(fractionCompleted, format: .percent.rounded(increment: 1)) 52 | .monospacedDigit() 53 | } 54 | } else { 55 | IndeterminateProgressView() 56 | } 57 | } 58 | } 59 | .animation(.default, value: configuration.isLoading) 60 | } 61 | } 62 | 63 | extension AsyncButtonStyle where Self == OverlayAsyncButtonStyle { 64 | public static var overlay: OverlayAsyncButtonStyle { 65 | OverlayAsyncButtonStyle() 66 | } 67 | public static func overlay(style: OverlayAsyncButtonStyle.ProgressStyle) -> OverlayAsyncButtonStyle { 68 | OverlayAsyncButtonStyle(style: style) 69 | } 70 | } 71 | 72 | #Preview("Indeterminate") { 73 | AsyncButton { 74 | try await Task.sleep(nanoseconds: 30_000_000_000) 75 | } label: { 76 | Text("Overlay") 77 | } 78 | .buttonStyle(.borderedProminent) 79 | .asyncButtonStyle(.overlay) 80 | } 81 | 82 | #Preview("Determinate (bar)") { 83 | AsyncButton(progress: .discrete(totalUnitCount: 100)) { progress in 84 | for _ in 1...100 { 85 | try await Task.sleep(nanoseconds: 10_000_000) 86 | progress.completedUnitCount += 1 87 | } 88 | } label: { 89 | Text("Overlay") 90 | } 91 | .buttonStyle(.borderedProminent) 92 | .asyncButtonStyle(.overlay(style: .bar)) 93 | } 94 | 95 | #Preview("Determinate (percent)") { 96 | AsyncButton(progress: .discrete(totalUnitCount: 100)) { progress in 97 | for _ in 1...100 { 98 | try await Task.sleep(nanoseconds: 10_000_000) 99 | progress.completedUnitCount += 1 100 | } 101 | } label: { 102 | Text("Overlay") 103 | } 104 | .buttonStyle(.borderedProminent) 105 | .asyncButtonStyle(.overlay(style: .percent)) 106 | } 107 | -------------------------------------------------------------------------------- /Sources/ButtonKit/Style/Async/AsyncStyle+Pulse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncStyle+Pulse.swift 3 | // ButtonKit 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2025 Thomas Durand 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import SwiftUI 29 | 30 | public struct PulseAsyncButtonStyle: AsyncButtonStyle { 31 | public init() {} 32 | 33 | public func makeButton(configuration: ButtonConfiguration) -> some View { 34 | configuration.button 35 | .compositingGroup() 36 | .opacity(configuration.isLoading ? 0.5 : 1) 37 | .animation(configuration.isLoading ? .linear(duration: 1).repeatForever() : nil, value: configuration.isLoading) 38 | } 39 | } 40 | 41 | extension AsyncButtonStyle where Self == PulseAsyncButtonStyle { 42 | public static var pulse: PulseAsyncButtonStyle { 43 | PulseAsyncButtonStyle() 44 | } 45 | } 46 | 47 | #Preview { 48 | AsyncButton { 49 | try await Task.sleep(nanoseconds: 5_000_000_000) 50 | } label: { 51 | Text("Pulse") 52 | } 53 | .buttonStyle(.borderedProminent) 54 | .asyncButtonStyle(.pulse) 55 | } 56 | -------------------------------------------------------------------------------- /Sources/ButtonKit/Style/Async/AsyncStyle+Trailing.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncStyle+Trailing.swift 3 | // ButtonKit 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2025 Thomas Durand 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import SwiftUI 29 | 30 | public struct TrailingAsyncButtonStyle: AsyncButtonStyle { 31 | public init() {} 32 | 33 | public func makeLabel(configuration: LabelConfiguration) -> some View { 34 | HStack(spacing: 8) { 35 | configuration.label 36 | if configuration.isLoading { 37 | if let fractionCompleted = configuration.fractionCompleted { 38 | CircularProgressView(value: fractionCompleted) 39 | } else { 40 | IndeterminateProgressView() 41 | } 42 | } 43 | } 44 | .animation(.default, value: configuration.isLoading) 45 | .animation(.default, value: configuration.fractionCompleted) 46 | } 47 | } 48 | 49 | extension AsyncButtonStyle where Self == TrailingAsyncButtonStyle { 50 | public static var trailing: TrailingAsyncButtonStyle { 51 | TrailingAsyncButtonStyle() 52 | } 53 | } 54 | 55 | #Preview("Indeterminate") { 56 | AsyncButton { 57 | try await Task.sleep(nanoseconds: 30_000_000_000) 58 | } label: { 59 | Text("Trailing") 60 | } 61 | .buttonStyle(.borderedProminent) 62 | .asyncButtonStyle(.trailing) 63 | } 64 | 65 | #Preview("Determinate") { 66 | AsyncButton(progress: .discrete(totalUnitCount: 100)) { progress in 67 | for _ in 1...100 { 68 | try await Task.sleep(nanoseconds: 10_000_000) 69 | progress.completedUnitCount += 1 70 | } 71 | } label: { 72 | Text("Trailing") 73 | } 74 | .buttonStyle(.borderedProminent) 75 | .asyncButtonStyle(.trailing) 76 | } 77 | -------------------------------------------------------------------------------- /Sources/ButtonKit/Style/Button+AsyncStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Button+AsyncStyle.swift 3 | // ButtonKit 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2025 Thomas Durand 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import SwiftUI 29 | 30 | // MARK: Public protocol 31 | 32 | extension View { 33 | public func asyncButtonStyle(_ style: S) -> some View { 34 | environment(\.asyncButtonStyle, AnyAsyncButtonStyle(style)) 35 | } 36 | } 37 | 38 | public protocol AsyncButtonStyle: Sendable { 39 | associatedtype ButtonLabel: View 40 | associatedtype ButtonView: View 41 | typealias LabelConfiguration = AsyncButtonStyleLabelConfiguration 42 | typealias ButtonConfiguration = AsyncButtonStyleButtonConfiguration 43 | 44 | @MainActor @ViewBuilder func makeLabel(configuration: LabelConfiguration) -> ButtonLabel 45 | @MainActor @ViewBuilder func makeButton(configuration: ButtonConfiguration) -> ButtonView 46 | } 47 | extension AsyncButtonStyle { 48 | public func makeLabel(configuration: LabelConfiguration) -> some View { 49 | configuration.label 50 | } 51 | public func makeButton(configuration: ButtonConfiguration) -> some View { 52 | configuration.button 53 | } 54 | } 55 | 56 | public struct AsyncButtonStyleLabelConfiguration { 57 | public typealias Label = AnyView 58 | 59 | public let label: Label 60 | /// Returns true if the button is in a loading state, and false if the button is idle 61 | public let isLoading: Bool 62 | /// Returns the fraction completed when the task is determinate. nil when the task is indeterminate 63 | public let fractionCompleted: Double? 64 | /// A callable closure to cancel the current task if any 65 | public let cancel: () -> Void 66 | } 67 | 68 | public struct AsyncButtonStyleButtonConfiguration { 69 | public typealias Button = AnyView 70 | 71 | public let button: Button 72 | /// Returns true if the button is in a loading state, and false if the button is idle 73 | public let isLoading: Bool 74 | /// Returns the fraction completed when the task is determinate. nil when the task is indeterminate 75 | public let fractionCompleted: Double? 76 | /// A callable closure to cancel the current task if any 77 | public let cancel: () -> Void 78 | } 79 | 80 | // MARK: SwiftUI Environment 81 | 82 | extension AsyncButtonStyle where Self == OverlayAsyncButtonStyle { 83 | public static var auto: some AsyncButtonStyle { 84 | OverlayAsyncButtonStyle(style: .bar) 85 | } 86 | } 87 | 88 | struct AsyncButtonStyleKey: EnvironmentKey { 89 | static let defaultValue: AnyAsyncButtonStyle = AnyAsyncButtonStyle(.auto) 90 | } 91 | 92 | extension EnvironmentValues { 93 | var asyncButtonStyle: AnyAsyncButtonStyle { 94 | get { 95 | return self[AsyncButtonStyleKey.self] 96 | } 97 | set { 98 | self[AsyncButtonStyleKey.self] = newValue 99 | } 100 | } 101 | } 102 | 103 | // MARK: - Type erasure 104 | 105 | struct AnyAsyncButtonStyle: AsyncButtonStyle, Sendable { 106 | private let _makeLabel: @MainActor @Sendable (AsyncButtonStyle.LabelConfiguration) -> AnyView 107 | private let _makeButton: @MainActor @Sendable (AsyncButtonStyle.ButtonConfiguration) -> AnyView 108 | 109 | init(_ style: S) { 110 | self._makeLabel = style.makeLabelTypeErased 111 | self._makeButton = style.makeButtonTypeErased 112 | } 113 | 114 | func makeLabel(configuration: LabelConfiguration) -> AnyView { 115 | self._makeLabel(configuration) 116 | } 117 | 118 | func makeButton(configuration: ButtonConfiguration) -> AnyView { 119 | self._makeButton(configuration) 120 | } 121 | } 122 | 123 | extension AsyncButtonStyle { 124 | @MainActor 125 | func makeLabelTypeErased(configuration: LabelConfiguration) -> AnyView { 126 | AnyView(self.makeLabel(configuration: configuration)) 127 | } 128 | @MainActor 129 | func makeButtonTypeErased(configuration: ButtonConfiguration) -> AnyView { 130 | AnyView(self.makeButton(configuration: configuration)) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /Sources/ButtonKit/Style/Button+ThrowableStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Button+ThrowableStyle.swift 3 | // ButtonKit 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2025 Thomas Durand 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import SwiftUI 29 | 30 | // MARK: Public protocol 31 | 32 | extension View { 33 | public func throwableButtonStyle(_ style: S) -> some View { 34 | environment(\.throwableButtonStyle, AnyThrowableButtonStyle(style)) 35 | } 36 | } 37 | 38 | public protocol ThrowableButtonStyle: Sendable { 39 | associatedtype ButtonLabel: View 40 | associatedtype ButtonView: View 41 | typealias LabelConfiguration = ThrowableButtonStyleLabelConfiguration 42 | typealias ButtonConfiguration = ThrowableButtonStyleButtonConfiguration 43 | 44 | @MainActor @ViewBuilder func makeLabel(configuration: LabelConfiguration) -> ButtonLabel 45 | @MainActor @ViewBuilder func makeButton(configuration: ButtonConfiguration) -> ButtonView 46 | } 47 | extension ThrowableButtonStyle { 48 | public func makeLabel(configuration: LabelConfiguration) -> some View { 49 | configuration.label 50 | } 51 | public func makeButton(configuration: ButtonConfiguration) -> some View { 52 | configuration.button 53 | } 54 | } 55 | 56 | public struct ThrowableButtonStyleLabelConfiguration { 57 | public typealias Label = AnyView 58 | 59 | public let label: Label 60 | /// Is incremented at each new error 61 | public let errorCount: Int 62 | } 63 | public struct ThrowableButtonStyleButtonConfiguration { 64 | public typealias Button = AnyView 65 | 66 | public let button: Button 67 | /// Is incremented at each new error 68 | public let errorCount: Int 69 | } 70 | // MARK: SwiftUI Environment 71 | 72 | extension ThrowableButtonStyle where Self == ShakeThrowableButtonStyle { 73 | public static var auto: some ThrowableButtonStyle { 74 | ShakeThrowableButtonStyle() 75 | } 76 | } 77 | 78 | struct ThrowableButtonStyleKey: EnvironmentKey { 79 | static let defaultValue: AnyThrowableButtonStyle = AnyThrowableButtonStyle(.auto) 80 | } 81 | 82 | extension EnvironmentValues { 83 | var throwableButtonStyle: AnyThrowableButtonStyle { 84 | get { 85 | return self[ThrowableButtonStyleKey.self] 86 | } 87 | set { 88 | self[ThrowableButtonStyleKey.self] = newValue 89 | } 90 | } 91 | } 92 | 93 | // MARK: - Type erasure 94 | 95 | struct AnyThrowableButtonStyle: ThrowableButtonStyle { 96 | private let _makeLabel: @MainActor @Sendable (ThrowableButtonStyle.LabelConfiguration) -> AnyView 97 | private let _makeButton: @MainActor @Sendable (ThrowableButtonStyle.ButtonConfiguration) -> AnyView 98 | 99 | init(_ style: S) { 100 | self._makeLabel = style.makeLabelTypeErased 101 | self._makeButton = style.makeButtonTypeErased 102 | } 103 | 104 | func makeLabel(configuration: LabelConfiguration) -> AnyView { 105 | self._makeLabel(configuration) 106 | } 107 | 108 | func makeButton(configuration: ButtonConfiguration) -> AnyView { 109 | self._makeButton(configuration) 110 | } 111 | } 112 | 113 | extension ThrowableButtonStyle { 114 | @MainActor 115 | func makeLabelTypeErased(configuration: LabelConfiguration) -> AnyView { 116 | AnyView(self.makeLabel(configuration: configuration)) 117 | } 118 | @MainActor 119 | func makeButtonTypeErased(configuration: ButtonConfiguration) -> AnyView { 120 | AnyView(self.makeButton(configuration: configuration)) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /Sources/ButtonKit/Style/Throwable/ThrowableStyle+None.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThrowableStyle+None.swift 3 | // ButtonKit 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2025 Thomas Durand 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import SwiftUI 29 | 30 | public struct NoStyleThrowableButtonStyle: ThrowableButtonStyle { 31 | public init() {} 32 | } 33 | 34 | extension ThrowableButtonStyle where Self == NoStyleThrowableButtonStyle { 35 | public static var none: NoStyleThrowableButtonStyle { 36 | NoStyleThrowableButtonStyle() 37 | } 38 | } 39 | 40 | #Preview { 41 | AsyncButton { 42 | throw NSError() as Error 43 | } label: { 44 | Text("None") 45 | } 46 | .buttonStyle(.borderedProminent) 47 | .throwableButtonStyle(.none) 48 | } 49 | -------------------------------------------------------------------------------- /Sources/ButtonKit/Style/Throwable/ThrowableStyle+Shake.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThrowableStyle+Shake.swift 3 | // ButtonKit 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2025 Thomas Durand 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import SwiftUI 29 | 30 | public struct ShakeThrowableButtonStyle: ThrowableButtonStyle { 31 | public init() {} 32 | 33 | public func makeButton(configuration: ButtonConfiguration) -> some View { 34 | configuration.button 35 | .modifier(Shake(animatableData: CGFloat(configuration.errorCount))) 36 | .animation(.easeInOut, value: configuration.errorCount) 37 | } 38 | } 39 | 40 | extension ThrowableButtonStyle where Self == ShakeThrowableButtonStyle { 41 | public static var shake: ShakeThrowableButtonStyle { 42 | ShakeThrowableButtonStyle() 43 | } 44 | } 45 | 46 | struct Shake: GeometryEffect { 47 | let amount: CGFloat = 10 48 | let shakesPerUnit = 4 49 | var animatableData: CGFloat 50 | 51 | nonisolated func effectValue(size: CGSize) -> ProjectionTransform { 52 | ProjectionTransform(CGAffineTransform(translationX: amount * sin(animatableData * .pi * CGFloat(shakesPerUnit)), y: 0)) 53 | } 54 | } 55 | 56 | #Preview { 57 | AsyncButton { 58 | throw NSError() as Error 59 | } label: { 60 | Text("Shake") 61 | } 62 | .buttonStyle(.borderedProminent) 63 | .throwableButtonStyle(.shake) 64 | } 65 | -------------------------------------------------------------------------------- /Sources/ButtonKit/Trigger/Trigger+Environment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Trigger+Environment.swift 3 | // ButtonKit 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2025 Thomas Durand 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import OSLog 29 | import SwiftUI 30 | 31 | /// Allow to trigger an arbitrary but identified `AsyncButton` 32 | public final class TriggerButton: Sendable { 33 | @MainActor private var buttons: [AnyHashable: @MainActor () -> Void] = [:] 34 | 35 | fileprivate init() {} 36 | 37 | @MainActor 38 | public func callAsFunction(id: AnyHashable) { 39 | guard let closure = buttons[id] else { 40 | Logger(subsystem: "ButtonKit", category: "Trigger").warning("Could not trigger button with id: \(id). It is not currently on screen!") 41 | return 42 | } 43 | closure() 44 | } 45 | 46 | @MainActor 47 | func register(id: AnyHashable, action: @escaping @MainActor () -> Void) { 48 | if buttons.keys.contains(id) { 49 | Logger(subsystem: "ButtonKit", category: "Trigger").warning("Registering a button with an already existing id: \(id). The previous one was overridden.") 50 | } 51 | buttons.updateValue(action, forKey: id) 52 | } 53 | 54 | @MainActor 55 | func unregister(id: AnyHashable) { 56 | buttons.removeValue(forKey: id) 57 | } 58 | } 59 | 60 | private struct TriggerEnvironmentKey: EnvironmentKey { 61 | static let defaultValue = TriggerButton() 62 | } 63 | 64 | extension EnvironmentValues { 65 | public var triggerButton: TriggerButton { 66 | self[TriggerEnvironmentKey.self] 67 | } 68 | } 69 | --------------------------------------------------------------------------------