├── .github ├── CODEOWNERS └── FUNDING.yml ├── Preview ├── pulse.gif ├── shake.gif ├── leading.gif ├── overlay.gif ├── trailing.gif ├── determinant-bar.gif ├── determinant-leading.gif ├── determinant-percent.gif └── determinant-trailing.gif ├── Demo ├── ButtonKitDemo │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ └── AppIcon.appiconset │ │ │ └── Contents.json │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── ButtonKitDemo.entitlements │ ├── DemoApp.swift │ ├── Buttons │ │ ├── ThrowableButtonDemo.swift │ │ └── AsyncButtonDemo.swift │ ├── Progress │ │ ├── EstimatedProgressDemo.swift │ │ └── DiscreteProgressDemo.swift │ ├── ContentView.swift │ ├── Trigger │ │ └── TriggerDemo.swift │ └── Advanced │ │ └── AppStoreButtonDemo.swift ├── ButtonKitDemo.xcodeproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ ├── xcshareddata │ │ └── xcschemes │ │ │ └── ButtonKitDemo.xcscheme │ └── project.pbxproj └── .gitignore ├── .gitignore ├── Package.swift ├── Package@swift-5.10.swift ├── LICENSE ├── Sources └── ButtonKit │ ├── Progress │ ├── Progress+Indeterminate.swift │ ├── Progress.swift │ ├── Progress+Discrete.swift │ ├── Progress+NSProgress.swift │ └── Progress+Estimated.swift │ ├── Style │ ├── Throwable │ │ ├── ThrowableStyle+None.swift │ │ ├── ThrowableStyle+Shake.swift │ │ └── ThrowableStyle+SymbolEffect.swift │ ├── Async │ │ ├── AsyncStyle+Pulse.swift │ │ ├── AsyncStyle+None.swift │ │ ├── AsyncStyle+Leading.swift │ │ ├── AsyncStyle+Trailing.swift │ │ ├── AsyncStyle+Overlay.swift │ │ └── AsyncStyle+SymbolEffect.swift │ ├── Button+ThrowableStyle.swift │ └── Button+AsyncStyle.swift │ ├── Internal │ ├── IndeterminateProgressView.swift │ ├── BarProgressView.swift │ └── CircularProgressView.swift │ ├── Modifiers │ ├── Button+AsyncDisabled.swift │ └── Button+Events.swift │ ├── Trigger │ └── Trigger+Environment.swift │ ├── Button+Reader.swift │ ├── Button+AppIntent.swift │ └── Button.swift └── README.md /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @Dean151 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [Dean151] 2 | -------------------------------------------------------------------------------- /Preview/pulse.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dean151/ButtonKit/HEAD/Preview/pulse.gif -------------------------------------------------------------------------------- /Preview/shake.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dean151/ButtonKit/HEAD/Preview/shake.gif -------------------------------------------------------------------------------- /Preview/leading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dean151/ButtonKit/HEAD/Preview/leading.gif -------------------------------------------------------------------------------- /Preview/overlay.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dean151/ButtonKit/HEAD/Preview/overlay.gif -------------------------------------------------------------------------------- /Preview/trailing.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dean151/ButtonKit/HEAD/Preview/trailing.gif -------------------------------------------------------------------------------- /Preview/determinant-bar.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dean151/ButtonKit/HEAD/Preview/determinant-bar.gif -------------------------------------------------------------------------------- /Preview/determinant-leading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dean151/ButtonKit/HEAD/Preview/determinant-leading.gif -------------------------------------------------------------------------------- /Preview/determinant-percent.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dean151/ButtonKit/HEAD/Preview/determinant-percent.gif -------------------------------------------------------------------------------- /Preview/determinant-trailing.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dean151/ButtonKit/HEAD/Preview/determinant-trailing.gif -------------------------------------------------------------------------------- /Demo/ButtonKitDemo/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/ButtonKitDemo/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.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/ButtonKitDemo.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /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.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.10.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.10 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/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/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/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+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 | -------------------------------------------------------------------------------- /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 | .onButtonStateError { event in 53 | // Do something with the error 54 | print("Button \(event.buttonID) failed with \(event.error)") 55 | } 56 | } 57 | } 58 | 59 | #Preview { 60 | ThrowableButtonDemo() 61 | } 62 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.numberOfFailures))) 36 | .animation(.easeInOut, value: configuration.numberOfFailures) 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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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+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/Button+Reader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Button+Reader.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 AsyncButtonReader: View { 31 | @State var latestEvent: StateChangedEvent? = nil 32 | let content: (StateChangedEvent?) -> Content 33 | 34 | public var body: some View { 35 | content(latestEvent) 36 | .onButtonStateChange { latestEvent = $0 } 37 | } 38 | 39 | public init(@ViewBuilder content: @escaping (StateChangedEvent?) -> Content) { 40 | self.content = content 41 | } 42 | } 43 | 44 | #Preview { 45 | AsyncButtonReader { event in 46 | Group { 47 | AsyncButton("I succeed") { 48 | try await Task.sleep(nanoseconds: 2_000_000_000) 49 | } 50 | 51 | AsyncButton("I fail") { 52 | throw NSError() 53 | } 54 | 55 | AsyncButton("I'm random") { 56 | try await Task.sleep(nanoseconds: 2_000_000_000) 57 | if Bool.random() { 58 | throw NSError() 59 | } 60 | } 61 | } 62 | .buttonStyle(.bordered) 63 | 64 | if let event { 65 | VStack { 66 | Text(verbatim: "Button \(event.buttonID)") 67 | switch event.state { 68 | case .started: 69 | Text("In Progress") 70 | case .ended(.completed): 71 | Text("Completed") 72 | case .ended(.cancelled): 73 | Text("Cancelled") 74 | case let .ended(.errored(_, count)): 75 | Text("Errored \(count) time(s)") 76 | } 77 | } 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /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.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | if #available(iOS 18.0, *) { 65 | AsyncButton { 66 | try await Task.sleep(nanoseconds: 2_000_000_000) 67 | } label: { 68 | Label("Symbol effect", systemImage: "ellipsis") 69 | } 70 | .asyncButtonStyle(.symbolEffect(.variableColor)) 71 | } 72 | 73 | AsyncButton { 74 | try await Task.sleep(nanoseconds: 2_000_000_000) 75 | } label: { 76 | Text("No style") 77 | } 78 | .asyncButtonStyle(.none) 79 | } 80 | .onButtonStateChange { event in 81 | switch event.state { 82 | case .started: 83 | print("task started: \(event.buttonID)") 84 | case .ended(.completed): 85 | print("task completed: \(event.buttonID)") 86 | case .ended(.cancelled): 87 | print("task cancelled: \(event.buttonID)") 88 | case .ended(.errored(let error, _)): 89 | print("task errored: \(event.buttonID) \(error)") 90 | } 91 | } 92 | } 93 | .buttonStyle(.borderedProminent) 94 | } 95 | } 96 | 97 | #Preview { 98 | AsyncButtonDemo() 99 | } 100 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Sources/ButtonKit/Style/Throwable/ThrowableStyle+SymbolEffect.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThrowableStyle+SymbolEffect.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 Symbols 29 | import SwiftUI 30 | 31 | @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) 32 | public struct SymbolEffectThrowableButtonStyle: ThrowableButtonStyle { 33 | let effect: Effect 34 | 35 | public func makeLabel(configuration: LabelConfiguration) -> some View { 36 | configuration.label 37 | .symbolEffect(effect, value: configuration.numberOfFailures) 38 | } 39 | } 40 | 41 | @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) 42 | extension ThrowableButtonStyle where Self == SymbolEffectThrowableButtonStyle { 43 | public static func symbolEffect(_ effect: BounceSymbolEffect) -> some ThrowableButtonStyle { 44 | SymbolEffectThrowableButtonStyle(effect: effect) 45 | } 46 | } 47 | @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) 48 | extension ThrowableButtonStyle where Self == SymbolEffectThrowableButtonStyle { 49 | public static func symbolEffect(_ effect: PulseSymbolEffect) -> some ThrowableButtonStyle { 50 | SymbolEffectThrowableButtonStyle(effect: effect) 51 | } 52 | } 53 | @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) 54 | extension ThrowableButtonStyle where Self == SymbolEffectThrowableButtonStyle { 55 | public static func symbolEffect(_ effect: VariableColorSymbolEffect) -> some ThrowableButtonStyle { 56 | SymbolEffectThrowableButtonStyle(effect: effect) 57 | } 58 | } 59 | 60 | @available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) 61 | extension ThrowableButtonStyle where Self == SymbolEffectThrowableButtonStyle { 62 | public static func symbolEffect(_ effect: BreatheSymbolEffect) -> some ThrowableButtonStyle { 63 | SymbolEffectThrowableButtonStyle(effect: effect) 64 | } 65 | } 66 | @available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) 67 | extension ThrowableButtonStyle where Self == SymbolEffectThrowableButtonStyle { 68 | public static func symbolEffect(_ effect: RotateSymbolEffect) -> some ThrowableButtonStyle { 69 | SymbolEffectThrowableButtonStyle(effect: effect) 70 | } 71 | } 72 | @available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) 73 | extension ThrowableButtonStyle where Self == SymbolEffectThrowableButtonStyle { 74 | public static func symbolEffect(_ effect: WiggleSymbolEffect) -> some ThrowableButtonStyle { 75 | SymbolEffectThrowableButtonStyle(effect: effect) 76 | } 77 | } 78 | 79 | @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) 80 | #Preview { 81 | AsyncButton { 82 | throw NSError() as Error 83 | } label: { 84 | Label("Hello", systemImage: "link") 85 | } 86 | .buttonStyle(.borderedProminent) 87 | .throwableButtonStyle(.symbolEffect(.bounce)) 88 | } 89 | -------------------------------------------------------------------------------- /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/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 | public let latestError: Error? 61 | /// Is incremented at each new error 62 | public let numberOfFailures: Int 63 | @available(*, deprecated, renamed: "numberOfFailures") 64 | public var errorCount: Int { 65 | numberOfFailures 66 | } 67 | } 68 | public struct ThrowableButtonStyleButtonConfiguration { 69 | public typealias Button = AnyView 70 | 71 | public let button: Button 72 | public let latestError: Error? 73 | /// Is incremented at each new error 74 | public let numberOfFailures: Int 75 | @available(*, deprecated, renamed: "numberOfFailures") 76 | public var errorCount: Int { 77 | numberOfFailures 78 | } 79 | } 80 | // MARK: SwiftUI Environment 81 | 82 | extension ThrowableButtonStyle where Self == ShakeThrowableButtonStyle { 83 | public static var auto: some ThrowableButtonStyle { 84 | ShakeThrowableButtonStyle() 85 | } 86 | } 87 | 88 | struct ThrowableButtonStyleKey: EnvironmentKey { 89 | static let defaultValue: AnyThrowableButtonStyle = AnyThrowableButtonStyle(.auto) 90 | } 91 | 92 | extension EnvironmentValues { 93 | var throwableButtonStyle: AnyThrowableButtonStyle { 94 | get { 95 | return self[ThrowableButtonStyleKey.self] 96 | } 97 | set { 98 | self[ThrowableButtonStyleKey.self] = newValue 99 | } 100 | } 101 | } 102 | 103 | // MARK: - Type erasure 104 | 105 | struct AnyThrowableButtonStyle: ThrowableButtonStyle { 106 | private let _makeLabel: @MainActor @Sendable (ThrowableButtonStyle.LabelConfiguration) -> AnyView 107 | private let _makeButton: @MainActor @Sendable (ThrowableButtonStyle.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 ThrowableButtonStyle { 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+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/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 | onStateChange: (@MainActor (AsyncButtonState) -> Void)? = nil 39 | ) { 40 | self.init(role: role, id: id, action: { _ = try await intent.perform() }, label: label, onStateChange: onStateChange) 41 | } 42 | } 43 | 44 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) 45 | extension AsyncButton where P == IndeterminateProgress, S == Text { 46 | public init( 47 | _ titleKey: LocalizedStringKey, 48 | role: ButtonRole? = nil, 49 | id: AnyHashable? = nil, 50 | intent: some AppIntent, 51 | onStateChange: (@MainActor (AsyncButtonState) -> Void)? = nil 52 | ) { 53 | self.init(titleKey, role: role, id: id, action: { _ = try await intent.perform() }, onStateChange: onStateChange) 54 | } 55 | 56 | @_disfavoredOverload 57 | public init( 58 | _ title: some StringProtocol, 59 | role: ButtonRole? = nil, 60 | id: AnyHashable? = nil, 61 | intent: some AppIntent, 62 | onStateChange: (@MainActor (AsyncButtonState) -> Void)? = nil 63 | ) { 64 | self.init(title, role: role, id: id, action: { _ = try await intent.perform() }, onStateChange: onStateChange) 65 | } 66 | } 67 | 68 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) 69 | extension AsyncButton where P == IndeterminateProgress, S == Label { 70 | public init( 71 | _ titleKey: LocalizedStringKey, 72 | image name: String, 73 | role: ButtonRole? = nil, 74 | id: AnyHashable? = nil, 75 | intent: some AppIntent, 76 | onStateChange: (@MainActor (AsyncButtonState) -> Void)? = nil 77 | ) { 78 | self.init(titleKey, image: name, role: role, id: id, action: { _ = try await intent.perform() }, onStateChange: onStateChange) 79 | } 80 | 81 | @_disfavoredOverload 82 | public init( 83 | _ title: some StringProtocol, 84 | image name: String, 85 | role: ButtonRole? = nil, 86 | id: AnyHashable? = nil, 87 | intent: some AppIntent, 88 | onStateChange: (@MainActor (AsyncButtonState) -> Void)? = nil 89 | ) { 90 | self.init(title, image: name, role: role, id: id, action: { _ = try await intent.perform() }, onStateChange: onStateChange) 91 | } 92 | 93 | @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) 94 | public init( 95 | _ titleKey: LocalizedStringKey, 96 | image: ImageResource, 97 | role: ButtonRole? = nil, 98 | id: AnyHashable? = nil, 99 | intent: some AppIntent, 100 | onStateChange: (@MainActor (AsyncButtonState) -> Void)? = nil 101 | ) { 102 | self.init(titleKey, image: image, role: role, id: id, action: { _ = try await intent.perform() }, onStateChange: onStateChange) 103 | } 104 | 105 | @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) 106 | @_disfavoredOverload 107 | public init( 108 | _ title: some StringProtocol, 109 | image: ImageResource, 110 | role: ButtonRole? = nil, 111 | id: AnyHashable? = nil, 112 | intent: some AppIntent, 113 | onStateChange: (@MainActor (AsyncButtonState) -> Void)? = nil 114 | ) { 115 | self.init(title, image: image, role: role, id: id, action: { _ = try await intent.perform() }, onStateChange: onStateChange) 116 | } 117 | 118 | public init( 119 | _ titleKey: LocalizedStringKey, 120 | systemImage: String, 121 | role: ButtonRole? = nil, 122 | id: AnyHashable? = nil, 123 | intent: some AppIntent, 124 | onStateChange: (@MainActor (AsyncButtonState) -> Void)? = nil 125 | ) { 126 | self.init(titleKey, systemImage: systemImage, role: role, id: id, action: { _ = try await intent.perform() }, onStateChange: onStateChange) 127 | } 128 | 129 | @_disfavoredOverload 130 | public init( 131 | _ title: some StringProtocol, 132 | systemImage: String, 133 | role: ButtonRole? = nil, 134 | id: AnyHashable? = nil, 135 | intent: some AppIntent, 136 | onStateChange: (@MainActor (AsyncButtonState) -> Void)? = nil 137 | ) { 138 | self.init(title, systemImage: systemImage, role: role, id: id, action: { _ = try await intent.perform() }, onStateChange: onStateChange) 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /Sources/ButtonKit/Style/Async/AsyncStyle+SymbolEffect.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncStyle+SymbolEffect.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 Symbols 29 | import SwiftUI 30 | 31 | @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) 32 | public struct SymbolEffectAsyncButtonStyle: AsyncButtonStyle { 33 | let effect: Effect 34 | 35 | public func makeLabel(configuration: LabelConfiguration) -> some View { 36 | configuration.label 37 | .symbolEffect(effect, isActive: configuration.isLoading) 38 | } 39 | } 40 | 41 | @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) 42 | extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { 43 | public static func symbolEffect(_ effect: AppearSymbolEffect) -> some AsyncButtonStyle { 44 | SymbolEffectAsyncButtonStyle(effect: effect) 45 | } 46 | } 47 | @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) 48 | extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { 49 | public static func symbolEffect(_ effect: DisappearSymbolEffect) -> some AsyncButtonStyle { 50 | SymbolEffectAsyncButtonStyle(effect: effect) 51 | } 52 | } 53 | @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) 54 | extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { 55 | public static func symbolEffect(_ effect: PulseSymbolEffect) -> some AsyncButtonStyle { 56 | SymbolEffectAsyncButtonStyle(effect: effect) 57 | } 58 | } 59 | @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) 60 | extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { 61 | public static func symbolEffect(_ effect: ScaleSymbolEffect) -> some AsyncButtonStyle { 62 | SymbolEffectAsyncButtonStyle(effect: effect) 63 | } 64 | } 65 | @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) 66 | extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { 67 | public static func symbolEffect(_ effect: VariableColorSymbolEffect) -> some AsyncButtonStyle { 68 | SymbolEffectAsyncButtonStyle(effect: effect) 69 | } 70 | } 71 | 72 | @available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) 73 | extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { 74 | public static func symbolEffect(_ effect: BounceSymbolEffect) -> some AsyncButtonStyle { 75 | SymbolEffectAsyncButtonStyle(effect: effect) 76 | } 77 | } 78 | @available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) 79 | extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { 80 | public static func symbolEffect(_ effect: BreatheSymbolEffect) -> some AsyncButtonStyle { 81 | SymbolEffectAsyncButtonStyle(effect: effect) 82 | } 83 | } 84 | @available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) 85 | extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { 86 | public static func symbolEffect(_ effect: RotateSymbolEffect) -> some AsyncButtonStyle { 87 | SymbolEffectAsyncButtonStyle(effect: effect) 88 | } 89 | } 90 | @available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) 91 | extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { 92 | public static func symbolEffect(_ effect: WiggleSymbolEffect) -> some AsyncButtonStyle { 93 | SymbolEffectAsyncButtonStyle(effect: effect) 94 | } 95 | } 96 | 97 | #if swift(>=6.2) 98 | 99 | @available(macOS 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, *) 100 | extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { 101 | public static func symbolEffect(_ effect: DrawOffSymbolEffect) -> some AsyncButtonStyle { 102 | SymbolEffectAsyncButtonStyle(effect: effect) 103 | } 104 | } 105 | @available(macOS 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, *) 106 | extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { 107 | public static func symbolEffect(_ effect: DrawOnSymbolEffect) -> some AsyncButtonStyle { 108 | SymbolEffectAsyncButtonStyle(effect: effect) 109 | } 110 | } 111 | 112 | #endif 113 | 114 | @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) 115 | #Preview("Indeterminate") { 116 | AsyncButton { 117 | try await Task.sleep(nanoseconds: 30_000_000_000) 118 | } label: { 119 | Image(systemName: "ellipsis") 120 | } 121 | .buttonStyle(.borderedProminent) 122 | .asyncButtonStyle(.symbolEffect(.pulse)) 123 | } 124 | -------------------------------------------------------------------------------- /Sources/ButtonKit/Modifiers/Button+Events.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Button+Events.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 ButtonStateChangedHandler = @MainActor @Sendable (StateChangedEvent) -> Void 33 | public typealias ButtonStateErrorHandler = @MainActor @Sendable (ErrorOccurredEvent) -> Void 34 | 35 | #if swift(>=6.2) 36 | @MainActor 37 | public struct StateChangedEvent: @MainActor Equatable { 38 | public let buttonID: AnyHashable 39 | public let state: AsyncButtonState 40 | let time: Date = .now 41 | } 42 | #else 43 | public struct StateChangedEvent: Equatable, Sendable { 44 | nonisolated(unsafe) public let buttonID: AnyHashable 45 | public let state: AsyncButtonState 46 | let time: Date = .now 47 | } 48 | #endif 49 | 50 | public struct ErrorOccurredEvent { 51 | public let buttonID: AnyHashable 52 | public let error: Error 53 | let time: Date = .now 54 | } 55 | 56 | extension View { 57 | public func onButtonStateChange(_ handler: @escaping ButtonStateChangedHandler) -> some View { 58 | modifier(OnButtonLatestStateChangeModifier { state in 59 | if let state { 60 | handler(state) 61 | } 62 | }) 63 | } 64 | public func onButtonStateError(_ handler: @escaping ButtonStateErrorHandler) -> some View { 65 | modifier(OnButtonLatestStateChangeModifier { event in 66 | if case let .ended(.errored(error, _)) = event?.state { 67 | handler(.init(buttonID: event!.buttonID, error: error)) 68 | } 69 | }) 70 | } 71 | } 72 | 73 | // MARK: Deprecated public protocol 74 | 75 | @available(*, deprecated) 76 | public typealias AsyncButtonTaskStartedHandler = @MainActor @Sendable (Task) -> Void 77 | @available(*, deprecated) 78 | public typealias AsyncButtonTaskChangedHandler = @MainActor @Sendable (Task?) -> Void 79 | @available(*, deprecated) 80 | public typealias AsyncButtonTaskEndedHandler = @MainActor @Sendable () -> Void 81 | @available(*, deprecated) 82 | public typealias AsyncButtonErrorHandler = @MainActor @Sendable (Error) -> Void 83 | 84 | extension View { 85 | @available(*, deprecated, message: "use onButtonStateChange instead") 86 | public func asyncButtonTaskStarted(_ handler: @escaping AsyncButtonTaskStartedHandler) -> some View { 87 | modifier(OnButtonLatestStateChangeModifier { event in 88 | if let task = event?.state.task { 89 | handler(task) 90 | } 91 | }) 92 | } 93 | 94 | @available(*, deprecated, message: "use onButtonStateChange instead") 95 | public func asyncButtonTaskChanged(_ handler: @escaping AsyncButtonTaskChangedHandler) -> some View { 96 | modifier(OnButtonLatestStateChangeModifier { event in 97 | if let event { 98 | handler(event.state.task) 99 | } 100 | }) 101 | } 102 | 103 | @available(*, deprecated, message: "use onButtonStateChange instead") 104 | public func asyncButtonTaskEnded(_ handler: @escaping AsyncButtonTaskEndedHandler) -> some View { 105 | modifier(OnButtonLatestStateChangeModifier { event in 106 | if let event, event.state.task == nil { 107 | handler() 108 | } 109 | }) 110 | } 111 | 112 | @available(*, deprecated, message: "use onButtonStateError or onButtonStateChange instead") 113 | public func onButtonError(_ handler: @escaping AsyncButtonErrorHandler) -> some View { 114 | modifier(OnButtonLatestStateChangeModifier { event in 115 | if case let .ended(.errored(error, _)) = event?.state { 116 | handler(error) 117 | } 118 | }) 119 | } 120 | } 121 | 122 | // MARK: - Internal implementation 123 | 124 | typealias OptionalButtonStateChangedHandler = @MainActor @Sendable (StateChangedEvent?) -> Void 125 | 126 | struct ButtonLatestStatePreferenceKey: PreferenceKey { 127 | static let defaultValue: StateChangedEvent? = nil 128 | 129 | static func reduce(value: inout StateChangedEvent?, nextValue: () -> StateChangedEvent?) { 130 | guard let next = nextValue() else { 131 | return 132 | } 133 | if value == nil || next.time > value!.time { 134 | value = next 135 | } 136 | } 137 | } 138 | 139 | struct OnButtonLatestStateChangeModifier: ViewModifier { 140 | let handler: OptionalButtonStateChangedHandler 141 | 142 | init(handler: @escaping OptionalButtonStateChangedHandler) { 143 | self.handler = handler 144 | } 145 | 146 | func body(content: Content) -> some View { 147 | content 148 | .onPreferenceChange(ButtonLatestStatePreferenceKey.self) { state in 149 | MainActor.assumeIsolated { 150 | handler(state) 151 | } 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.10+ (Xcode 15.3+) 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.7.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 | You can monitor when one of your buttons fails 48 | ```swift 49 | Group { 50 | AsyncButton(id: "Button 1") { 51 | ... 52 | } 53 | AsyncButton(id: "Button 2") { 54 | ... 55 | } 56 | } 57 | .onButtonStateError { event in 58 | // event.error will contain the Swift Error 59 | // event.buttonID will contain either "button 1" or "button 2" 60 | // if id parameter is omitted, a UUID is generated for the button. 61 | } 62 | ``` 63 | 64 | When the button closure throws, the button will shake by default 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 |
No preview
.throwableButtonStyle(.shake).throwableButtonStyle(.symbolEffect(.wiggle))
76 | 77 | You can still disable it by passing `.none` to throwableButtonStyle: 78 | 79 | ```swift 80 | AsyncButton { 81 | try doSomethingThatCanFail() 82 | } label { 83 | Text("Do something") 84 | } 85 | .throwableButtonStyle(.none) 86 | ``` 87 | 88 | You can also bring your own behavior using the `ThrowableButtonStyle` protocol. 89 | 90 | In ThrowableButtonStyle, you can implement `makeLabel`, `makeButton` or both to alter the button look and behavior. 91 | 92 | ```swift 93 | public struct TryAgainThrowableButtonStyle: ThrowableButtonStyle { 94 | public init() {} 95 | 96 | public func makeLabel(configuration: LabelConfiguration) -> some View { 97 | if configuration.errorCount > 0 { 98 | Text("Try again!") 99 | } else { 100 | configuration.label 101 | } 102 | } 103 | } 104 | 105 | extension ThrowableButtonStyle where Self == TryAgainThrowableButtonStyle { 106 | public static var tryAgain: TryAgainThrowableButtonStyle { 107 | TryAgainThrowableButtonStyle() 108 | } 109 | } 110 | ``` 111 | 112 | Then, use it: 113 | ```swift 114 | AsyncButton { 115 | try doSomethingThatCanFail() 116 | } label { 117 | Text("Do something") 118 | } 119 | .throwableButtonStyle(.tryAgain) 120 | ``` 121 | 122 | ### Asynchronous 123 | 124 | Use it as any SwiftUI button, but the closure will support both try and await. 125 | 126 | ```swift 127 | AsyncButton { 128 | try await doSomethingThatTakeTime() 129 | } label { 130 | Text("Do something") 131 | } 132 | ``` 133 | 134 | 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. 135 | You can disable the button on loading using `disabledWhenLoading` modifier. 136 | ```swift 137 | AsyncButton { 138 | ... 139 | } 140 | .disabledWhenLoading() 141 | ``` 142 | 143 | You can also disable hitTesting when loading with `allowsHitTestingWhenLoading` modifier. 144 | ```swift 145 | AsyncButton { 146 | ... 147 | } 148 | .allowsHitTestingWhenLoading(false) 149 | ``` 150 | 151 | Access and react to the underlying button state using `onStateChange` parameter. 152 | ```swift 153 | AsyncButton { 154 | ... 155 | } onStateChange: { state in 156 | switch state { 157 | case let .started(task): 158 | // Task started 159 | case let .ended(completion): 160 | // Task ended, failed or was cancelled 161 | } 162 | } 163 | ``` 164 | 165 | You can also monitor more than one button at once 166 | ```swift 167 | Group { 168 | AsyncButton(id: "Button 1") { 169 | ... 170 | } 171 | AsyncButton(id: "Button 2") { 172 | ... 173 | } 174 | } 175 | .onButtonStateChange { event in 176 | // event.state will contain the actual state of the button 177 | // event.buttonID will contain either "button 1" or "button 2" 178 | // if id parameter is omitted, a UUID is generated for the button. 179 | } 180 | ``` 181 | 182 | While the progress is loading, the button will animate, defaulting by replacing the label of the button with a `ProgressView`. 183 | All sort of styles are built-in: 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 |
.asyncButtonStyle(.overlay).asyncButtonStyle(.pulse)
.asyncButtonStyle(.leading).asyncButtonStyle(.trailing)
No preview
.asyncButtonStyle(.symbolEffect(.bounce))
211 | 212 | You can disable this behavior by passing `.none` to `asyncButtonStyle` 213 | ```swift 214 | AsyncButton { 215 | try await doSomethingThatTakeTime() 216 | } label { 217 | Text("Do something") 218 | } 219 | .asyncButtonStyle(.none) 220 | ``` 221 | 222 | You can also build your own customization by implementing `AsyncButtonStyle` protocol. 223 | 224 | 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. 225 | 226 | ### External triggering 227 | 228 | You might need to trigger the behavior behind a button with specific user actions, like when pressing the "Send" key on the virtual keyboard. 229 | 230 | 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. 231 | 232 | To do so, you need to set a unique identifier to your button: 233 | 234 | ```swift 235 | enum LoginViewButton: Hashable { 236 | case login 237 | } 238 | 239 | struct ContentView: View { 240 | var body: some View { 241 | AsyncButton(id: LoginViewButton.login) { 242 | try await login() 243 | } label: { 244 | Text("Login") 245 | } 246 | } 247 | } 248 | ``` 249 | 250 | And from any view, access the triggerButton environment: 251 | 252 | ```swift 253 | struct ContentView: View { 254 | @Environment(\.triggerButton) 255 | private var triggerButton 256 | 257 | ... 258 | 259 | func performLogin() { 260 | triggerButton(LoginViewButton.login) 261 | } 262 | } 263 | ``` 264 | 265 | Note that: 266 | - The button **Must be on screen** to trigger it using this method. 267 | - If the triggered button is disabled, calling triggerButton will have no effect 268 | - If a task has already started on the triggered button, calling triggerButton will have no effect 269 | 270 | ### Deterministic progress 271 | 272 | AsyncButton supports progress reporting: 273 | 274 | ```swift 275 | AsyncButton(progress: .discrete(totalUnitCount: files.count)) { progress in 276 | for file in files { 277 | try await file.doExpensiveComputation() 278 | progress.completedUnitCount += 1 279 | } 280 | } label: { 281 | Text("Process") 282 | } 283 | .buttonStyle(.borderedProminent) 284 | .buttonBorderShape(.roundedRectangle) 285 | ``` 286 | 287 | `AsyncButtonStyle` now also supports determinate progress as well, responding to `configuration.fractionCompleted: Double?` property: 288 | 289 | ```swift 290 | AsyncButton(progress: .discrete(totalUnitCount: files.count)) { progress in 291 | for file in files { 292 | try await file.doExpensiveComputation() 293 | progress.completedUnitCount += 1 294 | } 295 | } label: { 296 | Text("Process") 297 | } 298 | .buttonStyle(.borderedProminent) 299 | .buttonBorderShape(.roundedRectangle) 300 | .asyncButtonStyle(.trailing) 301 | ``` 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 |
.asyncButtonStyle(.overlay).asyncButtonStyle(.overlay(style: .percent))
.asyncButtonStyle(.leading).asyncButtonStyle(.trailing)
321 | 322 | You can also create your own progression logic by implementing the `TaskProgress` protocol. 323 | 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) 324 | 325 | Available TaskProgress implementation are: 326 | - Indeterminate, default non-determinant progress with `.indeterminate` 327 | - Discrete linear (completed / total) with `.discrete(totalUnitsCount: Int)` 328 | - Estimated progress that fill the bar in the provided time interval, stopping at 85% to simulate determinant loading with `.estimated(for: Duration)` 329 | - (NS)Progress bridge with `.progress` 330 | 331 | ## Contribute 332 | 333 | You are encouraged to contribute to this repository, by opening issues, or pull requests for bug fixes, improvement requests, or support. Suggestions for contributions: 334 | 335 | - Improving documentation 336 | - Adding some automated tests 😜 337 | - Helping me out to remove/improve all the type erasure stuff if possible? 338 | - Adding some new built-in styles, options or properties for more use cases 339 | -------------------------------------------------------------------------------- /Sources/ButtonKit/Button.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Button.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 | public enum AsyncButtonCompletion: Equatable { 34 | case completed 35 | case cancelled 36 | case errored(error: Error, numberOfFailures: Int) 37 | 38 | public static func ==(lhs: AsyncButtonCompletion, rhs: AsyncButtonCompletion) -> Bool { 39 | switch (lhs, rhs) { 40 | case (.completed, .completed): true 41 | case (.cancelled, .cancelled): true 42 | case let (.errored(_, lhs), .errored(_, rhs)): lhs == rhs 43 | default: false 44 | } 45 | } 46 | } 47 | 48 | @MainActor 49 | public enum AsyncButtonState: Equatable { 50 | case started(Task) 51 | case ended(AsyncButtonCompletion) 52 | 53 | mutating func cancel() { 54 | switch self { 55 | case let .started(task): 56 | task.cancel() 57 | self = .ended(.cancelled) 58 | default: 59 | break 60 | } 61 | } 62 | 63 | public var isLoading: Bool { 64 | switch self { 65 | case .started: 66 | return true 67 | case .ended: 68 | return false 69 | } 70 | } 71 | 72 | public var error: Error? { 73 | switch self { 74 | case let .ended(.errored(error, _)): error 75 | default: nil 76 | } 77 | } 78 | 79 | @available(*, deprecated) 80 | var task: Task? { 81 | switch self { 82 | case let .started(task): task 83 | default : nil 84 | } 85 | } 86 | } 87 | 88 | public struct AsyncButton: View { 89 | @Environment(\.asyncButtonStyle) 90 | private var asyncButtonStyle 91 | @Environment(\.allowsHitTestingWhenLoading) 92 | private var allowsHitTestingWhenLoading 93 | @Environment(\.disabledWhenLoading) 94 | private var disabledWhenLoading 95 | @Environment(\.isEnabled) 96 | private var isEnabled 97 | @Environment(\.throwableButtonStyle) 98 | private var throwableButtonStyle 99 | @Environment(\.triggerButton) 100 | private var triggerButton 101 | 102 | private let role: ButtonRole? 103 | private let id: AnyHashable? 104 | private let action: @MainActor (P) async throws -> Void 105 | private let label: S 106 | private let onStateChange: (@MainActor (AsyncButtonState) -> Void)? 107 | 108 | // Environmnent lies when called from triggerButton 109 | // Let's copy it in our own State :) 110 | @State private var isDisabled = false 111 | @State private var uuid = UUID() 112 | @State private var state: AsyncButtonState? = nil 113 | @ObservedObject private var progress: P 114 | @State private var numberOfFailures = 0 115 | @State private var latestError: Error? 116 | 117 | public var body: some View { 118 | let throwableLabelConfiguration = ThrowableButtonStyleLabelConfiguration( 119 | label: AnyView(label), 120 | latestError: latestError, 121 | numberOfFailures: numberOfFailures 122 | ) 123 | let label: AnyView 124 | let asyncLabelConfiguration = AsyncButtonStyleLabelConfiguration( 125 | label: AnyView(throwableButtonStyle.makeLabel(configuration: throwableLabelConfiguration)), 126 | isLoading: state?.isLoading ?? false, 127 | fractionCompleted: progress.fractionCompleted, 128 | cancel: cancel 129 | ) 130 | label = asyncButtonStyle.makeLabel(configuration: asyncLabelConfiguration) 131 | let button = Button(role: role, action: perform) { 132 | label 133 | } 134 | let throwableConfiguration = ThrowableButtonStyleButtonConfiguration( 135 | button: AnyView(button), 136 | latestError: latestError, 137 | numberOfFailures: numberOfFailures 138 | ) 139 | let asyncConfiguration = AsyncButtonStyleButtonConfiguration( 140 | button: AnyView(throwableButtonStyle.makeButton(configuration: throwableConfiguration)), 141 | isLoading: state?.isLoading ?? false, 142 | fractionCompleted: progress.fractionCompleted, 143 | cancel: cancel 144 | ) 145 | return asyncButtonStyle 146 | .makeButton(configuration: asyncConfiguration) 147 | .allowsHitTesting(allowsHitTestingWhenLoading || !(state?.isLoading ?? false)) 148 | .disabled(disabledWhenLoading && (state?.isLoading ?? false)) 149 | .preference( 150 | key: ButtonLatestStatePreferenceKey.self, 151 | value: state.flatMap { .init(buttonID: id ?? uuid as AnyHashable, state: $0) } 152 | ) 153 | .onAppear { 154 | isDisabled = !isEnabled 155 | guard let id else { 156 | return 157 | } 158 | triggerButton.register(id: id, action: perform) 159 | } 160 | .onDisappear { 161 | guard let id else { 162 | return 163 | } 164 | triggerButton.unregister(id: id) 165 | } 166 | .onChange(of: state) { newState in 167 | guard let newState else { 168 | return 169 | } 170 | onStateChange?(newState) 171 | } 172 | .onChange(of: isEnabled) { newValue in 173 | isDisabled = !newValue 174 | } 175 | } 176 | 177 | public init( 178 | role: ButtonRole? = nil, 179 | id: AnyHashable? = nil, 180 | progress: P, 181 | action: @MainActor @escaping (P) async throws -> Void, 182 | @ViewBuilder label: @escaping () -> S, 183 | onStateChange: (@MainActor (AsyncButtonState) -> Void)? = nil 184 | ) { 185 | self.role = role 186 | self.id = id 187 | self._progress = .init(initialValue: progress) 188 | self.action = action 189 | self.label = label() 190 | self.onStateChange = onStateChange 191 | } 192 | 193 | private func perform() { 194 | guard !(state?.isLoading ?? false), !isDisabled else { 195 | return 196 | } 197 | state = .started(Task { 198 | // Initialize progress 199 | progress.reset() 200 | await progress.started() 201 | let completion: AsyncButtonCompletion 202 | do { 203 | try await action(progress) 204 | completion = .completed 205 | } catch { 206 | latestError = error 207 | numberOfFailures += 1 208 | completion = .errored(error: error, numberOfFailures: numberOfFailures) 209 | } 210 | // Reset progress 211 | await progress.ended() 212 | state = .ended(completion) 213 | }) 214 | } 215 | 216 | private func cancel() { 217 | state?.cancel() 218 | } 219 | } 220 | 221 | extension AsyncButton where S == Text { 222 | public init( 223 | _ titleKey: LocalizedStringKey, 224 | role: ButtonRole? = nil, 225 | id: AnyHashable? = nil, 226 | progress: P, 227 | action: @MainActor @escaping (P) async throws -> Void, 228 | onStateChange: (@MainActor (AsyncButtonState) -> Void)? = nil 229 | ) { 230 | self.role = role 231 | self.id = id 232 | self._progress = .init(initialValue: progress) 233 | self.action = action 234 | self.label = Text(titleKey) 235 | self.onStateChange = onStateChange 236 | } 237 | 238 | @_disfavoredOverload 239 | public init( 240 | _ title: some StringProtocol, 241 | role: ButtonRole? = nil, 242 | id: AnyHashable? = nil, 243 | progress: P, 244 | action: @MainActor @escaping (P) async throws -> Void, 245 | onStateChange: (@MainActor (AsyncButtonState) -> Void)? = nil 246 | ) { 247 | self.role = role 248 | self.id = id 249 | self._progress = .init(initialValue: progress) 250 | self.action = action 251 | self.label = Text(title) 252 | self.onStateChange = onStateChange 253 | } 254 | } 255 | 256 | extension AsyncButton where S == Label { 257 | public init( 258 | _ titleKey: LocalizedStringKey, 259 | image name: String, 260 | role: ButtonRole? = nil, 261 | id: AnyHashable? = nil, 262 | progress: P, 263 | action: @MainActor @escaping (P) async throws -> Void, 264 | onStateChange: (@MainActor (AsyncButtonState) -> Void)? = nil 265 | ) { 266 | self.role = role 267 | self.id = id 268 | self._progress = .init(initialValue: progress) 269 | self.action = action 270 | self.label = Label(titleKey, image: name) 271 | self.onStateChange = onStateChange 272 | } 273 | 274 | @_disfavoredOverload 275 | public init( 276 | _ title: some StringProtocol, 277 | image name: String, 278 | role: ButtonRole? = nil, 279 | id: AnyHashable? = nil, 280 | progress: P, 281 | action: @MainActor @escaping (P) async throws -> Void, 282 | onStateChange: (@MainActor (AsyncButtonState) -> Void)? = nil 283 | ) { 284 | self.role = role 285 | self.id = id 286 | self._progress = .init(initialValue: progress) 287 | self.action = action 288 | self.label = Label(title, image: name) 289 | self.onStateChange = onStateChange 290 | } 291 | 292 | @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) 293 | public init( 294 | _ titleKey: LocalizedStringKey, 295 | image: ImageResource, 296 | role: ButtonRole? = nil, 297 | id: AnyHashable? = nil, 298 | progress: P, 299 | action: @MainActor @escaping (P) async throws -> Void, 300 | onStateChange: (@MainActor (AsyncButtonState) -> Void)? = nil 301 | ) { 302 | self.role = role 303 | self.id = id 304 | self._progress = .init(initialValue: progress) 305 | self.action = action 306 | self.label = Label(titleKey, image: image) 307 | self.onStateChange = onStateChange 308 | } 309 | 310 | @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) 311 | @_disfavoredOverload 312 | public init( 313 | _ title: some StringProtocol, 314 | image: ImageResource, 315 | role: ButtonRole? = nil, 316 | id: AnyHashable? = nil, 317 | progress: P, 318 | action: @MainActor @escaping (P) async throws -> Void, 319 | onStateChange: (@MainActor (AsyncButtonState) -> Void)? = nil 320 | ) { 321 | self.role = role 322 | self.id = id 323 | self._progress = .init(initialValue: progress) 324 | self.action = action 325 | self.label = Label(title, image: image) 326 | self.onStateChange = onStateChange 327 | } 328 | 329 | public init( 330 | _ titleKey: LocalizedStringKey, 331 | systemImage: String, 332 | role: ButtonRole? = nil, 333 | id: AnyHashable? = nil, 334 | progress: P, 335 | action: @MainActor @escaping (P) async throws -> Void, 336 | onStateChange: (@MainActor (AsyncButtonState) -> Void)? = nil 337 | ) { 338 | self.role = role 339 | self.id = id 340 | self._progress = .init(initialValue: progress) 341 | self.action = action 342 | self.label = Label(titleKey, systemImage: systemImage) 343 | self.onStateChange = onStateChange 344 | } 345 | 346 | @_disfavoredOverload 347 | public init( 348 | _ title: some StringProtocol, 349 | systemImage: String, 350 | role: ButtonRole? = nil, 351 | id: AnyHashable? = nil, 352 | progress: P, 353 | action: @MainActor @escaping (P) async throws -> Void, 354 | onStateChange: (@MainActor (AsyncButtonState) -> Void)? = nil 355 | ) { 356 | self.role = role 357 | self.id = id 358 | self._progress = .init(initialValue: progress) 359 | self.action = action 360 | self.label = Label(title, systemImage: systemImage) 361 | self.onStateChange = onStateChange 362 | } 363 | } 364 | 365 | extension AsyncButton where P == IndeterminateProgress { 366 | public init( 367 | role: ButtonRole? = nil, 368 | id: AnyHashable? = nil, 369 | action: @escaping () async throws -> Void, 370 | @ViewBuilder label: @escaping () -> S, 371 | onStateChange: (@MainActor (AsyncButtonState) -> Void)? = nil 372 | ) { 373 | self.role = role 374 | self.id = id 375 | self._progress = .init(initialValue: .indeterminate) 376 | self.action = { _ in try await action()} 377 | self.label = label() 378 | self.onStateChange = onStateChange 379 | } 380 | } 381 | 382 | extension AsyncButton where P == IndeterminateProgress, S == Text { 383 | public init( 384 | _ titleKey: LocalizedStringKey, 385 | role: ButtonRole? = nil, 386 | id: AnyHashable? = nil, 387 | action: @escaping () async throws -> Void, 388 | onStateChange: (@MainActor (AsyncButtonState) -> Void)? = nil 389 | ) { 390 | self.role = role 391 | self.id = id 392 | self._progress = .init(initialValue: .indeterminate) 393 | self.action = { _ in try await action()} 394 | self.label = Text(titleKey) 395 | self.onStateChange = onStateChange 396 | } 397 | 398 | @_disfavoredOverload 399 | public init( 400 | _ title: some StringProtocol, 401 | role: ButtonRole? = nil, 402 | id: AnyHashable? = nil, 403 | action: @escaping () async throws -> Void, 404 | onStateChange: (@MainActor (AsyncButtonState) -> Void)? = nil 405 | ) { 406 | self.role = role 407 | self.id = id 408 | self._progress = .init(initialValue: .indeterminate) 409 | self.action = { _ in try await action()} 410 | self.label = Text(title) 411 | self.onStateChange = onStateChange 412 | } 413 | } 414 | 415 | extension AsyncButton where P == IndeterminateProgress, S == Label { 416 | public init( 417 | _ titleKey: LocalizedStringKey, 418 | image name: String, 419 | role: ButtonRole? = nil, 420 | id: AnyHashable? = nil, 421 | action: @escaping () async throws -> Void, 422 | onStateChange: (@MainActor (AsyncButtonState) -> Void)? = nil 423 | ) { 424 | self.role = role 425 | self.id = id 426 | self._progress = .init(initialValue: .indeterminate) 427 | self.action = { _ in try await action()} 428 | self.label = Label(titleKey, image: name) 429 | self.onStateChange = onStateChange 430 | } 431 | 432 | @_disfavoredOverload 433 | public init( 434 | _ title: some StringProtocol, 435 | image name: String, 436 | role: ButtonRole? = nil, 437 | id: AnyHashable? = nil, 438 | action: @escaping () async throws -> Void, 439 | onStateChange: (@MainActor (AsyncButtonState) -> Void)? = nil 440 | ) { 441 | self.role = role 442 | self.id = id 443 | self._progress = .init(initialValue: .indeterminate) 444 | self.action = { _ in try await action()} 445 | self.label = Label(title, image: name) 446 | self.onStateChange = onStateChange 447 | } 448 | 449 | @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) 450 | public init( 451 | _ titleKey: LocalizedStringKey, 452 | image: ImageResource, 453 | role: ButtonRole? = nil, 454 | id: AnyHashable? = nil, 455 | action: @escaping () async throws -> Void, 456 | onStateChange: (@MainActor (AsyncButtonState) -> Void)? = nil 457 | ) { 458 | self.role = role 459 | self.id = id 460 | self._progress = .init(initialValue: .indeterminate) 461 | self.action = { _ in try await action()} 462 | self.label = Label(titleKey, image: image) 463 | self.onStateChange = onStateChange 464 | } 465 | 466 | @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) 467 | @_disfavoredOverload 468 | public init( 469 | _ title: some StringProtocol, 470 | image: ImageResource, 471 | role: ButtonRole? = nil, 472 | id: AnyHashable? = nil, 473 | action: @escaping () async throws -> Void, 474 | onStateChange: (@MainActor (AsyncButtonState) -> Void)? = nil 475 | ) { 476 | self.role = role 477 | self.id = id 478 | self._progress = .init(initialValue: .indeterminate) 479 | self.action = { _ in try await action()} 480 | self.label = Label(title, image: image) 481 | self.onStateChange = onStateChange 482 | } 483 | 484 | public init( 485 | _ titleKey: LocalizedStringKey, 486 | systemImage: String, 487 | role: ButtonRole? = nil, 488 | id: AnyHashable? = nil, 489 | action: @escaping () async throws -> Void, 490 | onStateChange: (@MainActor (AsyncButtonState) -> Void)? = nil 491 | ) { 492 | self.role = role 493 | self.id = id 494 | self._progress = .init(initialValue: .indeterminate) 495 | self.action = { _ in try await action()} 496 | self.label = Label(titleKey, systemImage: systemImage) 497 | self.onStateChange = onStateChange 498 | } 499 | 500 | @_disfavoredOverload 501 | public init( 502 | _ title: some StringProtocol, 503 | systemImage: String, 504 | role: ButtonRole? = nil, 505 | id: AnyHashable? = nil, 506 | action: @escaping () async throws -> Void, 507 | onStateChange: (@MainActor (AsyncButtonState) -> Void)? = nil 508 | ) { 509 | self.role = role 510 | self.id = id 511 | self._progress = .init(initialValue: .indeterminate) 512 | self.action = { _ in try await action()} 513 | self.label = Label(title, systemImage: systemImage) 514 | self.onStateChange = onStateChange 515 | } 516 | } 517 | 518 | #Preview("Indeterminate") { 519 | AsyncButton { 520 | try? await Task.sleep(nanoseconds: 1_000_000_000) 521 | } label: { 522 | Text("Process") 523 | } 524 | .buttonStyle(.borderedProminent) 525 | .buttonBorderShape(.roundedRectangle) 526 | } 527 | 528 | #Preview("Determinate") { 529 | AsyncButton(progress: .discrete(totalUnitCount: 100)) { progress in 530 | for _ in 1...100 { 531 | try await Task.sleep(nanoseconds: 20_000_000) 532 | progress.completedUnitCount += 1 533 | } 534 | } label: { 535 | Text("Process") 536 | } 537 | .buttonStyle(.borderedProminent) 538 | .buttonBorderShape(.roundedRectangle) 539 | } 540 | 541 | #Preview("Indeterminate error") { 542 | AsyncButton { 543 | try await Task.sleep(nanoseconds: 1_000_000_000) 544 | throw NSError() as Error 545 | } label: { 546 | Text("Process") 547 | } 548 | .buttonStyle(.borderedProminent) 549 | .buttonBorderShape(.roundedRectangle) 550 | .asyncButtonStyle(.overlay) 551 | .throwableButtonStyle(.shake) 552 | } 553 | 554 | #Preview("Determinate error") { 555 | AsyncButton(progress: .discrete(totalUnitCount: 100)) { progress in 556 | for _ in 1...42 { 557 | try await Task.sleep(nanoseconds: 20_000_000) 558 | progress.completedUnitCount += 1 559 | } 560 | throw NSError() as Error 561 | } label: { 562 | Text("Process") 563 | } 564 | .buttonStyle(.borderedProminent) 565 | .buttonBorderShape(.roundedRectangle) 566 | } 567 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------