I am letting users create "booklists" and I'm storing the Google Books ID for the specific book they added in firestore. When they go to their profile to see what books they've added, I run a fetch request to recreate all of those books in their list.
Previously, I was using completion handlers to run multiple network requests but after some feedback, I'm trying to restructure the function to use async/await to make sure everything is done before completing. I think I have successfully built the reusable function in my book service, but now I'm struggling to actually use it. I have four groups of books being returned - current, next, finished, and sharing. I've tried adding them all as tasks on the view but that seemed inefficient to me. I've also tried using my old structure to call the functions again from the ListsViewModel but I get tons of errors (which is the way it is in the following code). Thank you!
BookService
class BookService {
//MARK: REUSABLE FETCH BOOK FUNCTION
func fetchBooks(uid: String, document: String) async throws -> [Book] {
let bookIds = try await fetchBookIdsFromFirebase(uid: uid, document: document)
print("got bookIds in to fetchbooks func")
let books = try await withThrowingTaskGroup(of: Book.self, returning: [Book].self) { group in
for bookId in bookIds {
group.addTask {
try await self.decodeBook(bookId: bookId)
}
}
var books = [Book]()
for try await result in group {
books.append(result)
}
return books
}
return books
}
func decodeBook(bookId: String) async throws -> Book {
guard let url = try await createListURLs(for: bookId) else { throw SearchError.badUrl }
print("got to create url in fetchbooks func")
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw SearchError.badResponse }
return try JSONDecoder().decode(Book.self, from: data)
}
func fetchBookIdsFromFirebase(uid: String, document: String) async throws -> [String] {
let snapshot = try await COLLECTION_BOOKLIST.document(uid).collection(document)
.getDocuments()
let documents = snapshot.documents
let bookIds = documents.compactMap({ try? $0.data(as: String.self) })
print("got bookIds from firebase : \(bookIds)")
return bookIds
}
func createListURLs(for bookId: String) async throws -> URL? {
let baseURL = "https://www.googleapis.com/books/v1/volumes/\(bookId)"
let components = URLComponents(string: baseURL)
return components?.url
}
}
ListsViewModel
class ListsViewModel: ObservableObject {
var user: User
@Published var users = [User]()
@Published var currentBooks: [Book] = []
@Published var nextBooks: [Book] = []
@Published var doneBooks: [Book] = []
@Published var sharingBooks: [Book] = []
@Published var book: Book?
let bookservice = BookService()
let service = UserService()
init(user: User) {
self.user = user
fetchCurrent()
fetchNextUp()
fetchDone()
fetchSharing()
}
func lists(forFilter filter: Lists) -> [Book] {
switch filter {
case .current:
return currentBooks
case .next:
return nextBooks
case .done:
return doneBooks
case .sharing:
return sharingBooks
}
}
func fetchLists(forFilter filter: Lists) -> () {
switch filter {
case .current:
return self.fetchCurrent()
case .next:
return self.fetchNextUp()
case .done:
return self.fetchDone()
case .sharing:
return self.fetchSharing()
}
}
func fetchCurrent() {
guard let uid = user.id else { return }
do {
currentBooks = try await bookservice.fetchBooks(uid: uid, document: "current")
} catch {
return currentBooks
}
}
func fetchNextUp() {
guard let uid = user.id else { return }
do {
nextBooks = try await bookservice.fetchBooks(uid: uid, document: "next-up")
} catch {
return nextBooks
}
}
func fetchDone() {
guard let uid = user.id else { return }
do {
doneBooks = try await bookservice.fetchBooks(uid: uid, document: "done")
} catch {
return doneBooks
}
}
func fetchSharing() {
guard let uid = user.id else { return }
// old way of using function
bookservice.fetch2(forUid: uid, document: "sharing") { books in
DispatchQueue.main.async {
self.sharingBooks = books
}
}
}
BookListView
struct BookListView: View {
@Binding var selectedList: Lists
@StateObject var viewModel: ListsViewModel
var body: some View {
ForEach(viewModel.lists(forFilter: self.selectedList)) { book in
NavigationLink {
LazyView(BookPageView(viewModel: BookPageViewModel(book: book)))
} label: {
ListItemBookWithButton(book: book, selectedList: $selectedList, viewModel: viewModel)
.padding(.horizontal)
}
}
}
}
Book Model
struct ApiResponse: Codable {
var items: [Book]
let error: ApiError?
let timestamp: Timestamp?
}
struct Book: Codable, Identifiable {
var id: String?
let selfLink: String?
var volumeInfo: VolumeInfo
var timestamp: Timestamp?
}
struct VolumeInfo: Codable {
var title: String
let authors: [String]?
let categories: [String]?
let description: String?
let industryIdentifier: [IndustryIdentifier]?
let imageLinks: ImageLinks?
}
struct ImageLinks: Codable {
let thumbnail: URL?
}
struct IndustryIdentifier: Codable {
let type: String
let identifier: String
}
struct ErrorMessage: Codable {
let domain: String
let message: String
let reason: String
let location: String
let locationType: String
}
struct ApiError: Error, Codable {
let code: Int
let message: String
let errors: [ErrorMessage]
}
MainListView
struct MainListView: View {
@StateObject var viewModel: ListsViewModel
private let twoItems = [GridItem(spacing: 8), GridItem(spacing: 8)]
private let height = UIScreen.main.bounds.width / 2
@State var image = Image("book-empty-1")
@State var selectedList: Lists = .current
@State var rotate: [Int] = [-6, 0, 6]
@State var offset: [Int] = [2, 8, 16]
@State var offsettwo: [Int] = [-4, 2, 10]
var body: some View {
LazyVGrid(columns: twoItems) {
ForEach(viewModel.isSharingListUser ? Lists.allCases : Lists.filteredList, id: \.rawValue) { item in
NavigationLink {
LazyView(BookListView(viewModel: viewModel))
} label: {
VStack (alignment: .center) {
Text(item.title)
.foregroundColor(Color(.label))
.font(Font.body)
.padding(.top, 8)
if viewModel.lists(forFilter: item).isEmpty {
EmptyImages
} else {
HStack (spacing: -15) {
ForEach(Array(zip(viewModel.lists(forFilter: item).prefix(3))), id: \.0) { index, book in
AsyncImage(url: book.volumeInfo.imageLinks?.thumbnail) { phase in
if let image = phase.image {
image
.resizable()
.scaledToFit()
.frame(width: 64, height: 93)
.cornerRadius(6)
.rotationEffect(Angle(degrees: Double(rotate[index])))
.offset(y: CGFloat(offset[index]))
} else {
Image("empty-book")
.resizable()
.scaledToFit()
.frame(width: 64, height: 93)
.cornerRadius(6)
.rotationEffect(Angle(degrees: Double(rotate[index])))
.offset(y: CGFloat(offset[index]))
}
}
}
}
}
}
.frame(maxWidth: 175, maxHeight: height)
.clipped()
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color(.systemGray5), lineWidth: 1)
)
.padding(.top)
.padding(1)
}
.simultaneousGesture(TapGesture().onEnded{
self.selectedList = item
})
}
}
.padding(.horizontal)
}
}
extension MainListView {
var EmptyImages: some View {
HStack (spacing: -15) {
ForEach(0...2, id: \.self) { index in
image
.resizable()
.scaledToFit()
.frame(width: 64, height: 93)
.cornerRadius(6)
.rotationEffect(Angle(degrees: Double(rotate[index])))
.offset(y: CGFloat(offsettwo[index]))
}
}
}
}
EDIT
code for linking :
@MainActor
class ListsViewModel: ObservableObject {
var user: User
@Published var selectedBooks: [Book] = []
@Published var selectedList: Lists = .current
@Published var users = [User]()
@Published var sharedUsers = [User]()
@Published var shareListCount: Int = 0
@Published var currentBooks: [Book] = []
@Published var nextBooks: [Book] = []
@Published var doneBooks: [Book] = []
@Published var sharingBooks: [Book] = []
@Published var book: Book?
@Published var state: ListsStates = .loading
@Published var isListsCompleted = false
@Published var isShareCompleted = false
@Published var isSharedUsersCompleted = false
private var currentFetchTask: Task<Void, Error>?
let bookservice = BookService()
let service = UserService()
enum ListsStates {
case loading
case loaded
case empty
}
var isSharingListUser: Bool { return user.isCurrentUser == true || user.isSharedWithMe == true }
init(user: User) {
self.user = user
identifySelected()
}
func fetch(forFilter filter: Lists) {
guard let uid = user.id else { return }
self.selectedBooks = []
currentFetchTask?.cancel()
currentFetchTask = Task {
self.selectedBooks = try await bookservice.fetchBooks(uid: uid,
document: filter.document)
}
}
func identifySelected() {
if selectedList == .current {
selectedBooks = currentBooks
} else if selectedList == .next {
selectedBooks = nextBooks
} else if selectedList == .done {
selectedBooks = doneBooks
} else if selectedList == .sharing {
selectedBooks = sharingBooks
}
}
}
This is mostly fine as a starting point. An important change you should make, however, is to drive the View directly from the ViewModel. What I mean is that there should be a single @Published
property that always represents the books to display. A simple implementation might look like this:
enum Lists: String {
case current, next = "next-up", done, sharing
}
@MainActor
class ListsViewModel: ObservableObject {
var user: User
@Published var books: [Book] = []
let bookservice = BookService()
private var currentFetchTask: Task<Void, Error>?
init(user: User) {
self.user = user
}
func fetch(forFilter filter: Lists) {
guard let uid = user.id else { return }
self.books = []
currentFetchTask?.cancel()
currentFetchTask = Task {
self.books = try await bookservice.fetchBooks(uid: uid,
document: filter.rawValue)
}
}
Then the View would always just display the current value of books
, no matter what it is:
ForEach(viewModel.books) { book in
This isn't a complete answer. You need to think about what happens when there are errors, or the fetch is interrupted. Currently this just clears the list while it's being changed, but you might want something else. And you may want to move selectedList
into the view model, so that it's easier to synchronize. But this should be a good starting point.
Your latest code is quite close IMO. It just can benefit from some minor cleanup:
Don't make selectedList @Published
. This is something other objects modify, not something other objects watch. And put a didSet
on it to update selectedBooks
:
var selectedList: Lists = .current {
didSet {
selectedBooks = selectedBooks(for: selectedList)
}
}
And then create a method to get the right books. This uses the new "switch is a value" feature from the latest Swift. If you are using an older Swift, you can just add return
to each leg.
func selectedBooks(for list: Lists) -> [Book] {
return switch list {
case .current: currentBooks
case .next: nextBooks
case .sharing: sharingBooks
case .done: doneBooks
}
}