391 lines
13 KiB
Swift
391 lines
13 KiB
Swift
//
|
||
// XSSearchViewController.swift
|
||
// XSeri
|
||
//
|
||
// Created by 长沙鸿瑶 on 2026/2/2.
|
||
//
|
||
|
||
import UIKit
|
||
import SnapKit
|
||
import LYEmptyView
|
||
|
||
final class XSSearchViewController: XSViewController {
|
||
|
||
private enum Mode {
|
||
case idle
|
||
case suggest
|
||
case result
|
||
}
|
||
|
||
private let headerView = XSSearchHeaderView()
|
||
private let historyHotView = XSSearchHistoryHotView()
|
||
|
||
private lazy var suggestionCollectionView: XSCollectionView = {
|
||
let layout = UICollectionViewFlowLayout()
|
||
layout.minimumLineSpacing = 12
|
||
layout.minimumInteritemSpacing = 0
|
||
layout.sectionInset = .init(top: 0, left: 16, bottom: 0, right: 16)
|
||
let collectionView = XSCollectionView(frame: .zero, collectionViewLayout: layout)
|
||
collectionView.isScrollEnabled = true
|
||
collectionView.keyboardDismissMode = .onDrag
|
||
collectionView.delegate = self
|
||
collectionView.dataSource = self
|
||
collectionView.register(XSSearchSuggestionCell.self, forCellWithReuseIdentifier: "XSSearchSuggestionCell")
|
||
return collectionView
|
||
}()
|
||
|
||
private lazy var resultCollectionView: XSCollectionView = {
|
||
let layout = UICollectionViewFlowLayout()
|
||
layout.minimumLineSpacing = 16
|
||
layout.minimumInteritemSpacing = 8
|
||
layout.sectionInset = .init(top: 0, left: 16, bottom: 0, right: 16)
|
||
let collectionView = XSCollectionView(frame: .zero, collectionViewLayout: layout)
|
||
collectionView.isScrollEnabled = true
|
||
collectionView.keyboardDismissMode = .onDrag
|
||
collectionView.delegate = self
|
||
collectionView.dataSource = self
|
||
collectionView.register(XSSearchResultCell.self, forCellWithReuseIdentifier: "XSSearchResultCell")
|
||
return collectionView
|
||
}()
|
||
|
||
private var history: [String] = []
|
||
private var suggestionItems: [XSShortModel] = []
|
||
private var resultItems: [XSShortModel] = []
|
||
private var currentSuggestKeyword = ""
|
||
private var currentResultKeyword = ""
|
||
|
||
private var mode: Mode = .idle {
|
||
didSet {
|
||
xs_updateVisibility()
|
||
}
|
||
}
|
||
|
||
private var suggestionTask: Task<Void, Never>?
|
||
private var resultTask: Task<Void, Never>?
|
||
private var hotTask: Task<Void, Never>?
|
||
|
||
deinit {
|
||
suggestionTask?.cancel()
|
||
resultTask?.cancel()
|
||
hotTask?.cancel()
|
||
}
|
||
|
||
override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
|
||
xs_setupUI()
|
||
xs_bindData()
|
||
xs_loadHistory()
|
||
xs_loadHotSearches()
|
||
}
|
||
|
||
override func viewWillAppear(_ animated: Bool) {
|
||
super.viewWillAppear(animated)
|
||
self.navigationController?.setNavigationBarHidden(true, animated: true)
|
||
}
|
||
|
||
|
||
override func viewDidAppear(_ animated: Bool) {
|
||
super.viewDidAppear(animated)
|
||
let _ = self.headerView.becomeFirstResponder()
|
||
}
|
||
|
||
}
|
||
|
||
extension XSSearchViewController {
|
||
|
||
private func xs_setupUI() {
|
||
view.addSubview(headerView)
|
||
view.addSubview(historyHotView)
|
||
view.addSubview(suggestionCollectionView)
|
||
view.addSubview(resultCollectionView)
|
||
|
||
headerView.snp.makeConstraints { make in
|
||
make.top.equalToSuperview().offset(XSScreen.safeTop + 12)
|
||
make.left.equalToSuperview().offset(16)
|
||
make.right.equalToSuperview().offset(-16)
|
||
make.height.equalTo(38)
|
||
}
|
||
|
||
historyHotView.snp.makeConstraints { make in
|
||
make.top.equalTo(headerView.snp.bottom).offset(22)
|
||
make.left.right.equalToSuperview()
|
||
}
|
||
|
||
suggestionCollectionView.snp.makeConstraints { make in
|
||
make.top.equalTo(headerView.snp.bottom).offset(22)
|
||
make.left.right.equalToSuperview()
|
||
make.bottom.equalTo(view.safeAreaLayoutGuide)
|
||
}
|
||
|
||
resultCollectionView.snp.makeConstraints { make in
|
||
make.top.equalTo(headerView.snp.bottom).offset(22)
|
||
make.left.right.equalToSuperview()
|
||
make.bottom.equalTo(view.safeAreaLayoutGuide)
|
||
}
|
||
|
||
// 搜索结果为空时显示空白样式
|
||
resultCollectionView.ly_emptyView = XSEmpty.xs_emptyView()
|
||
// 联想为空时显示空白样式
|
||
suggestionCollectionView.ly_emptyView = XSEmpty.xs_emptyView()
|
||
}
|
||
|
||
private func xs_bindData() {
|
||
headerView.placeholder = XSSearchData.searchPlaceholder
|
||
headerView.didTapBack = { [weak self] in
|
||
self?.navigationController?.popViewController(animated: true)
|
||
}
|
||
headerView.didBeginEditing = { [weak self] text in
|
||
guard let self = self else { return }
|
||
// 已经显示搜索结果时,不在进入编辑时立刻切换联想
|
||
if self.mode == .result { return }
|
||
self.xs_handleSuggest(text)
|
||
}
|
||
headerView.didChangeText = { [weak self] text in
|
||
self?.xs_handleSuggest(text)
|
||
}
|
||
headerView.didTapSearch = { [weak self] text in
|
||
self?.xs_submitSearch(text)
|
||
}
|
||
|
||
historyHotView.historyTitle = XSSearchData.recentTitle
|
||
historyHotView.didTapClear = { [weak self] in
|
||
self?.xs_clearHistory()
|
||
}
|
||
historyHotView.didSelectHistory = { [weak self] text in
|
||
self?.headerView.text = text
|
||
self?.xs_submitSearch(text)
|
||
}
|
||
historyHotView.didSelectHot = { [weak self] model in
|
||
self?.xs_pushDetail(model: model)
|
||
}
|
||
historyHotView.hotTitle = XSSearchData.hotSearchesTitle
|
||
xs_updateVisibility()
|
||
}
|
||
}
|
||
|
||
// MARK: - Data
|
||
extension XSSearchViewController {
|
||
|
||
private func xs_loadHotSearches() {
|
||
hotTask?.cancel()
|
||
hotTask = Task { [weak self] in
|
||
// 热门榜单数据来源接口
|
||
let hotList = await XSHomeAPI.requestHotSearchList() ?? []
|
||
let topList = await XSHomeAPI.requestTopSearchList() ?? []
|
||
|
||
guard !Task.isCancelled else { return }
|
||
await MainActor.run {
|
||
self?.xs_updateHotSection(hotList: Array(hotList.prefix(5)), topList: Array(topList.prefix(5)))
|
||
}
|
||
}
|
||
}
|
||
|
||
private func xs_updateHotSection(hotList: [XSShortModel], topList: [XSShortModel]) {
|
||
|
||
var arr: [XSSearchHotSection] = []
|
||
|
||
if !hotList.isEmpty {
|
||
arr.append(XSSearchHotSection(icon: UIImage(named: "hot_icon_03"), title: XSSearchData.hotSectionTitles[0], style: .gold, items: hotList))
|
||
}
|
||
if !topList.isEmpty {
|
||
arr.append(XSSearchHotSection(icon: UIImage(named: "hot_icon_04"), title: XSSearchData.hotSectionTitles[1], style: .purple, items: topList))
|
||
}
|
||
historyHotView.hotSections = arr
|
||
|
||
xs_updateVisibility()
|
||
}
|
||
|
||
private func xs_fetchSuggestions(_ keyword: String) {
|
||
suggestionTask?.cancel()
|
||
let expectedKeyword = keyword
|
||
suggestionTask = Task { [weak self] in
|
||
// 联想词同样走搜索接口
|
||
let list = await XSHomeAPI.requestSearch(text: keyword) ?? []
|
||
guard !Task.isCancelled else { return }
|
||
await MainActor.run {
|
||
guard let self = self,
|
||
self.mode == .suggest,
|
||
self.currentSuggestKeyword == expectedKeyword else {
|
||
return
|
||
}
|
||
self.suggestionItems = list
|
||
self.suggestionCollectionView.reloadData()
|
||
}
|
||
}
|
||
}
|
||
|
||
private func xs_fetchResults(_ keyword: String) {
|
||
resultTask?.cancel()
|
||
let expectedKeyword = keyword
|
||
resultTask = Task { [weak self] in
|
||
// 搜索结果走搜索接口
|
||
let list = await XSHomeAPI.requestSearch(text: keyword) ?? []
|
||
guard !Task.isCancelled else { return }
|
||
await MainActor.run {
|
||
guard let self = self,
|
||
self.mode == .result,
|
||
self.currentResultKeyword == expectedKeyword else {
|
||
return
|
||
}
|
||
self.resultItems = list
|
||
self.resultCollectionView.reloadData()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Actions
|
||
extension XSSearchViewController {
|
||
|
||
private func xs_handleSuggest(_ text: String?) {
|
||
let keyword = (text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if keyword.isEmpty {
|
||
suggestionTask?.cancel()
|
||
resultTask?.cancel()
|
||
currentSuggestKeyword = ""
|
||
mode = .idle
|
||
suggestionItems = []
|
||
suggestionCollectionView.reloadData()
|
||
return
|
||
}
|
||
|
||
resultTask?.cancel()
|
||
resultItems = []
|
||
resultCollectionView.reloadData()
|
||
resultCollectionView.isHidden = true
|
||
currentSuggestKeyword = keyword
|
||
mode = .suggest
|
||
suggestionCollectionView.isHidden = false
|
||
xs_fetchSuggestions(keyword)
|
||
}
|
||
|
||
private func xs_submitSearch(_ text: String?) {
|
||
let keyword = (text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !keyword.isEmpty else {
|
||
mode = .idle
|
||
return
|
||
}
|
||
|
||
suggestionTask?.cancel()
|
||
suggestionItems = []
|
||
suggestionCollectionView.reloadData()
|
||
suggestionCollectionView.isHidden = true
|
||
currentResultKeyword = keyword
|
||
view.endEditing(true)
|
||
mode = .result
|
||
xs_addHistory(keyword)
|
||
xs_fetchResults(keyword)
|
||
}
|
||
}
|
||
|
||
// MARK: - History
|
||
extension XSSearchViewController {
|
||
|
||
private func xs_loadHistory() {
|
||
history = UserDefaults.standard.stringArray(forKey: XSSearchData.historyKey) ?? []
|
||
historyHotView.historyTags = history
|
||
xs_updateVisibility()
|
||
}
|
||
|
||
private func xs_addHistory(_ keyword: String) {
|
||
// 搜索历史本地缓存,最多保留 10 条
|
||
history.removeAll { $0.caseInsensitiveCompare(keyword) == .orderedSame }
|
||
history.insert(keyword, at: 0)
|
||
if history.count > 10 {
|
||
history = Array(history.prefix(10))
|
||
}
|
||
UserDefaults.standard.set(history, forKey: XSSearchData.historyKey)
|
||
historyHotView.historyTags = history
|
||
xs_updateVisibility()
|
||
}
|
||
|
||
private func xs_clearHistory() {
|
||
history = []
|
||
UserDefaults.standard.removeObject(forKey: XSSearchData.historyKey)
|
||
historyHotView.historyTags = []
|
||
xs_updateVisibility()
|
||
}
|
||
}
|
||
|
||
// MARK: - Layout
|
||
extension XSSearchViewController {
|
||
|
||
private func xs_updateVisibility() {
|
||
switch mode {
|
||
case .idle:
|
||
historyHotView.isHidden = !historyHotView.hasContent
|
||
suggestionCollectionView.isHidden = true
|
||
resultCollectionView.isHidden = true
|
||
headerView.style = .home
|
||
case .suggest:
|
||
historyHotView.isHidden = true
|
||
suggestionCollectionView.isHidden = false
|
||
resultCollectionView.isHidden = true
|
||
headerView.style = .input
|
||
case .result:
|
||
historyHotView.isHidden = true
|
||
suggestionCollectionView.isHidden = true
|
||
resultCollectionView.isHidden = false
|
||
headerView.style = .input
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - UICollectionViewDelegate UICollectionViewDataSource
|
||
extension XSSearchViewController: UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
|
||
|
||
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||
if collectionView == suggestionCollectionView {
|
||
return suggestionItems.count
|
||
}
|
||
return resultItems.count
|
||
}
|
||
|
||
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||
if collectionView == suggestionCollectionView {
|
||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "XSSearchSuggestionCell", for: indexPath) as! XSSearchSuggestionCell
|
||
let item = suggestionItems[indexPath.row]
|
||
let keyword = headerView.text ?? ""
|
||
cell.configure(model: item, keyword: keyword)
|
||
return cell
|
||
} else {
|
||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "XSSearchResultCell", for: indexPath) as! XSSearchResultCell
|
||
cell.configure(model: resultItems[indexPath.row])
|
||
return cell
|
||
}
|
||
}
|
||
|
||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||
if collectionView == suggestionCollectionView {
|
||
let item = suggestionItems[indexPath.row]
|
||
xs_pushDetail(model: item)
|
||
} else if collectionView == resultCollectionView {
|
||
let item = resultItems[indexPath.row]
|
||
xs_pushDetail(model: item)
|
||
}
|
||
}
|
||
|
||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
|
||
if collectionView == suggestionCollectionView {
|
||
let width = XSScreen.width - 32
|
||
return CGSize(width: floor(width), height: 104)
|
||
} else {
|
||
let width = (XSScreen.width - 32 - 16) / 3
|
||
return CGSize(width: floor(width), height: 186)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Detail
|
||
extension XSSearchViewController {
|
||
|
||
private func xs_pushDetail(model: XSShortModel) {
|
||
let shortId = model.short_play_id ?? model.short_play_video_id ?? model.id
|
||
guard let shortId = shortId, !shortId.isEmpty else { return }
|
||
let controller = XSShortDetailViewController()
|
||
controller.shortId = shortId
|
||
navigationController?.pushViewController(controller, animated: true)
|
||
}
|
||
}
|