Skip to content

Instantly share code, notes, and snippets.

@moyerr
Last active July 16, 2026 17:55
Show Gist options
  • Select an option

  • Save moyerr/442869f7f06a93010adceadf595c5304 to your computer and use it in GitHub Desktop.

Select an option

Save moyerr/442869f7f06a93010adceadf595c5304 to your computer and use it in GitHub Desktop.
An AsyncContent view for SwiftUI
/// The current phase of the asynchronous loading operation.
enum AsyncContentPhase<Value: Sendable> {
/// No value is loaded yet.
case empty
/// The value loaded successfully.
case success(Value)
/// The load operation failed with an error.
case failure(any Error)
/// The loaded value, if any.
var value: Value? {
guard case .success(let value) = self else { return nil }
return value
}
/// The error that occurred when attempting to load the value, if any.
var error: (any Error)? {
guard case .failure(let error) = self else { return nil }
return error
}
}
/// A view that asynchronously loads a value and renders content based on the loading state.
///
/// `AsyncContent` performs an async throwing operation when the view appears and transitions
/// through loading, success, and failure states, allowing the caller to render appropriate UI
/// for each phase.
///
/// ```swift
/// AsyncContent {
/// try await fetchProfile(id: id)
/// } content: { phase in
/// switch phase {
/// case .empty:
/// ProgressView()
/// case .success(let profile):
/// ProfileScreen(profile)
/// case .failure(let error):
/// ErrorView(error)
/// }
/// }
/// ```
struct AsyncContent<Value: Sendable, Content: View>: View {
@State private var phase: AsyncContentPhase<Value> = .empty
/// The async throwing closure used to load the value.
private let loadValue: () async throws -> Value
/// The transaction applied when transitioning between phases.
private let transaction: Transaction
/// A closure that produces the view for a given ``AsyncContentPhase`` value.
private let content: (AsyncContentPhase<Value>) -> Content
/// Loads a value asynchronously and displays content in phases.
///
/// Before the load operation begins, and while it is in progress, the phase is
/// ``AsyncContentPhase/empty``. After the operation completes, the phase becomes either
/// ``AsyncContentPhase/failure(_:)`` or ``AsyncContentPhase/success(_:)``. In the first
/// case, the phase's ``AsyncContentPhase/error`` value indicates the reason for failure.
/// In the second case, the phase's ``AsyncContentPhase/value`` property contains the loaded
/// value. Use the phase to drive the output of the `content` closure, which defines the
/// view's appearance:
///
/// ```swift
/// AsyncContent {
/// try await fetchProfile(id: id)
/// } content: { phase in
/// if let profile = phase.value {
/// ProfileScreen(profile) // Displays the loaded profile
/// } else if phase.error != nil {
/// Color.red // Indicates an error.
/// } else {
/// ProgressView() // Acts as a placeholder.
/// }
/// }
/// ```
///
/// - Parameters:
/// - loadValue: An async throwing closure that produces the value to load.
/// - transaction: The transaction to use when the phase changes.
/// - content: A closure that takes the load phase as an input, and returns the view to
/// display for the specified phase.
init(
loadValue: @escaping () async throws -> Value,
transaction: Transaction = Transaction(),
@ViewBuilder content: @escaping (AsyncContentPhase<Value>) -> Content
) {
self.loadValue = loadValue
self.transaction = transaction
self.content = content
}
/// Loads a value and displays a view using a custom placeholder until the value loads.
///
/// Until the value loads, ``AsyncContent`` displays the placeholder view that you specify.
/// When the load operation completes successfully, ``AsyncContent`` updates the view to show
/// the content you specify, which you create using the loaded value. For example, you can
/// show a progress view followed by the loaded content:
///
/// ```swift
/// AsyncContent {
/// try await fetchProfile(id: id)
/// } success: { profile in
/// ProfileScreen(profile)
/// } placeholder: {
/// ProgressView()
/// }
/// ```
///
/// If the load operation fails, ``AsyncContent`` continues to display the placeholder.
/// To display a different view on a load error, use ``init(loadValue:transaction:content:)``
/// instead.
///
/// - Parameters:
/// - loadValue: An async throwing closure that produces the value to load.
/// - success: A closure that takes the loaded value as an input and returns the view to
/// show. You can return a view based on the value directly, or modify it as needed
/// before returning it.
/// - placeholder: A closure that returns the view to show until the load operation
/// completes successfully.
init<Success: View, Placeholder: View>(
loadValue: @escaping () async throws -> Value,
@ViewBuilder success: @escaping (Value) -> Success,
@ViewBuilder placeholder: @escaping () -> Placeholder
) where Content == _ConditionalContent<Success, Placeholder> {
self.init(loadValue: loadValue) { phase in
if let value = phase.value {
success(value)
} else {
placeholder()
}
}
}
var body: some View {
content(phase)
.task {
updatePhase(.empty)
do {
updatePhase(.success(try await loadValue()))
} catch {
updatePhase(.failure(error))
}
}
}
private func updatePhase(_ newPhase: AsyncContentPhase<Value>) {
withTransaction(transaction) {
phase = newPhase
}
}
}
#Preview {
AsyncContent {
try await Task.sleep(for: .seconds(2))
return "Hello, world!"
} success: { value in
Text(value)
.font(.headline)
.foregroundColor(.white)
.padding()
.background(.green, in: .rect(cornerRadius: 12))
} placeholder: {
ProgressView()
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment