From 4861844d15e1563a85f9c3db6c0ec7c8627ea312 Mon Sep 17 00:00:00 2001 From: onesunny2 Date: Thu, 7 May 2026 23:07:58 +0900 Subject: [PATCH 01/12] =?UTF-8?q?[Feat]=20#703=20-=20=EC=B5=9C=EA=B7=BC=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=EC=96=B4=20=EB=AC=B8=EC=9E=90=EC=97=B4=20?= =?UTF-8?q?=EB=B0=8F=20URL=20=EC=83=81=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Resource/Constants/Strings/StringLiterals+Search.swift | 3 +++ WSSiOS/Resource/Constants/URLs/URLs.swift | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/WSSiOS/Resource/Constants/Strings/StringLiterals+Search.swift b/WSSiOS/Resource/Constants/Strings/StringLiterals+Search.swift index 852bfb100..067a01402 100644 --- a/WSSiOS/Resource/Constants/Strings/StringLiterals+Search.swift +++ b/WSSiOS/Resource/Constants/Strings/StringLiterals+Search.swift @@ -11,6 +11,9 @@ extension StringLiterals { enum Search { static let title = "탐색하기" static let searchbar = "작품 제목, 작가를 검색하세요" + + static let recentSearchTitle = "최근 검색어" + static let deleteAll = "전체삭제" static let induceTitle = "뭐 읽을지 고민될 땐?" static let induceDescription = "장르, 연재상태, 별점, 키워드로 작품 찾기" diff --git a/WSSiOS/Resource/Constants/URLs/URLs.swift b/WSSiOS/Resource/Constants/URLs/URLs.swift index 96e81de3a..0bd70ef9a 100644 --- a/WSSiOS/Resource/Constants/URLs/URLs.swift +++ b/WSSiOS/Resource/Constants/URLs/URLs.swift @@ -191,6 +191,11 @@ enum URLs { static let sosoPick = "/soso-picks" static let normalSearch = "/novels" static let detailSearch = "/novels/filtered" + static let recentSearch = "/novels/recent-searches" + static func deleteRecentSearchKeyword(id: Int) -> String { + return "/novels/recent-searches/\(id)" + } + static let deleteAllRecentSearchKeywords = "/novels/recent-searches" } enum Keyword { From 157db4c5b5d9a1626d0546c791226660b4a7dbc2 Mon Sep 17 00:00:00 2001 From: onesunny2 Date: Thu, 7 May 2026 23:08:01 +0900 Subject: [PATCH 02/12] =?UTF-8?q?[Feat]=20#703=20-=20=EC=B5=9C=EA=B7=BC=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=EC=96=B4=20DTO=20=EB=B0=8F=20=EB=84=A4?= =?UTF-8?q?=ED=8A=B8=EC=9B=8C=ED=81=AC=20=EB=A0=88=EC=9D=B4=EC=96=B4=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WSSiOS/Network/Search/SearchService.swift | 48 +++++++++++++++++++ WSSiOS/Source/Data/DTO/SearchResult.swift | 8 +++- .../Data/Repository/SearchRepository.swift | 15 ++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/WSSiOS/Network/Search/SearchService.swift b/WSSiOS/Network/Search/SearchService.swift index c74919dd0..10ec466e3 100644 --- a/WSSiOS/Network/Search/SearchService.swift +++ b/WSSiOS/Network/Search/SearchService.swift @@ -19,11 +19,59 @@ protocol SearchService { keywordIds: [Int], page: Int, size: Int) -> Single + func getRecentSearches() -> Single<[RecentSearch]> + func deleteRecentSearch(id: Int) -> Single + func deleteAllRecentSearches() -> Single } final class DefaultSearchService: NSObject, Networking { } extension DefaultSearchService: SearchService { + func getRecentSearches() -> Single<[RecentSearch]> { + do { + let request = try makeHTTPRequest(method: .get, + path: URLs.Search.recentSearch, + headers: APIConstants.accessTokenHeader, + body: nil) + NetworkLogger.log(request: request) + return tokenCheckURLSession.rx.data(request: request) + .map { try self.decode(data: $0, to: [RecentSearch].self) } + .asSingle() + } catch { + return Single.error(error) + } + } + + func deleteRecentSearch(id: Int) -> Single { + do { + let request = try makeHTTPRequest(method: .delete, + path: URLs.Search.deleteRecentSearchKeyword(id: id), + headers: APIConstants.accessTokenHeader, + body: nil) + NetworkLogger.log(request: request) + return tokenCheckURLSession.rx.data(request: request) + .map { _ in } + .asSingle() + } catch { + return Single.error(error) + } + } + + func deleteAllRecentSearches() -> Single { + do { + let request = try makeHTTPRequest(method: .delete, + path: URLs.Search.deleteAllRecentSearchKeywords, + headers: APIConstants.accessTokenHeader, + body: nil) + NetworkLogger.log(request: request) + return tokenCheckURLSession.rx.data(request: request) + .map { _ in } + .asSingle() + } catch { + return Single.error(error) + } + } + func getSosopicks() -> Single { do { let request = try makeHTTPRequest(method: .get, diff --git a/WSSiOS/Source/Data/DTO/SearchResult.swift b/WSSiOS/Source/Data/DTO/SearchResult.swift index 09a805e2c..c352e950a 100644 --- a/WSSiOS/Source/Data/DTO/SearchResult.swift +++ b/WSSiOS/Source/Data/DTO/SearchResult.swift @@ -45,10 +45,16 @@ struct SearchNovel: Codable { var interestCount: Int var novelRating: Float var novelRatingCount: Int - + enum CodingKeys: String, CodingKey { case novelId, novelImage, interestCount, novelRating, novelRatingCount case novelTitle = "title" case novelAuthor = "author" } } + +/// 최근 검색어 조회 API +struct RecentSearch: Codable { + let id: Int + let keyword: String +} diff --git a/WSSiOS/Source/Data/Repository/SearchRepository.swift b/WSSiOS/Source/Data/Repository/SearchRepository.swift index 170d18c31..d72ddba3b 100644 --- a/WSSiOS/Source/Data/Repository/SearchRepository.swift +++ b/WSSiOS/Source/Data/Repository/SearchRepository.swift @@ -18,6 +18,9 @@ protocol SearchRepository { upperNovelRating: Float, keywordIds: [Int], page: Int) -> Observable + func getRecentSearches() -> Observable<[RecentSearch]> + func deleteRecentSearch(id: Int) -> Observable + func deleteAllRecentSearches() -> Observable } struct DefaultSearchRepository: SearchRepository { @@ -52,4 +55,16 @@ struct DefaultSearchRepository: SearchRepository { page: page, size: searchSize).asObservable() } + + func getRecentSearches() -> Observable<[RecentSearch]> { + return searchService.getRecentSearches().asObservable() + } + + func deleteRecentSearch(id: Int) -> Observable { + return searchService.deleteRecentSearch(id: id).asObservable() + } + + func deleteAllRecentSearches() -> Observable { + return searchService.deleteAllRecentSearches().asObservable() + } } From 7148915c1a74f38a7d34f4985c57afeb878357a0 Mon Sep 17 00:00:00 2001 From: onesunny2 Date: Thu, 7 May 2026 23:08:08 +0900 Subject: [PATCH 03/12] =?UTF-8?q?[Feat]=20#703=20-=20=EC=B5=9C=EA=B7=BC=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=EC=96=B4=20=EB=B7=B0=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NormalSearchRecentView.swift | 84 +++++++++++++++ .../NormalSearchRecentTagCell.swift | 100 ++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalAssistantView/NormalSearchRecentView.swift create mode 100644 WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalSearchCell/NormalSearchRecentTagCell.swift diff --git a/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalAssistantView/NormalSearchRecentView.swift b/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalAssistantView/NormalSearchRecentView.swift new file mode 100644 index 000000000..68b86e8b6 --- /dev/null +++ b/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalAssistantView/NormalSearchRecentView.swift @@ -0,0 +1,84 @@ +// +// NormalSearchRecentView.swift +// WSSiOS +// +// Created by onesunny2 on 5/6/25. +// + +import UIKit + +import SnapKit +import Then + +final class NormalSearchRecentView: UIView { + + //MARK: - Components + + private let titleLabel = UILabel() + let deleteAllButton = UIButton() + let recentTagCollectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewLayout()) + + //MARK: - Life Cycle + + override init(frame: CGRect) { + super.init(frame: frame) + + setUI() + setHierarchy() + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + //MARK: - UI + + private func setUI() { + titleLabel.do { + $0.applyWSSFont(.title2, with: StringLiterals.Search.recentSearchTitle) + $0.textColor = .wssBlack + } + + deleteAllButton.do { + $0.setTitle(StringLiterals.Search.deleteAll, for: .normal) + $0.setTitleColor(.wssGray200, for: .normal) + $0.titleLabel?.font = .Body4 + } + + recentTagCollectionView.do { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .horizontal + layout.minimumInteritemSpacing = 8 + layout.sectionInset = UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20) + + $0.collectionViewLayout = layout + $0.isScrollEnabled = true + $0.showsHorizontalScrollIndicator = false + $0.backgroundColor = .clear + } + } + + private func setHierarchy() { + self.addSubviews(titleLabel, deleteAllButton, recentTagCollectionView) + } + + private func setLayout() { + titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().inset(8) + $0.leading.equalToSuperview().inset(20) + } + + deleteAllButton.snp.makeConstraints { + $0.centerY.equalTo(titleLabel) + $0.trailing.equalToSuperview().inset(20) + } + + recentTagCollectionView.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(12) + $0.leading.trailing.equalToSuperview() + $0.height.equalTo(35) + $0.bottom.equalToSuperview().inset(12) + } + } +} diff --git a/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalSearchCell/NormalSearchRecentTagCell.swift b/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalSearchCell/NormalSearchRecentTagCell.swift new file mode 100644 index 000000000..4a7444634 --- /dev/null +++ b/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalSearchCell/NormalSearchRecentTagCell.swift @@ -0,0 +1,100 @@ +// +// NormalSearchRecentTagCell.swift +// WSSiOS +// +// Created by onesunny2 on 5/6/25. +// + +import UIKit + +import RxSwift +import SnapKit +import Then + +final class NormalSearchRecentTagCell: UICollectionViewCell { + + //MARK: - Properties + + var deleteAction: (() -> Void)? + private var disposeBag = DisposeBag() + + //MARK: - Components + + private let keywordLabel = UILabel() + private let deleteButton = UIButton() + private let contentStackView = UIStackView() + + //MARK: - Life Cycle + + override init(frame: CGRect) { + super.init(frame: frame) + + setUI() + setHierarchy() + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + deleteAction = nil + disposeBag = DisposeBag() + } + + //MARK: - UI + + private func setUI() { + self.contentView.do { + $0.layer.cornerRadius = 17.5 + $0.layer.borderWidth = 1 + $0.layer.borderColor = UIColor.wssPrimary100.cgColor + $0.backgroundColor = .wssWhite + } + + contentStackView.do { + $0.axis = .horizontal + $0.spacing = 6 + $0.alignment = .center + } + + keywordLabel.do { + $0.textColor = .wssPrimary100 + } + + deleteButton.do { + $0.setImage(.icKeywordCancel, for: .normal) + $0.isUserInteractionEnabled = true + } + } + + private func setHierarchy() { + contentView.addSubview(contentStackView) + contentStackView.addArrangedSubviews(keywordLabel, deleteButton) + } + + private func setLayout() { + contentStackView.snp.makeConstraints { + $0.horizontalEdges.equalToSuperview().inset(13) + $0.centerY.equalToSuperview() + } + + deleteButton.snp.makeConstraints { + $0.size.equalTo(16) + } + } + + //MARK: - Data + + func bindData(keyword: String) { + keywordLabel.applyWSSFont(.body2, with: keyword) + + deleteButton.rx.tap + .subscribe(onNext: { [weak self] in + self?.deleteAction?() + }) + .disposed(by: disposeBag) + } +} From dd8ea336fca3239ec70e884eef69cc4df778e48f Mon Sep 17 00:00:00 2001 From: onesunny2 Date: Thu, 7 May 2026 23:08:11 +0900 Subject: [PATCH 04/12] =?UTF-8?q?[Feat]=20#703=20-=20=EC=9D=BC=EB=B0=98=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=ED=99=94=EB=A9=B4=20=EC=B5=9C=EA=B7=BC=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=EC=96=B4=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NormalSearchView/NormalSearchView.swift | 41 +++++++----- .../NormalSearchViewController.swift | 67 +++++++++++++++++-- .../NormalSearchViewModel.swift | 63 +++++++++++++++-- 3 files changed, 143 insertions(+), 28 deletions(-) diff --git a/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalSearchView.swift b/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalSearchView.swift index 7a4dead22..72c248ead 100644 --- a/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalSearchView.swift +++ b/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalSearchView.swift @@ -11,33 +11,39 @@ import SnapKit import Then final class NormalSearchView: UIView { - + //MARK: - Components - + let headerView = NormalSearchHeaderView() + private let contentStackView = UIStackView() + let recentSearchView = NormalSearchRecentView() let sosoPickView = SearchSosoPickView() let resultView = NormalSearchResultView() let emptyView = NormalSearchEmptyView() - let loadingView = WSSLoadingView() - + // MARK: - Life Cycle - + override init(frame: CGRect) { super.init(frame: frame) - + setUI() setHierarchy() setLayout() } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + //MARK: - UI - + private func setUI() { + contentStackView.do { + $0.axis = .vertical + $0.spacing = 32 + } + recentSearchView.isHidden = true resultView.isHidden = true emptyView.isHidden = true loadingView.isHidden = true @@ -45,20 +51,21 @@ final class NormalSearchView: UIView { private func setHierarchy() { self.addSubviews(headerView, - sosoPickView, + contentStackView, resultView, emptyView, loadingView) + contentStackView.addArrangedSubviews(recentSearchView, sosoPickView) } - + private func setLayout() { headerView.snp.makeConstraints { $0.top.equalTo(self.safeAreaLayoutGuide.snp.top).inset(1) $0.leading.trailing.equalToSuperview() } - sosoPickView.snp.makeConstraints { - $0.top.equalTo(headerView.snp.bottom).offset(16) + contentStackView.snp.makeConstraints { + $0.top.equalTo(headerView.snp.bottom).offset(8) $0.leading.trailing.equalToSuperview() } @@ -66,20 +73,20 @@ final class NormalSearchView: UIView { $0.top.equalTo(headerView.snp.bottom) $0.horizontalEdges.bottom.equalToSuperview() } - + emptyView.snp.makeConstraints { $0.top.equalTo(headerView.snp.bottom) $0.horizontalEdges.bottom.equalToSuperview() } - + loadingView.snp.makeConstraints { $0.top.equalTo(headerView.snp.bottom) $0.horizontalEdges.bottom.equalToSuperview() } } - + //MARK: - Custom Methods - + func showLoadingView(isShow: Bool) { loadingView.do { $0.isHidden = !isShow diff --git a/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchViewController/NormalSearchViewController.swift b/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchViewController/NormalSearchViewController.swift index 31fbc2042..15dfe5674 100644 --- a/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchViewController/NormalSearchViewController.swift +++ b/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchViewController/NormalSearchViewController.swift @@ -14,12 +14,15 @@ import RxGesture final class NormalSearchViewController: UIViewController, UIScrollViewDelegate { //MARK: - Properties - + private let viewModel: NormalSearchViewModel private let disposeBag = DisposeBag() - + private let viewWillAppearRelay = PublishRelay() + private let deleteRecentSearchRelay = PublishRelay() + private var currentRecentKeywords: [RecentSearch] = [] + //MARK: - Components - + private let rootView = NormalSearchView() private let emptyView = NormalSearchEmptyView() @@ -40,9 +43,10 @@ final class NormalSearchViewController: UIViewController, UIScrollViewDelegate { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - + setNavigationBar() swipeBackGesture() + viewWillAppearRelay.accept(()) } override func viewDidLoad() { @@ -89,10 +93,14 @@ final class NormalSearchViewController: UIViewController, UIScrollViewDelegate { rootView.sosoPickView.sosopickCollectionView.register( SosoPickCollectionViewCell.self, forCellWithReuseIdentifier: SosoPickCollectionViewCell.cellIdentifier) + rootView.recentSearchView.recentTagCollectionView.register( + NormalSearchRecentTagCell.self, + forCellWithReuseIdentifier: NormalSearchRecentTagCell.cellIdentifier) } - + private func setDelegate() { rootView.headerView.searchTextField.delegate = self + rootView.recentSearchView.recentTagCollectionView.delegate = self } private func bindViewModel() { @@ -118,7 +126,11 @@ final class NormalSearchViewController: UIViewController, UIScrollViewDelegate { normalSearchCellSelected: rootView.resultView.normalSearchCollectionView.rx.itemSelected, reachedBottom: reachedBottom, normalSearchCollectionViewSwipeGesture: collectionViewSwipeGesture, - sosoPickCellSelected: rootView.sosoPickView.sosopickCollectionView.rx.itemSelected) + sosoPickCellSelected: rootView.sosoPickView.sosopickCollectionView.rx.itemSelected, + viewWillAppear: viewWillAppearRelay.asObservable(), + recentSearchTagSelected: rootView.recentSearchView.recentTagCollectionView.rx.itemSelected, + recentSearchDeleteAllButtonDidTap: rootView.recentSearchView.deleteAllButton.rx.tap, + recentSearchDeleteButtonDidTap: deleteRecentSearchRelay.asObservable()) let output = viewModel.transform(from: input, disposeBag: disposeBag) output.resultCount @@ -241,6 +253,34 @@ final class NormalSearchViewController: UIViewController, UIScrollViewDelegate { owner.presentInduceLoginViewController() }) .disposed(by: disposeBag) + + output.recentSearchList + .observe(on: MainScheduler.instance) + .do(onNext: { [weak self] list in + self?.currentRecentKeywords = list + }) + .bind(to: rootView.recentSearchView.recentTagCollectionView.rx.items( + cellIdentifier: NormalSearchRecentTagCell.cellIdentifier, + cellType: NormalSearchRecentTagCell.self)) { [weak self] _, recentSearch, cell in + cell.bindData(keyword: recentSearch.keyword) + cell.deleteAction = { self?.deleteRecentSearchRelay.accept(recentSearch.id) } + } + .disposed(by: disposeBag) + + output.showRecentSearchView + .drive(with: self, onNext: { owner, shouldShow in + owner.rootView.recentSearchView.isHidden = !shouldShow + }) + .disposed(by: disposeBag) + + output.fillSearchTextField + .observe(on: MainScheduler.instance) + .subscribe(with: self, onNext: { owner, keyword in + owner.rootView.headerView.searchTextField.text = keyword + owner.rootView.headerView.searchTextField.sendActions(for: .valueChanged) + owner.rootView.headerView.searchTextField.sendActions(for: .editingDidEndOnExit) + }) + .disposed(by: disposeBag) } private func bindAction() { @@ -266,7 +306,7 @@ final class NormalSearchViewController: UIViewController, UIScrollViewDelegate { } extension NormalSearchViewController: UITextFieldDelegate { - func textField(_ textField: UITextField, + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { let currentText = textField.text ?? "" @@ -274,3 +314,16 @@ extension NormalSearchViewController: UITextFieldDelegate { return newText.count <= 30 } } + +extension NormalSearchViewController: UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath) -> CGSize { + guard collectionView == rootView.recentSearchView.recentTagCollectionView, + indexPath.row < currentRecentKeywords.count else { return .zero } + let keyword = currentRecentKeywords[indexPath.row].keyword + let textWidth = (keyword as NSString).size(withAttributes: [.font: UIFont.Body2]).width + let width = ceil(textWidth) + 13 * 2 + 6 + 16 + return CGSize(width: min(width, UIScreen.main.bounds.width - 40), height: 35) + } +} diff --git a/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchViewModel/NormalSearchViewModel.swift b/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchViewModel/NormalSearchViewModel.swift index 129478b4b..616f1a642 100644 --- a/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchViewModel/NormalSearchViewModel.swift +++ b/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchViewModel/NormalSearchViewModel.swift @@ -37,9 +37,12 @@ final class NormalSearchViewModel: ViewModelType { private let isLogined = APIConstants.isLogined private let sosoPickList = BehaviorRelay<[SosoPickNovel]>(value: []) private let presentToInduceLoginView = PublishRelay() + + // 최근 검색어 + private let recentSearchList = BehaviorRelay<[RecentSearch]>(value: []) //MARK: - Inputs - + struct Input { let searchTextUpdated: ControlProperty let searchTextFieldEditingDidBegin: ControlEvent @@ -54,10 +57,14 @@ final class NormalSearchViewModel: ViewModelType { let reachedBottom: Observable let normalSearchCollectionViewSwipeGesture: Observable let sosoPickCellSelected: ControlEvent + let viewWillAppear: Observable + let recentSearchTagSelected: ControlEvent + let recentSearchDeleteAllButtonDidTap: ControlEvent + let recentSearchDeleteButtonDidTap: Observable } - + //MARK: - Outputs - + struct Output { let resultCount: Observable let normalSearchList: Observable<[SearchNovel]> @@ -73,6 +80,9 @@ final class NormalSearchViewModel: ViewModelType { let showLoadingView: Observable let sosoPickList: Observable<[SosoPickNovel]> let presentToInduceLoginView: Observable + let recentSearchList: Observable<[RecentSearch]> + let showRecentSearchView: Driver + let fillSearchTextField: Observable } //MARK: - init @@ -217,6 +227,48 @@ final class NormalSearchViewModel: ViewModelType { let endEditing = input.normalSearchCollectionViewSwipeGesture .map { _ in () } + // 최근 검색어 로직 + input.viewWillAppear + .filter { self.isLogined } + .flatMapLatest { _ in + self.searchRepository.getRecentSearches() + .catchAndReturn([]) + } + .subscribe(with: self, onNext: { owner, data in + owner.recentSearchList.accept(data) + }) + .disposed(by: disposeBag) + + input.recentSearchDeleteButtonDidTap + .do(onNext: { id in + var list = self.recentSearchList.value + list.removeAll { $0.id == id } + self.recentSearchList.accept(list) + }) + .flatMapLatest { id in + self.searchRepository.deleteRecentSearch(id: id) + .catchAndReturn(()) + } + .subscribe() + .disposed(by: disposeBag) + + input.recentSearchDeleteAllButtonDidTap + .do(onNext: { self.recentSearchList.accept([]) }) + .flatMapLatest { self.searchRepository.deleteAllRecentSearches().catchAndReturn(()) } + .subscribe() + .disposed(by: disposeBag) + + let fillSearchTextField = input.recentSearchTagSelected + .withLatestFrom(recentSearchList) { indexPath, list in list[indexPath.row].keyword } + + let showRecentSearchView = Observable.combineLatest( + recentSearchList.map { !$0.isEmpty }, + input.searchTextUpdated.map { $0.isEmpty }, + normalSearchList.map { $0.isEmpty } + ) + .map { $0 && $1 && $2 } + .asDriver(onErrorJustReturn: false) + return Output(resultCount: resultCount.asObservable(), normalSearchList: normalSearchList.asObservable(), scrollToTop: returnKeyEnabled.asObservable(), @@ -230,6 +282,9 @@ final class NormalSearchViewModel: ViewModelType { endEditing: endEditing, showLoadingView: showLoadingView.asObservable(), sosoPickList: sosoPickList.asObservable(), - presentToInduceLoginView: presentToInduceLoginView.asObservable()) + presentToInduceLoginView: presentToInduceLoginView.asObservable(), + recentSearchList: recentSearchList.asObservable(), + showRecentSearchView: showRecentSearchView, + fillSearchTextField: fillSearchTextField.asObservable()) } } From cbfa64dd44a71a2b07a3258640d7f4227846d7b5 Mon Sep 17 00:00:00 2001 From: onesunny2 Date: Thu, 7 May 2026 23:13:51 +0900 Subject: [PATCH 05/12] =?UTF-8?q?[Chore]=20#703=20-=20=EC=B5=9C=EA=B7=BC?= =?UTF-8?q?=20=EA=B2=80=EC=83=89=EC=96=B4=20=EB=B7=B0=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=BB=A8=EB=B2=A4=EC=85=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NormalAssistantView/NormalSearchRecentView.swift | 1 + .../NormalSearchCell/NormalSearchRecentTagCell.swift | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalAssistantView/NormalSearchRecentView.swift b/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalAssistantView/NormalSearchRecentView.swift index 68b86e8b6..ec43fcac5 100644 --- a/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalAssistantView/NormalSearchRecentView.swift +++ b/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalAssistantView/NormalSearchRecentView.swift @@ -28,6 +28,7 @@ final class NormalSearchRecentView: UIView { setLayout() } + @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } diff --git a/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalSearchCell/NormalSearchRecentTagCell.swift b/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalSearchCell/NormalSearchRecentTagCell.swift index 4a7444634..f58a75fc4 100644 --- a/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalSearchCell/NormalSearchRecentTagCell.swift +++ b/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalSearchCell/NormalSearchRecentTagCell.swift @@ -34,6 +34,7 @@ final class NormalSearchRecentTagCell: UICollectionViewCell { setLayout() } + @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -66,7 +67,6 @@ final class NormalSearchRecentTagCell: UICollectionViewCell { deleteButton.do { $0.setImage(.icKeywordCancel, for: .normal) - $0.isUserInteractionEnabled = true } } From c07ee177cd2f29fc5217526ff2414b7184f89b89 Mon Sep 17 00:00:00 2001 From: onesunny2 Date: Fri, 8 May 2026 20:32:30 +0900 Subject: [PATCH 06/12] =?UTF-8?q?[Chore]=20#703=20-=20=EC=B5=9C=EA=B7=BC?= =?UTF-8?q?=EA=B2=80=EC=83=89=EC=96=B4=20=EC=A1=B0=ED=9A=8C=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EA=B0=92=20=ED=95=84=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WSSiOS/Network/Search/SearchService.swift | 2 +- WSSiOS/Source/Data/DTO/SearchResult.swift | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/WSSiOS/Network/Search/SearchService.swift b/WSSiOS/Network/Search/SearchService.swift index 10ec466e3..fc98603ed 100644 --- a/WSSiOS/Network/Search/SearchService.swift +++ b/WSSiOS/Network/Search/SearchService.swift @@ -35,7 +35,7 @@ extension DefaultSearchService: SearchService { body: nil) NetworkLogger.log(request: request) return tokenCheckURLSession.rx.data(request: request) - .map { try self.decode(data: $0, to: [RecentSearch].self) } + .map { try self.decode(data: $0, to: RecentSearches.self).recentSearches } .asSingle() } catch { return Single.error(error) diff --git a/WSSiOS/Source/Data/DTO/SearchResult.swift b/WSSiOS/Source/Data/DTO/SearchResult.swift index c352e950a..33e1ada07 100644 --- a/WSSiOS/Source/Data/DTO/SearchResult.swift +++ b/WSSiOS/Source/Data/DTO/SearchResult.swift @@ -54,6 +54,10 @@ struct SearchNovel: Codable { } /// 최근 검색어 조회 API +struct RecentSearches: Codable { + let recentSearches: [RecentSearch] +} + struct RecentSearch: Codable { let id: Int let keyword: String From 7136990f2ead6bf1c9cddc1e3c2def9c4c693df0 Mon Sep 17 00:00:00 2001 From: onesunny2 Date: Fri, 8 May 2026 00:34:08 +0900 Subject: [PATCH 07/12] =?UTF-8?q?[Feat]=20#705=20-=20=EC=9E=A5=EB=A5=B4?= =?UTF-8?q?=EB=B3=84=20=EA=B2=80=EC=83=89=20=EB=AC=B8=EC=9E=90=EC=97=B4=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9E=A5=EB=A5=B4=20=EC=88=9C=EC=84=9C=20=EC=83=81?= =?UTF-8?q?=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WSSiOS/Resource/Constants/Strings/StringLiterals+Search.swift | 1 + WSSiOS/Source/Data/Base/NovelGenre.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/WSSiOS/Resource/Constants/Strings/StringLiterals+Search.swift b/WSSiOS/Resource/Constants/Strings/StringLiterals+Search.swift index 067a01402..9450e52e8 100644 --- a/WSSiOS/Resource/Constants/Strings/StringLiterals+Search.swift +++ b/WSSiOS/Resource/Constants/Strings/StringLiterals+Search.swift @@ -14,6 +14,7 @@ extension StringLiterals { static let recentSearchTitle = "최근 검색어" static let deleteAll = "전체삭제" + static let genreSearchTitle = "장르별 검색" static let induceTitle = "뭐 읽을지 고민될 땐?" static let induceDescription = "장르, 연재상태, 별점, 키워드로 작품 찾기" diff --git a/WSSiOS/Source/Data/Base/NovelGenre.swift b/WSSiOS/Source/Data/Base/NovelGenre.swift index 20c8ba0e1..40cb04121 100644 --- a/WSSiOS/Source/Data/Base/NovelGenre.swift +++ b/WSSiOS/Source/Data/Base/NovelGenre.swift @@ -216,4 +216,5 @@ extension NovelGenre { static let feedFilterGenres: [NovelGenre] = [.fantasy, .modernFantasy, .romance, .romanceFantasy, .wuxia, .mystery, .drama, .lightNovel, .bl, .etc] static let detailSearchGenres: [NovelGenre] = [.fantasy, .modernFantasy, .romance, .romanceFantasy, .wuxia, .mystery, .drama, .lightNovel, .bl] static let myPageEditGenres: [NovelGenre] = [.romance, .romanceFantasy, .fantasy, .modernFantasy, .wuxia, .bl, .lightNovel, .mystery, .drama] + static let normalSearchGenres: [NovelGenre] = [.modernFantasy, .romanceFantasy, .fantasy, .romance, .wuxia, .bl, .drama, .mystery, .lightNovel] } From d85211bd388d740630e78418c03282042f93329c Mon Sep 17 00:00:00 2001 From: onesunny2 Date: Fri, 8 May 2026 00:34:12 +0900 Subject: [PATCH 08/12] =?UTF-8?q?[Feat]=20#705=20-=20=EC=9E=A5=EB=A5=B4?= =?UTF-8?q?=EB=B3=84=20=EA=B2=80=EC=83=89=20=EB=B7=B0=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NormalSearchGenreView.swift | 91 +++++++++++++++++++ .../NormalSearchGenreCell.swift | 76 ++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalAssistantView/NormalSearchGenreView.swift create mode 100644 WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalSearchCell/NormalSearchGenreCell.swift diff --git a/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalAssistantView/NormalSearchGenreView.swift b/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalAssistantView/NormalSearchGenreView.swift new file mode 100644 index 000000000..fa747c10e --- /dev/null +++ b/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalAssistantView/NormalSearchGenreView.swift @@ -0,0 +1,91 @@ +// +// NormalSearchGenreView.swift +// WSSiOS +// +// Created by onesunny2 on 5/8/25. +// + +import UIKit + +import SnapKit +import Then + +final class NormalSearchGenreView: UIView { + + //MARK: - Components + + private let titleLabel = UILabel() + private let chevronImageView = UIImageView() + let headerButton = UIButton() + let genreCollectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewLayout()) + + //MARK: - Life Cycle + + override init(frame: CGRect) { + super.init(frame: frame) + + setUI() + setHierarchy() + setLayout() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + //MARK: - UI + + private func setUI() { + titleLabel.do { + $0.applyWSSFont(.title2, with: StringLiterals.Search.genreSearchTitle) + $0.textColor = .wssBlack + } + + chevronImageView.do { + $0.image = .icChevronRightMini + $0.contentMode = .scaleAspectFit + } + + genreCollectionView.do { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .horizontal + layout.minimumInteritemSpacing = 12 + layout.sectionInset = UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20) + + $0.collectionViewLayout = layout + $0.isScrollEnabled = true + $0.showsHorizontalScrollIndicator = false + $0.backgroundColor = .clear + } + } + + private func setHierarchy() { + self.addSubviews(titleLabel, chevronImageView, headerButton, genreCollectionView) + } + + private func setLayout() { + titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().inset(8) + $0.leading.equalToSuperview().inset(20) + } + + chevronImageView.snp.makeConstraints { + $0.centerY.equalTo(titleLabel) + $0.leading.equalTo(titleLabel.snp.trailing).offset(3) + $0.size.equalTo(16) + } + + headerButton.snp.makeConstraints { + $0.top.leading.bottom.equalTo(titleLabel) + $0.trailing.equalTo(chevronImageView) + } + + genreCollectionView.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(12) + $0.leading.trailing.equalToSuperview() + $0.height.equalTo(69) + $0.bottom.equalToSuperview().inset(12) + } + } +} diff --git a/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalSearchCell/NormalSearchGenreCell.swift b/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalSearchCell/NormalSearchGenreCell.swift new file mode 100644 index 000000000..51499bdcc --- /dev/null +++ b/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalSearchCell/NormalSearchGenreCell.swift @@ -0,0 +1,76 @@ +// +// NormalSearchGenreCell.swift +// WSSiOS +// +// Created by onesunny2 on 5/8/25. +// + +import UIKit + +import SnapKit +import Then + +final class NormalSearchGenreCell: UICollectionViewCell { + + //MARK: - Components + + private let circleImageView = UIImageView() + private let genreIconImageView = UIImageView() + private let genreLabel = UILabel() + + //MARK: - Life Cycle + + override init(frame: CGRect) { + super.init(frame: frame) + + setUI() + setHierarchy() + setLayout() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + //MARK: - UI + + private func setUI() { + circleImageView.do { + $0.image = .icGenreBackground + } + + genreLabel.do { + $0.textColor = .wssGray300 + $0.textAlignment = .center + } + } + + private func setHierarchy() { + contentView.addSubviews(circleImageView, genreIconImageView, genreLabel) + } + + private func setLayout() { + circleImageView.snp.makeConstraints { + $0.top.centerX.equalToSuperview() + $0.size.equalTo(44) + } + + genreIconImageView.snp.makeConstraints { + $0.center.equalTo(circleImageView) + $0.size.equalTo(32) + } + + genreLabel.snp.makeConstraints { + $0.top.equalTo(circleImageView.snp.bottom).offset(4) + $0.centerX.equalToSuperview() + } + } + + //MARK: - Data + + func bindData(genre: NovelGenre) { + genreIconImageView.image = genre.image + genreLabel.applyWSSFont(.body3, with: genre.withKorean) + } +} From 88be2b606effc1325a32fd13fcad98ecc8fc36f0 Mon Sep 17 00:00:00 2001 From: onesunny2 Date: Fri, 8 May 2026 00:34:15 +0900 Subject: [PATCH 09/12] =?UTF-8?q?[Feat]=20#705=20-=20=EC=9D=BC=EB=B0=98=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=ED=99=94=EB=A9=B4=20=EC=9E=A5=EB=A5=B4?= =?UTF-8?q?=EB=B3=84=20=EA=B2=80=EC=83=89=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NormalSearchView/NormalSearchView.swift | 4 +- .../NormalSearchViewController.swift | 53 ++++++++++++++++++- .../NormalSearchViewModel.swift | 22 ++++++-- 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalSearchView.swift b/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalSearchView.swift index 72c248ead..65abd5900 100644 --- a/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalSearchView.swift +++ b/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalSearchView.swift @@ -17,6 +17,7 @@ final class NormalSearchView: UIView { let headerView = NormalSearchHeaderView() private let contentStackView = UIStackView() let recentSearchView = NormalSearchRecentView() + let genreView = NormalSearchGenreView() let sosoPickView = SearchSosoPickView() let resultView = NormalSearchResultView() let emptyView = NormalSearchEmptyView() @@ -44,6 +45,7 @@ final class NormalSearchView: UIView { $0.spacing = 32 } recentSearchView.isHidden = true + genreView.isHidden = true resultView.isHidden = true emptyView.isHidden = true loadingView.isHidden = true @@ -55,7 +57,7 @@ final class NormalSearchView: UIView { resultView, emptyView, loadingView) - contentStackView.addArrangedSubviews(recentSearchView, sosoPickView) + contentStackView.addArrangedSubviews(recentSearchView, genreView, sosoPickView) } private func setLayout() { diff --git a/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchViewController/NormalSearchViewController.swift b/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchViewController/NormalSearchViewController.swift index 15dfe5674..3d3ba688c 100644 --- a/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchViewController/NormalSearchViewController.swift +++ b/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchViewController/NormalSearchViewController.swift @@ -96,11 +96,15 @@ final class NormalSearchViewController: UIViewController, UIScrollViewDelegate { rootView.recentSearchView.recentTagCollectionView.register( NormalSearchRecentTagCell.self, forCellWithReuseIdentifier: NormalSearchRecentTagCell.cellIdentifier) + rootView.genreView.genreCollectionView.register( + NormalSearchGenreCell.self, + forCellWithReuseIdentifier: NormalSearchGenreCell.cellIdentifier) } private func setDelegate() { rootView.headerView.searchTextField.delegate = self rootView.recentSearchView.recentTagCollectionView.delegate = self + rootView.genreView.genreCollectionView.delegate = self } private func bindViewModel() { @@ -130,7 +134,9 @@ final class NormalSearchViewController: UIViewController, UIScrollViewDelegate { viewWillAppear: viewWillAppearRelay.asObservable(), recentSearchTagSelected: rootView.recentSearchView.recentTagCollectionView.rx.itemSelected, recentSearchDeleteAllButtonDidTap: rootView.recentSearchView.deleteAllButton.rx.tap, - recentSearchDeleteButtonDidTap: deleteRecentSearchRelay.asObservable()) + recentSearchDeleteButtonDidTap: deleteRecentSearchRelay.asObservable(), + genreSelected: rootView.genreView.genreCollectionView.rx.itemSelected, + genreHeaderDidTap: rootView.genreView.headerButton.rx.tap) let output = viewModel.transform(from: input, disposeBag: disposeBag) output.resultCount @@ -281,6 +287,48 @@ final class NormalSearchViewController: UIViewController, UIScrollViewDelegate { owner.rootView.headerView.searchTextField.sendActions(for: .editingDidEndOnExit) }) .disposed(by: disposeBag) + + Observable.just(NovelGenre.normalSearchGenres) + .observe(on: MainScheduler.instance) + .bind(to: rootView.genreView.genreCollectionView.rx.items( + cellIdentifier: NormalSearchGenreCell.cellIdentifier, + cellType: NormalSearchGenreCell.self)) { _, genre, cell in + cell.bindData(genre: genre) + } + .disposed(by: disposeBag) + + output.pushToGenreSearchResult + .observe(on: MainScheduler.instance) + .subscribe(with: self, onNext: { owner, genre in + let filterQuery = SearchFilterQuery( + keywords: [], + genres: [genre], + isCompleted: nil, + lowerNovelRating: 0.0, + upperNovelRating: 5.0 + ) + let viewModel = DetailSearchResultViewModel( + searchRepository: DefaultSearchRepository(searchService: DefaultSearchService()), + option: filterQuery + ) + let viewController = DetailSearchResultViewController(viewModel: viewModel) + viewController.hidesBottomBarWhenPushed = true + owner.navigationController?.pushViewController(viewController, animated: true) + }) + .disposed(by: disposeBag) + + output.pushToDetailSearch + .observe(on: MainScheduler.instance) + .subscribe(with: self, onNext: { owner, _ in + owner.pushToDetailSearchViewController() + }) + .disposed(by: disposeBag) + + output.showGenreView + .drive(with: self, onNext: { owner, shouldShow in + owner.rootView.genreView.isHidden = !shouldShow + }) + .disposed(by: disposeBag) } private func bindAction() { @@ -319,6 +367,9 @@ extension NormalSearchViewController: UICollectionViewDelegateFlowLayout { func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + if collectionView == rootView.genreView.genreCollectionView { + return CGSize(width: 44, height: 69) + } guard collectionView == rootView.recentSearchView.recentTagCollectionView, indexPath.row < currentRecentKeywords.count else { return .zero } let keyword = currentRecentKeywords[indexPath.row].keyword diff --git a/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchViewModel/NormalSearchViewModel.swift b/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchViewModel/NormalSearchViewModel.swift index 616f1a642..da6296ac1 100644 --- a/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchViewModel/NormalSearchViewModel.swift +++ b/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchViewModel/NormalSearchViewModel.swift @@ -61,6 +61,8 @@ final class NormalSearchViewModel: ViewModelType { let recentSearchTagSelected: ControlEvent let recentSearchDeleteAllButtonDidTap: ControlEvent let recentSearchDeleteButtonDidTap: Observable + let genreSelected: ControlEvent + let genreHeaderDidTap: ControlEvent } //MARK: - Outputs @@ -83,6 +85,9 @@ final class NormalSearchViewModel: ViewModelType { let recentSearchList: Observable<[RecentSearch]> let showRecentSearchView: Driver let fillSearchTextField: Observable + let pushToGenreSearchResult: Observable + let pushToDetailSearch: Observable + let showGenreView: Driver } //MARK: - init @@ -263,12 +268,20 @@ final class NormalSearchViewModel: ViewModelType { let showRecentSearchView = Observable.combineLatest( recentSearchList.map { !$0.isEmpty }, - input.searchTextUpdated.map { $0.isEmpty }, normalSearchList.map { $0.isEmpty } ) - .map { $0 && $1 && $2 } + .map { $0 && $1 } .asDriver(onErrorJustReturn: false) + let pushToGenreSearchResult = input.genreSelected + .map { NovelGenre.normalSearchGenres[$0.row] } + + let pushToDetailSearch = input.genreHeaderDidTap.asObservable() + + let showGenreView = normalSearchList + .map { $0.isEmpty } + .asDriver(onErrorJustReturn: true) + return Output(resultCount: resultCount.asObservable(), normalSearchList: normalSearchList.asObservable(), scrollToTop: returnKeyEnabled.asObservable(), @@ -285,6 +298,9 @@ final class NormalSearchViewModel: ViewModelType { presentToInduceLoginView: presentToInduceLoginView.asObservable(), recentSearchList: recentSearchList.asObservable(), showRecentSearchView: showRecentSearchView, - fillSearchTextField: fillSearchTextField.asObservable()) + fillSearchTextField: fillSearchTextField.asObservable(), + pushToGenreSearchResult: pushToGenreSearchResult.asObservable(), + pushToDetailSearch: pushToDetailSearch, + showGenreView: showGenreView) } } From d74fc5a8cff2f8b0e493c465b4a3a17eb1c926ae Mon Sep 17 00:00:00 2001 From: onesunny2 Date: Fri, 8 May 2026 20:39:33 +0900 Subject: [PATCH 10/12] =?UTF-8?q?[Chore]=20#705=20-=20=EC=84=B9=EC=85=98?= =?UTF-8?q?=20=EA=B0=84=20=EA=B0=84=EA=B2=A9=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NormalAssistantView/NormalSearchGenreView.swift | 4 ++-- .../NormalAssistantView/NormalSearchRecentView.swift | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalAssistantView/NormalSearchGenreView.swift b/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalAssistantView/NormalSearchGenreView.swift index fa747c10e..fe90419db 100644 --- a/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalAssistantView/NormalSearchGenreView.swift +++ b/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalAssistantView/NormalSearchGenreView.swift @@ -66,7 +66,7 @@ final class NormalSearchGenreView: UIView { private func setLayout() { titleLabel.snp.makeConstraints { - $0.top.equalToSuperview().inset(8) + $0.top.equalToSuperview() $0.leading.equalToSuperview().inset(20) } @@ -85,7 +85,7 @@ final class NormalSearchGenreView: UIView { $0.top.equalTo(titleLabel.snp.bottom).offset(12) $0.leading.trailing.equalToSuperview() $0.height.equalTo(69) - $0.bottom.equalToSuperview().inset(12) + $0.bottom.equalToSuperview() } } } diff --git a/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalAssistantView/NormalSearchRecentView.swift b/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalAssistantView/NormalSearchRecentView.swift index ec43fcac5..61f3f1fa4 100644 --- a/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalAssistantView/NormalSearchRecentView.swift +++ b/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalAssistantView/NormalSearchRecentView.swift @@ -66,7 +66,7 @@ final class NormalSearchRecentView: UIView { private func setLayout() { titleLabel.snp.makeConstraints { - $0.top.equalToSuperview().inset(8) + $0.top.equalToSuperview() $0.leading.equalToSuperview().inset(20) } @@ -79,7 +79,7 @@ final class NormalSearchRecentView: UIView { $0.top.equalTo(titleLabel.snp.bottom).offset(12) $0.leading.trailing.equalToSuperview() $0.height.equalTo(35) - $0.bottom.equalToSuperview().inset(12) + $0.bottom.equalToSuperview() } } } From e806e4213f91731202351ebec1ddccfedd3bbf81 Mon Sep 17 00:00:00 2001 From: onesunny2 Date: Fri, 8 May 2026 21:25:03 +0900 Subject: [PATCH 11/12] =?UTF-8?q?[Chore]=20#705=20-=20=EC=9E=A5=EB=A5=B4?= =?UTF-8?q?=EB=B3=84=20=EA=B2=80=EC=83=89=EC=9D=98=20=EC=9E=A5=EB=A5=B4=20?= =?UTF-8?q?=EC=88=9C=EC=84=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WSSiOS/Source/Data/Base/NovelGenre.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WSSiOS/Source/Data/Base/NovelGenre.swift b/WSSiOS/Source/Data/Base/NovelGenre.swift index 40cb04121..53074cb4e 100644 --- a/WSSiOS/Source/Data/Base/NovelGenre.swift +++ b/WSSiOS/Source/Data/Base/NovelGenre.swift @@ -216,5 +216,5 @@ extension NovelGenre { static let feedFilterGenres: [NovelGenre] = [.fantasy, .modernFantasy, .romance, .romanceFantasy, .wuxia, .mystery, .drama, .lightNovel, .bl, .etc] static let detailSearchGenres: [NovelGenre] = [.fantasy, .modernFantasy, .romance, .romanceFantasy, .wuxia, .mystery, .drama, .lightNovel, .bl] static let myPageEditGenres: [NovelGenre] = [.romance, .romanceFantasy, .fantasy, .modernFantasy, .wuxia, .bl, .lightNovel, .mystery, .drama] - static let normalSearchGenres: [NovelGenre] = [.modernFantasy, .romanceFantasy, .fantasy, .romance, .wuxia, .bl, .drama, .mystery, .lightNovel] + static let normalSearchGenres: [NovelGenre] = [.modernFantasy, .romanceFantasy, .romance, .fantasy, .wuxia, .bl, .lightNovel, .drama, .mystery] } From e275cd2086198f709b013dfb398cc8100f22d4a8 Mon Sep 17 00:00:00 2001 From: onesunny2 Date: Thu, 14 May 2026 23:57:08 +0900 Subject: [PATCH 12/12] =?UTF-8?q?[Chore]=20#705=20-=20=EC=9E=A5=EB=A5=B4?= =?UTF-8?q?=EB=B3=84=20=EA=B2=80=EC=83=89=20=EA=B2=B0=EA=B3=BC=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20placeholder=20=EB=B6=84=EA=B8=B0=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Strings/StringLiterals+Search.swift | 1 + .../DetailSearchResultHeaderView.swift | 5 +++- .../DetailSearchResultViewController.swift | 2 ++ .../DetailSearchViewController.swift | 3 +- .../DetailSearchResultViewModel.swift | 29 +++++++++++++++---- .../NormalSearchViewController.swift | 3 +- 6 files changed, 34 insertions(+), 9 deletions(-) diff --git a/WSSiOS/Resource/Constants/Strings/StringLiterals+Search.swift b/WSSiOS/Resource/Constants/Strings/StringLiterals+Search.swift index 9450e52e8..3a23bff80 100644 --- a/WSSiOS/Resource/Constants/Strings/StringLiterals+Search.swift +++ b/WSSiOS/Resource/Constants/Strings/StringLiterals+Search.swift @@ -57,5 +57,6 @@ extension StringLiterals { static let empty = "해당하는 작품이 없어요\n검색의 범위를 더 넓혀보세요" static let applyOption = "장르, 연재상태, 별점, 키워드 적용" + static let applyGenre = "장르 적용" } } diff --git a/WSSiOS/Source/Presentation/Search/DetailSearch/DetailSearchView/DetailSearchAssistantView/DetailSearchResultHeaderView.swift b/WSSiOS/Source/Presentation/Search/DetailSearch/DetailSearchView/DetailSearchAssistantView/DetailSearchResultHeaderView.swift index dfb1c03e3..481cb80ec 100644 --- a/WSSiOS/Source/Presentation/Search/DetailSearch/DetailSearchView/DetailSearchAssistantView/DetailSearchResultHeaderView.swift +++ b/WSSiOS/Source/Presentation/Search/DetailSearch/DetailSearchView/DetailSearchAssistantView/DetailSearchResultHeaderView.swift @@ -42,7 +42,6 @@ final class DetailSearchResultHeaderView: UIView { $0.layer.cornerRadius = 14 headerLabel.do { - $0.applyWSSFont(.body4, with: StringLiterals.DetailSearch.applyOption) $0.textColor = .wssGray200 } @@ -84,4 +83,8 @@ final class DetailSearchResultHeaderView: UIView { } } } + + func bindPlaceholder(_ text: String) { + headerLabel.applyWSSFont(.body4, with: text) + } } diff --git a/WSSiOS/Source/Presentation/Search/DetailSearch/DetailSearchViewController/DetailSearchResultViewController.swift b/WSSiOS/Source/Presentation/Search/DetailSearch/DetailSearchViewController/DetailSearchResultViewController.swift index c2b165882..d513a5520 100644 --- a/WSSiOS/Source/Presentation/Search/DetailSearch/DetailSearchViewController/DetailSearchResultViewController.swift +++ b/WSSiOS/Source/Presentation/Search/DetailSearch/DetailSearchViewController/DetailSearchResultViewController.swift @@ -52,6 +52,8 @@ final class DetailSearchResultViewController: UIViewController, UIScrollViewDele registerCell() setDelegate() + rootView.headerView.bindPlaceholder(viewModel.entryType.placeholder) + bindViewModel() bindAction() diff --git a/WSSiOS/Source/Presentation/Search/DetailSearch/DetailSearchViewController/DetailSearchViewController.swift b/WSSiOS/Source/Presentation/Search/DetailSearch/DetailSearchViewController/DetailSearchViewController.swift index 6392466f7..3b4a5c711 100644 --- a/WSSiOS/Source/Presentation/Search/DetailSearch/DetailSearchViewController/DetailSearchViewController.swift +++ b/WSSiOS/Source/Presentation/Search/DetailSearch/DetailSearchViewController/DetailSearchViewController.swift @@ -159,7 +159,8 @@ final class DetailSearchViewController: UIViewController, UIScrollViewDelegate { .subscribe(with: self, onNext: { owner, filterQuery in let viewModel = DetailSearchResultViewModel( searchRepository: DefaultSearchRepository(searchService: DefaultSearchService()), - option: filterQuery + option: filterQuery, + entryType: .fullOption ) let viewController = DetailSearchResultViewController(viewModel: viewModel) viewController.hidesBottomBarWhenPushed = true diff --git a/WSSiOS/Source/Presentation/Search/DetailSearch/DetailSearchViewModel/DetailSearchResultViewModel.swift b/WSSiOS/Source/Presentation/Search/DetailSearch/DetailSearchViewModel/DetailSearchResultViewModel.swift index 1106bcd23..e9301f4bc 100644 --- a/WSSiOS/Source/Presentation/Search/DetailSearch/DetailSearchViewModel/DetailSearchResultViewModel.swift +++ b/WSSiOS/Source/Presentation/Search/DetailSearch/DetailSearchViewModel/DetailSearchResultViewModel.swift @@ -11,14 +11,15 @@ import RxSwift import RxCocoa final class DetailSearchResultViewModel: ViewModelType { - + //MARK: - Properties - + private let searchRepository: SearchRepository - - // 검색 필터 옵션 + + // 검색 필터 옵션 var option: SearchFilterQuery - + let entryType: EntryType + // 무한 스크롤 private var currentPage: Int = 0 private var isLoadable: Bool = false @@ -54,9 +55,11 @@ final class DetailSearchResultViewModel: ViewModelType { } init(searchRepository: SearchRepository, - option: SearchFilterQuery) { + option: SearchFilterQuery, + entryType: EntryType) { self.searchRepository = searchRepository self.option = option + self.entryType = entryType } func transform(from input: Input, disposeBag: DisposeBag) -> Output { @@ -171,3 +174,17 @@ final class DetailSearchResultViewModel: ViewModelType { page: page) } } + +extension DetailSearchResultViewModel { + enum EntryType { + case fullOption + case genreOnly + + var placeholder: String { + switch self { + case .fullOption: return StringLiterals.DetailSearch.applyOption + case .genreOnly: return StringLiterals.DetailSearch.applyGenre + } + } + } +} diff --git a/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchViewController/NormalSearchViewController.swift b/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchViewController/NormalSearchViewController.swift index 3d3ba688c..4aa7baf1c 100644 --- a/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchViewController/NormalSearchViewController.swift +++ b/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchViewController/NormalSearchViewController.swift @@ -309,7 +309,8 @@ final class NormalSearchViewController: UIViewController, UIScrollViewDelegate { ) let viewModel = DetailSearchResultViewModel( searchRepository: DefaultSearchRepository(searchService: DefaultSearchService()), - option: filterQuery + option: filterQuery, + entryType: .genreOnly ) let viewController = DetailSearchResultViewController(viewModel: viewModel) viewController.hidesBottomBarWhenPushed = true