Progression is a type-safe, actor-based Swift package for managing hierarchical task execution with progress tracking, cooperative cancellation, and pause/resume support.
- Hierarchical Tasks - Tasks can have nested subtasks with automatic progress aggregation
- Progress Reporting - Report named steps and numerical progress (0.0 to 1.0)
- Cooperative Cancellation - Leverages Swift's native Task cancellation model
- Pause/Resume - Pause tasks and all their children simultaneously
- Retry - Re-run failed or cancelled tasks with a single click
- Error Propagation - Errors in subtasks propagate to parent tasks
- SwiftUI Integration - Ready-to-use views for displaying task progress
The central types are TaskExecutor (an actor that manages all tasks) and TaskContext (passed to your task functions for progress reporting). Tasks can have nested subtasks via TaskContext/push(_:_:), and progress is automatically aggregated from children to parents.
Add Progression to your Package.swift:
dependencies: [
.package(url: "https://github.com/yourusername/Progression.git", from: "1.0.0")
]Or in Xcode:
- File → Add Package Dependency
- Enter the repository URL
- Add to your target
import Progression
let executor = TaskExecutor()
// Start a task with progress reporting
await executor.addTask(name: "Data Import", options: .interactive) { context in
// Report a named step
try await context.report(.named("Initializing..."))
try await Task.sleep(for: .seconds(1))
// Report numerical progress
try await context.report(.progress(0.2))
// Create a subtask
try await context.push("Downloading files") { downloadContext in
for i in 1...10 {
try await Task.sleep(for: .milliseconds(100))
try await downloadContext.report(.progress(Double(i) / 10.0))
}
}
// Report completion
try await context.report(.progress(1.0))
}Progression uses Swift's native cooperative cancellation model. When a user cancels a task, the underlying Swift Task is cancelled, and your code can detect this in two ways:
Option 1: Use report() (recommended)
The report() method automatically checks for cancellation and throws CancellationError if the task was cancelled:
for file in files {
try await context.report(.progress(Double(i) / Double(total)))
process(file) // Won't run if cancelled
}Option 2: Manual check
Use Swift's native Task.checkCancellation() for work that doesn't otherwise report progress:
for file in files {
try Task.checkCancellation() // Throws if cancelled
process(file)
}
// Or check directly:
for file in files {
if Task.isCancelled { break }
process(file)
}Best Practices
- Always check for cancellation during long-running work
- Use
report()frequently - it automatically checks cancellation - Use
push()for subtasks - gives you hierarchical progress tracking - Catch errors from
push()- child errors propagate to parent
What NOT to Do
// BAD: Long operation without cancellation check
for i in 1...10000 {
heavyComputation() // Cannot be cancelled!
}
// GOOD: Check periodically
for i in 1...10000 {
if i % 100 == 0 { try Task.checkCancellation() }
heavyComputation()
}ProgressionUI provides convenient extensions for common async operations like network requests. These automatically create subtasks with progress reporting.
Fetch data or download files with automatic progress tracking:
// Simple fetch from URL
let (data, response) = try await context.fetch(url: myURL)
// Fetch with custom request
var request = URLRequest(url: myURL)
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await context.fetch(request: request)
// Download a file to temporary location
let localURL = try await context.download(url: fileURL)Each network operation creates a "Download" subtask that reports:
- "Connecting..." when starting
- "Downloading..." when data is received
- "Complete" when finished
For more control, use the URLSession extensions directly:
// URLSession.data(for:using:) with progress
let (data, response) = try await URLSession.shared.data(for: request, using: context)
// URLSession.download(for:using:) with progress
let (tempURL, _) = try await URLSession.shared.download(for: request, using: context)The central actor that manages all tasks and exposes progress updates via an async stream:
public actor TaskExecutor {
/// Adds and starts a new task
public func addTask(
name: String,
id: String? = nil,
options: TaskOptions = .default,
_ task: @escaping @Sendable (any TaskContext) async throws -> Void
) async -> String
/// Pauses a task and all its children
public func pause(taskID: String)
/// Resumes a paused task and all its children
public func resume(taskID: String)
/// Cancels a task
public func cancel(taskID: String)
/// Retries a failed or cancelled task (requires `canRetry` option)
public func retry(taskID: String) async -> String?
/// Removes a failed or cancelled task
public func remove(taskID: String)
/// Cancels all tasks
public func cancelAll()
/// Async stream of task graph updates
public nonisolated var progressStream: AsyncStream<TaskGraph>
}Tasks can be identified by a custom string ID, or a UUID string will be generated automatically.
Passed to your task function, providing methods for progress reporting and subtask creation:
public protocol TaskContext: AnyObject, Sendable {
/// Reports progress (named step, numerical value, or both)
func report(_ progress: TaskProgress) async throws
/// Creates a nested subtask
func push(_ name: String, _ step: @escaping @Sendable (any TaskContext) async throws -> Void) async throws
}Represents a progress update with optional name and numerical value:
// Named step only (indeterminate)
try await context.report(.named("Processing..."))
// Numerical progress only
try await context.report(.progress(0.5))
// Both name and progress
try await context.report(.named("Step 3").progress(0.75))
// Step-based progress (automatically calculates percentage)
try await context.report(.step(3, of: 10))Configures task behavior with fluent API for composition:
/// Default: cancellable, not pausable, not retryable
TaskOptions.default
/// Fully interactive: cancellable, pausable, not retryable
TaskOptions.interactive
/// Immutable: cannot be cancelled, paused, or retried
TaskOptions.immutable
/// Custom with fluent API
TaskOptions.default
.cancellable(false)
.pausable(true)
.retryable(true)
.timeout(.seconds(30))Tasks can be configured to allow retry after failure or cancellation:
await executor.addTask(
name: "Network Request",
options: TaskOptions.default.retryable()
) { context in
try await fetchData()
}
// If it fails, a retry button appears in the UIWhen a task with canRetry: true fails or is cancelled, the UI displays a retry button (arrow icon) that allows users to re-execute the task. The task maintains the same ID when retried.
Tasks can have a maximum execution duration:
await executor.addTask(
name: "Network Request",
options: TaskOptions.default.timeout(.seconds(30))
) { context in
// If this takes > 30 seconds, throws TaskTimeoutError
try await fetchData()
}Use Duration for natural time expressions:
.seconds(30).minutes(5).milliseconds(500)
An immutable snapshot of task state for the UI:
public struct TaskSnapshot: Identifiable, Sendable, Equatable {
public let id: String // Custom ID or UUID string
public let name: String
public let progress: Float? // nil = indeterminate
public let stepName: String?
public let status: TaskStatus
public let options: TaskOptions
public let isPaused: Bool
public let children: [TaskSnapshot]
public let errorDescription: String?
// Computed properties
public var isCompleted: Bool
public var isFailed: Bool
public var isRunning: Bool
}ProgressionUI provides ready-to-use SwiftUI views:
import ProgressionUI
struct ContentView: View {
@StateObject private var model = MyViewModel()
var body: some View {
TaskProgressView(executor: model.executor)
}
}A hierarchical tree view for displaying all tasks. This is a typealias for ProgressContainer with a default list layout:
// Default layout (name above progress bar)
TaskProgressView(executor: myExecutor)For custom layouts, use ProgressContainer directly:
ProgressContainer(executor: myExecutor) { tasks in
// Custom rendering of tasks
ForEach(tasks) { task in
Text(task.name)
}
}A simple progress bar component:
// Determinate progress
ProgressBar(0.5)
// Indeterminate (animated)
ProgressBar(nil)Tasks can report errors that propagate to parent tasks:
try await context.push("Risky Operation") { subContext in
do {
try await riskyFunction()
} catch {
// Error propagates to parent and marks both as failed
throw error
}
}Failed tasks display their error message and require manual dismissal.
All Progression error types conform to LocalizedError, providing user-facing error messages that are automatically localized. The following errors are supported:
ProgressionError.cancelled→ "Task was cancelled"ProgressionError.invalidProgressValue→ "Invalid progress value"ProgressionError.subtaskFailed(underlying:)→ Uses underlying error'slocalizedDescriptionif availableTaskTimeoutError→ "Task timed out after 30 seconds" (duration is locale-aware)
If your tasks throw custom errors, you can provide localized messages by conforming to LocalizedError:
import Foundation
enum MyAppError: Error, LocalizedError {
case networkUnavailable
case fileNotFound(String)
case validationFailed(String)
var errorDescription: String? {
switch self {
case .networkUnavailable:
return NSLocalizedString("Network is unavailable", comment: "")
case .fileNotFound(let name):
return NSLocalizedString("File not found: \(name)", comment: "")
case .validationFailed(let reason):
return NSLocalizedString("Validation failed: \(reason)", comment: "")
}
}
}When thrown from a task, Progression will automatically use your errorDescription:
try await context.push("Save Data") { _ in
guard saveData() else {
throw MyAppError.networkUnavailable
}
}
// Error shows: "Subtask failed: Network is unavailable"Progression uses .strings files for translations. To add translations, create localized resource directories:
Sources/Progression/
├── Resources/
│ ├── Errors.strings // Base (English)
│ ├── es.lproj/
│ │ └── Errors.strings // Spanish
│ └── ja.lproj/
│ └── Errors.strings // Japanese
The framework's error messages will automatically use the appropriate localization based on the device's locale settings.
Control how long completed tasks remain visible:
let executor = TaskExecutor()
executor.completedTaskVisibilityDuration = 2.0 // secondsListen for progress changes:
for await graph in executor.progressStream {
// graph.tasks contains all current tasks
updateUI(with: graph.tasks)
}All task management operations are thread-safe:
TaskExecutoris an actor, ensuring serial accessTaskNodeuses locks for thread-safe property accessTaskSnapshotis immutable and Sendable-safe
- macOS 15.0+ / iOS 18.0+
- Swift 6.0+
Progression is released under the MIT license. See LICENSE for details.