diff --git a/Poppool/PresentationLayer/Presentation/Presentation/Scene/Search/BeforeSearch/SearchController.swift b/Poppool/PresentationLayer/Presentation/Presentation/Scene/Search/BeforeSearch/SearchController.swift index 9cac2f2b..cc473a5c 100644 --- a/Poppool/PresentationLayer/Presentation/Presentation/Scene/Search/BeforeSearch/SearchController.swift +++ b/Poppool/PresentationLayer/Presentation/Presentation/Scene/Search/BeforeSearch/SearchController.swift @@ -23,14 +23,17 @@ final class SearchController: BaseViewController, View { private var mainView = SearchView() private var sections: [any Sectionable] = [] private let cellTapped: PublishSubject = .init() - private let pageChange: PublishSubject = .init() + private let loadNextPage = PublishSubject() } // MARK: - Life Cycle extension SearchController { override func viewDidLoad() { super.viewDidLoad() - setUp() + + self.addViews() + self.setupContstraints() + self.configureUI() } override func viewWillAppear(_ animated: Bool) { @@ -41,36 +44,50 @@ extension SearchController { // MARK: - SetUp private extension SearchController { - func setUp() { + func addViews() { + [mainView].forEach { + self.view.addSubview($0) + } + } + + func setupContstraints() { + mainView.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) + } + } + + func configureUI() { if let layout = reactor?.compositionalLayout { mainView.contentCollectionView.collectionViewLayout = layout } + mainView.contentCollectionView.delegate = self mainView.contentCollectionView.dataSource = self + mainView.contentCollectionView.register( SearchTitleSectionCell.self, forCellWithReuseIdentifier: SearchTitleSectionCell.identifiers ) + mainView.contentCollectionView.register( SpacingSectionCell.self, forCellWithReuseIdentifier: SpacingSectionCell.identifiers ) + mainView.contentCollectionView.register( CancelableTagSectionCell.self, forCellWithReuseIdentifier: CancelableTagSectionCell.identifiers ) + mainView.contentCollectionView.register( SearchCountTitleSectionCell.self, forCellWithReuseIdentifier: SearchCountTitleSectionCell.identifiers ) + mainView.contentCollectionView.register( HomeCardSectionCell.self, forCellWithReuseIdentifier: HomeCardSectionCell.identifiers ) - view.addSubview(mainView) - mainView.snp.makeConstraints { make in - make.edges.equalTo(view.safeAreaLayoutGuide) - } } } @@ -90,19 +107,45 @@ extension SearchController { .bind(to: reactor.action) .disposed(by: disposeBag) - pageChange - .throttle(.milliseconds(1000), scheduler: MainScheduler.asyncInstance) - .map { Reactor.Action.changePage } + loadNextPage + .throttle(.seconds(1), latest: false, scheduler: MainScheduler.asyncInstance) + .map { Reactor.Action.loadNextPage } .bind(to: reactor.action) .disposed(by: disposeBag) reactor.state + .filter { $0.newBottomSearchList.isEmpty && $0.bottomSearchListLastIndexPath == nil } .withUnretained(self) - .subscribe { (owner, state) in + .subscribe { owner, state in owner.sections = state.sections owner.mainView.contentCollectionView.reloadData() } .disposed(by: disposeBag) + + reactor.state + .map { (sections: $0.sections, + newItems: $0.newBottomSearchList, + indexPath: $0.bottomSearchListLastIndexPath) } + .filter { !$0.newItems.isEmpty && $0.indexPath != nil } + .withUnretained(self) + .subscribe { (owner, subscribeResponse) in + let (updatedSections, newPopUpItems, popUpGridindexPath) = subscribeResponse + guard let popUpGridindexPath = popUpGridindexPath else { return } + + let start = popUpGridindexPath.item + let count = newPopUpItems.count + let section = popUpGridindexPath.section + let indexPaths = (start.. UICollectionViewCell { let cell = sections[indexPath.section].getCell(collectionView: collectionView, indexPath: indexPath) guard let userDefaultService = reactor?.userDefaultService else { return cell } - var searchList = userDefaultService.fetchArray(key: "searchList") ?? [] + let searchList = userDefaultService.fetchArray(key: "searchList") ?? [] guard let reactor = reactor else { return cell } + if let cell = cell as? SearchTitleSectionCell { cell.titleButton.rx.tap .map { Reactor.Action.recentSearchListAllDeleteButtonTapped } @@ -168,6 +212,7 @@ extension SearchController: UICollectionViewDelegate, UICollectionViewDataSource .bind(to: reactor.action) .disposed(by: cell.disposeBag) } + return cell } @@ -177,7 +222,7 @@ extension SearchController: UICollectionViewDelegate, UICollectionViewDataSource let scrollViewHeight = scrollView.frame.size.height let contentOffsetY = scrollView.contentOffset.y if contentOffsetY + scrollViewHeight >= contentHeight { - pageChange.onNext(()) + loadNextPage.onNext(()) } } diff --git a/Poppool/PresentationLayer/Presentation/Presentation/Scene/Search/BeforeSearch/SearchReactor.swift b/Poppool/PresentationLayer/Presentation/Presentation/Scene/Search/BeforeSearch/SearchReactor.swift index 17cd6119..63c8179b 100644 --- a/Poppool/PresentationLayer/Presentation/Presentation/Scene/Search/BeforeSearch/SearchReactor.swift +++ b/Poppool/PresentationLayer/Presentation/Presentation/Scene/Search/BeforeSearch/SearchReactor.swift @@ -21,7 +21,7 @@ final class SearchReactor: Reactor { case changeCategory(categoryList: [Int64], categoryTitleList: [String?]) case categoryDelteButtonTapped(indexPath: IndexPath) case resetCategory - case changePage + case loadNextPage case bookmarkButtonTapped(indexPath: IndexPath) case resetSearchKeyWord } @@ -33,11 +33,29 @@ final class SearchReactor: Reactor { case moveToDetailScene(controller: BaseViewController, indexPath: IndexPath) case setSearchKeyWord(text: String?) case resetSearchKeyWord + case updateBottomSearchList(newItems: [HomeCardSectionCell.Input], IndexPath: IndexPath) } struct State { var sections: [any Sectionable] = [] var searchKeyWord: String? + var newBottomSearchList: [HomeCardSectionCell.Input] = [] + var bottomSearchListLastIndexPath: IndexPath? + + mutating func resetPaginationState() { + self.newBottomSearchList = [] + self.bottomSearchListLastIndexPath = nil + } + + mutating func updateBottomGridSection(by newItems: [HomeCardSectionCell.Input]) { + sections = sections.map { section in + if var grid = section as? HomeCardGridSection { + grid.inputDataList.append(contentsOf: newItems) + return grid + } + return section + } + } } // MARK: - properties @@ -102,18 +120,11 @@ final class SearchReactor: Reactor { switch action { case .resetSearchKeyWord: return Observable.just(.resetSearchKeyWord) - case .changePage: - if isLoading { - return Observable.just(.loadView) - } else { - if currentPage < lastPage { - isLoading = true - currentPage += 1 - return setBottomSearchList(sort: sort) - } else { - return Observable.just(.loadView) - } - } + case .loadNextPage: + guard !isLoading, currentPage < lastPage else { return Observable.empty() } + isLoading = true + currentPage += 1 + return setBottomSearchList(sort: sort) case .viewWillAppear: setSearchList() return setBottomSearchList(sort: sort) @@ -186,6 +197,7 @@ final class SearchReactor: Reactor { switch mutation { case .loadView: newState.sections = getSection() + newState.resetPaginationState() case .moveToCategoryScene(let controller): let categoryIDList = searchCategorySection.inputDataList.compactMap { $0.id } let nextController = SearchCategoryController() @@ -236,6 +248,11 @@ final class SearchReactor: Reactor { case .resetSearchKeyWord: newState.searchKeyWord = nil newState.sections = getSection() + + case .updateBottomSearchList(let newItems, let indexPath): + newState.updateBottomGridSection(by: newItems) + newState.newBottomSearchList = newItems + newState.bottomSearchListLastIndexPath = indexPath } return newState } @@ -306,52 +323,69 @@ final class SearchReactor: Reactor { } func setBottomSearchList(sort: String?) -> Observable { - let isOpen = filterIndex == 0 ? true : false - let categorys = searchCategorySection.inputDataList.compactMap { $0.id } - return popUpAPIUseCase.getSearchBottomPopUpList(isOpen: isOpen, categories: categorys, page: currentPage, size: 50, sort: sort) - .withUnretained(self) - .map { (owner, response) in - let isLogin = response.loginYn - if owner.currentPage == 0 { - owner.searchListSection.inputDataList = response.popUpStoreList.map { - return .init( - imagePath: $0.mainImageUrl, - id: $0.id, - category: $0.category, - title: $0.name, - address: $0.address, - startDate: $0.startDate, - endDate: $0.endDate, - isBookmark: $0.bookmarkYn, - isLogin: isLogin - ) - } - } else { - if owner.currentPage != owner.lastAppendPage { - owner.lastAppendPage = owner.currentPage - let newData = response.popUpStoreList.map { - return HomeCardSectionCell.Input( - imagePath: $0.mainImageUrl, - id: $0.id, - category: $0.category, - title: $0.name, - address: $0.address, - startDate: $0.startDate, - endDate: $0.endDate, - isBookmark: $0.bookmarkYn, - isLogin: isLogin - ) - } - owner.searchListSection.inputDataList.append(contentsOf: newData) - } - } + let isOpen = filterIndex == 0 + let categories = searchCategorySection.inputDataList.compactMap { $0.id } + + return popUpAPIUseCase.getSearchBottomPopUpList( + isOpen: isOpen, + categories: categories, + page: currentPage, + size: 10, + sort: sort + ) + .withUnretained(self) + .map { (owner, response) in + // 1) 새로 받아오기 전의 기존 아이템 개수 저장 + let previousCount = owner.searchListSection.inputDataList.count + + // 2) API 결과 매핑 + let newItems = response.popUpStoreList.map { + HomeCardSectionCell.Input( + imagePath: $0.mainImageUrl, + id: $0.id, + category: $0.category, + title: $0.name, + address: $0.address, + startDate: $0.startDate, + endDate: $0.endDate, + isBookmark: $0.bookmarkYn, + isLogin: response.loginYn + ) + } + + // 3) 첫 페이지 vs 이후 페이지 분기 + if owner.currentPage == 0 { + // 첫 페이지는 전체 reload + // SearchCountTitleSection 설정 let isOpenString = isOpen ? "오픈・" : "종료・" let sortedString = owner.sortedIndex == 0 ? "신규순" : "인기순" let sortedTitle = isOpenString + sortedString - owner.searchSortedSection.inputDataList = [.init(count: response.totalElements, sortedTitle: sortedTitle)] + owner.searchSortedSection.inputDataList = [ + SearchCountTitleSectionCell.Input( + count: response.totalElements, + sortedTitle: sortedTitle + ) + ] + owner.searchListSection.inputDataList = newItems + owner.lastAppendPage = owner.currentPage owner.lastPage = response.totalPages owner.isLoading = false return .loadView + } else { + // 다음 페이지는 append 후 부분 업데이트 + owner.lastAppendPage = owner.currentPage + owner.searchListSection.inputDataList.append(contentsOf: newItems) + owner.lastPage = response.totalPages + owner.isLoading = false + + // HomeCardGridSection이 컬렉션뷰에서 몇 번째 섹션인지 계산 + let sectionIndex = owner.getSection().enumerated() + .first { _, section in section is HomeCardGridSection }!.offset + + // append된 첫 아이템의 IndexPath + let firstIndexPath = IndexPath(item: previousCount, section: sectionIndex) + return .updateBottomSearchList(newItems: newItems, IndexPath: firstIndexPath) } + } } } diff --git a/Poppool/PresentationLayer/Presentation/Presentation/Scene/Search/BeforeSearch/View/SearchView.swift b/Poppool/PresentationLayer/Presentation/Presentation/Scene/Search/BeforeSearch/View/SearchView.swift index 874e3b1a..8ffc6413 100644 --- a/Poppool/PresentationLayer/Presentation/Presentation/Scene/Search/BeforeSearch/View/SearchView.swift +++ b/Poppool/PresentationLayer/Presentation/Presentation/Scene/Search/BeforeSearch/View/SearchView.swift @@ -12,26 +12,31 @@ import SnapKit final class SearchView: UIView { // MARK: - Components - let contentCollectionView: UICollectionView = { - return UICollectionView(frame: .zero, collectionViewLayout: .init()) - }() + let contentCollectionView = UICollectionView(frame: .zero, collectionViewLayout: .init()) // MARK: - init init() { super.init(frame: .zero) - setUpConstraints() + + self.addSubviews() + self.setUpConstraints() } required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") + fatalError("\(#file), \(#function) Error") } } // MARK: - SetUp private extension SearchView { + func addSubviews() { + [contentCollectionView].forEach { + self.addSubview($0) + } + } + func setUpConstraints() { - self.addSubview(contentCollectionView) contentCollectionView.snp.makeConstraints { make in make.top.equalToSuperview().offset(56) make.leading.trailing.bottom.equalToSuperview() diff --git a/Poppool/PresentationLayer/Presentation/Presentation/Scene/Search/Main/SearchMainController.swift b/Poppool/PresentationLayer/Presentation/Presentation/Scene/Search/Main/SearchMainController.swift index 7eb6542e..33d083b1 100644 --- a/Poppool/PresentationLayer/Presentation/Presentation/Scene/Search/Main/SearchMainController.swift +++ b/Poppool/PresentationLayer/Presentation/Presentation/Scene/Search/Main/SearchMainController.swift @@ -9,6 +9,7 @@ import RxCocoa import RxSwift import SnapKit import Tabman +import Then final class SearchMainController: BaseTabmanController, View { @@ -19,23 +20,19 @@ final class SearchMainController: BaseTabmanController, View { private var mainView = SearchMainView() - var beforeController: SearchController = { - let controller = SearchController() - controller.reactor = SearchReactor( + var beforeController = SearchController().then { + $0.reactor = SearchReactor( userAPIUseCase: DIContainer.resolve(UserAPIUseCase.self), popUpAPIUseCase: DIContainer.resolve(PopUpAPIUseCase.self) ) - return controller - }() + } - var afterController: SearchResultController = { - let controller = SearchResultController() - controller.reactor = SearchResultReactor( + var afterController = SearchResultController().then { + $0.reactor = SearchResultReactor( userAPIUseCase: DIContainer.resolve(UserAPIUseCase.self), popUpAPIUseCase: DIContainer.resolve(PopUpAPIUseCase.self) ) - return controller - }() + } lazy var controllers = [ beforeController, @@ -49,7 +46,9 @@ final class SearchMainController: BaseTabmanController, View { extension SearchMainController { override func viewDidLoad() { super.viewDidLoad() - setUp() + + self.addViews() + self.setupConstraints() } override func viewDidAppear(_ animated: Bool) { @@ -68,14 +67,19 @@ extension SearchMainController { // MARK: - SetUp private extension SearchMainController { - func setUp() { - view.addSubview(mainView) + func addViews() { + [mainView] + .forEach { self.view.addSubview($0) } + } + + func setupConstraints() { + self.dataSource = self + self.isScrollEnabled = false + mainView.snp.makeConstraints { make in make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) make.height.equalTo(56) } - self.dataSource = self - self.isScrollEnabled = false } } @@ -98,25 +102,6 @@ extension SearchMainController { }) .disposed(by: disposeBag) -// mainView.searchTextField.rx.controlEvent(.editingDidEndOnExit) -// .withUnretained(self) -// .map { (owner, _) in -// Reactor.Action.returnSearchKeyWord(text: owner.mainView.searchTextField.text ) -// } -// .bind(to: reactor.action) -// .disposed(by: disposeBag) -// -// mainView.searchTextField.rx.controlEvent(.editingDidEndOnExit) -// .withUnretained(self) -// .subscribe(onNext: { (owner, _) in -// if let text = owner.mainView.searchTextField.text { -// if !text.isEmpty { -// owner.scrollToPage(.at(index: 1), animated: false) -// } -// } -// owner.beforeController.reactor?.action.onNext(.returnSearchKeyword(text: owner.mainView.searchTextField.text)) -// }) -// .disposed(by: disposeBag) mainView.searchTextField.rx.controlEvent(.editingDidEndOnExit) .withLatestFrom(mainView.searchTextField.rx.text.orEmpty) .withUnretained(self) diff --git a/Poppool/PresentationLayer/Presentation/Presentation/Scene/Search/Main/SearchMainView.swift b/Poppool/PresentationLayer/Presentation/Presentation/Scene/Search/Main/SearchMainView.swift index 3d3ffda9..8798ec65 100644 --- a/Poppool/PresentationLayer/Presentation/Presentation/Scene/Search/Main/SearchMainView.swift +++ b/Poppool/PresentationLayer/Presentation/Presentation/Scene/Search/Main/SearchMainView.swift @@ -1,101 +1,96 @@ -// -// SearchMainView.swift -// Poppool -// -// Created by SeoJunYoung on 12/7/24. -// - import UIKit import SnapKit +import Then final class SearchMainView: UIView { // MARK: - Components - private let searchTrailingView: UIView = { - let view = UIView() - view.backgroundColor = .g50 - view.layer.cornerRadius = 4 - view.clipsToBounds = true - return view - }() - - private let searchIconImageView: UIImageView = { - let view = UIImageView() - view.image = UIImage(named: "icon_search_gray") - return view - }() - - private let searchStackView: UIStackView = { - let view = UIStackView() - view.spacing = 4 - view.alignment = .center - return view - }() - - let cancelButton: UIButton = { - let button = UIButton(type: .system) - button.setTitle("취소", for: .normal) - button.setTitleColor(.g1000, for: .normal) - button.titleLabel?.font = .korFont(style: .regular, size: 14) - button.imageView?.contentMode = .scaleAspectFit - return button - }() - - let searchTextField: UITextField = { - let view = UITextField() - view.font = .korFont(style: .regular, size: 14) - view.setPlaceholder(text: "팝업스토어명을 입력해보세요", color: .g400, font: .korFont(style: .regular, size: 14)!) - return view - }() - - let clearButton: UIButton = { - let button = UIButton() - button.setImage(UIImage(named: "icon_clearButton"), for: .normal) - return button - }() - - private var headerStackView: UIStackView = { - let view = UIStackView() - view.alignment = .center - view.spacing = 16 - return view - }() + private let searchTrailingView = UIView().then { + $0.backgroundColor = .g50 + $0.layer.cornerRadius = 4 + $0.clipsToBounds = true + } + + private let searchIconImageView = UIImageView().then { + $0.image = UIImage(named: "icon_search_gray") + } + + private let searchStackView = UIStackView().then { + $0.spacing = 4 + $0.alignment = .center + } + + let cancelButton = UIButton(type: .system).then { + $0.setTitle("취소", for: .normal) + $0.setTitleColor(.g1000, for: .normal) + $0.titleLabel?.font = .korFont(style: .regular, size: 14) + $0.imageView?.contentMode = .scaleAspectFit + } + + let searchTextField = UITextField().then { + $0.font = .korFont(style: .regular, size: 14) + $0.setPlaceholder( + text: "팝업스토어명을 입력해보세요", + color: .g400, + font: .korFont(style: .regular, size: 14)! + ) + } + + let clearButton = UIButton().then { + $0.setImage(UIImage(named: "icon_clearButton"), for: .normal) + } + + private var headerStackView = UIStackView().then { + $0.alignment = .center + $0.spacing = 16 + } // MARK: - init init() { super.init(frame: .zero) - setUpConstraints() + + self.addViews() + self.setUpConstraints() } required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") + fatalError("\(#file), \(#function) Error") } } // MARK: - SetUp private extension SearchMainView { + func addViews() { + [headerStackView] + .forEach { self.addSubview($0) } + + [searchTrailingView, cancelButton] + .forEach { headerStackView.addArrangedSubview($0) } + + [searchStackView] + .forEach { searchTrailingView.addSubview($0) } + + [searchIconImageView, searchTextField, clearButton] + .forEach { searchStackView.addArrangedSubview($0) } + } + func setUpConstraints() { searchTrailingView.snp.makeConstraints { make in make.height.equalTo(37) } - headerStackView.addArrangedSubview(searchTrailingView) - headerStackView.addArrangedSubview(cancelButton) - self.addSubview(headerStackView) + headerStackView.snp.makeConstraints { make in make.top.equalToSuperview().inset(7) make.leading.equalToSuperview().inset(20) make.trailing.equalToSuperview().inset(16) } - searchTrailingView.addSubview(searchStackView) + searchStackView.snp.makeConstraints { make in make.top.bottom.equalToSuperview() make.leading.trailing.equalToSuperview().inset(12) } - searchStackView.addArrangedSubview(searchIconImageView) - searchStackView.addArrangedSubview(searchTextField) - searchStackView.addArrangedSubview(clearButton) searchIconImageView.snp.makeConstraints { make in make.size.equalTo(20)