Skip to content

Instantly share code, notes, and snippets.

@groue
Created February 14, 2024 12:27
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save groue/c17af67a285ea441e4b16de4e5a35530 to your computer and use it in GitHub Desktop.
Save groue/c17af67a285ea441e4b16de4e5a35530 to your computer and use it in GitHub Desktop.
A Trigger type that helps SwiftUI views control when cancellable async jobs are run.
import SwiftUI
extension View {
/// Adds a task to perform before this view appears or when the trigger
/// is fired.
///
/// This method behaves like `View.task(id:priority:_:)`, except that it
/// cancels and recreates the task when the `fire` method of the
/// trigger is called.
///
/// For example:
///
/// ```swift
/// struct MyView: View {
/// @State var myTrigger = Trigger()
///
/// var body: some View {
/// Group {
/// Button("Fire") {
/// myTrigger.fire()
/// }
/// }
/// .task(trigger: myTrigger) { fired in
/// // Run before the view appears, or when the trigger is
/// // fired. Test the `fired` argument if you want to
/// // distinguish between the two situations.
/// await performJob()
/// }
/// }
/// }
/// ```
///
/// - Parameters:
/// - trigger: The trigger to observe for changes.
/// - priority: The task priority to use when creating the asynchronous
/// task.
/// - action: A closure that SwiftUI calls as an asynchronous task
/// before the view appears. SwiftUI can automatically cancel the
/// task after the view disappears before the action completes. If
/// the `trigger` is fired, SwiftUI cancels and restarts the task.
/// The argument is true if the task is started because the trigger
/// was fired, and it is false if the task is started before the
/// view appears.
///
/// - Returns: A view that runs the specified action asynchronously before
/// the view appears, or restarts the task with the `trigger` value
/// is fired.
public func task(
trigger: Trigger,
priority: TaskPriority = .userInitiated,
@_inheritActorContext _ action: @escaping @Sendable (_ fired: Bool) async -> Void)
-> some View
{
modifier(TriggerModifier(
triggerId: trigger.id,
priority: priority,
action: action))
}
}
private struct TriggerModifier: ViewModifier {
@State private var lastTriggerId = Trigger.ID()
var triggerId: Trigger.ID
var priority: TaskPriority
var action: @Sendable (_ fired: Bool) async -> Void
func body(content: Content) -> some View {
content.task(id: triggerId, priority: priority) {
let fired = (lastTriggerId != triggerId)
lastTriggerId = triggerId
await action(fired)
}
}
}
public struct Trigger: Identifiable, Sendable {
public struct ID: Hashable, Sendable {
private var rawValue: Int = 0
mutating func touch() {
rawValue += 1
}
}
public var id = ID()
public init() { }
public mutating func fire() {
self.id.touch()
}
}
#if DEBUG
#Preview {
struct Preview: View {
@State var trigger = Trigger()
@State var isRunning = false
@State var wasFired = false
@State var cancelledTaskCount = 0
@State var completedTaskCount = 0
var body: some View {
VStack {
Button {
trigger.fire()
} label: {
Text(verbatim: "Fire Task")
}
Text(verbatim: "Number of completed tasks: \(completedTaskCount)")
Text(verbatim: "Number of cancelled tasks: \(cancelledTaskCount)")
if isRunning {
HStack(spacing: 10) {
Text(verbatim: "Task is running (fired: \(wasFired))")
ProgressView()
}
}
}
.task(trigger: trigger) { fired in
do {
wasFired = fired
isRunning = true
try await Task.sleep(for: .seconds(2))
completedTaskCount += 1
isRunning = false
} catch {
cancelledTaskCount += 1
isRunning = false
}
}
}
}
return TabView {
Preview()
.tabItem {
Label("A", systemImage: "circle")
}
Preview()
.tabItem {
Label("B", systemImage: "square")
}
}
}
#endif
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment