From f0d56bf1ff3b573fba024751d651d802241f423d Mon Sep 17 00:00:00 2001 From: Thomas Durand Date: Mon, 13 Oct 2025 23:28:47 +0200 Subject: [PATCH 1/4] chore!: stop supporting Swift 5.9 --- ...wift-5.9.swift => Package@swift-5.10.swift | 2 +- README.md | 2 +- Sources/ButtonKit/Button+Reader.swift | 80 +++++++++++++++++++ .../{Button+Async.swift => Button.swift} | 4 +- .../ButtonKit/Modifiers/Button+Events.swift | 17 +++- .../Style/Async/AsyncStyle+SymbolEffect.swift | 72 +++++++++-------- .../ThrowableStyle+SymbolEffect.swift | 37 ++++----- 7 files changed, 155 insertions(+), 59 deletions(-) rename Package@swift-5.9.swift => Package@swift-5.10.swift (94%) create mode 100644 Sources/ButtonKit/Button+Reader.swift rename Sources/ButtonKit/{Button+Async.swift => Button.swift} (99%) diff --git a/Package@swift-5.9.swift b/Package@swift-5.10.swift similarity index 94% rename from Package@swift-5.9.swift rename to Package@swift-5.10.swift index cc98c6b..089b90f 100644 --- a/Package@swift-5.9.swift +++ b/Package@swift-5.10.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.9 +// swift-tools-version: 5.10 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription diff --git a/README.md b/README.md index 2dbe959..1d42091 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ With ButtonKit, you'll have access to an `AsyncButton` view, accepting a `() asy ## Requirements -- Swift 5.9+ (Xcode 15+) +- Swift 5.10+ (Xcode 15.3+) - iOS 15+, iPadOS 15+, tvOS 15+, watchOS 8+, macOS 12+, visionOS 1+ ## Installation diff --git a/Sources/ButtonKit/Button+Reader.swift b/Sources/ButtonKit/Button+Reader.swift new file mode 100644 index 0000000..40dc629 --- /dev/null +++ b/Sources/ButtonKit/Button+Reader.swift @@ -0,0 +1,80 @@ +// +// Button+Reader.swift +// ButtonKit +// +// MIT License +// +// Copyright (c) 2025 Thomas Durand +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import SwiftUI + +public struct AsyncButtonReader: View { + @State var latestEvent: StateChangedEvent? = nil + let content: (StateChangedEvent?) -> Content + + public var body: some View { + content(latestEvent) + .onButtonStateChange { latestEvent = $0 } + } + + public init(@ViewBuilder content: @escaping (StateChangedEvent?) -> Content) { + self.content = content + } +} + +#Preview { + AsyncButtonReader { event in + Group { + AsyncButton("I succeed") { + try await Task.sleep(nanoseconds: 2_000_000_000) + } + + AsyncButton("I fail") { + throw NSError() + } + + AsyncButton("I'm random") { + try await Task.sleep(nanoseconds: 2_000_000_000) + if Bool.random() { + throw NSError() + } + } + } + .buttonStyle(.bordered) + + if let event { + VStack { + Text(verbatim: "Button \(event.buttonID)") + switch event.state { + case .started: + Text("In Progress") + case .ended(.completed): + Text("Completed") + case .ended(.cancelled): + Text("Cancelled") + case let .ended(.errored(_, count)): + Text("Errored \(count) time(s)") + } + } + } + } +} diff --git a/Sources/ButtonKit/Button+Async.swift b/Sources/ButtonKit/Button.swift similarity index 99% rename from Sources/ButtonKit/Button+Async.swift rename to Sources/ButtonKit/Button.swift index c3cc9e0..996b661 100644 --- a/Sources/ButtonKit/Button+Async.swift +++ b/Sources/ButtonKit/Button.swift @@ -1,5 +1,5 @@ // -// Button+Async.swift +// Button.swift // ButtonKit // // MIT License @@ -100,7 +100,6 @@ public struct AsyncButton: View { private var triggerButton private let role: ButtonRole? - private let uuid = UUID() private let id: AnyHashable? private let action: @MainActor (P) async throws -> Void private let label: S @@ -109,6 +108,7 @@ public struct AsyncButton: View { // Environmnent lies when called from triggerButton // Let's copy it in our own State :) @State private var isDisabled = false + @State private var uuid = UUID() @State private var state: AsyncButtonState? = nil @ObservedObject private var progress: P @State private var numberOfFailures = 0 diff --git a/Sources/ButtonKit/Modifiers/Button+Events.swift b/Sources/ButtonKit/Modifiers/Button+Events.swift index 568ad4e..0f91724 100644 --- a/Sources/ButtonKit/Modifiers/Button+Events.swift +++ b/Sources/ButtonKit/Modifiers/Button+Events.swift @@ -32,12 +32,25 @@ import SwiftUI public typealias ButtonStateChangedHandler = @MainActor @Sendable (StateChangedEvent) -> Void public typealias ButtonStateErrorHandler = @MainActor @Sendable (ErrorOccurredEvent) -> Void +#if swift(>=6.2) @MainActor public struct StateChangedEvent: @MainActor Equatable { public let buttonID: AnyHashable public let state: AsyncButtonState let time: Date = .now } +#else +@MainActor +public struct StateChangedEvent: @preconcurrency Equatable { + public let buttonID: AnyHashable + public let state: AsyncButtonState + let time: Date = .now + + public static func ==(lhs: StateChangedEvent, rhs: StateChangedEvent) -> Bool { + lhs.buttonID == rhs.buttonID && lhs.state == rhs.state && lhs.time == rhs.time + } +} +#endif public struct ErrorOccurredEvent { public let buttonID: AnyHashable @@ -138,13 +151,9 @@ struct OnButtonLatestStateChangeModifier: ViewModifier { func body(content: Content) -> some View { content .onPreferenceChange(ButtonLatestStatePreferenceKey.self) { state in - #if swift(>=5.10) MainActor.assumeIsolated { handler(state) } - #else - handler(state) - #endif } } } diff --git a/Sources/ButtonKit/Style/Async/AsyncStyle+SymbolEffect.swift b/Sources/ButtonKit/Style/Async/AsyncStyle+SymbolEffect.swift index 10f7d80..b4aee47 100644 --- a/Sources/ButtonKit/Style/Async/AsyncStyle+SymbolEffect.swift +++ b/Sources/ButtonKit/Style/Async/AsyncStyle+SymbolEffect.swift @@ -1,5 +1,5 @@ // -// ThrowableStyle+Shake.swift +// AsyncStyle+SymbolEffect.swift // ButtonKit // // MIT License @@ -27,7 +27,7 @@ import SwiftUI -@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) public struct SymbolEffectAsyncButtonStyle: AsyncButtonStyle { let effect: Effect @@ -37,74 +37,80 @@ public struct SymbolEffectAsyncButtonStyle { public static func symbolEffect(_ effect: AppearSymbolEffect) -> some AsyncButtonStyle { SymbolEffectAsyncButtonStyle(effect: effect) } } -@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, *) -extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { - public static func symbolEffect(_ effect: BounceSymbolEffect) -> some AsyncButtonStyle { +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) +extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { + public static func symbolEffect(_ effect: DisappearSymbolEffect) -> some AsyncButtonStyle { SymbolEffectAsyncButtonStyle(effect: effect) } } -@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, *) -extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { - public static func symbolEffect(_ effect: BreatheSymbolEffect) -> some AsyncButtonStyle { +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) +extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { + public static func symbolEffect(_ effect: PulseSymbolEffect) -> some AsyncButtonStyle { SymbolEffectAsyncButtonStyle(effect: effect) } } -@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) -extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { - public static func symbolEffect(_ effect: DisappearSymbolEffect) -> some AsyncButtonStyle { +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) +extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { + public static func symbolEffect(_ effect: ScaleSymbolEffect) -> some AsyncButtonStyle { SymbolEffectAsyncButtonStyle(effect: effect) } } -@available(iOS 26.0, macOS 26.0, tvOS 26.0, watchOS 26.0, *) -extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { - public static func symbolEffect(_ effect: DrawOffSymbolEffect) -> some AsyncButtonStyle { +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) +extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { + public static func symbolEffect(_ effect: VariableColorSymbolEffect) -> some AsyncButtonStyle { SymbolEffectAsyncButtonStyle(effect: effect) } } -@available(iOS 26.0, macOS 26.0, tvOS 26.0, watchOS 26.0, *) -extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { - public static func symbolEffect(_ effect: DrawOnSymbolEffect) -> some AsyncButtonStyle { + +@available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) +extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { + public static func symbolEffect(_ effect: BounceSymbolEffect) -> some AsyncButtonStyle { SymbolEffectAsyncButtonStyle(effect: effect) } } -@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) -extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { - public static func symbolEffect(_ effect: PulseSymbolEffect) -> some AsyncButtonStyle { +@available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) +extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { + public static func symbolEffect(_ effect: BreatheSymbolEffect) -> some AsyncButtonStyle { SymbolEffectAsyncButtonStyle(effect: effect) } } -@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, *) +@available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { public static func symbolEffect(_ effect: RotateSymbolEffect) -> some AsyncButtonStyle { SymbolEffectAsyncButtonStyle(effect: effect) } } -@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) -extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { - public static func symbolEffect(_ effect: ScaleSymbolEffect) -> some AsyncButtonStyle { +@available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) +extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { + public static func symbolEffect(_ effect: WiggleSymbolEffect) -> some AsyncButtonStyle { SymbolEffectAsyncButtonStyle(effect: effect) } } -@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, *) -extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { - public static func symbolEffect(_ effect: VariableColorSymbolEffect) -> some AsyncButtonStyle { + +#if swift(>=6.2) + +@available(macOS 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, *) +extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { + public static func symbolEffect(_ effect: DrawOffSymbolEffect) -> some AsyncButtonStyle { SymbolEffectAsyncButtonStyle(effect: effect) } } -@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, *) -extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { - public static func symbolEffect(_ effect: WiggleSymbolEffect) -> some AsyncButtonStyle { +@available(macOS 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, *) +extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { + public static func symbolEffect(_ effect: DrawOnSymbolEffect) -> some AsyncButtonStyle { SymbolEffectAsyncButtonStyle(effect: effect) } } -@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, *) +#endif + +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) #Preview("Indeterminate") { AsyncButton { try await Task.sleep(nanoseconds: 30_000_000_000) @@ -112,5 +118,5 @@ extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle: ThrowableButtonStyle { let effect: Effect @@ -37,44 +37,45 @@ public struct SymbolEffectThrowableButtonStyle { public static func symbolEffect(_ effect: BounceSymbolEffect) -> some ThrowableButtonStyle { SymbolEffectThrowableButtonStyle(effect: effect) } } -@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, *) -extension ThrowableButtonStyle where Self == SymbolEffectThrowableButtonStyle { - public static func symbolEffect(_ effect: BreatheSymbolEffect) -> some ThrowableButtonStyle { +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) +extension ThrowableButtonStyle where Self == SymbolEffectThrowableButtonStyle { + public static func symbolEffect(_ effect: PulseSymbolEffect) -> some ThrowableButtonStyle { SymbolEffectThrowableButtonStyle(effect: effect) } } -@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) -extension ThrowableButtonStyle where Self == SymbolEffectThrowableButtonStyle { - public static func symbolEffect(_ effect: PulseSymbolEffect) -> some ThrowableButtonStyle { +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) +extension ThrowableButtonStyle where Self == SymbolEffectThrowableButtonStyle { + public static func symbolEffect(_ effect: VariableColorSymbolEffect) -> some ThrowableButtonStyle { SymbolEffectThrowableButtonStyle(effect: effect) } } -@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, *) -extension ThrowableButtonStyle where Self == SymbolEffectThrowableButtonStyle { - public static func symbolEffect(_ effect: RotateSymbolEffect) -> some ThrowableButtonStyle { + +@available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) +extension ThrowableButtonStyle where Self == SymbolEffectThrowableButtonStyle { + public static func symbolEffect(_ effect: BreatheSymbolEffect) -> some ThrowableButtonStyle { SymbolEffectThrowableButtonStyle(effect: effect) } } -@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, *) -extension ThrowableButtonStyle where Self == SymbolEffectThrowableButtonStyle { - public static func symbolEffect(_ effect: VariableColorSymbolEffect) -> some ThrowableButtonStyle { +@available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) +extension ThrowableButtonStyle where Self == SymbolEffectThrowableButtonStyle { + public static func symbolEffect(_ effect: RotateSymbolEffect) -> some ThrowableButtonStyle { SymbolEffectThrowableButtonStyle(effect: effect) } } -@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, *) +@available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) extension ThrowableButtonStyle where Self == SymbolEffectThrowableButtonStyle { public static func symbolEffect(_ effect: WiggleSymbolEffect) -> some ThrowableButtonStyle { SymbolEffectThrowableButtonStyle(effect: effect) } } -@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, *) +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) #Preview { AsyncButton { throw NSError() as Error @@ -82,5 +83,5 @@ extension ThrowableButtonStyle where Self == SymbolEffectThrowableButtonStyle Date: Tue, 11 Nov 2025 13:26:48 +0100 Subject: [PATCH 2/4] feat: use Task.immediate for task creation to immediately start action --- Sources/ButtonKit/Button.swift | 49 +++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/Sources/ButtonKit/Button.swift b/Sources/ButtonKit/Button.swift index 996b661..5bbc837 100644 --- a/Sources/ButtonKit/Button.swift +++ b/Sources/ButtonKit/Button.swift @@ -194,23 +194,40 @@ public struct AsyncButton: View { guard !(state?.isLoading ?? false), !isDisabled else { return } - state = .started(Task { - // Initialize progress - progress.reset() - await progress.started() - let completion: AsyncButtonCompletion - do { - try await action(progress) - completion = .completed - } catch { - latestError = error - numberOfFailures += 1 - completion = .errored(error: error, numberOfFailures: numberOfFailures) + if #available(iOS 26.0, tvOS 26.0, watchOS 26.0, macOS 26.0, visionOS 26.0, *) { + var immediateTaskEnded = false + let immediateTask = Task.immediate { + let completion = await performAsyncAction() + state = .ended(completion) + immediateTaskEnded = true } - // Reset progress - await progress.ended() - state = .ended(completion) - }) + if !immediateTaskEnded { + state = .started(immediateTask) + } + } else { + state = .started(Task { + let completion = await performAsyncAction() + state = .ended(completion) + }) + } + } + + private func performAsyncAction() async -> AsyncButtonCompletion { + // Initialize progress + progress.reset() + await progress.started() + let completion: AsyncButtonCompletion + do { + try await action(progress) + completion = .completed + } catch { + latestError = error + numberOfFailures += 1 + completion = .errored(error: error, numberOfFailures: numberOfFailures) + } + // Reset progress + await progress.ended() + return completion } private func cancel() { From 5e7dde142622561c7678179dc43fd94c75b3d2a9 Mon Sep 17 00:00:00 2001 From: Thomas Durand Date: Mon, 13 Oct 2025 23:28:47 +0200 Subject: [PATCH 3/4] chore!: stop supporting Swift 5.9 --- ...wift-5.9.swift => Package@swift-5.10.swift | 2 +- README.md | 2 +- Sources/ButtonKit/Button+Reader.swift | 80 +++++++++++++++++++ .../{Button+Async.swift => Button.swift} | 4 +- .../ButtonKit/Modifiers/Button+Events.swift | 17 +++- .../Style/Async/AsyncStyle+SymbolEffect.swift | 73 +++++++++-------- .../ThrowableStyle+SymbolEffect.swift | 38 ++++----- 7 files changed, 157 insertions(+), 59 deletions(-) rename Package@swift-5.9.swift => Package@swift-5.10.swift (94%) create mode 100644 Sources/ButtonKit/Button+Reader.swift rename Sources/ButtonKit/{Button+Async.swift => Button.swift} (99%) diff --git a/Package@swift-5.9.swift b/Package@swift-5.10.swift similarity index 94% rename from Package@swift-5.9.swift rename to Package@swift-5.10.swift index cc98c6b..089b90f 100644 --- a/Package@swift-5.9.swift +++ b/Package@swift-5.10.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.9 +// swift-tools-version: 5.10 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription diff --git a/README.md b/README.md index 2dbe959..1d42091 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ With ButtonKit, you'll have access to an `AsyncButton` view, accepting a `() asy ## Requirements -- Swift 5.9+ (Xcode 15+) +- Swift 5.10+ (Xcode 15.3+) - iOS 15+, iPadOS 15+, tvOS 15+, watchOS 8+, macOS 12+, visionOS 1+ ## Installation diff --git a/Sources/ButtonKit/Button+Reader.swift b/Sources/ButtonKit/Button+Reader.swift new file mode 100644 index 0000000..40dc629 --- /dev/null +++ b/Sources/ButtonKit/Button+Reader.swift @@ -0,0 +1,80 @@ +// +// Button+Reader.swift +// ButtonKit +// +// MIT License +// +// Copyright (c) 2025 Thomas Durand +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import SwiftUI + +public struct AsyncButtonReader: View { + @State var latestEvent: StateChangedEvent? = nil + let content: (StateChangedEvent?) -> Content + + public var body: some View { + content(latestEvent) + .onButtonStateChange { latestEvent = $0 } + } + + public init(@ViewBuilder content: @escaping (StateChangedEvent?) -> Content) { + self.content = content + } +} + +#Preview { + AsyncButtonReader { event in + Group { + AsyncButton("I succeed") { + try await Task.sleep(nanoseconds: 2_000_000_000) + } + + AsyncButton("I fail") { + throw NSError() + } + + AsyncButton("I'm random") { + try await Task.sleep(nanoseconds: 2_000_000_000) + if Bool.random() { + throw NSError() + } + } + } + .buttonStyle(.bordered) + + if let event { + VStack { + Text(verbatim: "Button \(event.buttonID)") + switch event.state { + case .started: + Text("In Progress") + case .ended(.completed): + Text("Completed") + case .ended(.cancelled): + Text("Cancelled") + case let .ended(.errored(_, count)): + Text("Errored \(count) time(s)") + } + } + } + } +} diff --git a/Sources/ButtonKit/Button+Async.swift b/Sources/ButtonKit/Button.swift similarity index 99% rename from Sources/ButtonKit/Button+Async.swift rename to Sources/ButtonKit/Button.swift index c3cc9e0..996b661 100644 --- a/Sources/ButtonKit/Button+Async.swift +++ b/Sources/ButtonKit/Button.swift @@ -1,5 +1,5 @@ // -// Button+Async.swift +// Button.swift // ButtonKit // // MIT License @@ -100,7 +100,6 @@ public struct AsyncButton: View { private var triggerButton private let role: ButtonRole? - private let uuid = UUID() private let id: AnyHashable? private let action: @MainActor (P) async throws -> Void private let label: S @@ -109,6 +108,7 @@ public struct AsyncButton: View { // Environmnent lies when called from triggerButton // Let's copy it in our own State :) @State private var isDisabled = false + @State private var uuid = UUID() @State private var state: AsyncButtonState? = nil @ObservedObject private var progress: P @State private var numberOfFailures = 0 diff --git a/Sources/ButtonKit/Modifiers/Button+Events.swift b/Sources/ButtonKit/Modifiers/Button+Events.swift index 568ad4e..0f91724 100644 --- a/Sources/ButtonKit/Modifiers/Button+Events.swift +++ b/Sources/ButtonKit/Modifiers/Button+Events.swift @@ -32,12 +32,25 @@ import SwiftUI public typealias ButtonStateChangedHandler = @MainActor @Sendable (StateChangedEvent) -> Void public typealias ButtonStateErrorHandler = @MainActor @Sendable (ErrorOccurredEvent) -> Void +#if swift(>=6.2) @MainActor public struct StateChangedEvent: @MainActor Equatable { public let buttonID: AnyHashable public let state: AsyncButtonState let time: Date = .now } +#else +@MainActor +public struct StateChangedEvent: @preconcurrency Equatable { + public let buttonID: AnyHashable + public let state: AsyncButtonState + let time: Date = .now + + public static func ==(lhs: StateChangedEvent, rhs: StateChangedEvent) -> Bool { + lhs.buttonID == rhs.buttonID && lhs.state == rhs.state && lhs.time == rhs.time + } +} +#endif public struct ErrorOccurredEvent { public let buttonID: AnyHashable @@ -138,13 +151,9 @@ struct OnButtonLatestStateChangeModifier: ViewModifier { func body(content: Content) -> some View { content .onPreferenceChange(ButtonLatestStatePreferenceKey.self) { state in - #if swift(>=5.10) MainActor.assumeIsolated { handler(state) } - #else - handler(state) - #endif } } } diff --git a/Sources/ButtonKit/Style/Async/AsyncStyle+SymbolEffect.swift b/Sources/ButtonKit/Style/Async/AsyncStyle+SymbolEffect.swift index 10f7d80..a227d50 100644 --- a/Sources/ButtonKit/Style/Async/AsyncStyle+SymbolEffect.swift +++ b/Sources/ButtonKit/Style/Async/AsyncStyle+SymbolEffect.swift @@ -1,5 +1,5 @@ // -// ThrowableStyle+Shake.swift +// AsyncStyle+SymbolEffect.swift // ButtonKit // // MIT License @@ -25,9 +25,10 @@ // SOFTWARE. // +import Symbols import SwiftUI -@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) public struct SymbolEffectAsyncButtonStyle: AsyncButtonStyle { let effect: Effect @@ -37,74 +38,80 @@ public struct SymbolEffectAsyncButtonStyle { public static func symbolEffect(_ effect: AppearSymbolEffect) -> some AsyncButtonStyle { SymbolEffectAsyncButtonStyle(effect: effect) } } -@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, *) -extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { - public static func symbolEffect(_ effect: BounceSymbolEffect) -> some AsyncButtonStyle { +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) +extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { + public static func symbolEffect(_ effect: DisappearSymbolEffect) -> some AsyncButtonStyle { SymbolEffectAsyncButtonStyle(effect: effect) } } -@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, *) -extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { - public static func symbolEffect(_ effect: BreatheSymbolEffect) -> some AsyncButtonStyle { +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) +extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { + public static func symbolEffect(_ effect: PulseSymbolEffect) -> some AsyncButtonStyle { SymbolEffectAsyncButtonStyle(effect: effect) } } -@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) -extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { - public static func symbolEffect(_ effect: DisappearSymbolEffect) -> some AsyncButtonStyle { +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) +extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { + public static func symbolEffect(_ effect: ScaleSymbolEffect) -> some AsyncButtonStyle { SymbolEffectAsyncButtonStyle(effect: effect) } } -@available(iOS 26.0, macOS 26.0, tvOS 26.0, watchOS 26.0, *) -extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { - public static func symbolEffect(_ effect: DrawOffSymbolEffect) -> some AsyncButtonStyle { +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) +extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { + public static func symbolEffect(_ effect: VariableColorSymbolEffect) -> some AsyncButtonStyle { SymbolEffectAsyncButtonStyle(effect: effect) } } -@available(iOS 26.0, macOS 26.0, tvOS 26.0, watchOS 26.0, *) -extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { - public static func symbolEffect(_ effect: DrawOnSymbolEffect) -> some AsyncButtonStyle { + +@available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) +extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { + public static func symbolEffect(_ effect: BounceSymbolEffect) -> some AsyncButtonStyle { SymbolEffectAsyncButtonStyle(effect: effect) } } -@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) -extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { - public static func symbolEffect(_ effect: PulseSymbolEffect) -> some AsyncButtonStyle { +@available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) +extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { + public static func symbolEffect(_ effect: BreatheSymbolEffect) -> some AsyncButtonStyle { SymbolEffectAsyncButtonStyle(effect: effect) } } -@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, *) +@available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { public static func symbolEffect(_ effect: RotateSymbolEffect) -> some AsyncButtonStyle { SymbolEffectAsyncButtonStyle(effect: effect) } } -@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) -extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { - public static func symbolEffect(_ effect: ScaleSymbolEffect) -> some AsyncButtonStyle { +@available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) +extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { + public static func symbolEffect(_ effect: WiggleSymbolEffect) -> some AsyncButtonStyle { SymbolEffectAsyncButtonStyle(effect: effect) } } -@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, *) -extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { - public static func symbolEffect(_ effect: VariableColorSymbolEffect) -> some AsyncButtonStyle { + +#if swift(>=6.2) + +@available(macOS 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, *) +extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { + public static func symbolEffect(_ effect: DrawOffSymbolEffect) -> some AsyncButtonStyle { SymbolEffectAsyncButtonStyle(effect: effect) } } -@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, *) -extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { - public static func symbolEffect(_ effect: WiggleSymbolEffect) -> some AsyncButtonStyle { +@available(macOS 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, *) +extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle { + public static func symbolEffect(_ effect: DrawOnSymbolEffect) -> some AsyncButtonStyle { SymbolEffectAsyncButtonStyle(effect: effect) } } -@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, *) +#endif + +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) #Preview("Indeterminate") { AsyncButton { try await Task.sleep(nanoseconds: 30_000_000_000) @@ -112,5 +119,5 @@ extension AsyncButtonStyle where Self == SymbolEffectAsyncButtonStyle: ThrowableButtonStyle { let effect: Effect @@ -37,44 +38,45 @@ public struct SymbolEffectThrowableButtonStyle { public static func symbolEffect(_ effect: BounceSymbolEffect) -> some ThrowableButtonStyle { SymbolEffectThrowableButtonStyle(effect: effect) } } -@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, *) -extension ThrowableButtonStyle where Self == SymbolEffectThrowableButtonStyle { - public static func symbolEffect(_ effect: BreatheSymbolEffect) -> some ThrowableButtonStyle { +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) +extension ThrowableButtonStyle where Self == SymbolEffectThrowableButtonStyle { + public static func symbolEffect(_ effect: PulseSymbolEffect) -> some ThrowableButtonStyle { SymbolEffectThrowableButtonStyle(effect: effect) } } -@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) -extension ThrowableButtonStyle where Self == SymbolEffectThrowableButtonStyle { - public static func symbolEffect(_ effect: PulseSymbolEffect) -> some ThrowableButtonStyle { +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) +extension ThrowableButtonStyle where Self == SymbolEffectThrowableButtonStyle { + public static func symbolEffect(_ effect: VariableColorSymbolEffect) -> some ThrowableButtonStyle { SymbolEffectThrowableButtonStyle(effect: effect) } } -@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, *) -extension ThrowableButtonStyle where Self == SymbolEffectThrowableButtonStyle { - public static func symbolEffect(_ effect: RotateSymbolEffect) -> some ThrowableButtonStyle { + +@available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) +extension ThrowableButtonStyle where Self == SymbolEffectThrowableButtonStyle { + public static func symbolEffect(_ effect: BreatheSymbolEffect) -> some ThrowableButtonStyle { SymbolEffectThrowableButtonStyle(effect: effect) } } -@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, *) -extension ThrowableButtonStyle where Self == SymbolEffectThrowableButtonStyle { - public static func symbolEffect(_ effect: VariableColorSymbolEffect) -> some ThrowableButtonStyle { +@available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) +extension ThrowableButtonStyle where Self == SymbolEffectThrowableButtonStyle { + public static func symbolEffect(_ effect: RotateSymbolEffect) -> some ThrowableButtonStyle { SymbolEffectThrowableButtonStyle(effect: effect) } } -@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, *) +@available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) extension ThrowableButtonStyle where Self == SymbolEffectThrowableButtonStyle { public static func symbolEffect(_ effect: WiggleSymbolEffect) -> some ThrowableButtonStyle { SymbolEffectThrowableButtonStyle(effect: effect) } } -@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, *) +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) #Preview { AsyncButton { throw NSError() as Error @@ -82,5 +84,5 @@ extension ThrowableButtonStyle where Self == SymbolEffectThrowableButtonStyle Date: Fri, 28 Nov 2025 11:27:18 +0100 Subject: [PATCH 4/4] Fix compilation when using Swift version below 6.2 --- Sources/ButtonKit/Button.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/ButtonKit/Button.swift b/Sources/ButtonKit/Button.swift index 5bbc837..1d9d2fe 100644 --- a/Sources/ButtonKit/Button.swift +++ b/Sources/ButtonKit/Button.swift @@ -194,6 +194,7 @@ public struct AsyncButton: View { guard !(state?.isLoading ?? false), !isDisabled else { return } +#if swift(>=6.2) if #available(iOS 26.0, tvOS 26.0, watchOS 26.0, macOS 26.0, visionOS 26.0, *) { var immediateTaskEnded = false let immediateTask = Task.immediate { @@ -210,6 +211,12 @@ public struct AsyncButton: View { state = .ended(completion) }) } +#else + state = .started(Task { + let completion = await performAsyncAction() + state = .ended(completion) + }) +#endif } private func performAsyncAction() async -> AsyncButtonCompletion {