Skip to content

Commit de92256

Browse files
committed
iOS: Add swipe to reply
1 parent 60468e5 commit de92256

File tree

2 files changed

+183
-1
lines changed

2 files changed

+183
-1
lines changed

apple/InlineIOS/Chat/MessageCollectionViewCell.swift

+133-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import InlineUI
33
import SwiftUI
44
import UIKit
55

6-
class MessageCollectionViewCell: UICollectionViewCell {
6+
protocol MessageCellDelegate: AnyObject {
7+
func didSwipeToReply(for message: FullMessage)
8+
}
9+
10+
class MessageCollectionViewCell: UICollectionViewCell, UIGestureRecognizerDelegate {
711
static let reuseIdentifier = "MessageCell"
812

913
var messageView: UIMessageView?
@@ -15,6 +19,13 @@ class MessageCollectionViewCell: UICollectionViewCell {
1519
var message: FullMessage!
1620
var spaceId: Int64 = 0
1721

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+
1829
lazy var nameLabel: UILabel = {
1930
var label = UILabel()
2031
label.font = .systemFont(ofSize: 13, weight: .medium)
@@ -27,6 +38,8 @@ class MessageCollectionViewCell: UICollectionViewCell {
2738
override init(frame: CGRect) {
2839
super.init(frame: frame)
2940
setupContentSize()
41+
setupSwipeGestures()
42+
setupReplyIndicator()
3043
}
3144

3245
@available(*, unavailable)
@@ -78,6 +91,125 @@ class MessageCollectionViewCell: UICollectionViewCell {
7891
}
7992

8093
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+
81213
func setupContentSize() {
82214
contentView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
83215
contentView.setContentHuggingPriority(.defaultLow, for: .horizontal)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import UIKit
2+
3+
class ReplyIndicatorView: UIView {
4+
private let iconView = UIImageView()
5+
6+
override init(frame: CGRect) {
7+
super.init(frame: frame)
8+
setupView()
9+
}
10+
11+
@available(*, unavailable)
12+
required init?(coder: NSCoder) {
13+
fatalError("init(coder:) has not been implemented")
14+
}
15+
16+
private func setupView() {
17+
iconView.image = UIImage(systemName: "arrowshape.turn.up.left.fill")
18+
iconView.tintColor = .systemGray4
19+
iconView.translatesAutoresizingMaskIntoConstraints = false
20+
iconView.alpha = 0
21+
addSubview(iconView)
22+
23+
NSLayoutConstraint.activate([
24+
iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
25+
iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
26+
iconView.widthAnchor.constraint(equalToConstant: 24),
27+
iconView.heightAnchor.constraint(equalToConstant: 24),
28+
])
29+
}
30+
31+
func updateProgress(_ progress: CGFloat) {
32+
let scaleFactor = 0.8 + (progress * 0.4)
33+
let scaleTransform = CGAffineTransform(scaleX: scaleFactor, y: scaleFactor)
34+
35+
UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseOut) {
36+
self.iconView.transform = scaleTransform
37+
self.iconView.alpha = progress * 1.2
38+
}
39+
}
40+
41+
func reset() {
42+
iconView.transform = .identity
43+
iconView.alpha = 0
44+
}
45+
46+
override func layoutSubviews() {
47+
super.layoutSubviews()
48+
layer.cornerRadius = bounds.height / 2
49+
}
50+
}

0 commit comments

Comments
 (0)