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
import NukeUI
import SwiftUI
struct AvatarView: View {
let url: String?
var size: CGFloat = 32
/// Set to false to suppress all NukeUI transitions — use when the avatar is inside
/// an animated parent (e.g. story parallax pane) so it snaps atomically.
var animated: Bool = true
private var imageURL: URL? {
guard let url else { return nil }
return URL(string: url)
}
var body: some View {
LazyImage(url: imageURL) { state in
if let image = state.image {
image
.resizable()
} else {
fallback
}
}
.animation(animated ? .default : nil, value: imageURL)
.frame(width: size, height: size)
.clipShape(Circle())
}
private var fallback: some View {
ZStack {
Circle().fill(Color(.systemGray4))
Image(systemName: "person.fill")
.font(.system(size: size * 0.45))
.foregroundStyle(Color(.systemGray2))
.accessibilityHidden(true)
}
}
}
#Preview {
VStack(spacing: 24) {
VStack(spacing: 8) {
Text("Fallback (nil URL)")
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: 20) {
AvatarView(url: nil, size: 32)
AvatarView(url: nil, size: 48)
AvatarView(url: nil, size: 80)
}
}
Divider()
VStack(spacing: 8) {
Text("Bad URL (load failure fallback)")
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: 20) {
AvatarView(url: "https://invalid.example/avatar.jpg", size: 32)
AvatarView(url: "https://invalid.example/avatar.jpg", size: 48)
AvatarView(url: "https://invalid.example/avatar.jpg", size: 80)
}
}
}
.padding()
.background(Color(.systemBackground))
.grainPreview()
}