1

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
    }
}
Arnav Motwani
  • 707
  • 7
  • 26

2 Answers2

1

It seems like you're not initializing the GoogleBooksApi.

@ObservedObject var api: GoogleBooksApi

neither any init where it can be modified.

Other than that - I'd suggest using @StateObject (provided you deployment target is minimum iOS 14.0). Using ObservableObject might lead to multiple initializations of the GoogleBooksApi (whereas you need only once)

You should use @StateObject for any observable properties that you initialize in the view that uses it. If the ObservableObject instance is created externally and passed to the view that uses it mark your property with @ObservedObject.

Peter Csala
  • 17,736
  • 16
  • 35
  • 75
Maor Atlas
  • 107
  • 5
1

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)
              })
          }
      }
}
vadian
  • 274,689
  • 30
  • 353
  • 361