swift-layout/Sources/LilithLayout/Card.swift

357 lines
13 KiB
Swift
Executable file

// Card.swift
// iOS UI Components - Layout Components
//
// Container with shadow, padding, and rounded corners
import SwiftUI
import LilithDesignTokens
/// Card container
///
/// Elevated container with shadow and customizable padding.
///
/// Example:
/// ```swift
/// Card {
/// VStack {
/// Text("Title")
/// Text("Content")
/// }
/// }
/// ```
public struct Card<Content: View>: View {
// MARK: - Properties
private let content: Content
private let padding: CGFloat
private let isElevated: Bool
private let backgroundColor: Color
private let onTap: (() -> Void)?
// MARK: - State
@State private var isPressed = false
// MARK: - Initialization
/// Create a card
/// - Parameters:
/// - padding: Inner padding (default: base)
/// - shadow: Shadow style (default: card)
/// - backgroundColor: Background color (default: surface)
/// - onTap: Optional tap action (makes card tappable)
/// - content: Card content
public init(
padding: CGFloat = AppSpacing.base,
elevated: Bool = false,
backgroundColor: Color = AppColors.surface,
onTap: (() -> Void)? = nil,
@ViewBuilder content: () -> Content
) {
self.padding = padding
self.isElevated = elevated
self.backgroundColor = backgroundColor
self.onTap = onTap
self.content = content()
}
// MARK: - Body
public var body: some View {
Group {
if let onTap = onTap {
Button(action: {
// Haptic feedback (iOS only)
#if canImport(UIKit)
let generator = UIImpactFeedbackGenerator(style: .light)
generator.impactOccurred()
#endif
onTap()
}) {
cardContent
}
.buttonStyle(PlainButtonStyle())
.onLongPressGesture(minimumDuration: .infinity, maximumDistance: .infinity, pressing: { pressing in
isPressed = pressing
}, perform: {})
} else {
cardContent
}
}
}
private var cardContent: some View {
content
.padding(padding)
.background(backgroundColor)
.clipShape(RoundedRectangle(cornerRadius: AppRadius.card))
.shadow(
color: Color.black.opacity(isElevated ? 0.15 : 0.1),
radius: isElevated ? 15 : 6,
x: 0,
y: isElevated ? 10 : 2
)
.scaleEffect(isPressed ? 0.98 : 1.0)
.animation(AppAnimations.buttonPress, value: isPressed)
}
}
// MARK: - Card Variants
extension Card {
/// Elevated card with larger shadow
public static func elevated(
padding: CGFloat = AppSpacing.base,
backgroundColor: Color = AppColors.surface,
onTap: (() -> Void)? = nil,
@ViewBuilder content: () -> Content
) -> Card<Content> {
Card(
padding: padding,
elevated: true,
backgroundColor: backgroundColor,
onTap: onTap,
content: content
)
}
/// Flat card without shadow
public static func flat(
padding: CGFloat = AppSpacing.base,
backgroundColor: Color = AppColors.surface,
onTap: (() -> Void)? = nil,
@ViewBuilder content: () -> Content
) -> Card<Content> {
Card(
padding: padding,
elevated: false,
backgroundColor: backgroundColor,
onTap: onTap,
content: content
)
}
}
// MARK: - Preview Provider
#if DEBUG
struct Card_Previews: PreviewProvider {
static var previews: some View {
ScrollView {
VStack(spacing: AppSpacing.xl) {
// Basic card
Card {
VStack(alignment: .leading, spacing: AppSpacing.sm) {
Text("Basic Card")
.font(AppTypography.h3())
.foregroundColor(AppColors.textPrimary)
Text("This is a basic card with default shadow and padding.")
.font(AppTypography.body())
.foregroundColor(AppColors.textSecondary)
}
}
// Elevated card
Card.elevated {
VStack(alignment: .leading, spacing: AppSpacing.sm) {
Text("Elevated Card")
.font(AppTypography.h3())
.foregroundColor(AppColors.textPrimary)
Text("This card has a larger shadow for more emphasis.")
.font(AppTypography.body())
.foregroundColor(AppColors.textSecondary)
}
}
// Flat card
Card.flat {
VStack(alignment: .leading, spacing: AppSpacing.sm) {
Text("Flat Card")
.font(AppTypography.h3())
.foregroundColor(AppColors.textPrimary)
Text("This card has no shadow.")
.font(AppTypography.body())
.foregroundColor(AppColors.textSecondary)
}
}
// Tappable card
Card(onTap: {
print("Card tapped")
}) {
HStack {
Image(systemName: "star.fill")
.foregroundColor(AppColors.Semantic.warning)
VStack(alignment: .leading, spacing: AppSpacing.xs) {
Text("Tappable Card")
.font(AppTypography.body(weight: .semibold))
.foregroundColor(AppColors.textPrimary)
Text("Tap me to interact")
.font(AppTypography.caption())
.foregroundColor(AppColors.textSecondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(AppColors.textTertiary)
}
}
// Custom padding
Card(padding: AppSpacing.lg) {
Text("Card with large padding")
.font(AppTypography.body())
.foregroundColor(AppColors.textPrimary)
}
Card(padding: AppSpacing.xs) {
Text("Card with small padding")
.font(AppTypography.caption())
.foregroundColor(AppColors.textPrimary)
}
Divider()
// In context - User profile card
Card {
HStack(spacing: AppSpacing.md) {
Circle()
.fill(AppColors.Gray.gray600)
.frame(width: 60, height: 60)
.overlay(
Image(systemName: "person.fill")
.font(.system(size: 24))
.foregroundColor(AppColors.Gray.gray400)
)
VStack(alignment: .leading, spacing: AppSpacing.xs) {
Text("John Doe")
.font(AppTypography.body(weight: .semibold))
.foregroundColor(AppColors.textPrimary)
Text("john.doe@example.com")
.font(AppTypography.caption())
.foregroundColor(AppColors.textSecondary)
Text("Premium Member")
.font(AppTypography.caption())
.foregroundColor(AppColors.primary)
}
Spacer()
}
}
// In context - Stat cards
HStack(spacing: AppSpacing.md) {
Card(padding: AppSpacing.md) {
VStack(spacing: AppSpacing.xs) {
Text("1,234")
.font(AppTypography.h2())
.foregroundColor(AppColors.textPrimary)
Text("Followers")
.font(AppTypography.caption())
.foregroundColor(AppColors.textSecondary)
}
.frame(maxWidth: .infinity)
}
Card(padding: AppSpacing.md) {
VStack(spacing: AppSpacing.xs) {
Text("567")
.font(AppTypography.h2())
.foregroundColor(AppColors.textPrimary)
Text("Following")
.font(AppTypography.caption())
.foregroundColor(AppColors.textSecondary)
}
.frame(maxWidth: .infinity)
}
}
// In context - Feature card
Card(onTap: {
print("Feature tapped")
}) {
VStack(alignment: .leading, spacing: AppSpacing.md) {
HStack {
Image(systemName: "sparkles")
.font(.system(size: 24))
.foregroundColor(AppColors.primary)
.frame(width: 40, height: 40)
.background(AppColors.primary.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.sm))
Spacer()
Text("NEW")
.font(AppTypography.caption(weight: .bold))
.foregroundColor(AppColors.primary)
.padding(.horizontal, AppSpacing.sm)
.padding(.vertical, AppSpacing.xs)
.background(AppColors.primary.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.full))
}
Text("Premium Features")
.font(AppTypography.h3())
.foregroundColor(AppColors.textPrimary)
Text("Unlock advanced features and remove ads with Premium.")
.font(AppTypography.body())
.foregroundColor(AppColors.textSecondary)
HStack {
Text("Learn More")
.font(AppTypography.body(weight: .semibold))
.foregroundColor(AppColors.primary)
Image(systemName: "arrow.right")
.font(.system(size: 12))
.foregroundColor(AppColors.primary)
}
}
}
// In context - List of cards
VStack(spacing: AppSpacing.md) {
ForEach(0..<3) { index in
Card(onTap: {
print("Item \(index) tapped")
}) {
HStack {
VStack(alignment: .leading, spacing: AppSpacing.xs) {
Text("Item \(index + 1)")
.font(AppTypography.body(weight: .semibold))
.foregroundColor(AppColors.textPrimary)
Text("Description for item \(index + 1)")
.font(AppTypography.caption())
.foregroundColor(AppColors.textSecondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(AppColors.textTertiary)
}
}
}
}
}
.padding()
}
.background(AppColors.background)
.previewDisplayName("Card States")
}
}
#endif