235 lines
8.4 KiB
Swift
235 lines
8.4 KiB
Swift
import SwiftUI
|
|
|
|
/// 搜索页 - 对应 Android 版 SearchActivity
|
|
struct SearchView: View {
|
|
/// 搜索状态与结果管理。
|
|
@StateObject private var viewModel = SearchViewModel()
|
|
|
|
#if os(iOS)
|
|
/// iOS 卡片网格参数。
|
|
private let columns = [
|
|
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
|
|
]
|
|
#else
|
|
/// macOS 卡片网格参数。
|
|
private let columns = [
|
|
GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16)
|
|
]
|
|
#endif
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
VStack(spacing: 0) {
|
|
// 搜索栏
|
|
searchBar
|
|
|
|
// 内容
|
|
if viewModel.isSearching {
|
|
Spacer()
|
|
ProgressView("搜索中...")
|
|
.tint(.orange)
|
|
Spacer()
|
|
} else if !viewModel.results.isEmpty {
|
|
searchResults
|
|
} else if viewModel.keyword.isEmpty {
|
|
// 输入为空时显示历史;输入非空但无结果时显示提示文案。
|
|
searchHistorySection
|
|
} else if let error = viewModel.errorMessage {
|
|
Spacer()
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "magnifyingglass")
|
|
.font(.largeTitle)
|
|
.foregroundColor(.gray)
|
|
Text(error)
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
Spacer()
|
|
}
|
|
}
|
|
.background(Color(red: 0.08, green: 0.08, blue: 0.1))
|
|
.navigationTitle("搜索")
|
|
#if os(iOS)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
#endif
|
|
}
|
|
}
|
|
|
|
// MARK: - 搜索栏
|
|
|
|
/// 顶部搜索输入区。
|
|
private var searchBar: some View {
|
|
HStack(spacing: 12) {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: "magnifyingglass")
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundColor(.white.opacity(0.8))
|
|
|
|
TextField("搜索影片...", text: $viewModel.keyword)
|
|
.textFieldStyle(.plain)
|
|
.font(.system(size: 16))
|
|
.foregroundColor(.white)
|
|
.submitLabel(.search)
|
|
.onSubmit {
|
|
Task { await viewModel.search() }
|
|
}
|
|
#if os(iOS)
|
|
.autocapitalization(.none)
|
|
#endif
|
|
|
|
if !viewModel.keyword.isEmpty {
|
|
Button {
|
|
withAnimation {
|
|
viewModel.keyword = ""
|
|
viewModel.results = []
|
|
}
|
|
} label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.foregroundColor(.white.opacity(0.4))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
.background(Color.white.opacity(0.05))
|
|
.cornerRadius(16)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 16)
|
|
.stroke(LinearGradient(colors: [.orange.opacity(0.5), .clear], startPoint: .topLeading, endPoint: .bottomTrailing), lineWidth: 1)
|
|
)
|
|
|
|
Button {
|
|
Task { await viewModel.search() }
|
|
} label: {
|
|
Text("搜索")
|
|
.font(.system(size: 15, weight: .bold))
|
|
.foregroundColor(.white)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
.background(LinearGradient(colors: [.orange, .red], startPoint: .leading, endPoint: .trailing))
|
|
.cornerRadius(14)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.top, 20)
|
|
.padding(.bottom, 10)
|
|
}
|
|
|
|
// MARK: - 搜索结果
|
|
|
|
/// 搜索结果网格。
|
|
private var searchResults: some View {
|
|
ScrollView {
|
|
LazyVGrid(columns: columns, spacing: 16) {
|
|
ForEach(viewModel.results) { video in
|
|
NavigationLink(value: video) {
|
|
VodCardView(video: video)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.vertical, 12)
|
|
}
|
|
.navigationDestination(for: Movie.Video.self) { video in
|
|
DetailView(video: video)
|
|
}
|
|
}
|
|
|
|
// MARK: - 搜索历史
|
|
|
|
/// 搜索历史区域,支持复用历史关键词与一键清空。
|
|
private var searchHistorySection: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
if !viewModel.searchHistory.isEmpty {
|
|
HStack {
|
|
Text("搜索历史")
|
|
.font(.headline)
|
|
.foregroundColor(.white)
|
|
Spacer()
|
|
Button {
|
|
viewModel.clearHistory()
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "trash")
|
|
Text("清空")
|
|
}
|
|
.font(.caption)
|
|
.foregroundColor(.gray)
|
|
}
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.top, 16)
|
|
|
|
FlowLayout(spacing: 8) {
|
|
ForEach(viewModel.searchHistory, id: \.self) { keyword in
|
|
Button {
|
|
viewModel.keyword = keyword
|
|
Task { await viewModel.search() }
|
|
} label: {
|
|
Text(keyword)
|
|
.font(.subheadline)
|
|
.foregroundColor(.white.opacity(0.8))
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 7)
|
|
.background(Color.white.opacity(0.1))
|
|
.cornerRadius(16)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding(.horizontal, 20)
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 流式布局
|
|
struct FlowLayout: Layout {
|
|
/// 子项间距。
|
|
var spacing: CGFloat = 8
|
|
|
|
/// 计算整体尺寸。
|
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
|
let result = arrangement(proposal: proposal, subviews: subviews)
|
|
return result.size
|
|
}
|
|
|
|
/// 按计算结果放置子视图。
|
|
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
|
let result = arrangement(proposal: ProposedViewSize(width: bounds.width, height: bounds.height), subviews: subviews)
|
|
for (index, position) in result.positions.enumerated() {
|
|
subviews[index].place(at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y), proposal: .unspecified)
|
|
}
|
|
}
|
|
|
|
/// 核心排版算法:按最大宽度逐个放置,超宽后自动换行。
|
|
private func arrangement(proposal: ProposedViewSize, subviews: Subviews) -> (size: CGSize, positions: [CGPoint]) {
|
|
let maxWidth = proposal.width ?? .infinity
|
|
var positions: [CGPoint] = []
|
|
var currentX: CGFloat = 0
|
|
var currentY: CGFloat = 0
|
|
var lineHeight: CGFloat = 0
|
|
var maxX: CGFloat = 0
|
|
|
|
for subview in subviews {
|
|
let size = subview.sizeThatFits(.unspecified)
|
|
if currentX + size.width > maxWidth && currentX > 0 {
|
|
currentX = 0
|
|
currentY += lineHeight + spacing
|
|
lineHeight = 0
|
|
}
|
|
positions.append(CGPoint(x: currentX, y: currentY))
|
|
lineHeight = max(lineHeight, size.height)
|
|
currentX += size.width + spacing
|
|
maxX = max(maxX, currentX)
|
|
}
|
|
|
|
return (CGSize(width: maxX, height: currentY + lineHeight), positions)
|
|
}
|
|
}
|