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
176
177
178
179
180
181
182
183
184
185
186
import os
import SwiftUI
import UIKit
private let stripSignposter = OSSignposter(subsystem: "social.grain.grain", category: "Animation.Strip")
// MARK: - Strip scroll state
/// Manages horizontal scroll position for the photo strip. Extracted from
/// PhotoStrip so the state persists across mode switches (owned by GalleryEditor).
///
/// `@Observable` so per-property mutations (e.g. dragTranslation every frame)
/// only invalidate views that read that specific property.
@Observable
@MainActor
final class StripScrollState {
var baseOffset: CGFloat = 0
var dragTranslation: CGFloat = 0
var currentOffset: CGFloat {
baseOffset + dragTranslation
}
static let thumbSize: CGFloat = 72
static let spacing: CGFloat = 20
// MARK: - Pure layout math
/// Centered offset for cell at `idx` within container width `width`.
static func offset(forIndex idx: Int, itemCount: Int, containerWidth width: CGFloat) -> CGFloat {
guard itemCount > 0, width > 0 else { return 0 }
let stride = thumbSize + spacing
let halfCell = thumbSize / 2
let unclamped = width / 2 - halfCell - CGFloat(idx) * stride
return clamp(unclamped, itemCount: itemCount, containerWidth: width)
}
/// Clamp offset so HStack can't scroll past its own bounds.
static func clamp(_ offset: CGFloat, itemCount: Int, containerWidth width: CGFloat) -> CGFloat {
guard itemCount > 0, width > 0 else { return 0 }
let contentWidth = CGFloat(itemCount) * thumbSize + CGFloat(max(0, itemCount - 1)) * spacing
let minOffset = min(0, width - contentWidth)
return max(minOffset, min(0, offset))
}
// MARK: - Actions
func handleDragEnded(
translation: CGFloat,
predictedEnd: CGFloat,
containerWidth width: CGFloat,
itemCount: Int
) {
let originalBase = baseOffset
let committed = Self.clamp(originalBase + translation, itemCount: itemCount, containerWidth: width)
baseOffset = committed
dragTranslation = 0
let projected = Self.clamp(originalBase + predictedEnd, itemCount: itemCount, containerWidth: width)
let nearest = (0 ..< itemCount).min(by: {
abs(Self.offset(forIndex: $0, itemCount: itemCount, containerWidth: width) - projected)
< abs(Self.offset(forIndex: $1, itemCount: itemCount, containerWidth: width) - projected)
}) ?? 0
stripSignposter.emitEvent("StripDragEnded", "snap=\(nearest)")
withAnimation(.snappy) {
baseOffset = Self.offset(forIndex: nearest, itemCount: itemCount, containerWidth: width)
}
}
func scrollToIndex(_ idx: Int, itemCount: Int, containerWidth: CGFloat, animated: Bool = true) {
let target = Self.offset(forIndex: idx, itemCount: itemCount, containerWidth: containerWidth)
guard abs(target - baseOffset) >= 0.5 else { return }
if animated {
withAnimation(.smooth) { baseOffset = target }
} else {
baseOffset = target
}
}
/// X-button fade opacity based on cell's screen position relative to the
/// container edges. Fades over one xRadius of margin so the button vanishes
/// exactly as it reaches the clip boundary.
func deleteOpacity(cellIndex idx: Int, containerWidth width: CGFloat) -> CGFloat {
let cellLeft = currentOffset + CGFloat(idx) * (Self.thumbSize + Self.spacing)
let offLeft = -cellLeft
let offRight = (cellLeft + Self.thumbSize) - width
let xCenterX = cellLeft + Self.thumbSize
let xRadius: CGFloat = 11
let xPadding: CGFloat = 2
if offLeft > 0 {
let dist = xCenterX
return dist >= xRadius + xPadding ? 1 : max(0, (dist - xPadding) / xRadius)
} else if offRight > 0 {
let dist = width - cellLeft
return dist >= xRadius + xPadding ? 1 : max(0, (dist - xPadding) / xRadius)
}
return 1
}
}
// MARK: - UIKit-backed pan recognizer
/// Horizontal pan for strip scroll. UIKit-backed so the Form's vertical
/// scroll still works. `isEnabled` gates on mode so the recognizer is
/// inert when the layout is in grid or captions mode.
struct StripPanRecognizer: UIGestureRecognizerRepresentable {
var isEnabled: Bool = true
let onChanged: (CGFloat) -> Void
let onEnded: (_ translation: CGFloat, _ predictedEnd: CGFloat) -> Void
func makeCoordinator(converter _: CoordinateSpaceConverter) -> Coordinator {
Coordinator(isEnabled: isEnabled, onChanged: onChanged, onEnded: onEnded)
}
func makeUIGestureRecognizer(context: Context) -> UIPanGestureRecognizer {
let recognizer = UIPanGestureRecognizer()
recognizer.cancelsTouchesInView = false
recognizer.delaysTouchesBegan = false
recognizer.delaysTouchesEnded = false
recognizer.delegate = context.coordinator
return recognizer
}
func updateUIGestureRecognizer(_: UIPanGestureRecognizer, context: Context) {
context.coordinator.isEnabled = isEnabled
context.coordinator.onChanged = onChanged
context.coordinator.onEnded = onEnded
}
func handleUIGestureRecognizerAction(_ recognizer: UIPanGestureRecognizer, context: Context) {
let coordinator = context.coordinator
let translation = recognizer.translation(in: recognizer.view).x
switch recognizer.state {
case .changed:
coordinator.onChanged(translation)
case .ended:
let velocity = recognizer.velocity(in: recognizer.view).x
coordinator.onEnded(translation, translation + velocity * 0.25)
case .cancelled, .failed:
coordinator.onEnded(translation, translation)
default:
break
}
}
@MainActor
final class Coordinator: NSObject, UIGestureRecognizerDelegate {
var isEnabled: Bool
var onChanged: (CGFloat) -> Void
var onEnded: (_ translation: CGFloat, _ predictedEnd: CGFloat) -> Void
init(
isEnabled: Bool,
onChanged: @escaping (CGFloat) -> Void,
onEnded: @escaping (_ translation: CGFloat, _ predictedEnd: CGFloat) -> Void
) {
self.isEnabled = isEnabled
self.onChanged = onChanged
self.onEnded = onEnded
}
nonisolated func gestureRecognizerShouldBegin(
_ gestureRecognizer: UIGestureRecognizer
) -> Bool {
MainActor.assumeIsolated {
guard isEnabled else { return false }
guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true }
let velocity = pan.velocity(in: pan.view)
return abs(velocity.x) > abs(velocity.y)
}
}
nonisolated func gestureRecognizer(
_: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith _: UIGestureRecognizer
) -> Bool {
// Prevent simultaneous H+V scrolling: once our horizontal pan begins,
// the List's vertical scroll recognizer cannot also activate.
// Vertical swipes never reach here — gestureRecognizerShouldBegin
// returns false for them, so list scroll still works normally.
false
}
}
}