@@ -3,7 +3,11 @@ import InlineUI
3
3
import SwiftUI
4
4
import UIKit
5
5
6
- class MessageCollectionViewCell : UICollectionViewCell {
6
+ protocol MessageCellDelegate : AnyObject {
7
+ func didSwipeToReply( for message: FullMessage )
8
+ }
9
+
10
+ class MessageCollectionViewCell : UICollectionViewCell , UIGestureRecognizerDelegate {
7
11
static let reuseIdentifier = " MessageCell "
8
12
9
13
var messageView : UIMessageView ?
@@ -15,6 +19,13 @@ class MessageCollectionViewCell: UICollectionViewCell {
15
19
var message : FullMessage !
16
20
var spaceId : Int64 = 0
17
21
22
+ private var panGesture : UIPanGestureRecognizer !
23
+ private let replyIndicator = ReplyIndicatorView ( )
24
+ private var swipeActive = false
25
+ private var initialTranslation : CGFloat = 0
26
+
27
+ weak var delegate : MessageCellDelegate ?
28
+
18
29
lazy var nameLabel : UILabel = {
19
30
var label = UILabel ( )
20
31
label. font = . systemFont( ofSize: 13 , weight: . medium)
@@ -27,6 +38,8 @@ class MessageCollectionViewCell: UICollectionViewCell {
27
38
override init ( frame: CGRect ) {
28
39
super. init ( frame: frame)
29
40
setupContentSize ( )
41
+ setupSwipeGestures ( )
42
+ setupReplyIndicator ( )
30
43
}
31
44
32
45
@available ( * , unavailable)
@@ -78,6 +91,125 @@ class MessageCollectionViewCell: UICollectionViewCell {
78
91
}
79
92
80
93
extension MessageCollectionViewCell {
94
+ override func gestureRecognizerShouldBegin( _ gestureRecognizer: UIGestureRecognizer ) -> Bool {
95
+ guard gestureRecognizer == panGesture else { return true }
96
+
97
+ let velocity = panGesture. velocity ( in: contentView)
98
+
99
+ // Calculate angle and only allow nearly horizontal swipes
100
+ // An 16 degree angle corresponds to tan(16°) ≈ 0.287
101
+ // This means vertical component should be at most 0.287 times the horizontal component
102
+ let maxAngleTangent : CGFloat = 0.287 // tan(16°)
103
+ let isHorizontalEnough = abs ( velocity. y) <= abs ( velocity. x) * maxAngleTangent
104
+
105
+ return abs ( velocity. x) > abs ( velocity. y) && isHorizontalEnough // Must be predominantly horizontal
106
+ }
107
+
108
+ @objc private func handlePan( _ gesture: UIPanGestureRecognizer ) {
109
+ let translation = gesture. translation ( in: contentView)
110
+ let velocity = gesture. velocity ( in: contentView)
111
+
112
+ switch gesture. state {
113
+ case . began:
114
+ initialTranslation = translation. x
115
+ replyIndicator. isHidden = false
116
+ replyIndicator. alpha = 1
117
+ replyIndicator. reset ( )
118
+ case . changed:
119
+ handleSwipeProgress ( translation: translation, velocity: velocity)
120
+ case . ended, . cancelled:
121
+ finalizeSwipe ( translation: translation, velocity: velocity)
122
+ default :
123
+ resetSwipeState ( )
124
+ }
125
+ }
126
+
127
+ private func handleSwipeProgress( translation: CGPoint , velocity: CGPoint ) {
128
+ let adjustedTranslation = translation. x - initialTranslation
129
+ let isTrailingSwipe = adjustedTranslation < 0
130
+
131
+ guard isTrailingSwipe else {
132
+ resetSwipeState ( )
133
+ return
134
+ }
135
+
136
+ let maxTranslation : CGFloat = 80
137
+ let progress = min ( abs ( adjustedTranslation) / maxTranslation, 1 )
138
+ let boundedTranslation = - maxTranslation * progress
139
+
140
+ messageView? . transform = CGAffineTransform ( translationX: boundedTranslation, y: 0 )
141
+ nameLabel. transform = CGAffineTransform ( translationX: boundedTranslation, y: 0 )
142
+ avatarHostingController? . view. transform = CGAffineTransform ( translationX: boundedTranslation, y: 0 )
143
+
144
+ replyIndicator. isHidden = false
145
+ replyIndicator. updateProgress ( progress)
146
+
147
+ if progress > 0.7 {
148
+ // Play haptic feedback when swipe crosses the activation threshold
149
+ if !swipeActive {
150
+ let feedbackGenerator = UIImpactFeedbackGenerator ( style: . medium)
151
+ feedbackGenerator. prepare ( )
152
+ feedbackGenerator. impactOccurred ( )
153
+ }
154
+ swipeActive = true
155
+ } else {
156
+ swipeActive = false
157
+ }
158
+ }
159
+
160
+ private func finalizeSwipe( translation: CGPoint , velocity: CGPoint ) {
161
+ let progress = min ( abs ( translation. x) / 80 , 1 )
162
+ let shouldTrigger = progress > 0.7 || abs ( velocity. x) > 600
163
+
164
+ if shouldTrigger {
165
+ ChatState . shared. setReplyingMessageId ( peer: message. message. peerId, id: message. message. messageId)
166
+ }
167
+
168
+ UIView . animate ( withDuration: 0.4 , delay: 0 , usingSpringWithDamping: 0.7 , initialSpringVelocity: 0.5 ) {
169
+ self . messageView? . transform = . identity
170
+ self . nameLabel. transform = . identity
171
+ self . avatarHostingController? . view. transform = . identity
172
+ self . replyIndicator. alpha = 0
173
+ } completion: { _ in
174
+ if shouldTrigger {
175
+ self . delegate? . didSwipeToReply ( for: self . message)
176
+ }
177
+ self . resetSwipeState ( )
178
+ }
179
+ }
180
+
181
+ private func resetSwipeState( ) {
182
+ replyIndicator. isHidden = true
183
+ replyIndicator. alpha = 1
184
+ replyIndicator. reset ( )
185
+ initialTranslation = 0
186
+ swipeActive = false
187
+
188
+ messageView? . transform = . identity
189
+ nameLabel. transform = . identity
190
+ avatarHostingController? . view. transform = . identity
191
+ }
192
+
193
+ func setupReplyIndicator( ) {
194
+ replyIndicator. translatesAutoresizingMaskIntoConstraints = false
195
+ contentView. insertSubview ( replyIndicator, belowSubview: messageView ?? UIView ( ) )
196
+ replyIndicator. isHidden = true
197
+ replyIndicator. alpha = 1
198
+
199
+ NSLayoutConstraint . activate ( [
200
+ replyIndicator. centerYAnchor. constraint ( equalTo: contentView. centerYAnchor) ,
201
+ replyIndicator. trailingAnchor. constraint ( equalTo: contentView. trailingAnchor, constant: - 5 ) ,
202
+ replyIndicator. widthAnchor. constraint ( equalToConstant: 40 ) ,
203
+ replyIndicator. heightAnchor. constraint ( equalToConstant: 40 ) ,
204
+ ] )
205
+ }
206
+
207
+ func setupSwipeGestures( ) {
208
+ panGesture = UIPanGestureRecognizer ( target: self , action: #selector( handlePan ( _: ) ) )
209
+ panGesture. delegate = self
210
+ contentView. addGestureRecognizer ( panGesture)
211
+ }
212
+
81
213
func setupContentSize( ) {
82
214
contentView. setContentCompressionResistancePriority ( . defaultLow, for: . horizontal)
83
215
contentView. setContentHuggingPriority ( . defaultLow, for: . horizontal)
0 commit comments