Skip to content

Commit

Permalink
Feature/task trigger button (#8)
Browse files Browse the repository at this point in the history
* add TaskTriggerUI Package

* implement TaskTriggerButton

* fix file headers

* fix available annotation for all platforms
  • Loading branch information
lukepistrol authored Nov 26, 2024
1 parent 7d0b1d5 commit c489972
Show file tree
Hide file tree
Showing 12 changed files with 550 additions and 111 deletions.
4 changes: 3 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ let package = Package(
products: [
.library(
name: taskTrigger,
targets: [taskTrigger]
targets: [
taskTrigger,
]
),
],
targets: [
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,17 @@ var body: some View {
}
```

### TaskTriggerButton

To make it even simpler to use when using a `TaskTrigger` with a button, we can also use
`TaskTriggerButton`. The following example is equivalent to the previous example:

```swift
TaskTriggerButton("Do Something") {
await someAsyncOperation()
}
```

## Contribution

If you have any ideas on how to take this further I'm happy to discuss things in an issue.
Expand Down
27 changes: 21 additions & 6 deletions Sources/TaskTrigger/Documentation.docc/Documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ hold the state in a view model.
## The Solution

To make things simpler on the caller's side let's wrap all of this functionality inside
a simple type ``TaskTrigger/TaskTrigger``.
a simple type ``TaskTrigger``.

```swift
@State var trigger = TaskTrigger<Int>()
Expand All @@ -96,10 +96,10 @@ var body: some View {
```

1. We declare a new state variable `trigger` and initialize the `TaskTrigger` of type `Int`.
2. In our button we call the ``TaskTrigger/TaskTrigger/trigger(value:id:)`` method on our `trigger` and pass in our value.
2. In our button we call the ``TaskTrigger/trigger(value:id:)`` method on our `trigger` and pass in our value.
3. We attach a new variant of the `task` view modifier to our view and bind it to our `trigger`.
4. The body will only execute when the `trigger` was triggered. The value we passed into the
``TaskTrigger/TaskTrigger/trigger(value:id:)`` method earlier gets passed into the closure as an argument.
``TaskTrigger/trigger(value:id:)`` method earlier gets passed into the closure as an argument.
5. All cancellation related handling, sanity checking, as well as resetting the state is handled
automatically behind the scenes.

Expand All @@ -110,7 +110,7 @@ automatically behind the scenes.
> In case you don't want that to happen explicitly set the `id` parameter and it won't cancel
> prior operations since both the `value` and `id` are still the same.
For triggers that don't need to attach a value, we can simply use ``TaskTrigger/PlainTaskTrigger`` (which is a
For triggers that don't need to attach a value, we can simply use ``PlainTaskTrigger`` (which is a
typealias for `TaskTrigger<Bool>`):

```swift
Expand All @@ -126,9 +126,24 @@ var body: some View {
}
```

### TaskTriggerButton

To make it even simpler to use when using a ``TaskTrigger`` with a button, we can also use
``TaskTriggerButton``. The following example is equivalent to the previous example:

```swift
TaskTriggerButton("Do Something") {
await someAsyncOperation()
}
```

## Topics

### Triggers

- ``TaskTrigger/TaskTrigger``
- ``TaskTrigger/PlainTaskTrigger``
- ``TaskTrigger``
- ``PlainTaskTrigger``

### Views

- ``TaskTriggerButton``
11 changes: 6 additions & 5 deletions Sources/TaskTrigger/TaskTrigger.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,18 @@ import SwiftUI

public struct TaskTrigger<Value: Equatable>: Equatable where Value: Sendable {

internal enum TaskState<T: Equatable>: Equatable {
public enum TaskState<T: Equatable>: Equatable {
case none
case active(value: T, uuid: UUID? = nil)
}

/// Creates a new ``TaskTrigger/TaskTrigger``.
/// Creates a new ``TaskTrigger``.
public init() {}

internal var state: TaskState<Value> = .none
/// The current state of the task.
public private(set) var state: TaskState<Value> = .none

/// Triggers the tasks associated with this ``TaskTrigger/TaskTrigger`` and passes along a value of type `Value`.
/// Triggers the tasks associated with this ``TaskTrigger`` and passes along a value of type `Value`.
/// - Parameters:
/// - value: The value to pass along.
/// - id: (Optional) An UUID which by default is initialized each time this method gets called.
Expand All @@ -37,7 +38,7 @@ public struct TaskTrigger<Value: Equatable>: Equatable where Value: Sendable {
public typealias PlainTaskTrigger = TaskTrigger<Bool>

public extension PlainTaskTrigger {
/// Triggers the tasks associated with this ``TaskTrigger/PlainTaskTrigger``.
/// Triggers the tasks associated with this ``PlainTaskTrigger``.
mutating func trigger() {
self.state = .active(value: true, uuid: .init())
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//
// TaskTriggerButton+Behavior.swift
// TaskTrigger
//
// Created by Lukas Pistrol on 26.11.24.
//

import Foundation

extension TaskTriggerButton {
/// Describes how the button behaves when tapped and a task is running.
///
/// All can show a separate placeholder while the task is running.
public enum Behavior {
/// The button is disabled while the task is running. A placeholder can be shown while the task is running.
case blocking(showPlaceholder: Bool)

/// The button can be tapped again to cancel the task. A placeholder can be shown while the task is running.
case cancellable(showPlaceholder: Bool)

/// The button can be tapped again to cancel and restart the task. A placeholder can be shown while the task
/// is running.
case restart(showPlaceholder: Bool)

var showPlaceholder: Bool {
switch self {
case .blocking(let showPlaceholder):
return showPlaceholder
case .cancellable(let showPlaceholder):
return showPlaceholder
case .restart(let showPlaceholder):
return showPlaceholder
}
}

var isBlocking: Bool {
if case .blocking = self {
return true
}
return false
}
}
}
81 changes: 81 additions & 0 deletions Sources/TaskTrigger/TaskTriggerButton/TaskTriggerButton+Init.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
//
// TaskTriggerButton+Init.swift
// TaskTrigger
//
// Created by Lukas Pistrol on 26.11.24.
//

import SwiftUI

extension TaskTriggerButton {
/// Creates a new ``TaskTriggerButton`` with a title.
/// - Parameters:
/// - text: The button's title.
/// - role: The button's role. Defaults to `nil`.
/// - behavior: The button's behavior. Defaults to `.blocking(showPlaceholder: true)`.
/// - action: The async action to perform when the button is tapped.
/// - placeholder: The placeholder to show while the task is running. Defaults to a `ProgressView`.
/// If `showPlaceholder` on the `role` is set to `false`, this is ignored.
public init(
_ text: LocalizedStringKey,
role: ButtonRole? = nil,
behavior: Behavior = .blocking(showPlaceholder: true),
action: @escaping @Sendable @MainActor () async -> Void,
@ViewBuilder placeholder: @escaping () -> P = { ProgressView() }
) where L == Text {
self.action = action
self.label = { Text(text) }
self.placeholder = placeholder
self.role = role
self.behavior = behavior
}

/// Creates a new ``TaskTriggerButton`` with a title and an image.
/// - Parameters:
/// - text: The button's title.
/// - image: The button's image.
/// - role: The button's role. Defaults to `nil`.
/// - behavior: The button's behavior. Defaults to `.blocking(showPlaceholder: true)`.
/// - action: The async action to perform when the button is tapped.
/// - placeholder: The placeholder to show while the task is running. Defaults to a `ProgressView`.
/// If `showPlaceholder` on the `role` is set to `false`, this is ignored.
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
public init(
_ text: LocalizedStringKey,
image: ImageResource,
role: ButtonRole? = nil,
behavior: Behavior = .blocking(showPlaceholder: true),
action: @escaping @Sendable @MainActor () async -> Void,
@ViewBuilder placeholder: @escaping () -> P = { ProgressView() }
) where L == Label<Text, Image> {
self.action = action
self.label = { Label(text, image: image) }
self.placeholder = placeholder
self.role = role
self.behavior = behavior
}

/// Creates a new ``TaskTriggerButton`` with a title and a system image.
/// - Parameters:
/// - text: The button's title.
/// - systemImage: The button's system image.
/// - role: The button's role. Defaults to `nil`.
/// - behavior: The button's behavior. Defaults to `.blocking(showPlaceholder: true)`.
/// - action: The async action to perform when the button is tapped.
/// - placeholder: The placeholder to show while the task is running. Defaults to a `ProgressView`.
/// If `showPlaceholder` on the `role` is set to `false`, this is ignored.
public init(
_ text: LocalizedStringKey,
systemImage: String,
role: ButtonRole? = nil,
behavior: Behavior = .blocking(showPlaceholder: true),
action: @escaping @Sendable @MainActor () async -> Void,
@ViewBuilder placeholder: @escaping () -> P = { ProgressView() }
) where L == Label<Text, Image> {
self.action = action
self.label = { Label(text, systemImage: systemImage) }
self.placeholder = placeholder
self.role = role
self.behavior = behavior
}
}
107 changes: 107 additions & 0 deletions Sources/TaskTrigger/TaskTriggerButton/TaskTriggerButton.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
//
// TaskTriggerButton.swift
// TaskTrigger
//
// Created by Lukas Pistrol on 26.11.24.
//

import SwiftUI

/// A `Button` that triggers an async task that is bound to the button using a ``TaskTrigger``.
///
/// > To get a better understanding of how a ``TaskTrigger`` works and its use cases, please refer to the
/// > documentation of the ``TaskTrigger`` package.
///
/// When the button is tapped the action is performed. Depending on the ``TaskTriggerButton/Behavior`` the button
/// behaves differently:
///
/// - `.blocking`:
/// The button is disabled while the task is running.
/// A placeholder can be shown while the task is running.
/// - `.cancellable`:
/// The button can be tapped again to cancel the task.
/// A placeholder can be shown while the task is running.
/// - `.restart`:
/// The button can be tapped again to cancel and restart
/// the task. A placeholder can be shown while the task is running.
///
/// ```swift
/// // ⛔ Bad:
/// // Task will not be cancelled when the button is
/// // removed from the view hierarchy or is tapped
/// // multiple times.
/// Button("Start Task") {
/// Task {
/// await someAsyncTask()
/// }
/// }
///
/// // ✅ Good:
/// TaskTriggerButton("Start Task") {
/// await someAsyncTask()
/// }
/// ```
///
/// The default behavior is `.blocking` with a `ProgressView` as a placeholder. In all cases the task is bound to
/// the ``TaskTriggerButton`` and will be cancelled as soon as the ``TaskTriggerButton`` is removed from the view
/// hierarchy.
public struct TaskTriggerButton<L: View, P: View>: View {

let action: @Sendable @MainActor () async -> Void
let label: () -> L
let placeholder: () -> P
let role: ButtonRole?
let behavior: Behavior

/// Creates a new ``TaskTriggerButton`` with a label.
/// - Parameters:
/// - role: The button's role. Defaults to `nil`.
/// - behavior: The button's behavior. Defaults to `.blocking(showPlaceholder: true)`.
/// - action: The async action to perform when the button is tapped.
/// - label: The button's label. This can be any `View`.
/// - placeholder: The placeholder to show while the task is running. Defaults to a `ProgressView`.
/// If `showPlaceholder` on the `role` is set to `false`, this is ignored.
public init(
role: ButtonRole? = nil,
behavior: Behavior = .blocking(showPlaceholder: true),
action: @escaping @Sendable @MainActor () async -> Void,
@ViewBuilder label: @escaping () -> L,
@ViewBuilder placeholder: @escaping () -> P = { ProgressView() }
) {
self.action = action
self.label = label
self.placeholder = placeholder
self.role = role
self.behavior = behavior
}

@State private var trigger = PlainTaskTrigger()

public var body: some View {
Button(role: role) {
switch trigger.state {
case .none:
trigger.trigger()
case .active:
if case .cancellable = behavior {
trigger.cancel()
} else if case .restart = behavior {
trigger.trigger()
}
}
} label: {
switch trigger.state {
case .none:
label()
case .active:
if behavior.showPlaceholder {
placeholder()
} else {
label()
}
}
}
.disabled(behavior.isBlocking && trigger.state != .none)
.task($trigger, action)
}
}
4 changes: 2 additions & 2 deletions Sources/TaskTrigger/View+Task.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import SwiftUI
public extension View {
/// Adds a task to perform whenever the specified trigger with an attached value fires.
/// - Parameters:
/// - trigger: A binding to a ``TaskTrigger/TaskTrigger``.
/// - trigger: A binding to a ``TaskTrigger``.
/// - action: An async action to perform whenever the trigger fires. The attached value
/// is passed into the closure as an argument.
func task<Value: Equatable>(
Expand All @@ -22,7 +22,7 @@ public extension View {

/// Adds a task to perform whenever the specified trigger fires.
/// - Parameters:
/// - trigger: A binding to a ``TaskTrigger/PlainTaskTrigger``.
/// - trigger: A binding to a ``PlainTaskTrigger``.
/// - action: An async action to perform whenever the trigger fires.
func task(
_ trigger: Binding<PlainTaskTrigger>,
Expand Down
Loading

0 comments on commit c489972

Please sign in to comment.