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
import SwiftUI
/// Lightweight display model shared by `ExifInfoView`.
/// Both `GrainExif` (feed) and `ExifSummary` (create flow) map to this
/// via their `displayData` computed properties defined below.
struct ExifDisplayData {
var camera: String?
var lens: String?
var focalLength: String?
var fNumber: String?
var exposureTime: String?
var iso: String?
}
extension GrainExif {
var displayData: ExifDisplayData {
ExifDisplayData(
camera: cameraName,
lens: lensName,
focalLength: formattedFocalLength,
fNumber: formattedFNumber,
exposureTime: formattedExposureTime,
iso: iSO.map { "ISO \($0)" }
)
}
}
extension ExifSummary {
var displayData: ExifDisplayData {
ExifDisplayData(
camera: camera,
lens: lens,
focalLength: focalLength,
fNumber: aperture,
exposureTime: shutterSpeed,
iso: iso
)
}
}
struct ExifInfoView: View {
let exif: ExifDisplayData?
/// Always reserve layout space for the camera row even when the current exif is nil.
/// Set true when any photo in the gallery has a camera name.
var reserveCameraRow: Bool = false
/// Always reserve layout space for the lens row even when the current exif is nil.
var reserveLensRow: Bool = false
/// Foreground style for all text and icons.
var style: AnyShapeStyle = .init(.secondary)
var body: some View {
let showCamera = reserveCameraRow || exif?.camera != nil
let showLens = reserveLensRow || exif?.lens != nil
if showCamera || showLens || exif != nil {
VStack(alignment: .leading, spacing: 4) {
if showCamera {
HStack(spacing: 6) {
Image(systemName: "camera").font(.caption2)
Text(exif?.camera ?? " ").font(.caption)
}
.foregroundStyle(style)
.opacity(exif?.camera != nil ? 1 : 0)
}
if showLens {
HStack(spacing: 6) {
Image(systemName: "circle.circle").font(.caption2)
Text(exif?.lens ?? " ").font(.caption)
}
.foregroundStyle(style)
.opacity(exif?.lens != nil ? 1 : 0)
}
let settingsTokens = [exif?.focalLength, exif?.fNumber, exif?.exposureTime, exif?.iso]
if settingsTokens.contains(where: { $0 != nil }) {
ExifSettingsRow(
tokens: settingsTokens,
style: style
)
}
}
.opacity(exif != nil ? 1 : 0)
.animation(.easeInOut(duration: 0.2), value: exif == nil)
}
}
}
struct ExifSettingsRow: View {
let tokens: [String?]
var style: AnyShapeStyle = .init(.secondary)
var body: some View {
ZStack(alignment: .leading) {
Text(" ").hidden() // holds caption line height when all tokens are nil
HStack(spacing: 6) {
ForEach(Array(tokens.enumerated()), id: \.offset) { _, value in
if let value {
Text(value.digitWidthProxy)
.hidden()
.overlay(Text(value).contentTransition(.identity))
// Zero-duration overrides the parent .smooth context for this
// view's appearance/disappearance — snaps in/out instantly while
// sibling positions still shift smoothly via the HStack animation.
.transition(.opacity.animation(.linear(duration: 0)))
}
}
}
.animation(.smooth, value: tokens)
}
.font(.caption)
.foregroundStyle(style)
}
}
#Preview {
ExifInfoView(exif: ExifDisplayData(
camera: "Leica M11",
lens: "Summilux-M 35mm f/1.4",
focalLength: "35mm",
fNumber: "f/2",
exposureTime: "1/500",
iso: "ISO 200"
))
.padding()
}