Свайп-реплай: Telegram-parity эффекты и иконка

This commit is contained in:
2026-03-29 16:50:59 +05:00
parent 3b26176875
commit 5e89e97301
10 changed files with 335 additions and 84 deletions

View File

@@ -58,7 +58,8 @@ struct MessageCellView: View, Equatable {
}
}
.modifier(ConditionalSwipeToReply(
enabled: !isSavedMessages && !isSystemAccount,
enabled: !isSavedMessages && !isSystemAccount
&& !message.attachments.contains(where: { $0.type == .avatar || $0.type == .call || $0.type == .messages }),
onReply: { actions.onReply(message) }
))
.overlay {
@@ -544,11 +545,16 @@ struct MessageCellView: View, Equatable {
private func bubbleActions(for message: ChatMessage) -> [BubbleContextAction] {
var result: [BubbleContextAction] = []
result.append(BubbleContextAction(
title: "Reply",
image: UIImage(systemName: "arrowshape.turn.up.left"),
role: []
) { actions.onReply(message) })
// Avatars, calls, and forwarded messages cannot be replied to or forwarded
let isAvatarOrForwarded = message.attachments.contains(where: { $0.type == .avatar || $0.type == .call || $0.type == .messages })
if !isAvatarOrForwarded {
result.append(BubbleContextAction(
title: "Reply",
image: UIImage(systemName: "arrowshape.turn.up.left"),
role: []
) { actions.onReply(message) })
}
result.append(BubbleContextAction(
title: "Copy",
@@ -556,7 +562,7 @@ struct MessageCellView: View, Equatable {
role: []
) { actions.onCopy(message.text) })
if !message.attachments.contains(where: { $0.type == .avatar }) {
if !isAvatarOrForwarded {
result.append(BubbleContextAction(
title: "Forward",
image: UIImage(systemName: "arrowshape.turn.up.right"),

View File

@@ -41,6 +41,29 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
private static let mediaClockMinImage = StatusIconRenderer.makeClockMinImage(color: mediaMetaColor)
private static let errorIcon = StatusIconRenderer.makeErrorIcon(color: .systemRed)
private static let maxVisiblePhotoTiles = 5
// Telegram-exact reply arrow rendered from SVG path (matches SwiftUI TelegramIconPath.replyArrow).
// Rendered at DISPLAY size (20×20) so UIImageView never upscales the raster.
private static let telegramReplyArrowImage: UIImage = {
let viewBox = CGSize(width: 16, height: 13)
let canvasSize = CGSize(width: 20, height: 20) // match replyIconView frame
let scale = UIScreen.main.scale
UIGraphicsBeginImageContextWithOptions(canvasSize, false, scale)
guard let ctx = UIGraphicsGetCurrentContext() else { return UIImage() }
var parser = SVGPathParser(pathData: TelegramIconPath.replyArrow)
let cgPath = parser.parse()
// Aspect-fit path into canvas, centered
let fitScale = min(canvasSize.width / viewBox.width, canvasSize.height / viewBox.height)
let scaledW = viewBox.width * fitScale
let scaledH = viewBox.height * fitScale
ctx.translateBy(x: (canvasSize.width - scaledW) / 2, y: (canvasSize.height - scaledH) / 2)
ctx.scaleBy(x: fitScale, y: fitScale)
ctx.addPath(cgPath)
ctx.setFillColor(UIColor.white.cgColor)
ctx.fillPath()
let image = UIGraphicsGetImageFromCurrentImageContext() ?? UIImage()
UIGraphicsEndImageContext()
return image.withRenderingMode(.alwaysOriginal)
}()
// Telegram-exact stretchable bubble images (raster, not vector only way to get exact tail)
private static let bubbleImages = BubbleImageFactory.generate(
outgoingColor: outgoingColor,
@@ -111,7 +134,12 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
private let forwardNameLabel = UILabel()
// Swipe-to-reply
private let replyCircleView = UIView()
private let replyIconView = UIImageView()
private var hasTriggeredSwipeHaptic = false
private let swipeHaptic = UIImpactFeedbackGenerator(style: .heavy)
/// Global X of the first touch reject if near left screen edge (back gesture zone).
private var swipeStartX: CGFloat?
private let deliveryFailedButton = UIButton(type: .custom)
// MARK: - State
@@ -145,6 +173,9 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
private func setupViews() {
contentView.backgroundColor = .clear
backgroundColor = .clear
// Allow reply swipe icon to extend beyond cell bounds
contentView.clipsToBounds = false
clipsToBounds = false
contentView.transform = CGAffineTransform(scaleX: 1, y: -1) // inverted scroll flip
// Bubble CAShapeLayer for shadow (index 0), then outline, then raster image on top
@@ -348,10 +379,14 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
forwardNameLabel.textColor = .white
bubbleView.addSubview(forwardNameLabel)
// Swipe reply icon
replyIconView.image = UIImage(systemName: "arrowshape.turn.up.left.fill")?
.withRenderingMode(.alwaysTemplate)
replyIconView.tintColor = UIColor.white.withAlphaComponent(0.5)
// Swipe reply icon circle + Telegram-exact arrow (same vector as SwiftUI SwipeToReplyModifier)
replyCircleView.backgroundColor = UIColor.white.withAlphaComponent(0.12)
replyCircleView.layer.cornerRadius = 17 // 34pt / 2
replyCircleView.alpha = 0
contentView.addSubview(replyCircleView)
replyIconView.image = Self.telegramReplyArrowImage
replyIconView.contentMode = .scaleAspectFit
replyIconView.alpha = 0
contentView.addSubview(replyIconView)
@@ -633,7 +668,6 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
let cellW = contentView.bounds.width
let tailProtrusion = Self.bubbleMetrics.tailProtrusion
let tailW: CGFloat = layout.hasTail ? tailProtrusion : 0
// Rule 2: Tail reserve (6pt) + margin (2pt) strict vertical body alignment
let bubbleX: CGFloat
@@ -838,14 +872,14 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
deliveryFailedButton.alpha = 0
}
// Reply icon (for swipe gesture) use actual bubbleView frame
replyIconView.frame = CGRect(
x: layout.isOutgoing
? bubbleView.frame.minX - 30
: bubbleView.frame.maxX + tailW + 8,
y: bubbleView.frame.midY - 10,
width: 20, height: 20
)
// Reply icon (for swipe gesture) positioned behind bubble's trailing edge.
// Starts hidden (alpha=0, scale=0). As bubble slides left via transform,
// the icon is revealed in the gap between shifted bubble and original position.
let replyIconDiameter: CGFloat = 34
let replyIconX = bubbleView.frame.maxX - replyIconDiameter
let replyIconY = bubbleView.frame.midY - replyIconDiameter / 2
replyCircleView.frame = CGRect(x: replyIconX, y: replyIconY, width: replyIconDiameter, height: replyIconDiameter)
replyIconView.frame = CGRect(x: replyIconX + 7, y: replyIconY + 7, width: 20, height: 20)
}
private static func formattedDuration(seconds: Int) -> String {
@@ -951,12 +985,16 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
actions.onCopy(message.text)
})
}
items.append(UIAction(title: "Reply", image: UIImage(systemName: "arrowshape.turn.up.left")) { _ in
actions.onReply(message)
})
items.append(UIAction(title: "Forward", image: UIImage(systemName: "arrowshape.turn.up.right")) { _ in
actions.onForward(message)
})
// Avatars, calls, and forwarded messages cannot be replied to or forwarded
let isAvatarOrForwarded = message.attachments.contains(where: { $0.type == .avatar || $0.type == .call || $0.type == .messages })
if !isAvatarOrForwarded {
items.append(UIAction(title: "Reply", image: UIImage(systemName: "arrowshape.turn.up.left")) { _ in
actions.onReply(message)
})
items.append(UIAction(title: "Forward", image: UIImage(systemName: "arrowshape.turn.up.right")) { _ in
actions.onForward(message)
})
}
items.append(UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { _ in
actions.onDelete(message)
})
@@ -967,28 +1005,81 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
// MARK: - Swipe to Reply
@objc private func handleSwipe(_ gesture: UIPanGestureRecognizer) {
// Block swipe on avatar, call, and forwarded-message attachments
let isReplyBlocked = message?.attachments.contains(where: { $0.type == .avatar || $0.type == .call || $0.type == .messages }) ?? false
if isReplyBlocked { return }
let translation = gesture.translation(in: contentView)
let isOutgoing = currentLayout?.isOutgoing ?? false
let threshold: CGFloat = 55
let elasticCap: CGFloat = 85 // match SwiftUI SwipeToReplyModifier
let backGestureEdge: CGFloat = 40
switch gesture.state {
case .began:
// Record start position reject if near left screen edge (iOS back gesture zone)
let startPoint = gesture.location(in: contentView.window)
swipeStartX = startPoint.x
// Pre-warm haptic engine for instant response at threshold
swipeHaptic.prepare()
case .changed:
let dx = isOutgoing ? min(translation.x, 0) : max(translation.x, 0)
let clamped = isOutgoing ? max(dx, -60) : min(dx, 60)
// Reject gestures from back gesture zone (left 40pt)
if let startX = swipeStartX, startX < backGestureEdge { return }
// Telegram: ALL messages swipe LEFT
let raw = min(translation.x, 0)
guard raw < 0 else { return }
// Elastic resistance past cap (Telegram rubber-band)
let absRaw = abs(raw)
let clamped: CGFloat
if absRaw > elasticCap {
clamped = -(elasticCap + (absRaw - elasticCap) * 0.15)
} else {
clamped = raw
}
bubbleView.transform = CGAffineTransform(translationX: clamped, y: 0)
let progress = min(abs(clamped) / 50, 1)
// Icon progress: fade in from 4pt to threshold
let absClamped = abs(clamped)
let progress: CGFloat = absClamped > 4 ? min((absClamped - 4) / (threshold - 4), 1) : 0
replyCircleView.alpha = progress
replyCircleView.transform = CGAffineTransform(scaleX: progress, y: progress)
replyIconView.alpha = progress
replyIconView.transform = CGAffineTransform(scaleX: progress, y: progress)
// Haptic at threshold crossing (once per gesture, pre-prepared)
if absClamped >= threshold, !hasTriggeredSwipeHaptic {
hasTriggeredSwipeHaptic = true
swipeHaptic.impactOccurred()
}
case .ended, .cancelled:
if abs(translation.x) > 50, let message, let actions {
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
let shouldReply = abs(translation.x) >= threshold
if shouldReply, let message, let actions {
actions.onReply(message)
}
UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseOut) {
hasTriggeredSwipeHaptic = false
swipeStartX = nil
// Velocity-aware spring (Telegram passes swipe velocity for natural spring-back)
let velocity = gesture.velocity(in: contentView)
let currentOffset = bubbleView.transform.tx
let relativeVx: CGFloat = currentOffset != 0 ? velocity.x / abs(currentOffset) : 0
let initialVelocity = CGVector(dx: relativeVx, dy: 0)
let timing = UISpringTimingParameters(mass: 1, stiffness: 386, damping: 33.4, initialVelocity: initialVelocity)
let animator = UIViewPropertyAnimator(duration: 0, timingParameters: timing)
animator.addAnimations {
self.bubbleView.transform = .identity
self.replyCircleView.alpha = 0
self.replyCircleView.transform = .identity
self.replyIconView.alpha = 0
self.replyIconView.transform = .identity
}
animator.startAnimation()
default:
break
}
@@ -1788,7 +1879,12 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
forwardNameLabel.isHidden = true
photoContainer.isHidden = true
bubbleView.transform = .identity
replyCircleView.alpha = 0
replyCircleView.transform = .identity
replyIconView.alpha = 0
replyIconView.transform = .identity
hasTriggeredSwipeHaptic = false
swipeStartX = nil
deliveryFailedButton.isHidden = true
deliveryFailedButton.alpha = 0
isDeliveryFailedVisible = false
@@ -1801,7 +1897,8 @@ extension NativeMessageCell: UIGestureRecognizerDelegate {
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true }
let velocity = pan.velocity(in: contentView)
return abs(velocity.x) > abs(velocity.y) * 1.5
// Telegram: only left swipe (negative velocity.x), clear horizontal dominance
return velocity.x < 0 && abs(velocity.x) > abs(velocity.y) * 2.0
}
}