Свайп-реплай: Telegram-parity эффекты и иконка
This commit is contained in:
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user