AsyncView – Asynchronous loading operations in SwiftUI

by @ralfebert · published November 16, 2021
SwiftUI, Xcode 13 & iOS 15
Advanced iOS Developers
English

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.

  1. 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.

    Ergebnis Länderanzeige via JSON
  2. 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.

  3. 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))")
            }
        }
    }
    
  4. Use the Result type to represent the state "an error has occurred" in the CountriesModel class.

    Show solution

    @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)
            }
        }
    }
    
  5. Modify the view accordingly so that an error message is displayed in case of an error.

    Show solution

    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()
            }
        }
    }
    
  6. 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)
    }
    
  7. Modify the CountriesModel accordingly.

    Show solution

    @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)
            }
        }
    }
    
  8. Modify the view accordingly.

    Show solution

    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()
            }
        }
    }
    
  9. 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)
            }
        }
    }
    
  10. 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)
            }
        }
    }
    
  11. 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()
            }
        }
    }
    
  12. 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)
        }
    }
)
    }
}