Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 87 additions & 15 deletions Sources/GroveViews/Views/Button/AsyncButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ public struct AsyncButton<Label: View>: View {

private let role: ButtonRole?
private let action: @MainActor () async throws -> Void
private let label: Label
private let label: Label?

@Environment(\.defaultErrorDescription)
private var defaultErrorDescription
Expand Down Expand Up @@ -113,20 +113,11 @@ public struct AsyncButton<Label: View>: View {
}

public var body: some View {
Button(role: role, action: submitAction) {
switch processingStyle {
case .overlay:
label
.processingOverlay(isProcessing: isConsideredProcessing)
case .listRow:
ListRow {
label
.foregroundStyle(consideredDisabled ? .tertiary : .primary)
} content: {
if isConsideredProcessing {
ProgressView()
}
}
Group {
if let label {
customLabelButton(label)
} else if #available(iOS 26, macOS 26, tvOS 26, watchOS 26, visionOS 26, *) {
roleOnlyButton
}
}
.disabled(consideredDisabled)
Expand All @@ -141,6 +132,27 @@ public struct AsyncButton<Label: View>: View {
}
}

@available(iOS 26, macOS 26, tvOS 26, watchOS 26, visionOS 26, *)
@ViewBuilder
private var roleOnlyButton: some View {
if let role {
switch processingStyle {
case .overlay:
Button(role: role, action: submitAction)
.processingOverlay(isProcessing: isConsideredProcessing)
case .listRow:
Button(role: role, action: submitAction)
.foregroundStyle(consideredDisabled ? .tertiary : .primary)
.frame(maxWidth: .infinity, alignment: .leading)
.overlay(alignment: .trailing) {
if isConsideredProcessing {
ProgressView()
}
}
}
}
}

/// Creates an async button that generates its label from a provided localized string.
/// - Parameters:
/// - title: The localized string used to generate the Label.
Expand Down Expand Up @@ -244,6 +256,66 @@ public struct AsyncButton<Label: View>: View {
self.label = label()
}

/// Creates an async button with a system-provided label for the supplied role.
/// - Parameters:
/// - role: A button role that determines the system-provided label.
/// - action: An asynchronous button action.
@available(iOS 26, macOS 26, tvOS 26, watchOS 26, visionOS 26, *)
public init(
role: ButtonRole,
action: @MainActor @escaping () async -> Void
) where Label == DefaultButtonLabel {
self.role = role
self._viewState = .constant(.idle)
self.action = action
self.label = nil
}

/// Creates an async throwing button with a system-provided label for the supplied role.
/// - Parameters:
/// - role: A button role that determines the system-provided label.
/// - state: A ``ViewState`` binding that is used to propagate any error caught in the button action.
/// It may also be used to externally control or observe the button's processing state.
/// - action: An asynchronous button action.
@available(iOS 26, macOS 26, tvOS 26, watchOS 26, visionOS 26, *)
public init(
role: ButtonRole,
state: Binding<ViewState>,
action: @MainActor @escaping () async throws -> Void
) where Label == DefaultButtonLabel {
self.role = role
self._viewState = state
self.action = action
self.label = nil
}

@ViewBuilder
private func customLabelButton(_ label: Label) -> some View {
Button(role: role, action: submitAction) {
switch processingStyle {
case .overlay:
label
.processingOverlay(isProcessing: isConsideredProcessing)
case .listRow:
ListRow {
label
.foregroundStyle(consideredDisabled ? .tertiary : .primary)
} content: {
if isConsideredProcessing {
ProgressView()
}
}
}
}
.accessibilityRepresentation {
Button(role: role, action: submitAction) {
label
}
if isConsideredProcessing {
ProgressView()
}
}
}

private func submitAction() {
guard buttonState == .idle else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,51 @@ import SwiftUI


struct AsyncButtonToolbarTestSheet: View {
@State private var cancelState: ViewState = .idle
@State private var didCancel = false
@State private var didFinishListRowAction = false
@State private var didFinishOverlayAction = false
@State private var didFinishStatelessAction = false
@State private var didTap = false
@State private var listRowState: ViewState = .idle
@State private var overlayState: ViewState = .idle

var body: some View {
NavigationStack {
Form {
LabeledContent("Did cancel", value: didCancel.description)
LabeledContent("Did tap", value: didTap.description)
Section("Role-only processing") {
AsyncButton(role: .confirm, state: $overlayState) {
try await Task.sleep(for: .seconds(2))
didFinishOverlayAction = true
}
.accessibilityIdentifier("Role Only Overlay")
LabeledContent("Overlay completed", value: didFinishOverlayAction.description)

AsyncButton(role: .confirm, state: $listRowState) {
try await Task.sleep(for: .seconds(2))
didFinishListRowAction = true
}
.asyncButtonProcessingStyle(.listRow)
.accessibilityIdentifier("Role Only List Row")
LabeledContent("List row completed", value: didFinishListRowAction.description)
}
Section("Role-only without state") {
AsyncButton(role: .confirm) {
didFinishStatelessAction = true
}
.accessibilityIdentifier("Role Only Stateless")
LabeledContent("Stateless completed", value: didFinishStatelessAction.description)
}
}
.navigationTitle("AsyncButtonInToolbar")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
DismissButton()
AsyncButton(role: .cancel, state: $cancelState) {
didCancel = true
}
.accessibilityIdentifier("Role Only Cancel")
}
ToolbarItem(placement: .primaryAction) {
AsyncButton("Tap Me!") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,12 +168,45 @@ final class ViewsTests: XCTestCase {
let app = XCUIApplication()
XCTAssertTrue(app.launchAndWait(for: app.buttons["AsyncButton Toolbar Behaviour"]))
app.buttons["AsyncButton Toolbar Behaviour"].tap()
XCTAssert(app.staticTexts["Did cancel, false"].waitForExistence(timeout: 2))
XCTAssert(app.staticTexts["Did tap, false"].waitForExistence(timeout: 2))
let cancelButton = app.navigationBars["AsyncButtonInToolbar"].buttons["Role Only Cancel"]
XCTAssert(cancelButton.waitForExistence(timeout: 2))
XCTAssertEqual(cancelButton.label, "Cancel")
cancelButton.tap()
XCTAssert(app.staticTexts["Did cancel, true"].waitForExistence(timeout: 2))

let toolbarButton = app.navigationBars["AsyncButtonInToolbar"].buttons["Tap Me!"]
XCTAssert(toolbarButton.wait(for: \.isHittable, toEqual: true, timeout: 2))
toolbarButton.tap()
XCTAssert(app.staticTexts["Did tap, true"].waitForExistence(timeout: 2))

let overlayButton = app.buttons["Role Only Overlay"]
XCTAssert(overlayButton.waitForExistence(timeout: 2))
XCTAssertEqual(overlayButton.label, "Done")
overlayButton.tap()
XCTAssert(app.activityIndicators.firstMatch.waitForExistence(timeout: 2))
XCTAssertFalse(overlayButton.isEnabled)
XCTAssert(app.staticTexts["Overlay completed, true"].waitForExistence(timeout: 4))
XCTAssert(app.activityIndicators.firstMatch.waitForNonExistence(timeout: 2))
XCTAssertTrue(overlayButton.isEnabled)

let listRowButton = app.buttons["Role Only List Row"]
XCTAssert(listRowButton.waitForExistence(timeout: 2))
XCTAssertEqual(listRowButton.label, "Done")
listRowButton.tap()
XCTAssert(app.activityIndicators.firstMatch.waitForExistence(timeout: 2))
XCTAssertFalse(listRowButton.isEnabled)
XCTAssert(app.staticTexts["List row completed, true"].waitForExistence(timeout: 4))
XCTAssert(app.activityIndicators.firstMatch.waitForNonExistence(timeout: 2))
XCTAssertTrue(listRowButton.isEnabled)

let statelessButton = app.buttons["Role Only Stateless"]
XCTAssert(statelessButton.waitForExistence(timeout: 2))
XCTAssertEqual(statelessButton.label, "Done")
XCTAssert(app.staticTexts["Stateless completed, false"].waitForExistence(timeout: 2))
statelessButton.tap()
XCTAssert(app.staticTexts["Stateless completed, true"].waitForExistence(timeout: 2))
}

@MainActor
Expand Down