diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000..85d62c3b --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,93 @@ +language: ko-KR # 언어 설정 + +early_access: true # 미리보기 기능 활성화 +enable_free_tier: true # 프리 티어 활성화 +auto_resolve_threads: false # 자동 해결 비활성화 + +reviews: + profile: chill + request_changes_workflow: true + high_level_summary: true # 리뷰에 대해 요약(high-level summary)를 자동 작성 + high_level_summary_placeholder: '@coderabbitai 요약' + auto_title_placeholder: '@coderabbitai' + poem: true + review_status: true # PR 리뷰 상태를 리뷰 요약란에 표시 + collapse_walkthrough: false # 리뷰 단계 설명을 기본적으로 접지 않음 + + abort_on_close: true # PR이 닫히면 리뷰 수행을 중단(abort) + + + auto_review: + enabled: true # 자동 리뷰 기능을 활성화 + auto_incremental_review: true # 커밋이 추가될 때마다 변경 사항에 대해서만 자동 수행 + ignore_title_keywords: [] # PR 제목에 포함되면 리뷰를 건너뛰는 키워드 목록 + labels: [] # 특정 라벨이 붙은 PR만 자동 리뷰 대상 + drafts: false # Draft 상태인 PR은 자동 리뷰 대상에서 제외(false면 제외) + base_branches: [] # 특정 브랜치만 리뷰하도록 + + tools: + shellcheck: # 셸 스크립트 문법 및 보안 검사 + enabled: true + ruff: # Python 코드 스타일 검사기 + enabled: true + markdownlint: # 마크다운 문법 검사 + enabled: true + github-checks: # GitHub 체크 연동 + 타임아웃(ms 단위) + enabled: true + timeout_ms: 90000 + languagetool: # 맞춤법, 문법 검사 + enabled: true + disabled_rules: + - EN_UNPAIRED_BRACKETS + - EN_UNPAIRED_QUOTES + disabled_categories: + - TYPOS + - TYPOGRAPHY + - CASING + enabled_only: false + level: default + enabled_rules: [] + enabled_categories: [] + biome: # JavaScript/TypeScript 정적 분석 + enabled: true + hadolint: # Dockerfile 코드 스타일 검사 + enabled: true + swiftlint: # Swift 코드 스타일 검사 + enabled: true + phpstan: # PHP 정적 분석 + enabled: true + level: default + golangci-lint: # Go 코드 스타일 검사 + enabled: true + yamllint: # YAML 형식 검사 + enabled: true + gitleaks: # Git 시크릿 노출 탐지 + enabled: true + checkov: # 인프라 보안 검사 + enabled: true + ast-grep: # AST 기반 코드 패턴 검사 + packages: [] + rule_dirs: [] + util_dirs: [] + essential_rules: true + +# CodeRabbit AI 챗 기능을 사용 가능하게 하고, +# 한 번에 처리 가능한 토큰 수를 최대 4096으로 제한 +chat: + enabled: true + max_token_length: 4096 + + +# 지식 기반에 사용할 학습 범위를 지정하십시오. +# 'Local' - Repository +# 'Global'- Organization +# 'Auto' - Repository(users public) + Organization(private) +knowledge_base: + web_search: # AI 웹 검색 허용 + enabled: true + learnings: # 학습 범위 설정 (local, global, auto) + scope: local + issues: # 이슈 자동 참조 범위 설정 (local, global, auto) + scope: auto + jira: + project_keys: [] diff --git a/.gitignore b/.gitignore index 3dd82707..01fa263e 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,7 @@ fastlane/report.xml fastlane/Preview.html fastlane/screenshots/**/*.png fastlane/test_output + +# Cursor +**/buildServer.json +.vscode/* diff --git a/Poppool/CoreLayer/Infrastructure/Infrastructure/Extension/Collection+.swift b/Poppool/CoreLayer/Infrastructure/Infrastructure/Extension/Collection+.swift new file mode 100644 index 00000000..f2c58874 --- /dev/null +++ b/Poppool/CoreLayer/Infrastructure/Infrastructure/Extension/Collection+.swift @@ -0,0 +1,7 @@ +import Foundation + +public extension Collection { + subscript(safe index: Index) -> Element? { + return indices.contains(index) ? self[index] : nil + } +} diff --git a/Poppool/PresentationLayer/DesignSystem/DesignSystem/Components/Layout/CollectionLayoutBuilder.swift b/Poppool/PresentationLayer/DesignSystem/DesignSystem/Components/Layout/CollectionLayoutBuilder.swift new file mode 100644 index 00000000..82c9a901 --- /dev/null +++ b/Poppool/PresentationLayer/DesignSystem/DesignSystem/Components/Layout/CollectionLayoutBuilder.swift @@ -0,0 +1,185 @@ +import UIKit + +public final class CollectionLayoutBuilder { + private var itemSize: NSCollectionLayoutSize? + private var groupSize: NSCollectionLayoutSize? + private var numberOfItemsPerGroup: Int = 1 + private var interItemSpacing: NSCollectionLayoutSpacing? + private var section: NSCollectionLayoutSection? + private var headerItem: NSCollectionLayoutBoundarySupplementaryItem? + + public init() { } + + public init(section existingSection: NSCollectionLayoutSection) { + self.section = existingSection + } + + @discardableResult + public func item( + width: NSCollectionLayoutDimension, + height: NSCollectionLayoutDimension + ) -> Self { + itemSize = NSCollectionLayoutSize( + widthDimension: width, + heightDimension: height + ) + + return self + } + + @discardableResult + public func group( + width: NSCollectionLayoutDimension, + height: NSCollectionLayoutDimension + ) -> Self { + groupSize = NSCollectionLayoutSize( + widthDimension: width, + heightDimension: height + ) + + return self + } + + @discardableResult + public func numberOfItemsPerGroup(_ count: Int) -> Self { + numberOfItemsPerGroup = count + + return self + } + + @discardableResult + public func itemSpacing(_ spacing: CGFloat) -> Self { + interItemSpacing = .fixed(spacing) + + return self + } + + @discardableResult + public func withContentInsets( + top: CGFloat = 0, + leading: CGFloat = 0, + bottom: CGFloat = 0, + trailing: CGFloat = 0 + ) -> Self { + section?.contentInsets = NSDirectionalEdgeInsets( + top: top, + leading: leading, + bottom: bottom, + trailing: trailing + ) + + return self + } + + @discardableResult + public func composeSection(_ axis: UIAxis) -> Self { + guard let itemSize, let groupSize else { + fatalError("Item and Group must be set before creating section") + } + + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + var group: NSCollectionLayoutGroup! + + switch axis { + case .vertical: + group = NSCollectionLayoutGroup.vertical( + layoutSize: groupSize, + subitems: Array(repeating: item, count: numberOfItemsPerGroup) + ) + + case .horizontal: + group = NSCollectionLayoutGroup.horizontal( + layoutSize: groupSize, + subitems: Array(repeating: item, count: numberOfItemsPerGroup) + ) + + default: fatalError("Can't compose section to selected axis") + } + + if let interItemSpacing { + group.interItemSpacing = interItemSpacing + } + + section = NSCollectionLayoutSection(group: group) + + return self + } + + @discardableResult + public func header( + elementKind: String, + width: NSCollectionLayoutDimension = .fractionalWidth(1.0), + height: NSCollectionLayoutDimension = .fractionalHeight(1.0), + alignment: NSRectAlignment = .top + ) -> Self { + let headerSize = NSCollectionLayoutSize( + widthDimension: width, + heightDimension: height + ) + + headerItem = NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: headerSize, + elementKind: elementKind, + alignment: alignment + ) + + if let headerItem { + section?.boundarySupplementaryItems = [headerItem] + } + + return self + } + + @discardableResult + public func withScrollingBehavior(_ behavior: UICollectionLayoutSectionOrthogonalScrollingBehavior) -> Self { + section?.orthogonalScrollingBehavior = behavior + + return self + } + + @discardableResult + public func groupSpacing(_ spacing: CGFloat) -> Self { + section?.interGroupSpacing = spacing + + return self + } + + @discardableResult + public func modifySection(_ modifier: (NSCollectionLayoutSection) -> Void) -> Self { + if let section = self.section { + modifier(section) + } + return self + } + + @discardableResult + public func withExistingHeader(_ headerItem: NSCollectionLayoutBoundarySupplementaryItem) -> Self { + self.headerItem = headerItem + + if let section = self.section { + section.boundarySupplementaryItems = [headerItem] + } + + return self + } + + @discardableResult + public func header(_ headerItems: [NSCollectionLayoutBoundarySupplementaryItem]) -> Self { + if let section = self.section { + section.boundarySupplementaryItems = headerItems + } + + return self + } + + public func build() -> NSCollectionLayoutSection { + guard let section else { fatalError("Section must be created before building") } + return section + } + + public func buildHeader() -> NSCollectionLayoutBoundarySupplementaryItem { + guard let headerItem else { fatalError("Header must be created before building") } + return headerItem + } +} diff --git a/Poppool/PresentationLayer/DesignSystem/DesignSystem/Components/Layout/CollectionLayoutProvidable.swift b/Poppool/PresentationLayer/DesignSystem/DesignSystem/Components/Layout/CollectionLayoutProvidable.swift new file mode 100644 index 00000000..d7c3efec --- /dev/null +++ b/Poppool/PresentationLayer/DesignSystem/DesignSystem/Components/Layout/CollectionLayoutProvidable.swift @@ -0,0 +1,5 @@ +import UIKit + +public protocol CollectionLayoutProvidable { + func makeLayout() -> NSCollectionLayoutSection +} diff --git a/Poppool/PresentationLayer/DesignSystem/DesignSystem/Components/Layout/GridCollectionLayoutProvider.swift b/Poppool/PresentationLayer/DesignSystem/DesignSystem/Components/Layout/GridCollectionLayoutProvider.swift new file mode 100644 index 00000000..15322073 --- /dev/null +++ b/Poppool/PresentationLayer/DesignSystem/DesignSystem/Components/Layout/GridCollectionLayoutProvider.swift @@ -0,0 +1,17 @@ +import UIKit + +public struct GridCollectionLayoutProvider: CollectionLayoutProvidable { + public init() { } + + public func makeLayout() -> NSCollectionLayoutSection { + return CollectionLayoutBuilder() + .item(width: .fractionalWidth(0.5), height: .absolute(249)) + .group(width: .fractionalWidth(1.0), height: .absolute(249)) + .numberOfItemsPerGroup(2) + .itemSpacing(16) + .composeSection(.horizontal) + .withContentInsets(top: 16, leading: 20, bottom: 0, trailing: 20) + .groupSpacing(24) + .build() + } +} diff --git a/Poppool/PresentationLayer/DesignSystem/DesignSystem/Components/Layout/HeaderLayoutProvidable.swift b/Poppool/PresentationLayer/DesignSystem/DesignSystem/Components/Layout/HeaderLayoutProvidable.swift new file mode 100644 index 00000000..ad2e25fe --- /dev/null +++ b/Poppool/PresentationLayer/DesignSystem/DesignSystem/Components/Layout/HeaderLayoutProvidable.swift @@ -0,0 +1,5 @@ +import UIKit + +public protocol HeaderLayoutProvidable { + func makeHeaderLayout(_ elementKind: String) -> NSCollectionLayoutBoundarySupplementaryItem +} diff --git a/Poppool/PresentationLayer/DesignSystem/DesignSystem/Components/Layout/TagCollectionLayoutProvider.swift b/Poppool/PresentationLayer/DesignSystem/DesignSystem/Components/Layout/TagCollectionLayoutProvider.swift new file mode 100644 index 00000000..47fc72c1 --- /dev/null +++ b/Poppool/PresentationLayer/DesignSystem/DesignSystem/Components/Layout/TagCollectionLayoutProvider.swift @@ -0,0 +1,21 @@ +import UIKit + +public struct TagCollectionLayoutProvider: CollectionLayoutProvidable, HeaderLayoutProvidable { + public init() { } + + public func makeLayout() -> NSCollectionLayoutSection { + return CollectionLayoutBuilder() + .item(width: .estimated(100), height: .absolute(31)) + .group(width: .estimated(100), height: .estimated(31)) + .composeSection(.vertical) + .withScrollingBehavior(.continuous) + .groupSpacing(6) + .build() + } + + public func makeHeaderLayout(_ elementKind: String) -> NSCollectionLayoutBoundarySupplementaryItem { + return CollectionLayoutBuilder() + .header(elementKind: elementKind, height: .absolute(24)) + .buildHeader() + } +} diff --git a/Poppool/PresentationLayer/SearchFeature/SearchFeature.xcodeproj/project.pbxproj b/Poppool/PresentationLayer/SearchFeature/SearchFeature.xcodeproj/project.pbxproj index 339781c4..e600b653 100644 --- a/Poppool/PresentationLayer/SearchFeature/SearchFeature.xcodeproj/project.pbxproj +++ b/Poppool/PresentationLayer/SearchFeature/SearchFeature.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 0506BE882DD79A6C006CDEDE /* RxCocoa in Frameworks */ = {isa = PBXBuildFile; productRef = 0506BE872DD79A6C006CDEDE /* RxCocoa */; }; 052413092DCF7DA100C42E2D /* DesignSystem.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 052413082DCF7DA100C42E2D /* DesignSystem.framework */; }; 0524130A2DCF7DA100C42E2D /* DesignSystem.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 052413082DCF7DA100C42E2D /* DesignSystem.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 054A96202DCE38B500C0DD58 /* SearchFeatureInterface.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 05734BF52DCDA6B90093825D /* SearchFeatureInterface.framework */; }; @@ -40,7 +41,6 @@ 05CFFBF42DCB908B0051129F /* DesignSystem.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 05EC23422DC49AA200C761A5 /* DesignSystem.framework */; }; 05CFFBF52DCB908B0051129F /* DesignSystem.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 05EC23422DC49AA200C761A5 /* DesignSystem.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 05CFFBF72DCB90A10051129F /* SnapKit in Frameworks */ = {isa = PBXBuildFile; productRef = 05CFFBF62DCB90A10051129F /* SnapKit */; }; - 05EC233C2DC49A7600C761A5 /* RxCocoa in Frameworks */ = {isa = PBXBuildFile; productRef = 05EC233B2DC49A7600C761A5 /* RxCocoa */; }; 05EC233E2DC49A7600C761A5 /* RxSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 05EC233D2DC49A7600C761A5 /* RxSwift */; }; 05EC23412DC49A8B00C761A5 /* ReactorKit in Frameworks */ = {isa = PBXBuildFile; productRef = 05EC23402DC49A8B00C761A5 /* ReactorKit */; }; 05EC23472DC49AA800C761A5 /* DomainInterface.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 05EC23462DC49AA800C761A5 /* DomainInterface.framework */; }; @@ -178,6 +178,7 @@ files = ( 05734C442DCDF7240093825D /* PresentationInterface.framework in Frameworks */, 05EC2AE92DC7C07400C761A5 /* RxRelay in Frameworks */, + 0506BE882DD79A6C006CDEDE /* RxCocoa in Frameworks */, 05EC285F2DC5C1CF00C761A5 /* DesignSystem.framework in Frameworks */, 05EC23472DC49AA800C761A5 /* DomainInterface.framework in Frameworks */, 05EC234B2DC49AB400C761A5 /* Then in Frameworks */, @@ -185,7 +186,6 @@ 05EC234E2DC49AC100C761A5 /* SnapKit in Frameworks */, 05734C082DCDA7D20093825D /* SearchFeatureInterface.framework in Frameworks */, 05EC23412DC49A8B00C761A5 /* ReactorKit in Frameworks */, - 05EC233C2DC49A7600C761A5 /* RxCocoa in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -306,12 +306,12 @@ ); name = SearchFeature; packageProductDependencies = ( - 05EC233B2DC49A7600C761A5 /* RxCocoa */, 05EC233D2DC49A7600C761A5 /* RxSwift */, 05EC23402DC49A8B00C761A5 /* ReactorKit */, 05EC234A2DC49AB400C761A5 /* Then */, 05EC234D2DC49AC100C761A5 /* SnapKit */, 05EC2AE82DC7C07400C761A5 /* RxRelay */, + 0506BE872DD79A6C006CDEDE /* RxCocoa */, ); productName = SearchFeature; productReference = 0516336D2DC457A900A6C0D1 /* SearchFeature.framework */; @@ -938,6 +938,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 0506BE872DD79A6C006CDEDE /* RxCocoa */ = { + isa = XCSwiftPackageProductDependency; + package = 05EC233A2DC49A7600C761A5 /* XCRemoteSwiftPackageReference "RxSwift" */; + productName = RxCocoa; + }; 054A96252DCE38E900C0DD58 /* Tabman */ = { isa = XCSwiftPackageProductDependency; package = 054A96242DCE38E900C0DD58 /* XCRemoteSwiftPackageReference "Tabman" */; @@ -973,11 +978,6 @@ package = 05EC234C2DC49AC100C761A5 /* XCRemoteSwiftPackageReference "SnapKit" */; productName = SnapKit; }; - 05EC233B2DC49A7600C761A5 /* RxCocoa */ = { - isa = XCSwiftPackageProductDependency; - package = 05EC233A2DC49A7600C761A5 /* XCRemoteSwiftPackageReference "RxSwift" */; - productName = RxCocoa; - }; 05EC233D2DC49A7600C761A5 /* RxSwift */ = { isa = XCSwiftPackageProductDependency; package = 05EC233A2DC49A7600C761A5 /* XCRemoteSwiftPackageReference "RxSwift" */; diff --git a/Poppool/PresentationLayer/SearchFeature/SearchFeature/PopupSearch/Factory/PopupSearchLayoutFactory.swift b/Poppool/PresentationLayer/SearchFeature/SearchFeature/PopupSearch/Factory/PopupSearchLayoutFactory.swift index 77e6d8e7..e134a780 100644 --- a/Poppool/PresentationLayer/SearchFeature/SearchFeature/PopupSearch/Factory/PopupSearchLayoutFactory.swift +++ b/Poppool/PresentationLayer/SearchFeature/SearchFeature/PopupSearch/Factory/PopupSearchLayoutFactory.swift @@ -1,139 +1,78 @@ import UIKit +import DesignSystem + // MARK: - Layout -final class PopupSearchLayoutFactory { +struct PopupSearchLayoutFactory { + private let tagLayoutProvider = TagCollectionLayoutProvider() + private let gridLayoutProvider = GridCollectionLayoutProvider() + + private var sectionProvider: ((Int) -> PopupSearchSection?)? - func makeCollectionViewLayout( - dataSourceProvider: @escaping () -> UICollectionViewDiffableDataSource? - ) -> UICollectionViewLayout { - return UICollectionViewCompositionalLayout(sectionProvider: { [weak self] sectionIndex, _ -> NSCollectionLayoutSection? in - guard let self = self, - let dataSource = dataSourceProvider() else { return nil } + mutating func setSectionProvider(_ provider: @escaping (Int) -> PopupSearchSection?) { + self.sectionProvider = provider + } - // sectionIndex를 사용하여 현재 dataSource에서 Section 타입을 가져옴 - guard sectionIndex < dataSource.snapshot().numberOfSections, - let sectionType = dataSource.sectionIdentifier(for: sectionIndex) else { return nil } + func makeCollectionViewLayout() -> UICollectionViewLayout { + return UICollectionViewCompositionalLayout { (sectionIndex, _) -> NSCollectionLayoutSection? in + + guard let sectionType = sectionProvider?(sectionIndex) else { return nil } switch sectionType { case .recentSearch: - return makeTagSectionLayout(PopupSearchView.SectionHeaderKind.recentSearch.rawValue) + return makeRecentSearchSectionLayout() case .category: - return makeTagSectionLayout(PopupSearchView.SectionHeaderKind.category.rawValue) + return makeCategorySectionLayout() case .searchResultHeader: return makeSearchResultHeaderSectionLayout() case .searchResult: - let sectionSnapshot = dataSource.snapshot(for: sectionType) - let hasEmptyItem = sectionSnapshot.items.contains { item in - if case .searchResultEmptyTitle = item { return true } - return false - } - return makeSearchResultSectionLayout(hasEmptyItem: hasEmptyItem) + return self.gridLayoutProvider.makeLayout() + + case .searchResultEmpty: + return makeSearchResultEmptySectionLayout() } - }) + } } - func makeTagSectionLayout(_ headerKind: String) -> NSCollectionLayoutSection { - // Item - let itemSize = NSCollectionLayoutSize( - widthDimension: .estimated(100), - heightDimension: .absolute(31) - ) - let item = NSCollectionLayoutItem(layoutSize: itemSize) - - // Group - let groupSize = NSCollectionLayoutSize( - widthDimension: .estimated(100), - heightDimension: .estimated(31) - ) - let group = NSCollectionLayoutGroup.horizontal( - layoutSize: groupSize, - subitems: [item] - ) - - // Section - let section = NSCollectionLayoutSection(group: group) - section.orthogonalScrollingBehavior = .continuous - - if headerKind == PopupSearchView.SectionHeaderKind.recentSearch.rawValue { - section.contentInsets = NSDirectionalEdgeInsets(top: 16, leading: 20, bottom: 48, trailing: 20) - } else { - section.contentInsets = NSDirectionalEdgeInsets(top: 16, leading: 20, bottom: 16, trailing: 20) - } + private func makeRecentSearchSectionLayout() -> NSCollectionLayoutSection { - section.interGroupSpacing = 6 + return CollectionLayoutBuilder(section: tagLayoutProvider.makeLayout()) + .withContentInsets(top: 16, leading: 20, bottom: 48, trailing: 20) + .header([self.tagLayoutProvider.makeHeaderLayout( + PopupSearchView.SectionHeaderKind.recentSearch.rawValue + )]) + .build() + } - section.boundarySupplementaryItems = [makeTagCollectionHeaderLayout(headerKind)] + private func makeCategorySectionLayout() -> NSCollectionLayoutSection { - return section + return CollectionLayoutBuilder(section: tagLayoutProvider.makeLayout()) + .withContentInsets(top: 16, leading: 20, bottom: 16, trailing: 20) + .header([ + tagLayoutProvider.makeHeaderLayout(PopupSearchView.SectionHeaderKind.category.rawValue) + ]) + .build() } - func makeSearchResultHeaderSectionLayout() -> NSCollectionLayoutSection { - // Item - let itemSize = NSCollectionLayoutSize( - widthDimension: .fractionalWidth(1.0), - heightDimension: .estimated(22) - ) - let item = NSCollectionLayoutItem(layoutSize: itemSize) - - // Group - let groupSize = NSCollectionLayoutSize( - widthDimension: .fractionalWidth(1.0), - heightDimension: .estimated(22) - ) - let group = NSCollectionLayoutGroup.horizontal( - layoutSize: groupSize, - subitems: [item] - ) - - // Section - let section = NSCollectionLayoutSection(group: group) - section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20) - - return section - } + private func makeSearchResultHeaderSectionLayout() -> NSCollectionLayoutSection { - func makeSearchResultSectionLayout(hasEmptyItem: Bool) -> NSCollectionLayoutSection { - let itemWidth: NSCollectionLayoutDimension = hasEmptyItem ? .fractionalWidth(1.0) : .fractionalWidth(0.5) - - // Item - let itemSize = NSCollectionLayoutSize( - widthDimension: itemWidth, - heightDimension: .absolute(249) - ) - let item = NSCollectionLayoutItem(layoutSize: itemSize) - - // Group - let groupSize = NSCollectionLayoutSize( - widthDimension: .fractionalWidth(1.0), - heightDimension: .absolute(249) - ) - let group = NSCollectionLayoutGroup.horizontal( - layoutSize: groupSize, - subitems: [item, item] - ) - group.interItemSpacing = .fixed(16) - - // Section - let section = NSCollectionLayoutSection(group: group) - section.contentInsets = NSDirectionalEdgeInsets(top: 16, leading: 20, bottom: 0, trailing: 20) - section.interGroupSpacing = 24 - - return section + return CollectionLayoutBuilder() + .item(width: .fractionalWidth(1.0), height: .estimated(22)) + .group(width: .fractionalWidth(1.0), height: .estimated(22)) + .composeSection(.horizontal) + .withContentInsets(top: 0, leading: 20, bottom: 0, trailing: 20) + .build() } - func makeTagCollectionHeaderLayout(_ elementKind: String) -> NSCollectionLayoutBoundarySupplementaryItem { - // Header - let headerSize = NSCollectionLayoutSize( - widthDimension: .fractionalWidth(1.0), - heightDimension: .absolute(24) - ) - return NSCollectionLayoutBoundarySupplementaryItem( - layoutSize: headerSize, - elementKind: elementKind, - alignment: .top - ) + private func makeSearchResultEmptySectionLayout() -> NSCollectionLayoutSection { + + return CollectionLayoutBuilder() + .item(width: .fractionalWidth(1.0), height: .fractionalHeight(1.0)) + .group(width: .fractionalWidth(1.0), height: .fractionalHeight(1.0)) + .composeSection(.vertical) + .build() } } diff --git a/Poppool/PresentationLayer/SearchFeature/SearchFeature/PopupSearch/Reactor/PopupSearchReactor.swift b/Poppool/PresentationLayer/SearchFeature/SearchFeature/PopupSearch/Reactor/PopupSearchReactor.swift index 646443d1..2c931308 100644 --- a/Poppool/PresentationLayer/SearchFeature/SearchFeature/PopupSearch/Reactor/PopupSearchReactor.swift +++ b/Poppool/PresentationLayer/SearchFeature/SearchFeature/PopupSearch/Reactor/PopupSearchReactor.swift @@ -48,9 +48,8 @@ public final class PopupSearchReactor: Reactor { case updateClearButtonIsHidden(to: Bool) case updateCurrentPage(to: Int32) case updateSearchingState(to: Bool) - case updateSearchResultEmptyTitle case updateSearchResultBookmark(indexPath: IndexPath) - case updateSearchResultDataSource + case updateSearchResultSection case present(target: PresentTarget) } @@ -68,13 +67,12 @@ public final class PopupSearchReactor: Reactor { var categoryItems: [TagModel] = [] var searchResultItems: [SearchResultModel] = [] var searchResultHeader: SearchResultHeaderModel = SearchResultHeaderModel(filterText: Filter.shared.title) - var searchResultEmptyTitle: String? @Pulse var searchBarText: String? = nil @Pulse var present: PresentTarget? @Pulse var clearButtonIsHidden: Bool? @Pulse var endEditing: Void? - @Pulse var updateSearchResultDataSource: Void? + @Pulse var updateSearchResultSection: String? @Pulse var dismiss: Void? fileprivate var isSearching: Bool = false @@ -109,193 +107,55 @@ public final class PopupSearchReactor: Reactor { public func mutate(action: Action) -> Observable { switch action { case .viewDidLoad: - return fetchSearchResult() - .withUnretained(self) - .flatMap { (owner, response) -> Observable in - return Observable.concat([ - .just(.setupRecentSearch(items: owner.makeRecentSearchItems())), - .just(.setupCategory(items: owner.makeCategoryItems())), - .just(.setupSearchResultHeader(item: owner.makeSearchResultHeaderInput(count: response.totalElements))), - .just(.setupSearchResult(items: owner.makeSearchResultItems(response.popUpStoreList, response.loginYn))), - .just(.setupSearchResultTotalPageCount(count: response.totalPages)), - .just(.updateCurrentPage(to: 0)), - .just(.updateSearchResultEmptyTitle), - .just(.updateSearchResultDataSource) - ]) - } + return handleViewDidLoad() case .searchBarEditing(let text): - return .just(.updateClearButtonIsHidden(to: text.isEmpty ? true : false)) + return handleSearchBarEditing(text) case .searchBarExitEditing(let text): - return fetchSearchResult(keyword: text) - .withUnretained(self) - .flatMap { (owner, response) -> Observable in - return Observable.concat([ - .just(.setupRecentSearch(items: [])), - .just(.setupCategory(items: [])), - .just(.setupSearchResult(items: owner.makeSearchResultItems(response.popupStoreList, response.loginYn))), - .just(.setupSearchResultHeader(item: owner.makeSearchResultHeaderInput( - keyword: owner.makePostPositionedText(text), - count: Int64(response.popupStoreList.count) - ))), // FIXME: API에 해당 결과값이 아직 없음 - .just(.setupSearchResultTotalPageCount(count: 0)), // FIXME: API에 해당 결과값이 아직 없음 - .just(.updateCurrentPage(to: 0)), - .just(.updateSearchingState(to: true)), - .just(.updateSearchResultEmptyTitle), - .just(.updateClearButtonIsHidden(to: true)), - .just(.updateEditingState), - .just(.updateSearchResultDataSource) - ]) - } + return handleSearchBarExitEditing(text) case .searchBarEndEditing: - return .concat([ - .just(.updateClearButtonIsHidden(to: true)), - .just(.updateEditingState) - ]) + return handleSearchBarEndEditing() case .searchBarClearButtonTapped: - return Observable.concat([ - .just(.updateClearButtonIsHidden(to: true)), - .just(.updateSearchBar(to: nil)) - ]) + return handleSearchBarClear() case .searchBarCancelButtonTapped: - if currentState.isSearching { - return fetchSearchResult() - .withUnretained(self) - .flatMap { (owner, response) -> Observable in - return Observable.concat([ - .just(.setupRecentSearch(items: owner.makeRecentSearchItems())), - .just(.setupCategory(items: owner.makeCategoryItems())), - .just(.setupSearchResult(items: owner.makeSearchResultItems(response.popUpStoreList, response.loginYn))), - .just(.setupSearchResultHeader(item: owner.makeSearchResultHeaderInput(count: response.totalElements))), - .just(.setupSearchResultTotalPageCount(count: response.totalPages)), - .just(.updateCurrentPage(to: 0)), - .just(.updateSearchingState(to: false)), - .just(.updateSearchResultEmptyTitle), - .just(.updateSearchBar(to: nil)), - .just(.updateEditingState), - .just(.updateSearchResultDataSource) - ]) - } - } else { return .just(.present(target: .before)) } + return handleSearchBarCancel() case .recentSearchTagButtonTapped(let indexPath): - let keyword = self.findRecentSearchKeyword(at: indexPath) - return fetchSearchResult(keyword: keyword) - .withUnretained(self) - .flatMap { (owner, response) -> Observable in - return Observable.concat([ - .just(.setupRecentSearch(items: [])), - .just(.setupCategory(items: [])), - .just(.setupSearchResult(items: owner.makeSearchResultItems(response.popupStoreList, response.loginYn))), - .just(.setupSearchResultHeader(item: owner.makeSearchResultHeaderInput( - keyword: owner.makePostPositionedText(keyword), - count: Int64(response.popupStoreList.count) - ))), - .just(.setupSearchResultTotalPageCount(count: 0)), // FIXME: API에 해당 결과값이 아직 없음 - .just(.updateCurrentPage(to: 0)), - .just(.updateSearchBar(to: keyword)), - .just(.updateSearchingState(to: true)), - .just(.updateSearchResultEmptyTitle), - .just(.updateClearButtonIsHidden(to: true)), - .just(.updateEditingState), - .just(.updateSearchResultDataSource) - ]) - } + return handleRecentSearchTagTap(at: indexPath) case .recentSearchTagRemoveButtonTapped(let text): - self.removeRecentSearchItem(text: text) - return Observable.concat([ - .just(.setupRecentSearch(items: self.makeRecentSearchItems())), - .just(.updateSearchResultDataSource) - ]) + return handleRecentSearchTagRemove(text) case .recentSearchTagRemoveAllButtonTapped: - self.removeAllRecentSearchItems() - return .concat([ - .just(.setupRecentSearch(items: self.makeRecentSearchItems())), - .just(.updateSearchResultDataSource) - ]) + return handleRecentSearchTagRemoveAll() case .categoryTagRemoveButtonTapped(let categoryID): - self.removeCategoryItem(by: categoryID) - return fetchSearchResult() - .withUnretained(self) - .flatMap { (owner, response) -> Observable in - return Observable.concat([ - .just(.setupCategory(items: owner.makeCategoryItems())), - .just(.setupSearchResult(items: owner.makeSearchResultItems(response.popUpStoreList, response.loginYn))), - .just(.setupSearchResultHeader(item: owner.makeSearchResultHeaderInput(count: response.totalElements))), - .just(.setupSearchResultTotalPageCount(count: response.totalPages)), - .just(.updateCurrentPage(to: 0)), - .just(.updateSearchResultEmptyTitle), - .just(.updateSearchResultDataSource) - ]) - } + return handleCategoryTagRemove(categoryID) case .categoryTagButtonTapped: return .just(.present(target: .categorySelector)) case .categoryChangedBySelector: - return fetchSearchResult() - .withUnretained(self) - .flatMap { (owner, response) -> Observable in - return .concat([ - .just(.setupRecentSearch(items: owner.makeRecentSearchItems())), - .just(.setupCategory(items: owner.makeCategoryItems())), - .just(.setupSearchResult(items: owner.makeSearchResultItems(response.popUpStoreList, response.loginYn))), - .just(.setupSearchResultHeader(item: owner.makeSearchResultHeaderInput(count: response.totalElements))), - .just(.setupSearchResultTotalPageCount(count: response.totalPages)), - .just(.updateCurrentPage(to: 0)), - .just(.updateSearchResultEmptyTitle), - .just(.updateSearchResultDataSource) - ]) - } + return handleCategoryChanged() case .searchResultFilterButtonTapped: return .just(.present(target: .filterSelector)) case .searchResultFilterChangedBySelector: - return fetchSearchResult() - .withUnretained(self) - .flatMap { (owner, response) -> Observable in - return .concat([ - .just(.setupRecentSearch(items: owner.makeRecentSearchItems())), - .just(.setupCategory(items: owner.makeCategoryItems())), - .just(.setupSearchResult(items: owner.makeSearchResultItems(response.popUpStoreList, response.loginYn))), - .just(.setupSearchResultHeader(item: owner.makeSearchResultHeaderInput(count: response.totalElements))), - .just(.setupSearchResultTotalPageCount(count: response.totalPages)), - .just(.updateCurrentPage(to: 0)), - .just(.updateSearchResultEmptyTitle), - .just(.updateSearchResultDataSource) - ]) - } + return handleFilterChanged() case .searchResultItemTapped(let indexPath): - guard let popupID = self.findPopupStoreID(at: indexPath) else { return .empty() } - return .just(.present(target: .popupDetail(popupID: popupID))) + return handleSearchResultItemTap(at: indexPath) case .searchResultBookmarkButtonTapped(let indexPath): - return fetchSearchResultBookmark(at: indexPath) - .andThen(.concat([ - .just(.updateSearchResultBookmark(indexPath: indexPath)), - .just(.updateSearchResultDataSource) - ])) + return handleSearchResultBookmark(at: indexPath) case .searchResultPrefetchItems(let indexPathList): - guard isPrefetchable(indexPathList: indexPathList) else { return .empty() } - return fetchSearchResult(page: currentState.currentPage + 1) - .withUnretained(self) - .flatMap { (owner, response) -> Observable in - return .concat([ - .just(.appendSearchResult(items: owner.makeSearchResultItems(response.popUpStoreList, response.loginYn))), - .just(.updateCurrentPage(to: owner.currentState.currentPage + 1)), - .just(.updateSearchResultDataSource) - ]) - } + return handleSearchResultPrefetch(at: indexPathList) } } @@ -335,14 +195,11 @@ public final class PopupSearchReactor: Reactor { case .updateSearchingState(let isSearching): newState.isSearching = isSearching - case .updateSearchResultEmptyTitle: - newState.searchResultEmptyTitle = makeSearchResultEmptyTitle(state: newState) - case .updateSearchResultBookmark(let indexPath): newState.searchResultItems[indexPath.item].isBookmark.toggle() - case .updateSearchResultDataSource: - newState.updateSearchResultDataSource = () + case .updateSearchResultSection: + newState.updateSearchResultSection = makeSearchResultEmpty(state: newState) case .present(let target): newState.present = target @@ -437,7 +294,7 @@ private extension PopupSearchReactor { ) } - func makeSearchResultEmptyTitle(state: State) -> String? { + func makeSearchResultEmpty(state: State) -> String? { if !currentState.searchResultItems.isEmpty { return nil } else if currentState.isSearching { return "검색 결과가 없어요 :(\n다른 키워드로 검색해주세요" } else { return "검색 결과가 없어요 :(\n다른 옵션을 선택해주세요" } } @@ -486,3 +343,145 @@ private extension PopupSearchReactor { return isScrollToEnd && hasNextPage } } + +// MARK: - Mutate Handlers +private extension PopupSearchReactor { + func handleViewDidLoad() -> Observable { + return loadDefaultSearchResults() + } + + func handleSearchBarEditing(_ text: String) -> Observable { + return .just(.updateClearButtonIsHidden(to: text.isEmpty)) + } + + func handleSearchBarExitEditing(_ text: String) -> Observable { + return loadKeywordSearchResults(text) + } + + func handleSearchBarEndEditing() -> Observable { + return Observable.concat([ + .just(.updateClearButtonIsHidden(to: true)), + .just(.updateEditingState) + ]) + } + + func handleSearchBarClear() -> Observable { + return Observable.concat([ + .just(.updateClearButtonIsHidden(to: true)), + .just(.updateSearchBar(to: nil)) + ]) + } + + func handleSearchBarCancel() -> Observable { + if currentState.isSearching { + return loadDefaultSearchResults() + } else { + return .just(.present(target: .before)) + } + } + + func handleRecentSearchTagTap(at indexPath: IndexPath) -> Observable { + let keyword = findRecentSearchKeyword(at: indexPath) + return loadKeywordSearchResults(keyword) + } + + func handleRecentSearchTagRemove(_ text: String) -> Observable { + removeRecentSearchItem(text: text) + return Observable.concat([ + .just(.setupRecentSearch(items: makeRecentSearchItems())), + .just(.updateSearchResultSection) + ]) + } + + func handleRecentSearchTagRemoveAll() -> Observable { + removeAllRecentSearchItems() + return Observable.concat([ + .just(.setupRecentSearch(items: makeRecentSearchItems())), + .just(.updateSearchResultSection) + ]) + } + + func handleCategoryTagRemove(_ categoryID: Int) -> Observable { + removeCategoryItem(by: categoryID) + return loadDefaultSearchResults() + } + + func handleCategoryChanged() -> Observable { + return loadDefaultSearchResults() + } + + func handleFilterChanged() -> Observable { + return loadDefaultSearchResults() + } + + func handleSearchResultItemTap(at indexPath: IndexPath) -> Observable { + guard let popupID = findPopupStoreID(at: indexPath) else { return .empty() } + return .just(.present(target: .popupDetail(popupID: popupID))) + } + + func handleSearchResultBookmark(at indexPath: IndexPath) -> Observable { + return fetchSearchResultBookmark(at: indexPath) + .andThen(.concat([ + .just(.updateSearchResultBookmark(indexPath: indexPath)), + .just(.updateSearchResultSection) + ])) + } + + func handleSearchResultPrefetch(at indexPathList: [IndexPath]) -> Observable { + guard isPrefetchable(prefetchCount: 4, indexPathList: indexPathList) else { return .empty() } + return fetchSearchResult(page: currentState.currentPage + 1) + .withUnretained(self) + .flatMap { owner, response in + Observable.concat([ + .just(.appendSearchResult(items: owner.makeSearchResultItems(response.popUpStoreList, response.loginYn))), + .just(.updateCurrentPage(to: owner.currentState.currentPage + 1)), + .just(.updateSearchResultSection) + ]) + } + } +} + +// MARK: - Load Search Results +private extension PopupSearchReactor { + func loadDefaultSearchResults(page: Int32 = 0) -> Observable { + return fetchSearchResult(page: page) + .withUnretained(self) + .flatMap { owner, response in + Observable.concat([ + .just(.setupRecentSearch(items: owner.makeRecentSearchItems())), + .just(.setupCategory(items: owner.makeCategoryItems())), + .just(.setupSearchResultHeader(item: owner.makeSearchResultHeaderInput(count: response.totalElements))), + .just(.setupSearchResult(items: owner.makeSearchResultItems(response.popUpStoreList, response.loginYn))), + .just(.setupSearchResultTotalPageCount(count: response.totalPages)), + .just(.updateCurrentPage(to: 0)), + .just(.updateSearchingState(to: false)), + .just(.updateSearchBar(to: nil)), + .just(.updateEditingState), + .just(.updateSearchResultSection) + ]) + } + } + + func loadKeywordSearchResults(_ keyword: String?) -> Observable { + guard let keyword = keyword else { return .empty() } + return fetchSearchResult(keyword: keyword) + .withUnretained(self) + .flatMap { owner, response in + Observable.concat([ + .just(.setupRecentSearch(items: [])), + .just(.setupCategory(items: [])), + .just(.setupSearchResult(items: owner.makeSearchResultItems(response.popupStoreList, response.loginYn))), + .just(.setupSearchResultHeader(item: owner.makeSearchResultHeaderInput( + keyword: owner.makePostPositionedText(keyword), + count: Int64(response.popupStoreList.count) + ))), + .just(.setupSearchResultTotalPageCount(count: 0)), + .just(.updateCurrentPage(to: 0)), + .just(.updateSearchingState(to: true)), + .just(.updateClearButtonIsHidden(to: true)), + .just(.updateEditingState), + .just(.updateSearchResultSection) + ]) + } + } +} diff --git a/Poppool/PresentationLayer/SearchFeature/SearchFeature/PopupSearch/SectionType/Section.swift b/Poppool/PresentationLayer/SearchFeature/SearchFeature/PopupSearch/SectionType/Section.swift new file mode 100644 index 00000000..bd858660 --- /dev/null +++ b/Poppool/PresentationLayer/SearchFeature/SearchFeature/PopupSearch/SectionType/Section.swift @@ -0,0 +1,9 @@ +import Foundation + +enum PopupSearchSection: CaseIterable, Hashable { + case recentSearch + case category + case searchResultHeader + case searchResult + case searchResultEmpty +} diff --git a/Poppool/PresentationLayer/SearchFeature/SearchFeature/PopupSearch/View/Component/Cell/SearchResultEmptyTitleCollectionViewCell.swift b/Poppool/PresentationLayer/SearchFeature/SearchFeature/PopupSearch/View/Component/Cell/SearchResultEmptyCollectionViewCell.swift similarity index 84% rename from Poppool/PresentationLayer/SearchFeature/SearchFeature/PopupSearch/View/Component/Cell/SearchResultEmptyTitleCollectionViewCell.swift rename to Poppool/PresentationLayer/SearchFeature/SearchFeature/PopupSearch/View/Component/Cell/SearchResultEmptyCollectionViewCell.swift index 7d3786fa..9a82752c 100644 --- a/Poppool/PresentationLayer/SearchFeature/SearchFeature/PopupSearch/View/Component/Cell/SearchResultEmptyTitleCollectionViewCell.swift +++ b/Poppool/PresentationLayer/SearchFeature/SearchFeature/PopupSearch/View/Component/Cell/SearchResultEmptyCollectionViewCell.swift @@ -6,7 +6,7 @@ import Infrastructure import SnapKit import Then -final class SearchResultEmptyTitleCollectionViewCell: UICollectionViewCell { +final class SearchResultEmptyCollectionViewCell: UICollectionViewCell { // MARK: - Properties private let emptyLabel = PPLabel( @@ -34,7 +34,7 @@ final class SearchResultEmptyTitleCollectionViewCell: UICollectionViewCell { } // MARK: - SetUp -private extension SearchResultEmptyTitleCollectionViewCell { +private extension SearchResultEmptyCollectionViewCell { func addViews() { [emptyLabel].forEach { self.addSubview($0) @@ -52,7 +52,7 @@ private extension SearchResultEmptyTitleCollectionViewCell { func configureUI() { } } -extension SearchResultEmptyTitleCollectionViewCell { +extension SearchResultEmptyCollectionViewCell { func configureCell(title: String) { self.emptyLabel.text = title } diff --git a/Poppool/PresentationLayer/SearchFeature/SearchFeature/PopupSearch/View/PopupSearchView.swift b/Poppool/PresentationLayer/SearchFeature/SearchFeature/PopupSearch/View/PopupSearchView.swift index 2c1421be..bcd5b3e8 100644 --- a/Poppool/PresentationLayer/SearchFeature/SearchFeature/PopupSearch/View/PopupSearchView.swift +++ b/Poppool/PresentationLayer/SearchFeature/SearchFeature/PopupSearch/View/PopupSearchView.swift @@ -11,8 +11,8 @@ import Then final class PopupSearchView: UIView { // MARK: - Properties - private var dataSource: UICollectionViewDiffableDataSource? - private let layoutFactory: PopupSearchLayoutFactory = PopupSearchLayoutFactory() + private var dataSource: UICollectionViewDiffableDataSource? + private var layoutFactory: PopupSearchLayoutFactory = PopupSearchLayoutFactory() let recentSearchTagRemoveButtonTapped = PublishRelay() let recentSearchTagRemoveAllButtonTapped = PublishRelay() @@ -27,9 +27,12 @@ final class PopupSearchView: UIView { public let searchBar = PPSearchBarView() lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: .init()).then { - let layout = layoutFactory.makeCollectionViewLayout { [weak self] in self?.dataSource } + layoutFactory.setSectionProvider { [weak self] index in + guard let self, let dataSource else { return nil } + return dataSource.sectionIdentifier(for: index) + } - $0.setCollectionViewLayout(layout, animated: false) + $0.setCollectionViewLayout(layoutFactory.makeCollectionViewLayout(), animated: false) $0.register( TagCollectionHeaderView.self, @@ -59,8 +62,8 @@ final class PopupSearchView: UIView { ) $0.register( - SearchResultEmptyTitleCollectionViewCell.self, - forCellWithReuseIdentifier: SearchResultEmptyTitleCollectionViewCell.identifiers + SearchResultEmptyCollectionViewCell.self, + forCellWithReuseIdentifier: SearchResultEmptyCollectionViewCell.identifiers ) // UICollectionView 최 상/하단 빈 영역 @@ -118,7 +121,7 @@ private extension PopupSearchView { extension PopupSearchView { private func configurationDataSourceItem() { self.dataSource = UICollectionViewDiffableDataSource< - PopupSearchView.Section, + PopupSearchSection, PopupSearchView.SectionItem >( collectionView: collectionView @@ -206,11 +209,11 @@ extension PopupSearchView { return cell - case .searchResultEmptyTitle(let title): + case .searchResultEmptyItem(let title): let cell = collectionView.dequeueReusableCell( - withReuseIdentifier: SearchResultEmptyTitleCollectionViewCell.identifiers, + withReuseIdentifier: SearchResultEmptyCollectionViewCell.identifiers, for: indexPath - ) as! SearchResultEmptyTitleCollectionViewCell + ) as! SearchResultEmptyCollectionViewCell cell.configureCell(title: title) @@ -252,7 +255,7 @@ extension PopupSearchView { } } - func updateSectionSnapshot(at section: Section, with items: [SectionItem]) { + func updateSectionSnapshot(at section: PopupSearchSection, with items: [SectionItem]) { if items.isEmpty { guard var snapshot = dataSource?.snapshot() else { return } snapshot.deleteSections([section]) @@ -278,43 +281,37 @@ extension PopupSearchView { empty: SectionItem? = nil ) { guard var snapshot = dataSource?.snapshot() else { return } - - snapshot.deleteSections([.searchResultHeader, .searchResult]) - - snapshot.appendSections( [.searchResultHeader, .searchResult]) - snapshot.appendItems([header], toSection: .searchResultHeader) + snapshot.deleteSections([.searchResultHeader, .searchResult, .searchResultEmpty]) if let empty { - snapshot.appendItems([empty], toSection: .searchResult) + snapshot.appendSections([.searchResultHeader, .searchResultEmpty]) + snapshot.appendItems([header], toSection: .searchResultHeader) + snapshot.appendItems([empty], toSection: .searchResultEmpty) + collectionView.isScrollEnabled = false } else { + snapshot.appendSections([.searchResultHeader, .searchResult]) + snapshot.appendItems([header], toSection: .searchResultHeader) snapshot.appendItems(items, toSection: .searchResult) + collectionView.isScrollEnabled = true } dataSource?.apply(snapshot, animatingDifferences: false) } - func getSectionsFromDataSource() -> [Section] { + func getSectionsFromDataSource() -> [PopupSearchSection] { return dataSource?.snapshot().sectionIdentifiers ?? [] } } // MARK: - Section information extension PopupSearchView { - /// View를 구성하는 section을 정의 - enum Section: CaseIterable, Hashable { - case recentSearch - case category - case searchResultHeader - case searchResult - } - /// Section에 들어갈 Item을 정의한 변수 enum SectionItem: Hashable { case recentSearchItem(TagModel) case categoryItem(TagModel) case searchResultHeaderItem(SearchResultHeaderModel) case searchResultItem(SearchResultModel) - case searchResultEmptyTitle(String) + case searchResultEmptyItem(String) } /// Section의 헤더를 구분하기 위한 변수 diff --git a/Poppool/PresentationLayer/SearchFeature/SearchFeature/PopupSearch/View/PopupSearchViewController.swift b/Poppool/PresentationLayer/SearchFeature/SearchFeature/PopupSearch/View/PopupSearchViewController.swift index 45262a60..f95cb7d0 100644 --- a/Poppool/PresentationLayer/SearchFeature/SearchFeature/PopupSearch/View/PopupSearchViewController.swift +++ b/Poppool/PresentationLayer/SearchFeature/SearchFeature/PopupSearch/View/PopupSearchViewController.swift @@ -93,12 +93,18 @@ extension PopupSearchViewController { switch sections[indexPath.section] { case .recentSearch: return Reactor.Action.recentSearchTagButtonTapped(indexPath: indexPath) + case .category: return Reactor.Action.categoryTagButtonTapped - case .searchResultHeader: return nil + case .searchResultHeader: + return nil + case .searchResult: return Reactor.Action.searchResultItemTapped(indexPath: indexPath) + + case .searchResultEmpty: + return nil } } .bind(to: reactor.action) @@ -201,22 +207,18 @@ extension PopupSearchViewController { } .disposed(by: disposeBag) - reactor.pulse(\.$updateSearchResultDataSource) + reactor.pulse(\.$updateSearchResultSection) .withLatestFrom(reactor.state) .withUnretained(self) .subscribe { (owner, state) in - if let emptyTitle = state.searchResultEmptyTitle { - owner.mainView.updateSearchResultSectionSnapshot( - with: state.searchResultItems.map(PopupSearchView.SectionItem.searchResultItem), - header: PopupSearchView.SectionItem.searchResultHeaderItem(state.searchResultHeader), - empty: PopupSearchView.SectionItem.searchResultEmptyTitle(emptyTitle) - ) - } else { - owner.mainView.updateSearchResultSectionSnapshot( - with: state.searchResultItems.map(PopupSearchView.SectionItem.searchResultItem), - header: PopupSearchView.SectionItem.searchResultHeaderItem(state.searchResultHeader) - ) - } + let isEmpty = state.updateSearchResultSection == nil + let emptyCaseTitle = state.updateSearchResultSection + + owner.mainView.updateSearchResultSectionSnapshot( + with: state.searchResultItems.map(PopupSearchView.SectionItem.searchResultItem), + header: PopupSearchView.SectionItem.searchResultHeaderItem(state.searchResultHeader), + empty: isEmpty ? nil : PopupSearchView.SectionItem.searchResultEmptyItem(emptyCaseTitle!) + ) } .disposed(by: disposeBag) }