diff --git a/WSSiOS/Network/Search/SearchService.swift b/WSSiOS/Network/Search/SearchService.swift index c74919dd0..fc98603ed 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: RecentSearches.self).recentSearches } + .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/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 { diff --git a/WSSiOS/Source/Data/DTO/SearchResult.swift b/WSSiOS/Source/Data/DTO/SearchResult.swift index 09a805e2c..33e1ada07 100644 --- a/WSSiOS/Source/Data/DTO/SearchResult.swift +++ b/WSSiOS/Source/Data/DTO/SearchResult.swift @@ -45,10 +45,20 @@ 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 RecentSearches: Codable { + let recentSearches: [RecentSearch] +} + +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() + } } 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..ec43fcac5 --- /dev/null +++ b/WSSiOS/Source/Presentation/Search/NormalSearch/NormalSearchView/NormalAssistantView/NormalSearchRecentView.swift @@ -0,0 +1,85 @@ +// +// 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() + } + + @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.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..f58a75fc4 --- /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() + } + + @available(*, unavailable) + 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) + } + } + + 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) + } +} 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()) } }