You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

318 lines
9.2 KiB

import SwiftUI
// MARK: -
struct ListView<Data: RandomAccessCollection, Content: View>: View where Data.Element: Identifiable {
let data: Data
let content: (Data.Element) -> Content
let spacing: CGFloat
let padding: EdgeInsets
let backgroundColor: Color
init(
data: Data,
spacing: CGFloat = 12,
padding: EdgeInsets = EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0),
backgroundColor: Color = Color(.systemGroupedBackground),
@ViewBuilder content: @escaping (Data.Element) -> Content
) {
self.data = data
self.spacing = spacing
self.padding = padding
self.backgroundColor = backgroundColor
self.content = content
}
var body: some View {
ScrollView {
LazyVStack(spacing: spacing) {
ForEach(data) { item in
content(item)
}
}
.padding(padding)
}
.background(backgroundColor)
}
}
// MARK: -
struct ListItem<Content: View>: View {
let content: Content
let padding: EdgeInsets
let cornerRadius: CGFloat
let backgroundColor: Color
let shadowColor: Color
let shadowRadius: CGFloat
let shadowOffset: CGSize
let onTap: (() -> Void)?
init(
padding: EdgeInsets = EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16),
cornerRadius: CGFloat = 12,
backgroundColor: Color = Color(.systemBackground),
shadowColor: Color = .black.opacity(0.05),
shadowRadius: CGFloat = 4,
shadowOffset: CGSize = CGSize(width: 0, height: 2),
onTap: (() -> Void)? = nil,
@ViewBuilder content: () -> Content
) {
self.padding = padding
self.cornerRadius = cornerRadius
self.backgroundColor = backgroundColor
self.shadowColor = shadowColor
self.shadowRadius = shadowRadius
self.shadowOffset = shadowOffset
self.onTap = onTap
self.content = content()
}
var body: some View {
Group {
if let onTap = onTap {
Button(action: onTap) {
content
.padding(padding)
.background(backgroundColor)
.cornerRadius(cornerRadius)
.shadow(
color: shadowColor,
radius: shadowRadius,
x: shadowOffset.width,
y: shadowOffset.height
)
}
.buttonStyle(PlainButtonStyle())
} else {
content
.padding(padding)
.background(backgroundColor)
.cornerRadius(cornerRadius)
.shadow(
color: shadowColor,
radius: shadowRadius,
x: shadowOffset.width,
y: shadowOffset.height
)
}
}
}
}
// MARK: -
extension ListItem {
static func standard<U: View>(
onTap: (() -> Void)? = nil,
@ViewBuilder content: () -> U
) -> ListItem<U> {
ListItem<U>(onTap: onTap, content: content)
}
static func compact<U: View>(
onTap: (() -> Void)? = nil,
@ViewBuilder content: () -> U
) -> ListItem<U> {
ListItem<U>(
padding: EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12),
cornerRadius: 8,
onTap: onTap,
content: content
)
}
static func elevated<U: View>(
onTap: (() -> Void)? = nil,
@ViewBuilder content: () -> U
) -> ListItem<U> {
ListItem<U>(
shadowColor: .black.opacity(0.1),
shadowRadius: 8,
shadowOffset: CGSize(width: 0, height: 4),
onTap: onTap,
content: content
)
}
}
// MARK: -
struct EmptyStateView: View {
let icon: String
let title: String
let subtitle: String?
let actionTitle: String?
let action: (() -> Void)?
init(
icon: String,
title: String,
subtitle: String? = nil,
actionTitle: String? = nil,
action: (() -> Void)? = nil
) {
self.icon = icon
self.title = title
self.subtitle = subtitle
self.actionTitle = actionTitle
self.action = action
}
var body: some View {
VStack(spacing: 20) {
Image(systemName: icon)
.font(.system(size: 60))
.foregroundColor(.secondary)
VStack(spacing: 8) {
Text(title)
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(.primary)
if let subtitle = subtitle {
Text(subtitle)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
}
if let actionTitle = actionTitle, let action = action {
Button(action: action) {
Text(actionTitle)
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(.blue)
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.blue.opacity(0.1))
)
}
}
}
.padding(40)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: -
struct LoadingStateView: View {
let message: String
init(message: String = "加载中...") {
self.message = message
}
var body: some View {
VStack(spacing: 16) {
ProgressView()
.scaleEffect(1.2)
Text(message)
.font(.subheadline)
.foregroundColor(.secondary)
}
.padding(40)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: -
struct ErrorStateView: View {
let title: String
let message: String
let retryAction: (() -> Void)?
init(
title: String = "出错了",
message: String = "加载失败,请重试",
retryAction: (() -> Void)? = nil
) {
self.title = title
self.message = message
self.retryAction = retryAction
}
var body: some View {
VStack(spacing: 20) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 60))
.foregroundColor(.orange)
VStack(spacing: 8) {
Text(title)
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(.primary)
Text(message)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
if let retryAction = retryAction {
Button(action: retryAction) {
Text("重试")
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(.blue)
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.blue.opacity(0.1))
)
}
}
}
.padding(40)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
#Preview {
VStack(spacing: 20) {
//
ListView(data: Array(1...5).map { ListItemData(id: $0, title: "项目 \($0)") }) { item in
ListItem<AnyView>.standard {
AnyView(
HStack {
Text(item.title)
.font(.body)
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
)
}
}
.frame(height: 300)
//
EmptyStateView(
icon: "tray",
title: "暂无数据",
subtitle: "这里还没有任何内容",
actionTitle: "添加内容",
action: {}
)
//
LoadingStateView(message: "正在加载数据...")
//
ErrorStateView(
title: "网络错误",
message: "无法连接到服务器,请检查网络连接",
retryAction: {}
)
}
}
// MARK: -
struct ListItemData: Identifiable {
let id: Int
let title: String
}