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
import SwiftUI
/// Shared photo cell used across all three editor modes (strip, grid, captions).
///
/// Layout model: three independently animatable pieces in a ZStack:
///
/// 1. **Photo** — rendered at `geometry.photoSize` (natural-aspect rectangle
/// scaled by mode-specific rule: fill in preview, fit in reorder).
/// 2. **Mask** — `maskSide x maskSide` square frame + `.clipped()`. The mask
/// animates between modes (72pt strip → column-width grid → 60pt captions).
/// 3. **X button** — `.position(x: maskSide, y: 0)` so its center sits on the
/// mask's top-right corner. Follows the mask as it animates.
///
/// With the `AdaptivePhotoLayout` approach, the same cell instance persists
/// across mode switches. SwiftUI interpolates maskSide and position changes
/// automatically — no matchedGeometryEffect needed.
struct PhotoThumbnailCell: View {
@Binding var item: PhotoItem
var geometry: CellGeometry
var isSelected: Bool = false
var isDragging: Bool = false
var hideDelete: Bool = false
var deleteOpacity: CGFloat = 1
var exifState: ExifState = .absent
/// True during mode transitions. Gates per-property `.animation()` so
/// selection/drag springs don't compete with the Layout transition.
var isAnimatingMode: Bool = false
let onTap: () -> Void
let onDelete: () -> Void
var body: some View {
ZStack(alignment: .topLeading) {
// Photo
Image(uiImage: item.thumbnail)
.resizable()
.frame(width: geometry.photoSize.width, height: geometry.photoSize.height)
.frame(width: geometry.maskSide, height: geometry.maskSide)
.clipShape(RoundedRectangle(cornerRadius: geometry.maskCornerRadius, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: geometry.maskCornerRadius, style: .continuous)
.strokeBorder(Color.accentColor, lineWidth: isSelected ? 2.5 : 0)
)
.overlay(alignment: .bottomTrailing) {
altPill.opacity(hideDelete ? 0 : 1)
}
.overlay(alignment: .bottomLeading) {
ExifChip(state: exifState)
.padding(5)
}
// X button
deleteButton
.scaleEffect((hideDelete ? 0 : deleteOpacity) / (isDragging ? 1.1 : 1))
.position(x: geometry.maskSide, y: 0)
.allowsHitTesting(!hideDelete && deleteOpacity > 0)
}
.frame(width: geometry.maskSide, height: geometry.maskSide)
.contentShape(Rectangle())
.animation(
isAnimatingMode ? nil : .easeInOut(duration: 0.2),
value: isSelected
)
.scaleEffect(isDragging ? 1.1 : 1)
.opacity(isDragging ? 0.8 : 1)
.shadow(color: isDragging ? .black.opacity(0.25) : .clear, radius: 10, y: 6)
.animation(isAnimatingMode ? nil : .spring(response: 0.28, dampingFraction: 0.72), value: isDragging)
.onTapGesture { onTap() }
}
@ViewBuilder private var altPill: some View {
let hasAlt = !item.alt.trimmingCharacters(in: .whitespaces).isEmpty
Text("ALT")
.font(.caption2.weight(.bold))
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(.black.opacity(0.6), in: RoundedRectangle(cornerRadius: 4))
.foregroundStyle(.white)
.opacity(hasAlt ? 1 : 0.5)
.padding(5)
.allowsHitTesting(false)
.accessibilityLabel(hasAlt ? "Has alt text" : "No alt text")
}
private var deleteButton: some View {
Button(action: onDelete) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 22))
.foregroundStyle(.white, Color.accentColor)
.frame(width: 44, height: 44)
.contentShape(Circle().scale(0.7))
}
.buttonStyle(.plain)
.accessibilityLabel("Remove photo")
}
}
#Preview {
let items = Array(PreviewData.photoItemsWithExif.prefix(3))
HStack(spacing: 20) {
PhotoThumbnailCell(
item: .constant(items[0]),
geometry: CellGeometry(mode: .preview, maskSide: 72, photoAspect: items[0].naturalAspect),
isSelected: true,
exifState: .active,
onTap: {},
onDelete: {}
)
PhotoThumbnailCell(
item: .constant(items[1]),
geometry: CellGeometry(mode: .preview, maskSide: 72, photoAspect: items[1].naturalAspect),
isSelected: false,
exifState: .inactive,
onTap: {},
onDelete: {}
)
PhotoThumbnailCell(
item: .constant(items[2]),
geometry: CellGeometry(mode: .reorder, maskSide: 110, photoAspect: items[2].naturalAspect),
isSelected: false,
exifState: .absent,
onTap: {},
onDelete: {}
)
}
.padding(30)
.grainPreview()
}