- Integrate Kingfisher for image caching with CachedAsyncImage component - Add CacheSettingsView for managing image cache size and clearing cache - Implement three card layout styles: compact, magazine (default), natural - Add AppearanceSettingsView with visual layout previews and theme settings - Create Clean Architecture for card layout with domain models and use cases - Implement FlowLayout for dynamic label width calculation - Add skeleton loading animation for initial bookmark loads - Replace delete confirmation dialogs with immediate delete + 3-second undo - Support multiple simultaneous undo operations with individual progress bars - Add grayed-out visual feedback for pending deletions - Centralize notification names in dedicated NotificationNames file - Remove pagination logic from label management (replaced with FlowLayout) - Update AsyncImage usage across BookmarkCardView, BookmarkDetailView, ImageViewerView - Improve UI consistency and spacing throughout the app
176 lines
5.8 KiB
Swift
176 lines
5.8 KiB
Swift
import SwiftUI
|
|
|
|
struct SkeletonLoadingView: View {
|
|
let layout: CardLayoutStyle
|
|
@State private var animateGradient = false
|
|
|
|
var body: some View {
|
|
LazyVStack(spacing: layout == .compact ? 8 : 12) {
|
|
ForEach(0..<6, id: \.self) { _ in
|
|
skeletonCard
|
|
}
|
|
}
|
|
.onAppear {
|
|
withAnimation(Animation.linear(duration: 1.5).repeatForever(autoreverses: false)) {
|
|
animateGradient = true
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var skeletonCard: some View {
|
|
switch layout {
|
|
case .compact:
|
|
compactSkeletonCard
|
|
case .magazine:
|
|
magazineSkeletonCard
|
|
case .natural:
|
|
naturalSkeletonCard
|
|
}
|
|
}
|
|
|
|
private var compactSkeletonCard: some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
// Image placeholder
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(shimmerGradient)
|
|
.frame(width: 80, height: 80)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
// Title placeholder
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(shimmerGradient)
|
|
.frame(height: 16)
|
|
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(shimmerGradient)
|
|
.frame(width: 180, height: 16)
|
|
|
|
// Description placeholder
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(shimmerGradient)
|
|
.frame(height: 14)
|
|
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(shimmerGradient)
|
|
.frame(width: 120, height: 14)
|
|
|
|
Spacer()
|
|
|
|
// Bottom info placeholder
|
|
HStack {
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(shimmerGradient)
|
|
.frame(width: 80, height: 12)
|
|
|
|
Spacer()
|
|
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(shimmerGradient)
|
|
.frame(width: 50, height: 12)
|
|
}
|
|
}
|
|
}
|
|
.padding(12)
|
|
.background(Color(R.color.bookmark_list_bg))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
|
|
private var magazineSkeletonCard: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
// Image placeholder
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(shimmerGradient)
|
|
.frame(height: 140)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
// Title placeholder
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(shimmerGradient)
|
|
.frame(height: 16)
|
|
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(shimmerGradient)
|
|
.frame(width: 200, height: 16)
|
|
|
|
// Info placeholders
|
|
HStack {
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(shimmerGradient)
|
|
.frame(width: 80, height: 12)
|
|
|
|
Spacer()
|
|
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(shimmerGradient)
|
|
.frame(width: 60, height: 12)
|
|
}
|
|
.padding(.top, 4)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.bottom, 12)
|
|
}
|
|
.background(Color(R.color.bookmark_list_bg))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
.shadow(color: Color.black.opacity(0.1), radius: 8, x: 0, y: 4)
|
|
.shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1)
|
|
}
|
|
|
|
private var naturalSkeletonCard: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
// Image placeholder
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(shimmerGradient)
|
|
.frame(minHeight: 180)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
// Title placeholder
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(shimmerGradient)
|
|
.frame(height: 16)
|
|
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(shimmerGradient)
|
|
.frame(width: 220, height: 16)
|
|
|
|
// Info placeholders
|
|
HStack {
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(shimmerGradient)
|
|
.frame(width: 90, height: 12)
|
|
|
|
Spacer()
|
|
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(shimmerGradient)
|
|
.frame(width: 70, height: 12)
|
|
}
|
|
.padding(.top, 4)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.bottom, 12)
|
|
}
|
|
.background(Color(R.color.bookmark_list_bg))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
.shadow(color: Color.black.opacity(0.08), radius: 6, x: 0, y: 3)
|
|
}
|
|
|
|
private var shimmerGradient: LinearGradient {
|
|
LinearGradient(
|
|
colors: [
|
|
Color.gray.opacity(0.3),
|
|
Color.gray.opacity(0.1),
|
|
Color.gray.opacity(0.3)
|
|
],
|
|
startPoint: animateGradient ? .topLeading : .topTrailing,
|
|
endPoint: animateGradient ? .bottomTrailing : .bottomLeading
|
|
)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
ScrollView {
|
|
SkeletonLoadingView(layout: .magazine)
|
|
.padding()
|
|
}
|
|
} |