1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
import SwiftUI
// MARK: - Shimmer
/// Sweeps a soft highlight band across a placeholder shape. A slow, steady
/// wave in the reading direction is the variant users perceive as fastest —
/// pulsing and rapid motion both test worse. Honors Reduce Motion by falling
/// back to a static fill.
private struct Shimmer: ViewModifier {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.layoutDirection) private var layoutDirection
@State private var phase: CGFloat = 0
private var highlight: Color {
colorScheme == .dark ? .white.opacity(0.10) : .white.opacity(0.7)
}
func body(content: Content) -> some View {
if reduceMotion {
content
} else {
content
.overlay {
GeometryReader { geo in
let width = geo.size.width
let band = max(width * 0.45, 60)
LinearGradient(
colors: [.clear, highlight, .clear],
startPoint: .leading,
endPoint: .trailing
)
.frame(width: band)
.offset(x: layoutDirection == .rightToLeft
? width - phase * (width + band)
: -band + phase * (width + band))
}
.allowsHitTesting(false)
}
.onAppear {
withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
phase = 1
}
}
}
}
}
extension View {
/// Animated loading sweep. Apply to a filled shape, then clip it.
func shimmering() -> some View {
modifier(Shimmer())
}
}
// MARK: - Flash guard
/// Holds back a loading placeholder for a beat so quick loads show nothing at
/// all instead of a gray flash. Matches the usual ladder: nothing under
/// ~200ms, placeholder beyond it.
struct DelayedSkeleton<Content: View>: View {
var delay: Duration = .milliseconds(150)
@ViewBuilder var content: () -> Content
@State private var visible = false
var body: some View {
ZStack {
if visible {
content()
.transition(.opacity)
}
}
.animation(.easeOut(duration: 0.15), value: visible)
.task {
try? await Task.sleep(for: delay)
guard !Task.isCancelled else { return }
visible = true
}
}
}
// MARK: - Building blocks
/// Rounded placeholder bar — use for text lines, buttons, thumbnails.
struct SkeletonBar: View {
var width: CGFloat?
var height: CGFloat = 12
var cornerRadius: CGFloat = 6
var body: some View {
Rectangle()
.fill(Color(.tertiarySystemFill))
.shimmering()
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.frame(width: width, height: height)
.frame(maxWidth: width == nil ? .infinity : nil)
}
}
/// Circular placeholder — use for avatars.
struct SkeletonCircle: View {
let size: CGFloat
var body: some View {
Circle()
.fill(Color(.tertiarySystemFill))
.shimmering()
.clipShape(Circle())
.frame(width: size, height: size)
}
}
/// Placeholder tile matching the 3-up gallery grid cells.
struct SkeletonGrid: View {
var rows: Int = 3
var columns: Int = 3
var aspectRatio: CGFloat = 3.0 / 4.0
var body: some View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 2), count: columns), spacing: 2) {
ForEach(0 ..< (rows * columns), id: \.self) { _ in
Rectangle()
.fill(Color(.tertiarySystemFill))
.shimmering()
.aspectRatio(aspectRatio, contentMode: .fit)
}
}
}
}
#Preview {
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 16) {
SkeletonCircle(size: 80)
SkeletonBar(height: 40, cornerRadius: 10)
}
SkeletonBar(width: 140, height: 14)
SkeletonBar(width: 100)
SkeletonGrid(rows: 2)
}
.padding()
.grainPreview()
}