From 7c7b730ebc9973f7dd0fb21c24b618fffd3c777c Mon Sep 17 00:00:00 2001 From: MaraMincho <103064352+MaraMincho@users.noreply.github.com> Date: Tue, 16 Jan 2024 11:06:05 +0900 Subject: [PATCH] =?UTF-8?q?[GWL-407]=20Refresh=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#447)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: HomeFeature DownSampling 모듈 추가 * feat: 다운 샘플링 적용 * feat: ImageCacher 및 PrepareForReuse 적용 * feat: Repository And UseCase 파일 생성 * feat: FetchCheckManager 구현 * move: 파일 위치 변경 * feat: coordaintor 및 UseCase 적용 * feat: 무한 스크롤 구현 * delete: 목 데이터 삭제 * feat: 화면에 피드가 표시되었을 때 Page넘버를 증가하는 기능 추가 * delete: Home화면 Resources 폴더 제거 * feat: UseCase, Repository에 refresh Feed 비지니스 로직 작성 * move: 파일 분리 * feat: 리프래시 기능 구현 --- .../Home/Sources/Data/FeedRepository.swift | 23 +++++++- .../Home/Sources/Domain/HomeUseCase.swift | 7 ++- .../FeedRepositoryRepresentable.swift | 1 + .../HomeScene/VIew/FeedImageCell.swift | 2 + .../VIew/FeedItemCollectionViewCell.swift | 1 + ...omeViewController+CompositionlLayout.swift | 24 +++++++++ .../ViewController/HomeViewController.swift | 53 ++++++++++++------- .../HomeScene/ViewModel/HomeViewModel.swift | 15 +++++- 8 files changed, 103 insertions(+), 23 deletions(-) create mode 100644 iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/ViewController/HomeViewController+CompositionlLayout.swift diff --git a/iOS/Projects/Features/Home/Sources/Data/FeedRepository.swift b/iOS/Projects/Features/Home/Sources/Data/FeedRepository.swift index 4b4eea40..dc7a2707 100644 --- a/iOS/Projects/Features/Home/Sources/Data/FeedRepository.swift +++ b/iOS/Projects/Features/Home/Sources/Data/FeedRepository.swift @@ -16,13 +16,14 @@ import Trinet public struct FeedRepository: FeedRepositoryRepresentable { let decoder = JSONDecoder() let provider: TNProvider + init(session: URLSessionProtocol = URLSession.shared) { provider = .init(session: session) } public func fetchFeed(at page: Int) -> AnyPublisher<[FeedElement], Never> { return Future<[FeedElement], Error> { promise in - Task { [provider] in + Task { do { let data = try await provider.request(.fetchPosts(page: page), interceptor: TNKeychainInterceptor.shared) let feedElementList = try decoder.decode([FeedElement].self, from: data) @@ -35,12 +36,30 @@ public struct FeedRepository: FeedRepositoryRepresentable { .catch { _ in return Empty() } .eraseToAnyPublisher() } + + public func refreshFeed() -> AnyPublisher<[FeedElement], Never> { + return Future<[FeedElement], Error> { promise in + Task { [provider] in + do { + let data = try await provider.request(.refreshFeed, interceptor: TNKeychainInterceptor.shared) + let feedElementList = try decoder.decode([FeedElement].self, from: data) + promise(.success(feedElementList)) + } catch { + promise(.failure(error)) + } + } + } + .catch { _ in return Empty() } + .eraseToAnyPublisher() + } } // MARK: - FeedEndPoint public enum FeedEndPoint: TNEndPoint { case fetchPosts(page: Int) + case refreshFeed + public var path: String { return "" } @@ -57,6 +76,8 @@ public enum FeedEndPoint: TNEndPoint { switch self { case let .fetchPosts(page): return page + case .refreshFeed: + return nil } } diff --git a/iOS/Projects/Features/Home/Sources/Domain/HomeUseCase.swift b/iOS/Projects/Features/Home/Sources/Domain/HomeUseCase.swift index c1c6e580..be25dc3a 100644 --- a/iOS/Projects/Features/Home/Sources/Domain/HomeUseCase.swift +++ b/iOS/Projects/Features/Home/Sources/Domain/HomeUseCase.swift @@ -13,6 +13,7 @@ import Foundation public protocol HomeUseCaseRepresentable { func fetchFeed() -> AnyPublisher<[FeedElement], Never> + func refreshFeed() -> AnyPublisher<[FeedElement], Never> mutating func didDisplayFeed() } @@ -34,7 +35,11 @@ public struct HomeUseCase: HomeUseCaseRepresentable { return Empty().eraseToAnyPublisher() } checkManager[latestFeedPage] = true - return feedElementPublisher.eraseToAnyPublisher() + return feedRepositoryRepresentable.fetchFeed(at: latestFeedPage) + } + + public func refreshFeed() -> AnyPublisher<[FeedElement], Never> { + return feedRepositoryRepresentable.refreshFeed() } public mutating func didDisplayFeed() { diff --git a/iOS/Projects/Features/Home/Sources/Domain/RepositoryInterface/FeedRepositoryRepresentable.swift b/iOS/Projects/Features/Home/Sources/Domain/RepositoryInterface/FeedRepositoryRepresentable.swift index 164e4122..33be035e 100644 --- a/iOS/Projects/Features/Home/Sources/Domain/RepositoryInterface/FeedRepositoryRepresentable.swift +++ b/iOS/Projects/Features/Home/Sources/Domain/RepositoryInterface/FeedRepositoryRepresentable.swift @@ -13,4 +13,5 @@ import Foundation public protocol FeedRepositoryRepresentable { func fetchFeed(at page: Int) -> AnyPublisher<[FeedElement], Never> + func refreshFeed() -> AnyPublisher<[FeedElement], Never> } diff --git a/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/VIew/FeedImageCell.swift b/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/VIew/FeedImageCell.swift index 275b40b5..ed82e60f 100644 --- a/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/VIew/FeedImageCell.swift +++ b/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/VIew/FeedImageCell.swift @@ -7,6 +7,7 @@ // import Cacher +import ImageDownsampling import UIKit // MARK: - FeedImageCell @@ -55,6 +56,7 @@ final class FeedImageCell: UICollectionViewCell { guard let data = try? Data(contentsOf: imageURL) else { return } DispatchQueue.main.async { [weak self] in self?.feedImage.image = UIImage(data: data) + self?.layoutIfNeeded() } } return diff --git a/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/VIew/FeedItemCollectionViewCell.swift b/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/VIew/FeedItemCollectionViewCell.swift index a13df4b5..14bfc91c 100644 --- a/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/VIew/FeedItemCollectionViewCell.swift +++ b/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/VIew/FeedItemCollectionViewCell.swift @@ -6,6 +6,7 @@ // Copyright © 2023 kr.codesquad.boostcamp8. All rights reserved. // +import Cacher import DesignSystem import UIKit diff --git a/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/ViewController/HomeViewController+CompositionlLayout.swift b/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/ViewController/HomeViewController+CompositionlLayout.swift new file mode 100644 index 00000000..778d1197 --- /dev/null +++ b/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/ViewController/HomeViewController+CompositionlLayout.swift @@ -0,0 +1,24 @@ +// +// HomeViewController+CompositionlLayout.swift +// HomeFeature +// +// Created by MaraMincho on 1/3/24. +// Copyright © 2024 kr.codesquad.boostcamp8. All rights reserved. +// + +import UIKit + +extension HomeViewController { + static func makeFeedCollectionViewLayout() -> UICollectionViewCompositionalLayout { + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + item.contentInsets = .init(top: 9, leading: 0, bottom: 9, trailing: 0) + + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(455)) + let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item]) + + let section = NSCollectionLayoutSection(group: group) + + return UICollectionViewCompositionalLayout(section: section) + } +} diff --git a/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/ViewController/HomeViewController.swift b/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/ViewController/HomeViewController.swift index 1d07dcdc..bb87eade 100644 --- a/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/ViewController/HomeViewController.swift +++ b/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/ViewController/HomeViewController.swift @@ -7,6 +7,7 @@ // import Combine +import CombineCocoa import DesignSystem import Log import UIKit @@ -24,6 +25,7 @@ final class HomeViewController: UIViewController { private let fetchFeedPublisher: PassthroughSubject = .init() private let didDisplayFeedPublisher: PassthroughSubject = .init() + private let refreshFeedPublisher: PassthroughSubject = .init() private var feedCount: Int = 0 @@ -89,6 +91,7 @@ private extension HomeViewController { setupHierarchyAndConstraints() setNavigationItem() bind() + configureRefreshControl() fetchFeedPublisher.send() } @@ -134,7 +137,8 @@ private extension HomeViewController { let output = viewModel.transform( input: HomeViewModelInput( requestFeedPublisher: fetchFeedPublisher.eraseToAnyPublisher(), - didDisplayFeed: didDisplayFeedPublisher.eraseToAnyPublisher() + didDisplayFeed: didDisplayFeedPublisher.eraseToAnyPublisher(), + refreshFeedPublisher: refreshFeedPublisher.eraseToAnyPublisher() ) ) @@ -144,6 +148,8 @@ private extension HomeViewController { break case let .fetched(feed): self?.updateFeed(feed) + case let .refresh(feed): + self?.refreshFeed(feed) } } .store(in: &subscriptions) @@ -154,6 +160,20 @@ private extension HomeViewController { navigationItem.leftBarButtonItem = titleBarButtonItem } + func refreshFeed(_ item: [FeedElement]) { + guard let dataSource else { + return + } + var snapshot = dataSource.snapshot() + snapshot.deleteAllItems() + snapshot.appendSections([0]) + snapshot.appendItems(item) + DispatchQueue.main.async { [weak self] in + dataSource.apply(snapshot) + self?.feedListCollectionView.refreshControl?.endRefreshing() + } + } + func updateFeed(_ item: [FeedElement]) { guard let dataSource else { return @@ -168,6 +188,17 @@ private extension HomeViewController { feedCount = snapshot.numberOfItems } + func configureRefreshControl() { + // Add the refresh control to your UIScrollView object. + feedListCollectionView.refreshControl = UIRefreshControl() + feedListCollectionView.refreshControl? + .publisher(.valueChanged) + .sink { [weak self] _ in + self?.refreshFeedPublisher.send() + } + .store(in: &subscriptions) + } + enum Constants { static let navigationTitleText = "홈" } @@ -175,28 +206,12 @@ private extension HomeViewController { enum Metrics {} } -private extension HomeViewController { - static func makeFeedCollectionViewLayout() -> UICollectionViewCompositionalLayout { - let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)) - let item = NSCollectionLayoutItem(layoutSize: itemSize) - item.contentInsets = .init(top: 9, leading: 0, bottom: 9, trailing: 0) - - let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(455)) - let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item]) - - let section = NSCollectionLayoutSection(group: group) - - return UICollectionViewCompositionalLayout(section: section) - } -} - // MARK: UICollectionViewDelegate extension HomeViewController: UICollectionViewDelegate { func collectionView(_: UICollectionView, willDisplay _: UICollectionViewCell, forItemAt indexPath: IndexPath) { - // 사용자가 아직 보지 않은 셀의 갯수 - let toShowCellCount = (feedCount - 1) - indexPath.row - if toShowCellCount < 3 { + // 만약 셀이 모자르다면 요청을 보냄 + if (feedCount - 1) - indexPath.row < 3 { fetchFeedPublisher.send() } } diff --git a/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/ViewModel/HomeViewModel.swift b/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/ViewModel/HomeViewModel.swift index 25b337bc..e2d4c08b 100644 --- a/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/ViewModel/HomeViewModel.swift +++ b/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/ViewModel/HomeViewModel.swift @@ -14,6 +14,7 @@ import Foundation public struct HomeViewModelInput { let requestFeedPublisher: AnyPublisher let didDisplayFeed: AnyPublisher + let refreshFeedPublisher: AnyPublisher } public typealias HomeViewModelOutput = AnyPublisher @@ -23,6 +24,7 @@ public typealias HomeViewModelOutput = AnyPublisher public enum HomeState { case idle case fetched(feed: [FeedElement]) + case refresh(feed: [FeedElement]) } // MARK: - HomeViewModelRepresentable @@ -52,7 +54,7 @@ extension HomeViewModel: HomeViewModelRepresentable { let fetched: HomeViewModelOutput = input.requestFeedPublisher .flatMap { [useCase] _ in - useCase.fetchFeed() + return useCase.fetchFeed() } .map { feed in return HomeState.fetched(feed: feed) @@ -65,9 +67,18 @@ extension HomeViewModel: HomeViewModelRepresentable { } .store(in: &subscriptions) + let refreshed: HomeViewModelOutput = input.refreshFeedPublisher + .flatMap { [useCase] _ in + return useCase.refreshFeed() + } + .map { feed in + return HomeState.refresh(feed: feed) + } + .eraseToAnyPublisher() + let initialState: HomeViewModelOutput = Just(.idle).eraseToAnyPublisher() - return initialState.merge(with: fetched) + return initialState.merge(with: fetched, refreshed) .eraseToAnyPublisher() } }