Search code examples
swiftswiftuiasync-awaitgoogle-books-api

Displaying State of an Async Api call in SwiftUI


This question builds on my previous question. Basically Im making an async call to the Google Books Api when a certain button is pressed. While I got the call working when its a method of the View however I want to overlay an activity indicator while it's loading. Hence I tried making an ObservableObject to make the call instead but Im not sure how to do it.

Here's what I have so far:

class GoogleBooksApi: ObservableObject {
    
    enum LoadingState<Value> {
        case loading(Double)
        case loaded(Value)
    }
    
    @Published var state: LoadingState<GoogleBook> = .loading(0.0)
    
    enum URLError : Error {
        case badURL
    }

    func fetchBook(id identifier: String) async throws {
        var components = URLComponents(string: "https://www.googleapis.com/books/v1/volumes")
        components?.queryItems = [URLQueryItem(name: "q", value: "isbn=\(identifier)")]
        guard let url = components?.url else { throw URLError.badURL }
        self.state = .loading(0.25)
        
        let (data, _) = try await URLSession.shared.data(from: url)
        self.state = .loading(0.75)
        self.state = .loaded(try JSONDecoder().decode(GoogleBook.self, from: data))
    }
}


struct ContentView: View {
    @State var name: String = ""
    @State var author: String = ""
    @State var total: String = ""
    
    @State var code = "ISBN"
    
    @ObservedObject var api: GoogleBooksApi
    
    var body: some View {
        VStack {
            Text("Name: \(name)")
            Text("Author: \(author)")
            Text("total: \(total)")

            Button(action: {
                code = "978-0441013593"
                Task {
                    do {
                        try await api.fetchBook(id: code)
                        let fetchedBooks = api.state
                        let book = fetchedBooks.items[0].volumeInfo
                        name = book.title
                        author = book.authors?[0] ?? ""
                        total = String(book.pageCount!)
                    } catch {
                        print(error)
                    }
                }
            }, label: {
                Rectangle()
                    .frame(width: 200, height: 100)
                    .foregroundColor(.blue)
            })
        }
    }
}

// MARK: - GoogleBook
struct GoogleBook: Codable {
    let kind: String
    let totalItems: Int
    let items: [Item]
}

// MARK: - Item
struct Item: Codable {
    let id, etag: String
    let selfLink: String
    let volumeInfo: VolumeInfo
}

// MARK: - VolumeInfo
struct VolumeInfo: Codable {
    let title: String
    let authors: [String]?
    let pageCount: Int?
    let categories: [String]?

    enum CodingKeys: String, CodingKey {
        case title, authors
        case pageCount, categories
    }
}

and this is what works without the loading states:

struct ContentView: View {
    @State var name: String = ""
    @State var author: String = ""
    @State var total: String = ""
    
    @State var code = "ISBN"
    
    enum URLError : Error {
        case badURL
    }

    private func fetchBook(id identifier: String) async throws -> GoogleBook {
        guard let encodedString = "https://www.googleapis.com/books/v1/volumes?q={\(identifier)}"
                                  .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
              let url = URL(string: encodedString) else { throw URLError.badURL}
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode(GoogleBook.self, from: data)
    }
    
    var body: some View {
        VStack {
            Text("Name: \(name)")
            Text("Author: \(author)")
            Text("total: \(total)")

            Button(action: {
                code = "978-0441013593"
                Task {
                    do {
                        let fetchedBooks = try await fetchBook(id: code)
                        let book = fetchedBooks.items[0].volumeInfo
                        name = book.title
                        author = book.authors?[0] ?? ""
                        total = String(book.pageCount!)
                    } catch {
                        print(error)
                    }
                }
            }, label: {
                Rectangle()
                    .frame(width: 200, height: 100)
                    .foregroundColor(.blue)
            })
        }
    }
}

// MARK: - GoogleBook
struct GoogleBook: Codable {
    let kind: String
    let totalItems: Int
    let items: [Item]
}

// MARK: - Item
struct Item: Codable {
    let id, etag: String
    let selfLink: String
    let volumeInfo: VolumeInfo
}

// MARK: - VolumeInfo
struct VolumeInfo: Codable {
    let title: String
    let authors: [String]?
    let pageCount: Int?
    let categories: [String]?

    enum CodingKeys: String, CodingKey {
        case title, authors
        case pageCount, categories
    }
}

Solution

  • I would go a step further and add idle and failed states.

    Then instead of throwing an error change the state to failed and pass the error description. I removed the Double value from the loading state to just show a spinning ProgressView

    @MainActor
    class GoogleBooksApi: ObservableObject {
        
        enum LoadingState {
            case idle
            case loading
            case loaded(GoogleBook)
            case failed(Error)
        }
        
        @Published var state: LoadingState = .idle
        
        func fetchBook(id identifier: String) async {
            var components = URLComponents(string: "https://www.googleapis.com/books/v1/volumes")
            components?.queryItems = [URLQueryItem(name: "q", value: "isbn=\(identifier)")]
            guard let url = components?.url else { state = .failed(URLError(.badURL)); return }
            self.state = .loading
            do {
                let (data, _) = try await URLSession.shared.data(from: url)
                let response = try JSONDecoder().decode(GoogleBook.self, from: data)
                self.state = .loaded(response)
            } catch {
                state = .failed(error)
            }
        }
    }
    

    In the view you have to switch on the state and show different views. And – very important – you have to declare the observable object as @StateObject. This is a very simple implementation

    struct ContentView: View {
          @State var code = "ISBN"
          
          @StateObject var api = GoogleBooksApi()
          
          var body: some View {
              VStack {
                  switch api.state {
                      case .idle: EmptyView()
                      case .loading: ProgressView()
                      case .loaded(let books):
                          if let info = books.items.first?.volumeInfo {
                              Text("Name: \(info.title)")
                              Text("Author: \(info.authors?.joined(separator: ", ") ?? "")")
                              Text("total: \(books.totalItems)")
                          }
                      case .failed(let error): 
                          if error is DecodingError {
                              Text(error.description)
                          } else {
                              Text(error.localizedDescription)
                          }
                  }
    
                  Button(action: {
                      code = "978-0441013593"
                      Task {
                        await api.fetchBook(id: code)
                      }
                  }, label: {
                      Rectangle()
                          .frame(width: 200, height: 100)
                          .foregroundColor(.blue)
                  })
              }
          }
    }