Last active
July 16, 2026 17:55
-
-
Save moyerr/442869f7f06a93010adceadf595c5304 to your computer and use it in GitHub Desktop.
An AsyncContent view for SwiftUI
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /// 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