From 006ec716e5ceab81432ee13528edc408ec58dda7 Mon Sep 17 00:00:00 2001 From: baegteun Date: Thu, 1 May 2025 16:56:02 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B1=85=20=EA=B2=80=EC=83=89=20Pagination?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/GoogleBooks/GoogleBooksClient.swift | 7 +++ .../Components/SearchResultBookCell.swift | 2 + .../NewBook/Components/SelectedBookView.swift | 2 + .../NewBook/NewBookViewController.swift | 47 ++++++++++++++++- ONMIR/Feature/NewBook/NewBookViewModel.swift | 51 +++++++++++++++++-- 5 files changed, 102 insertions(+), 7 deletions(-) diff --git a/ONMIR/Core/GoogleBooks/GoogleBooksClient.swift b/ONMIR/Core/GoogleBooks/GoogleBooksClient.swift index 4cd603f..afdd148 100644 --- a/ONMIR/Core/GoogleBooks/GoogleBooksClient.swift +++ b/ONMIR/Core/GoogleBooks/GoogleBooksClient.swift @@ -6,6 +6,7 @@ public struct GoogleBooksClient: Sendable { case unexpectedResponse case decodingError(Error) case underlying(Error) + case cancelled } public enum OrderByType: String, Sendable { @@ -56,6 +57,12 @@ public struct GoogleBooksClient: Sendable { return try decoder.decode(BookSearchResponse.self, from: data) } catch let decodingError as DecodingError { throw GoogleBooksError.decodingError(decodingError) + } catch let urlError as URLError { + if urlError.code == .cancelled { + throw GoogleBooksError.cancelled + } else { + throw GoogleBooksError.underlying(urlError) + } } catch { throw GoogleBooksError.underlying(error) } diff --git a/ONMIR/Feature/NewBook/Components/SearchResultBookCell.swift b/ONMIR/Feature/NewBook/Components/SearchResultBookCell.swift index c26263d..c1ed86c 100644 --- a/ONMIR/Feature/NewBook/Components/SearchResultBookCell.swift +++ b/ONMIR/Feature/NewBook/Components/SearchResultBookCell.swift @@ -93,6 +93,8 @@ extension NewBookViewController { await MainActor.run { self.coverImageView.image = image } + } catch let error as CancellationError { + Logger.info(error) } catch { Logger.error(error) } diff --git a/ONMIR/Feature/NewBook/Components/SelectedBookView.swift b/ONMIR/Feature/NewBook/Components/SelectedBookView.swift index dcd752f..9e3ab76 100644 --- a/ONMIR/Feature/NewBook/Components/SelectedBookView.swift +++ b/ONMIR/Feature/NewBook/Components/SelectedBookView.swift @@ -98,6 +98,8 @@ extension NewBookViewController { let image = try await imageTask.image guard Task.isCancelled == false else { return } coverImageView.image = image + } catch let error as CancellationError { + Logger.info(error) } catch { Logger.error(error) } diff --git a/ONMIR/Feature/NewBook/NewBookViewController.swift b/ONMIR/Feature/NewBook/NewBookViewController.swift index 36b37bf..86982fe 100644 --- a/ONMIR/Feature/NewBook/NewBookViewController.swift +++ b/ONMIR/Feature/NewBook/NewBookViewController.swift @@ -53,6 +53,12 @@ public final class NewBookViewController: UIViewController { private var searchTask: Task? private let searchDebounceTime: TimeInterval = 0.3 + private let loadingIndicator: UIActivityIndicatorView = { + let indicator = UIActivityIndicatorView(style: .medium) + indicator.hidesWhenStopped = true + return indicator + }() + init(completion: @MainActor @escaping () -> Void) { self.completion = completion super.init(nibName: nil, bundle: nil) @@ -84,6 +90,7 @@ public final class NewBookViewController: UIViewController { private func setupBindings() { observeBooks() observeSelectedBooks() + observeLoadingState() } private func observeBooks() { @@ -112,12 +119,31 @@ public final class NewBookViewController: UIViewController { } } } + + private func observeLoadingState() { + withObservationTracking { + _ = viewModel.isLoading + } onChange: { [weak self] in + Task { @MainActor in + guard let self else { return } + + if self.viewModel.isLoading { + self.loadingIndicator.startAnimating() + } else { + self.loadingIndicator.stopAnimating() + } + + self.observeLoadingState() + } + } + } private func setupUI() { view.backgroundColor = .secondarySystemBackground view.addSubview(selectedBookView) view.addSubview(collectionView) + view.addSubview(loadingIndicator) selectedBookView.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide) @@ -128,6 +154,11 @@ public final class NewBookViewController: UIViewController { make.top.equalTo(selectedBookView.snp.bottom) make.leading.trailing.bottom.equalToSuperview() } + + loadingIndicator.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-20) + } updateCollectionViewConstraints() } @@ -230,7 +261,7 @@ public final class NewBookViewController: UIViewController { if isEmpty { var configuration = UIContentUnavailableConfiguration.search() configuration.image = .init(systemName: "book.closed") - configuration.text = "magnifyingglass.circle.fill" + configuration.text = "No Books Found" configuration.secondaryText = "Try different keywords or check for typos in your search" self.contentUnavailableConfiguration = configuration @@ -251,7 +282,7 @@ public final class NewBookViewController: UIViewController { searchTask = Task { [weak self] in guard let self = self else { return } - try? await Task.sleep(for: .seconds(0.5)) + try? await Task.sleep(for: .seconds(0.3)) guard !Task.isCancelled else { return } @@ -268,6 +299,18 @@ extension NewBookViewController: UICollectionViewDelegate { guard let book = dataSource.itemIdentifier(for: indexPath) else { return } viewModel.toggleBookSelection(book: book) } + + public func scrollViewDidScroll(_ scrollView: UIScrollView) { + let offsetY = scrollView.contentOffset.y + let contentHeight = scrollView.contentSize.height + let height = scrollView.frame.size.height + + if offsetY > contentHeight - height { + if let visibleItems = collectionView.indexPathsForVisibleItems.map({ $0.row }).max() { + viewModel.loadMoreBooksIfNeeded(currentIndex: visibleItems) + } + } + } } extension NewBookViewController: UISearchBarDelegate { diff --git a/ONMIR/Feature/NewBook/NewBookViewModel.swift b/ONMIR/Feature/NewBook/NewBookViewModel.swift index b04a8e2..bbbdcb5 100644 --- a/ONMIR/Feature/NewBook/NewBookViewModel.swift +++ b/ONMIR/Feature/NewBook/NewBookViewModel.swift @@ -8,6 +8,12 @@ final class NewBookViewModel { private(set) var books: [BookSearchRepresentation] = [] private(set) var selectedBook: BookSearchRepresentation? + private(set) var isLoading = false + private(set) var currentPage = 0 + private(set) var hasMorePages = true + private(set) var lastQuery = "" + private let maxResultsPerPage = 20 + @ObservationIgnored private let googleBooksClient = GoogleBooksClient() @@ -16,21 +22,56 @@ final class NewBookViewModel { } func fetchBooks(query: String) async { + if query != lastQuery { + lastQuery = query + currentPage = 0 + books = [] + hasMorePages = true + } + + guard !isLoading && hasMorePages else { return } + + isLoading = true + do { + let startIndex = currentPage * maxResultsPerPage let response = try await googleBooksClient.searchBooks( query: query, - startIndex: 0 + startIndex: startIndex, + maxResults: maxResultsPerPage ) - if let items = response.items { + if let items = response.items, !items.isEmpty { let bookRepresentations = items.map { BookSearchRepresentation(from: $0) } await MainActor.run { - self.books = bookRepresentations + if currentPage == 0 { + self.books = bookRepresentations + } else { + self.books.append(contentsOf: bookRepresentations) + } + + currentPage += 1 + hasMorePages = items.count == maxResultsPerPage && self.books.count < response.totalItems } + } else { + hasMorePages = false } + } catch .cancelled { + Logger.info("Book Fetch Cancelled") } catch { - Logger.error("Error fetching books: \(error)") + Logger.error(error) + } + + isLoading = false + } + + func loadMoreBooksIfNeeded(currentIndex: Int) { + let thresholdIndex = books.count - 5 + if currentIndex >= thresholdIndex && !isLoading && hasMorePages { + Task { + await fetchBooks(query: lastQuery) + } } } @@ -45,4 +86,4 @@ final class NewBookViewModel { func clearSelection() { selectedBook = nil } -} +}