first commit
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 搜索页 - 对应 Android 版 SearchActivity
|
||||
struct SearchView: View {
|
||||
@StateObject private var viewModel = SearchViewModel()
|
||||
|
||||
#if os(iOS)
|
||||
private let columns = [
|
||||
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
|
||||
]
|
||||
#else
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user