sigilgreenhouse/pico-async-observable
AsyncObservable is a 'pico' — a tiny package. This one bridges a common case that requires boilerplate: an `async` function that performs some work, and then returns a result to a foreground actor of some sort — whether a SwiftUI view or another Observation client.
Using AsyncObservable with SwiftUI
The main entry point in AsyncObservable is AsyncState, a property wrapper intended for use in SwiftUI views, that runs an async loading operation and updates its wrapped value as the closure finishes. For example, let's say you have an async function like this one:
func chooseLuckyNumber() async throws -> Int {
try await Task.sleep(for: .seconds(2))
return (0...100).randomElement()!
}You can display and cache a result of its invocation like this:
struct LuckyNumberView: View {
@AsyncState({
try await chooseLuckyNumber()
}) var luckyNumber: Int?
var body: some View {
if let luckyNumber {
Text("Your lucky number is: \(luckyNumber)")
} else {
ProgressView()
}
}
}The luckyNumber variable will be nil until the block concludes, when it's set to the return value of the block and cached.
The block may throw an error, and in that case the variable remains nil — but the view will update, and let you update in response to the issue. You can access information about the underlying attempt by accessing the wrapper's projected attempt property.
if let error = $luckyNumber.attempt.error {
Text("There was an error: \(error.localizedDescription)")
}You can relaunch or cancel the loading operation by using the reload() or cancel() method. Only one loading operation will be running at a time — if you reload, any ongoing one will be canceled.
Button("Give Me Another") {
if luckyNumber != nil { // Only reload if not already picking.
$luckyNumber.reload()
}
}
Button("No, Wait, Nevermind", role: .cancel) {
$luckyNumber.cancel()
}Attempts have an identifier that changes at every reload, so you can monitor new attempts starting and ending by monitoring changes on the attempt's id and isLoading properties. Canceling will also change the attempt ID, but isLoading will remain false, and the result value will remain nil.
VStack {
Text("Attempt number \(attempts)…")
if $luckyNumber.attempt.isLoading {
ProgressView()
}
}
.onChange(of: $luckyNumber.attempt.id) { _, newValue in
attempts += 1
}Using AsyncObservable By Itself
The AsyncState wrapper is only available when SwiftUI; it registers the changes with the SwiftUI runtime (by having the underlying AsyncWrapper type be a State property).
On platforms where you do not want to use SwiftUI, you can use AsyncWrapper directly:
import Observation
@AsyncWrapper({
try await chooseLuckyNumber()
}) var luckyNumber: Int?
let observations = Observations { luckyNumber }
for await value in observations {
print(value)
// Prints, for example:
// nil
// Optional(52) [after a few seconds]
}The same API as AsyncState is exposed through AsyncWrapper:
if let error = $luckyNumber.attempt.error {
print(error)
// Retry!
attempts += 1
if attempts < 10 {
$luckyNumber.reload()
}
}If for any reason you want to prevent any linkage to SwiftUI in a build target that can import that package, you can depend on and import the AsyncObservableCore module instead. This will produce a library that only links to the Swift runtime and to Foundation (or FoundationEssentials if the latter is available).
.target(
…
dependencies: [
.product(name: "AsyncObservableCore", package: "pico-async-observable"),
]
)Package Metadata
Repository: sigilgreenhouse/pico-async-observable
Default branch: main
README: README.md