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
import NukeUI
import SwiftUI
/// One entry in a browse index — a camera, a place.
///
/// A square with a mosaic of up to four photos behind the name and a count.
/// The mosaic is a sample of what is behind the tile rather than a feed in
/// its own right: the tile is for choosing, and the screen it opens is for
/// looking. Fewer than four photos stretch to fill rather than leaving holes,
/// so a place with one gallery doesn't read as a broken 2x2.
struct BrowseTile: View {
let title: String
let meta: String?
let thumbs: [String]
let action: () -> Void
var body: some View {
Button(action: action) {
Color(.secondarySystemBackground)
.aspectRatio(1, contentMode: .fit)
.overlay { Mosaic(thumbs: Array(thumbs.prefix(4))) }
.overlay(alignment: .bottomLeading) { label }
.clipped()
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(meta.map { "\(title), \($0)" } ?? title)
}
/// The name has to survive whatever photo is behind it, so the scrim is
/// part of the tile. Deep and tall on purpose: a lot of what gets posted
/// here is high-key film, and a shallow scrim leaves white on white.
private var label: some View {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline.weight(.bold))
.lineLimit(1)
if let meta {
Text(meta)
.font(.caption)
.foregroundStyle(.white.opacity(0.72))
.lineLimit(1)
}
}
.foregroundStyle(.white)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 34)
.padding(.horizontal, 12)
.padding(.bottom, 11)
.background(
LinearGradient(
stops: [
.init(color: .clear, location: 0),
.init(color: .black.opacity(0.55), location: 0.55),
.init(color: .black.opacity(0.85), location: 1),
],
startPoint: .top,
endPoint: .bottom
)
)
}
}
/// Up to four photos in a 2x2, with no gutter between them: the grid between
/// tiles has one, and matching it here made two adjacent tiles read as one
/// strip of four columns.
///
/// Two splits across, not down — a pair of full-width bands still reads as
/// one tile, where two tall slivers read as two. Three puts the first down the
/// left side and stacks the other two beside it.
private struct Mosaic: View {
let thumbs: [String]
var body: some View {
switch thumbs.count {
case 0:
Color.clear
case 1:
photo(thumbs[0])
case 2:
VStack(spacing: 0) {
photo(thumbs[0])
photo(thumbs[1])
}
case 3:
HStack(spacing: 0) {
photo(thumbs[0])
VStack(spacing: 0) {
photo(thumbs[1])
photo(thumbs[2])
}
}
default:
VStack(spacing: 0) {
HStack(spacing: 0) {
photo(thumbs[0])
photo(thumbs[1])
}
HStack(spacing: 0) {
photo(thumbs[2])
photo(thumbs[3])
}
}
}
}
private func photo(_ url: String) -> some View {
Color.clear
.overlay {
LazyImage(url: URL(string: url)) { state in
if let image = state.image {
image.resizable().scaledToFill()
} else {
Color(.secondarySystemBackground)
}
}
}
.clipped()
}
}