Hey,
I've built a small, lightweight Swift package that helps you manage loading states in SwiftUI. It's called Processed.
It's a wrapper around two simple enums that represent loading states for either a loadable resource or a generic process:
// When you need to represent the loading state of some data
public enum LoadableState<Value> {
case absent
case loading
case error(Error)
case loaded(Value)
}
// When you need to represent the state of some generic process, i.e. saving, deleting, ...
public enum ProcessState<ProcessID> {
case idle
case running(ProcessID)
case failed(process: ProcessID, error: Swift.Error)
case finished(ProcessID)
}
First, these enums come with a lot of convenience methods and setters to make working with them easy and increase code clarity and readability.
However, the main thing that this package adds is two property wrappers around those enums that automate the state handling and Task
management, because loading some data for a view almost always requires the same, identical steps:
- Cancel any previous loading tasks
- Create a new task and store it somewhere for cancellation
- Set loading state to
.loading
- Load the data
- Set the loading state to either
.loaded(value)
or.error(error)
This is a lot of boilerplate that clutters up the code, in my opinion. That's why I built Processed:
@Loadable
The @Loadable
property wrapper exposes the above mentioned LoadableState<Value>
via its wrappedValue
property, so that you can use it (and switch
over it) just like any other enum property.
Additionally, via its projectedValue
, you can access some methods to start a new resource loading process, for example $numbers.load { ... }
(see below for a code example).
The body of this closure is asynchronous and throwing. @Loadable
will internally cancel any previous tasks, create and store a new Task
, set the state to .loading
(unless you want to load the data silently) and wait for the closure to finish. If a value is returned, the state will then be set to .loaded(value)
and if an error was thrown, the state will be set to .error(error)
. This all happens automatically, because it's almost always the same behavior.
struct DemoView: View {
@Loadable<[Int]> var numbers
@MainActor func loadNumbers() {
$numbers.load {
try await Task.sleep(for: .seconds(2))
return [0, 1, 2, 42, 73]
}
}
var body: some View {
List {
Button("Reload") { loadNumbers() }
.disabled(numbers.isLoading)
switch numbers {
case .absent:
EmptyView()
case .loading:
ProgressView()
case .error(let error):
Text("\(error.localizedDescription)")
case .loaded(let numbers):
ForEach(numbers, id: \.self) { number in
Text(String(number))
}
}
}
}
}
@Loadable
supports:
- Automatic state and task handling
- Option to control the states fully manually, if needed
- Proper Task cancellation support (state will not change anymore, when Task was cancelled, to prevent data races)
- Cancellation from within the
load { ... }
closure throughthrow CancelLoadable()
$numbers.cancel()
and$numbers.reset()
methods to cancel and reset that Task/state- All
load { ... }
methods have anasync
overload, so when you call them from an asynchronous context, it will not create a new internally managed Task, but instead simply use the current asynchronous context, so you canawait
the results of theload
call and manage the parent Task by yourself. - There is a
load { ... }
overload that, instead of waiting for a single return value, lets youyield
multiple values over time, which is really useful for long running observations through anAsyncStream
, for example.
@Process
The @Process
property wrapper is similar to @Loadable
, but it's semantically optimized for generic processes that don't return a value, but simply perform some work that needs to be tracked, like a save or delete action. It wraps around the ProcessState<ProcessID>
enum.
Instead of a load
method, its projectedValue
exposes a run
method (and more):
struct DemoView: View {
@Process var saving
@MainActor func save() {
$saving.run {
try await save()
}
}
var body: some View {
List {
Button("Save") { save() }
.disabled(numbers.isRunning)
switch saving {
case .idle:
Text("Idle")
case .running:
Text("Saving")
case .failed(_, let error):
Text("\(error.localizedDescription)")
case .finished:
Text("Finished Saving")
}
}
}
}
@Process
supports:
- Automatic state and task handling
- Option to control multiple kinds of processes via a
ProcessID
type:@Loadable<ProcessKind>
and then, for example,myProcess.run(.save) { ... }
ormyProcess.run(.delete) { ... }
- Option to control the states fully manually, if needed
- Proper Task cancellation support (state will not change anymore, when Task was cancelled, to prevent data races)
- Cancellation from within the
run { ... }
closure throughthrow CancelProcess()
$saving.cancel()
and$saving.reset()
methods to cancel and reset that Task/state- All
run { ... }
methods have anasync
overload, so when you call them from an asynchronous context, it will not create a new internally managed Task, but instead simply use the current asynchronous context, so you canawait
the results of therun { ... }
call and manage the parent Task by yourself.
LoadableState
and ProcessState
in classes
If you prefer to keep your state in a view model, or if you would like to use Processed completely outside of SwiftUI, you can also do all the things from above inside a class. However, it works slightly differently because of the nature of SwiftUI property wrappers (they hold @State
properties inside, which don't work outside the SwiftUI environment).
You simply have to conform your class to the LoadableSupport
and/or the ProcessSupport
protocol that implements the same load
, run
, cancel
and reset
methods as the @Loadable
and @Process
property wrappers, but this time defined on self
.
For example, for LoadableState
, you can do something like this:
@MainActor final class ViewModel: ObservableObject, LoadableSupport {
// Define the LoadableState enum as a normal @Published property
@Published var numbers: LoadableState<[Int]> = .absent
func loadNumbers() {
// Call the load method from the LoadableSupport protocol
load(\.numbers) {
try await Task.sleep(for: .seconds(2))
return [0, 1, 2, 42, 73]
}
}
// Alternatively, use the yielding overload to stream values over time
func loadStreamedNumbers() {
load(\.numbers) { yield in
var numbers: [Int] = []
for await number in [0, 1, 2, 42, 73].publisher.values {
try await Task.sleep(for: .seconds(1))
numbers.append(number)
yield(.loaded(numbers))
}
}
}
}
For more information on this, see the README.
What do you think about this? It has helped me quite a bit already and reduced a lot of repetitive boilerplate in my projects.