AsyncView – Asynchronous loading operations in SwiftUI
In the following tutorial, a SwiftUI View component is developed for handling in-progress and error states when loading data asynchronously, based on an example project that loads JSON data via async/await. This serves as an exercise in creating abstractions and using Swift generics in practice.
The resulting component is suitable as a ready-made package for use in projects that merely want to load data from URL endpoints and display it via SwiftUI, as well as a starting point for projects that require a more complex structure.
-
Download the starter version of the Countries project. This implements loading a list of countries in JSON format via async/await. Familiarize yourself with the code in the project. If there are any questions here, you can familiarize yourself with this project with the tutorial → Loading JSON data with async/await.
-
The project lacks error handling and progress indication during the loading operation. In the CountriesModel errors are only printed to the console:
@MainActor class CountriesModel: ObservableObject { @Published var countries: [Country] = [] func reload() async { let url = URL(string: "https://www.ralfebert.de/examples/v3/countries.json")! let urlSession = URLSession.shared do { let (data, _) = try await urlSession.data(from: url) self.countries = try JSONDecoder().decode([Country].self, from: data) } catch { // Error handling in case the data couldn't be loaded // For now, only display the error on the console debugPrint("Error loading \(url): \(String(describing: error))") } } }
In the following steps, an abstraction for displaying errors and progress indication for this loading operation is developed:
At the end you will find examples of using the resulting AsyncView package.
-
Extract the URL and decoding logic into a separate CountriesEndpoints type:
struct CountriesEndpoints { let urlSession = URLSession.shared let jsonDecoder = JSONDecoder() func countries() async throws -> [Country] { let url = URL(string: "https://www.ralfebert.de/examples/v3/countries.json")! let (data, _) = try await urlSession.data(from: url) return try jsonDecoder.decode([Country].self, from: data) } }
and use it for CountriesModel:
@MainActor class CountriesModel: ObservableObject { @Published var countries: [Country] = [] func reload() async { do { let endpoints = CountriesEndpoints() self.countries = try await endpoints.countries() } catch { // Error handling in case the data couldn't be loaded // For now, only display the error on the console debugPrint("Error: \(String(describing: error))") } } }
-
Use the Result type to represent the state "an error has occurred" in the CountriesModel class.
@MainActor class CountriesModel: ObservableObject { @Published var result: Result<[Country], Error> = .success([]) func reload() async { do { let endpoints = CountriesEndpoints() self.result = .success(try await endpoints.countries()) } catch { self.result = .failure(error) } } }
-
Modify the view accordingly so that an error message is displayed in case of an error.
struct CountriesView: View { @StateObject var countriesModel = CountriesModel() var body: some View { Group { switch countriesModel.result { case let .success(countries): List(countries) { country in Text(country.name) } case let .failure(error): Text(error.localizedDescription) } } .task { await self.countriesModel.reload() } .refreshable { await self.countriesModel.reload() } } }
-
Define your own enum type similar to the Swift result type and add a case for the states "loading in progress" and "empty/not yet loaded":
enum AsyncResult<Success> { case empty case inProgress case success(Success) case failure(Error) }
-
Modify the CountriesModel accordingly.
@MainActor class CountriesModel: ObservableObject { @Published var result: AsyncResult<[Country]> = .empty func reload() async { self.result = .inProgress do { let endpoints = CountriesEndpoints() self.result = .success(try await endpoints.countries()) } catch { self.result = .failure(error) } } }
-
Modify the view accordingly.
struct CountriesView: View { @StateObject var countriesModel = CountriesModel() var body: some View { Group { switch countriesModel.result { case .empty: EmptyView() case .inProgress: ProgressView() case let .success(countries): List(countries) { country in Text(country.name) } case let .failure(error): Text(error.localizedDescription) } } .task { await self.countriesModel.reload() } .refreshable { await self.countriesModel.reload() } } }
-
Extract the switch/case block that handles the different states into a generic, reusable view AsyncResultView that can be used as follows:
struct CountriesView: View { @StateObject var countriesModel = CountriesModel() var body: some View { AsyncResultView(countriesModel.result) { countries in List(countries) { country in Text(country.name) } } .task { await self.countriesModel.reload() } .refreshable { await self.countriesModel.reload() } } }
This is a bit tricky, since both the data type for the success case and the corresponding view type must be declared as generic arguments to use this view with arbitrary data types and arbitrary views:
struct AsyncResultView<Success, Content: View>: View { let result: AsyncResult<Success> let content: (_ item: Success) -> Content init(result: AsyncResult<Success>, @ViewBuilder content: @escaping (_ item: Success) -> Content) { self.result = result self.content = content } var body: some View { switch result { case .empty: EmptyView() case .inProgress: ProgressView() case let .success(value): content(value) case let .failure(error): Text(error.localizedDescription) } } }
-
CountriesModel can now become a generic type AsyncModel. This executes the asynchronous operation that is passed in as block:
@MainActor class AsyncModel<Success>: ObservableObject { @Published var result: AsyncResult<Success> = .empty typealias AsyncOperation = () async throws -> Success var operation : AsyncOperation init(operation : @escaping AsyncOperation) { self.operation = operation } func reload() async { self.result = .inProgress do { self.result = .success( try await operation()) } catch { self.result = .failure(error) } } }
-
AsyncModel can now be used in the view to coordinate the loading process:
struct CountriesView: View { @StateObject var countriesModel = AsyncModel { try await CountriesEndpoints().countries() } var body: some View { AsyncResultView(result: countriesModel.result) { countries in List(countries) { country in Text(country.name) } } .task { await self.countriesModel.reload() } .refreshable { await self.countriesModel.reload() } } }
-
Extract a generic type AsyncModelView from the CountriesView that can be used as follows:
struct CountriesView: View { @StateObject var countriesModel = AsyncModel { try await CountriesEndpoints().countries() } var body: some View { AsyncModelView(model: countriesModel) { countries in List(countries) { country in Text(country.name) } } } }
Implementation:
struct AsyncModelView<Success, Content: View>: View { @ObservedObject var model: AsyncModel<Success> let content: (_ item: Success) -> Content var body: some View { AsyncResultView( result: model.result, content: content ) .task { await model.reload() } .refreshable { await model.reload() } } }
Package AsyncView
I have provided the generic types from this tutorial as a package AsyncView. With this, you can implement an asynchronous loading operation including error handling and progress display as follows:
import SwiftUI
import AsyncView
struct CountriesView: View {
@StateObject var countriesModel = AsyncModel { try await CountriesEndpoints().countries() }
var body: some View {
AsyncModelView(model: countriesModel) { countries in
List(countries) { country in
Text(country.name)
}
}
}
}
It is also possible to define the model as a separate class:
class CountriesModel: AsyncModel<[Country]> {
override func asyncOperation() async throws -> [Country] {
try await CountriesEndpoints().countries()
}
}
struct CountriesView: View {
@StateObject var countriesModel = CountriesModel()
var body: some View {
AsyncModelView(model: countriesModel) { countries in
List(countries) { country in
Text(country.name)
}
}
}
}
For presenting data loaded from a URL endpoint without any additional logic, you can use AsyncView:
import SwiftUI
import AsyncView
struct CountriesView: View {
var body: some View {
AsyncView(
operation: { try await CountriesEndpoints().countries() },
content: { countries in
List(countries) { country in
Text(country.name)
}
}
)
}
}