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
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
import SwiftUI
// MARK: - Layout value key
/// Each cell tags itself with its array index so the Layout knows
/// which subview to place at which position.
struct PhotoIndexKey: LayoutValueKey {
static let defaultValue: Int = 0
}
// MARK: - Adaptive photo layout
/// Single Layout that places photo cells in three modes: horizontal strip,
/// 3-column grid, or vertical captions list. When `mode` changes inside
/// `withAnimation`, SwiftUI interpolates each subview's position/size from
/// old to new — no matchedGeometryEffect, no conditional view swaps.
struct AdaptivePhotoLayout: Layout {
var mode: EditorMode
var containerWidth: CGFloat
var stripScrollOffset: CGFloat = 0
var dragPlacement: ReorderDragPlacement?
// Strip — values must match StripScrollState.thumbSize / .spacing
private let stripThumbSize: CGFloat = 72
private let stripSpacing: CGFloat = 20
private let stripVerticalPadding: CGFloat = 22
// Grid
private let gridColumnCount = 3
private let gridSpacing: CGFloat = 4
private let gridOuterPadding: CGFloat = 16
private let gridTopPadding: CGFloat = 22
private let gridBottomPadding: CGFloat = 12
// Captions
private let captionsHorizontalPadding: CGFloat = 16
private let captionsVerticalPadding: CGFloat = 10
private var gridCellSide: CGFloat {
let total = max(0, containerWidth - gridOuterPadding * 2 - gridSpacing * CGFloat(gridColumnCount - 1))
return max(1, total / CGFloat(gridColumnCount))
}
private var gridStride: CGFloat {
gridCellSide + gridSpacing
}
// MARK: - Layout protocol
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache _: inout ()
) -> CGSize {
let width = proposal.width ?? containerWidth
guard !subviews.isEmpty else { return CGSize(width: width, height: 0) }
switch mode {
case .preview:
return CGSize(width: width, height: stripThumbSize + stripVerticalPadding * 2)
case .reorder:
let rows = Int(ceil(Double(subviews.count) / Double(gridColumnCount)))
let height = CGFloat(rows) * gridCellSide
+ CGFloat(max(0, rows - 1)) * gridSpacing
+ gridTopPadding + gridBottomPadding
return CGSize(width: width, height: height)
case .captions:
var height: CGFloat = 0
let rowWidth = max(0, width - captionsHorizontalPadding * 2)
let rowProposal = ProposedViewSize(width: rowWidth, height: nil)
for subview in subviews {
let size = subview.sizeThatFits(rowProposal)
height += size.height + captionsVerticalPadding * 2
}
return CGSize(width: width, height: height)
}
}
func placeSubviews(
in bounds: CGRect,
proposal _: ProposedViewSize,
subviews: Subviews,
cache _: inout ()
) {
switch mode {
case .preview: placeAsStrip(in: bounds, subviews: subviews)
case .reorder: placeAsGrid(in: bounds, subviews: subviews)
case .captions: placeAsCaptions(in: bounds, subviews: subviews)
}
}
// MARK: - Strip placement
private func placeAsStrip(in bounds: CGRect, subviews: Subviews) {
let proposal = ProposedViewSize(width: stripThumbSize, height: stripThumbSize)
let centerY = bounds.midY
for subview in subviews {
let index = subview[PhotoIndexKey.self]
let x = bounds.minX + stripScrollOffset
+ CGFloat(index) * (stripThumbSize + stripSpacing)
+ stripThumbSize / 2
subview.place(at: CGPoint(x: x, y: centerY), anchor: .center, proposal: proposal)
}
}
// MARK: - Grid placement
private func placeAsGrid(in bounds: CGRect, subviews: Subviews) {
let proposal = ProposedViewSize(width: gridCellSide, height: gridCellSide)
for subview in subviews {
let index = subview[PhotoIndexKey.self]
let col = index % gridColumnCount
let row = index / gridColumnCount
var offsetX: CGFloat = 0
var offsetY: CGFloat = 0
if let drag = dragPlacement {
if index == drag.draggedIndex {
// Dragged cell stays at its natural slot — the view-level
// .offset modifier positions it at the finger in real time.
// Keeping dragOffset out of the Layout ensures the sibling
// spring animation transaction is never contaminated by the
// immediate dragOffset update.
} else {
let slotDelta: Int = if drag.currentIndex > drag.draggedIndex,
index > drag.draggedIndex, index <= drag.currentIndex
{
-1
} else if drag.currentIndex < drag.draggedIndex,
index >= drag.currentIndex, index < drag.draggedIndex
{
1
} else {
0
}
if slotDelta != 0 {
let newIdx = index + slotDelta
let newRow = newIdx / gridColumnCount
let newCol = newIdx % gridColumnCount
offsetX = CGFloat(newCol - col) * gridStride
offsetY = CGFloat(newRow - row) * gridStride
}
}
}
let x = bounds.minX + gridOuterPadding
+ CGFloat(col) * gridStride + gridCellSide / 2 + offsetX
let y = bounds.minY + gridTopPadding
+ CGFloat(row) * gridStride + gridCellSide / 2 + offsetY
subview.place(at: CGPoint(x: x, y: y), anchor: .center, proposal: proposal)
}
}
// MARK: - Captions placement
private func placeAsCaptions(in bounds: CGRect, subviews: Subviews) {
var y = bounds.minY
let rowWidth = max(0, bounds.width - captionsHorizontalPadding * 2)
let rowProposal = ProposedViewSize(width: rowWidth, height: nil)
for subview in subviews {
let size = subview.sizeThatFits(rowProposal)
y += captionsVerticalPadding
subview.place(
at: CGPoint(x: bounds.minX + captionsHorizontalPadding, y: y),
anchor: .topLeading,
proposal: rowProposal
)
y += size.height + captionsVerticalPadding
}
}
}