ButtonKit

repository·main·Indexed 19 days ago

https://github.com/dean151/buttonkit

A SwiftUI replacement for the standard Button that natively supports asynchronous and throwable actions. It provides built-in handling for loading states, error animations, and progress reporting via AsyncButton. Key features include task lifecycle management to prevent duplicate tasks, customizable loading and error styles, deterministic progress tracking through the TaskProgress protocol, and the ability to trigger buttons externally using the triggerButton environment variable.

Tokens
4.1K
Snippets
17
Records
18
Agent score
68%

What's inside ButtonKit

  1. Report progress in AsyncButton

    main

    For actions that can report incremental progress, initialize the button using AsyncButton(progress: ...). You can then update the provided progress object during your asynchronous task to drive the button's UI feedback.

    // Example pattern
    let myProgress = Progress(totalUnitCount: 100)
    
    AsyncButton(progress: myProgress, action: { 
        for _ in 0..<100 {
            try await Task.sleep(nanoseconds: 10_000_000)
            myProgress.completedUnitCount += 1
        }
    }) {
        Text("Download")
    }
  2. Trigger AsyncButton from external events

    main

    If you need to trigger an AsyncButton action from an external source (such as a keyboard submit event), assign a unique id to the AsyncButton and use the @Environment(\.triggerButton) property wrapper to trigger it.

    // In the view containing the button
    @Environment(\.triggerButton) var triggerButton
    
    AsyncButton(id: "submit_button", action: { try await performWork() }) {
        Text("Submit")
    }
    
    // In another part of the view (e.g., on keyboard submit)
    // triggerButton("submit_button")
  3. Trigger AsyncButtons externally via Environment

    main

    You can trigger an AsyncButton from elsewhere in your view hierarchy using an id and the triggerButton environment action.

    Requirements:

    • The button must be currently on screen.
    • Triggering a button that is already loading or disabled will have no effect.
    enum FormButton: Hashable { case login }
    
    // 1. Define the button with an ID
    AsyncButton(id: FormButton.login) {
        try await login()
    } label: {
        Text("Login")
    }
    
    // 2. Trigger it from another view
    @Environment(\.\triggerButton) private var triggerButton
    
    // ... later in code
    triggerButton(id: FormButton.login)
  4. Handle asynchronous actions with AsyncButton

    main

    Use AsyncButton for closures that use async/await. The button manages task lifecycle: it prevents duplicate tasks if pressed while a task is already in progress, but remains hittable unless configured otherwise.

    Loading State Management:

    • .disabledWhenLoading(): Disables the button while the task is running.
    • .allowsHitTestingWhenLoading(false): Disables hit testing while the task is running.

    Monitoring State:

    • .onStateChange: React to .started(task) or .ended(completion).
    • .onButtonStateChange: Monitor multiple buttons in a group.
    • .onButtonStateCancelled: React to specific button cancellations.

    Visual Styles: Use .asyncButtonStyle() to change how the button looks during loading (defaults to a ProgressView replacing the label).

    • .overlay (supports .overlay(style: .percent) for deterministic progress)
    • .pulse
    • .leading
    • .trailing
    • .symbolEffect(.bounce)
    • .none (disables loading animation)
    // Basic async usage
    AsyncButton {
        try await doSomethingThatTakeTime()
    } label {
        Text("Do something")
    }
    .disabledWhenLoading()
    
    // Monitoring state
    AsyncButton {
      ...
    } onStateChange: { state in
      switch state {
      case let .started(task):
          // Task started
      case let .ended(completion):
          // Task ended, failed or was cancelled
      }
    }
  5. Trigger AsyncButton externally

    main

    To trigger an AsyncButton from an external source (like a keyboard action) while retaining built-in animations and error handling, you must use a unique id and a triggerButton namespace.

    1. Assign a unique id to the AsyncButton.
    2. Attach .buttonTriggerNamespace(scope) to the button using a @Namespace.
    3. Access the triggerButton environment variable in the calling view to invoke the action.
    // 1. Setup the button with an ID and Namespace
    enum LoginViewButton: Hashable {
      case login
    }
    
    struct ContentView: View {
        @Namespace private var triggerScope
    
        var body: some View {
            AsyncButton(id: LoginViewButton.login) {
                try await login()
            } label: {
                Text("Login")
            }
            .buttonTriggerNamespace(triggerScope)
        }
    }
    
    // 2. Trigger from elsewhere
    struct OtherView: View {
        @Environment(\.triggerButton) private var triggerButton
        @Namespace private var triggerScope
        
        func performLogin() {
            triggerButton(id: LoginViewButton.login, in: triggerScope)
        }
    }
  6. Handle throwable actions with AsyncButton

    main

    Use AsyncButton to wrap closures that can throw errors. By default, the button will perform a shake animation when an error is thrown. You can customize this behavior using .throwableButtonStyle().

    Available Styles:

    • .shake (default)
    • .symbolEffect(.wiggle)
    • .none (disables error animation)
    • Custom styles via the ThrowableButtonStyle protocol.

    Monitoring Errors: Use .onButtonStateError on a Group of buttons to monitor failures. If an id is not provided to the AsyncButton, a UUID is generated automatically.

    // Basic usage
    AsyncButton {
        try doSomethingThatCanFail()
    } label {
        Text("Do something")
    }
    
    // Monitoring errors in a group
    Group {
        AsyncButton(id: "Button 1") {
          ...
        }
        AsyncButton(id: "Button 2") {
          ...
        }
    }
    .onButtonStateError { event in
        // event.error contains the Swift Error
        // event.buttonID contains the identifier
    }
    
    // Customizing error behavior
    AsyncButton {
        try doSomethingThatCanFail()
    } label {
        Text("Do something")
    }
    .throwableButtonStyle(.none)
  7. Implement deterministic progress in AsyncButton

    main

    You can report progress within an AsyncButton closure using the progress parameter. This allows AsyncButtonStyle implementations to react to configuration.fractionCompleted.

    Available Progress Types:

    • .indeterminate: Default non-determinate progress.
    • .discrete(totalUnitCount: Int): Linear progress (completed / total).
    • .estimated(for: Duration): Fills the bar over a time interval, stopping at 85%.
    • .progress: Bridges to (NS)Progress.

    Custom Progress: Implement the TaskProgress protocol to create custom logic (e.g., logarithmic progress). TaskProgress.cancel() is called on the main actor when the task is cancelled.

    AsyncButton(progress: .discrete(totalUnitCount: files.count)) { progress in
        for file in files {
            try await file.doExpensiveComputation()
            progress.completedUnitCount += 1
        }
    } label: {
        Text("Process")
    }
    .asyncButtonStyle(.trailing) // Responds to progress
  8. Manage button interaction during loading

    main

    To prevent users from tapping a button while an asynchronous action is in progress, use one of the following modifiers:

    • disabledWhenLoading(): Disables the button during the loading state.
    • allowsHitTestingWhenLoading(false): Disables hit testing during the loading state.
    AsyncButton(action: { try await performWork() }) {
        Text("Submit")
    }
    .disabledWhenLoading()
  9. Install ButtonKit via Swift Package Manager

    main

    Add ButtonKit to your Swift Package Manager dependencies and include it in your target dependencies.

    Requirements:

    • Swift 6.0+ (Xcode 16.0+)
    • iOS 15+, iPadOS 15+, tvOS 15+, watchOS 8+, macOS 12+, visionOS 1+
    dependencies: [
        .package(url: "https://github.com/Dean151/ButtonKit.git", from: "0.7.0"),
    ],
    targets: [
        .target(name: "MyTarget", dependencies: [
            .product(name: "ButtonKit", package: "ButtonKit"),
        ]),
    ]
  10. Use AsyncButton for async or throwing SwiftUI actions

    main

    Use AsyncButton instead of manual Button { Task { ... } } or do/catch wrappers to handle asynchronous or throwing actions. AsyncButton automatically manages the task lifecycle, provides built-in loading/progress/error feedback, and de-duplicates in-flight actions to prevent multiple simultaneous taps.

    Core Workflow:

    1. Import the library: import ButtonKit.
    2. Replace standard Button actions with AsyncButton { try await ... }.
    3. Apply styles for feedback:
      • Use asyncButtonStyle for loading feedback.
      • Use throwableButtonStyle for error feedback.
    4. Handle state changes using onButtonStateError or onButtonStateChange.
    5. Important: Do not nest a Task inside an AsyncButton action; the component manages the task for you.
    import ButtonKit
    
    AsyncButton(action: { try await performAsyncWork() }) {
        Text("Submit")
    }
    .asyncButtonStyle()
    .onButtonStateError { error in
        // Handle error
    }