492 lines
19 KiB
Swift
492 lines
19 KiB
Swift
import UIKit
|
|
|
|
/// Pure UIKit message cell for text messages (with optional reply quote).
|
|
/// Replaces UIHostingConfiguration + SwiftUI for the most common message type.
|
|
/// Features: Figma-accurate bubble tail, context menu, swipe-to-reply, reply quote.
|
|
final class NativeTextBubbleCell: UICollectionViewCell, UIContextMenuInteractionDelegate {
|
|
|
|
// MARK: - Constants
|
|
|
|
private static let mainRadius: CGFloat = 18
|
|
private static let smallRadius: CGFloat = 8
|
|
private static let tailProtrusion: CGFloat = 6
|
|
private static let textFont = UIFont.systemFont(ofSize: 17, weight: .regular)
|
|
private static let timestampFont = UIFont.systemFont(ofSize: 11, weight: .regular)
|
|
private static let replyNameFont = UIFont.systemFont(ofSize: 13, weight: .semibold)
|
|
private static let replyTextFont = UIFont.systemFont(ofSize: 13, weight: .regular)
|
|
private static let outgoingColor = UIColor(red: 0, green: 0x8B/255.0, blue: 1, alpha: 1)
|
|
private static let incomingColor = UIColor(red: 0x2C/255.0, green: 0x2C/255.0, blue: 0x2E/255.0, alpha: 1)
|
|
private static let replyQuoteHeight: CGFloat = 41
|
|
|
|
// MARK: - Subviews
|
|
|
|
private let bubbleView = UIView()
|
|
private let bubbleLayer = CAShapeLayer()
|
|
private let textLabel = UILabel()
|
|
private let timestampLabel = UILabel()
|
|
private let checkmarkView = UIImageView()
|
|
|
|
// Reply quote
|
|
private let replyContainer = UIView()
|
|
private let replyBar = UIView()
|
|
private let replyNameLabel = UILabel()
|
|
private let replyTextLabel = UILabel()
|
|
|
|
// Swipe-to-reply
|
|
private let replyIconView = UIImageView()
|
|
private var swipeStartX: CGFloat = 0
|
|
|
|
// MARK: - State
|
|
|
|
private var message: ChatMessage?
|
|
private var actions: MessageCellActions?
|
|
private var isOutgoing = false
|
|
private var position: BubblePosition = .single
|
|
private var hasReplyQuote = false
|
|
private var preCalculatedHeight: CGFloat?
|
|
|
|
// MARK: - Init
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
setupViews()
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError() }
|
|
|
|
// MARK: - Setup
|
|
|
|
private func setupViews() {
|
|
contentView.backgroundColor = .clear
|
|
backgroundColor = .clear
|
|
|
|
// Flip for inverted scroll (scale, NOT rotation — rotation flips text)
|
|
contentView.transform = CGAffineTransform(scaleX: 1, y: -1)
|
|
|
|
// Bubble
|
|
bubbleLayer.fillColor = Self.outgoingColor.cgColor
|
|
bubbleView.layer.insertSublayer(bubbleLayer, at: 0)
|
|
contentView.addSubview(bubbleView)
|
|
|
|
// Text
|
|
textLabel.font = Self.textFont
|
|
textLabel.textColor = .white
|
|
textLabel.numberOfLines = 0
|
|
textLabel.lineBreakMode = .byWordWrapping
|
|
bubbleView.addSubview(textLabel)
|
|
|
|
// Timestamp
|
|
timestampLabel.font = Self.timestampFont
|
|
timestampLabel.textColor = UIColor.white.withAlphaComponent(0.55)
|
|
bubbleView.addSubview(timestampLabel)
|
|
|
|
// Checkmark
|
|
checkmarkView.contentMode = .scaleAspectFit
|
|
checkmarkView.tintColor = UIColor.white.withAlphaComponent(0.55)
|
|
bubbleView.addSubview(checkmarkView)
|
|
|
|
// Reply quote
|
|
replyBar.backgroundColor = .white
|
|
replyBar.layer.cornerRadius = 1.5
|
|
replyContainer.addSubview(replyBar)
|
|
|
|
replyNameLabel.font = Self.replyNameFont
|
|
replyNameLabel.textColor = .white
|
|
replyContainer.addSubview(replyNameLabel)
|
|
|
|
replyTextLabel.font = Self.replyTextFont
|
|
replyTextLabel.textColor = UIColor.white.withAlphaComponent(0.8)
|
|
replyTextLabel.lineBreakMode = .byTruncatingTail
|
|
replyContainer.addSubview(replyTextLabel)
|
|
|
|
replyContainer.isHidden = true
|
|
bubbleView.addSubview(replyContainer)
|
|
|
|
// Swipe reply icon (hidden, shown during swipe)
|
|
replyIconView.image = UIImage(systemName: "arrowshape.turn.up.left.fill")?
|
|
.withRenderingMode(.alwaysTemplate)
|
|
replyIconView.tintColor = UIColor.white.withAlphaComponent(0.5)
|
|
replyIconView.alpha = 0
|
|
contentView.addSubview(replyIconView)
|
|
|
|
// Context menu
|
|
let contextMenu = UIContextMenuInteraction(delegate: self)
|
|
bubbleView.addInteraction(contextMenu)
|
|
|
|
// Swipe-to-reply gesture
|
|
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleSwipe(_:)))
|
|
pan.delegate = self
|
|
contentView.addGestureRecognizer(pan)
|
|
}
|
|
|
|
// MARK: - Configure
|
|
|
|
func configure(
|
|
message: ChatMessage,
|
|
timestamp: String,
|
|
isOutgoing: Bool,
|
|
position: BubblePosition,
|
|
actions: MessageCellActions,
|
|
replyName: String? = nil,
|
|
replyText: String? = nil
|
|
) {
|
|
self.message = message
|
|
self.actions = actions
|
|
self.isOutgoing = isOutgoing
|
|
self.position = position
|
|
|
|
// Text
|
|
textLabel.text = message.text
|
|
textLabel.textColor = .white
|
|
|
|
// Timestamp
|
|
timestampLabel.text = timestamp
|
|
timestampLabel.textColor = isOutgoing
|
|
? UIColor.white.withAlphaComponent(0.55)
|
|
: UIColor.white.withAlphaComponent(0.6)
|
|
|
|
// Delivery indicator
|
|
if isOutgoing {
|
|
checkmarkView.isHidden = false
|
|
switch message.deliveryStatus {
|
|
case .delivered:
|
|
checkmarkView.image = UIImage(systemName: "checkmark")?.withRenderingMode(.alwaysTemplate)
|
|
checkmarkView.tintColor = message.isRead
|
|
? UIColor.white
|
|
: UIColor.white.withAlphaComponent(0.55)
|
|
case .waiting:
|
|
checkmarkView.image = UIImage(systemName: "clock")?.withRenderingMode(.alwaysTemplate)
|
|
checkmarkView.tintColor = UIColor.white.withAlphaComponent(0.55)
|
|
case .error:
|
|
checkmarkView.image = UIImage(systemName: "exclamationmark.circle")?.withRenderingMode(.alwaysTemplate)
|
|
checkmarkView.tintColor = UIColor.red
|
|
}
|
|
} else {
|
|
checkmarkView.isHidden = true
|
|
}
|
|
|
|
// Bubble color
|
|
bubbleLayer.fillColor = (isOutgoing ? Self.outgoingColor : Self.incomingColor).cgColor
|
|
|
|
// Reply quote
|
|
hasReplyQuote = (replyName != nil)
|
|
replyContainer.isHidden = !hasReplyQuote
|
|
if hasReplyQuote {
|
|
replyBar.backgroundColor = isOutgoing ? .white : Self.outgoingColor
|
|
replyNameLabel.text = replyName
|
|
replyNameLabel.textColor = isOutgoing ? .white : Self.outgoingColor
|
|
replyTextLabel.text = replyText ?? ""
|
|
replyTextLabel.textColor = isOutgoing
|
|
? UIColor.white.withAlphaComponent(0.8)
|
|
: UIColor.white.withAlphaComponent(0.6)
|
|
}
|
|
|
|
setNeedsLayout()
|
|
}
|
|
|
|
// MARK: - Layout
|
|
|
|
override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
|
|
let bounds = contentView.bounds
|
|
let hasTail = (position == .single || position == .bottom)
|
|
let isTopOrSingle = (position == .single || position == .top)
|
|
let topPad: CGFloat = isTopOrSingle ? 6 : 2
|
|
let tailW = hasTail ? Self.tailProtrusion : 0
|
|
|
|
// Text measurement
|
|
let tsTrailing: CGFloat = isOutgoing ? 53 : 37
|
|
let textMaxW = bounds.width - 11 - tsTrailing - tailW - 8
|
|
let textSize = textLabel.sizeThatFits(CGSize(width: max(textMaxW, 50), height: .greatestFiniteMagnitude))
|
|
|
|
// Bubble dimensions
|
|
let bubbleContentW = 11 + textSize.width + tsTrailing
|
|
let minW: CGFloat = isOutgoing ? 86 : 66
|
|
let bubbleW = max(min(bubbleContentW, bounds.width - tailW - 4), minW)
|
|
let replyH: CGFloat = hasReplyQuote ? Self.replyQuoteHeight + 5 : 0
|
|
let bubbleH = replyH + textSize.height + 10 // 5pt top + 5pt bottom
|
|
|
|
// Bubble X position
|
|
let bubbleX: CGFloat
|
|
if isOutgoing {
|
|
bubbleX = bounds.width - bubbleW - tailW - 2
|
|
} else {
|
|
bubbleX = tailW + 2
|
|
}
|
|
|
|
bubbleView.frame = CGRect(x: bubbleX, y: topPad, width: bubbleW, height: bubbleH)
|
|
|
|
// Bubble shape with tail
|
|
let shapeRect: CGRect
|
|
if hasTail {
|
|
if isOutgoing {
|
|
shapeRect = CGRect(x: 0, y: 0, width: bubbleW + tailW, height: bubbleH)
|
|
} else {
|
|
shapeRect = CGRect(x: -tailW, y: 0, width: bubbleW + tailW, height: bubbleH)
|
|
}
|
|
} else {
|
|
shapeRect = CGRect(x: 0, y: 0, width: bubbleW, height: bubbleH)
|
|
}
|
|
bubbleLayer.path = makeBubblePath(in: shapeRect, hasTail: hasTail).cgPath
|
|
bubbleLayer.frame = bubbleView.bounds
|
|
|
|
// Reply quote layout
|
|
if hasReplyQuote {
|
|
let rX: CGFloat = 5
|
|
replyContainer.frame = CGRect(x: rX, y: 5, width: bubbleW - 10, height: Self.replyQuoteHeight)
|
|
replyBar.frame = CGRect(x: 0, y: 0, width: 3, height: Self.replyQuoteHeight)
|
|
replyNameLabel.frame = CGRect(x: 9, y: 2, width: bubbleW - 24, height: 17)
|
|
replyTextLabel.frame = CGRect(x: 9, y: 20, width: bubbleW - 24, height: 17)
|
|
}
|
|
|
|
// Text
|
|
let textY: CGFloat = hasReplyQuote ? Self.replyQuoteHeight + 10 : 5
|
|
textLabel.frame = CGRect(x: 11, y: textY, width: textSize.width, height: textSize.height)
|
|
|
|
// Timestamp + checkmark
|
|
let tsSize = timestampLabel.sizeThatFits(CGSize(width: 60, height: 20))
|
|
let checkW: CGFloat = isOutgoing ? 14 : 0
|
|
timestampLabel.frame = CGRect(
|
|
x: bubbleW - tsSize.width - checkW - 11,
|
|
y: bubbleH - tsSize.height - 5,
|
|
width: tsSize.width, height: tsSize.height
|
|
)
|
|
if isOutgoing {
|
|
checkmarkView.frame = CGRect(
|
|
x: bubbleW - 11 - 10,
|
|
y: bubbleH - tsSize.height - 4,
|
|
width: 10, height: 10
|
|
)
|
|
}
|
|
|
|
// Swipe reply icon (off-screen right of bubble)
|
|
replyIconView.frame = CGRect(
|
|
x: isOutgoing ? bubbleX - 30 : bubbleX + bubbleW + tailW + 8,
|
|
y: topPad + bubbleH / 2 - 10,
|
|
width: 20, height: 20
|
|
)
|
|
}
|
|
|
|
// MARK: - Bubble Path (Figma-accurate with SVG tail)
|
|
|
|
private func makeBubblePath(in rect: CGRect, hasTail: Bool) -> UIBezierPath {
|
|
let r = Self.mainRadius
|
|
let s = Self.smallRadius
|
|
|
|
// Body rect
|
|
let bodyRect: CGRect
|
|
if hasTail {
|
|
if isOutgoing {
|
|
bodyRect = CGRect(x: rect.minX, y: rect.minY,
|
|
width: rect.width - Self.tailProtrusion, height: rect.height)
|
|
} else {
|
|
bodyRect = CGRect(x: rect.minX + Self.tailProtrusion, y: rect.minY,
|
|
width: rect.width - Self.tailProtrusion, height: rect.height)
|
|
}
|
|
} else {
|
|
bodyRect = rect
|
|
}
|
|
|
|
// Corner radii per position
|
|
let (tl, tr, bl, br) = cornerRadii(r: r, s: s)
|
|
let maxR = min(bodyRect.width, bodyRect.height) / 2
|
|
let cTL = min(tl, maxR), cTR = min(tr, maxR)
|
|
let cBL = min(bl, maxR), cBR = min(br, maxR)
|
|
|
|
let path = UIBezierPath()
|
|
|
|
// Rounded rect body
|
|
path.move(to: CGPoint(x: bodyRect.minX + cTL, y: bodyRect.minY))
|
|
path.addLine(to: CGPoint(x: bodyRect.maxX - cTR, y: bodyRect.minY))
|
|
path.addArc(withCenter: CGPoint(x: bodyRect.maxX - cTR, y: bodyRect.minY + cTR),
|
|
radius: cTR, startAngle: -.pi/2, endAngle: 0, clockwise: true)
|
|
path.addLine(to: CGPoint(x: bodyRect.maxX, y: bodyRect.maxY - cBR))
|
|
path.addArc(withCenter: CGPoint(x: bodyRect.maxX - cBR, y: bodyRect.maxY - cBR),
|
|
radius: cBR, startAngle: 0, endAngle: .pi/2, clockwise: true)
|
|
path.addLine(to: CGPoint(x: bodyRect.minX + cBL, y: bodyRect.maxY))
|
|
path.addArc(withCenter: CGPoint(x: bodyRect.minX + cBL, y: bodyRect.maxY - cBL),
|
|
radius: cBL, startAngle: .pi/2, endAngle: .pi, clockwise: true)
|
|
path.addLine(to: CGPoint(x: bodyRect.minX, y: bodyRect.minY + cTL))
|
|
path.addArc(withCenter: CGPoint(x: bodyRect.minX + cTL, y: bodyRect.minY + cTL),
|
|
radius: cTL, startAngle: .pi, endAngle: -.pi/2, clockwise: true)
|
|
path.close()
|
|
|
|
// Figma SVG tail (exact port from BubbleTailShape.swift)
|
|
if hasTail {
|
|
addFigmaTail(to: path, bodyRect: bodyRect)
|
|
}
|
|
|
|
return path
|
|
}
|
|
|
|
/// Port of BubbleTailShape.addTail — exact Figma SVG curves.
|
|
private func addFigmaTail(to path: UIBezierPath, bodyRect: CGRect) {
|
|
let svgStraightX: CGFloat = 5.59961
|
|
let svgMaxY: CGFloat = 33.2305
|
|
let sc = Self.tailProtrusion / svgStraightX
|
|
let tailH = svgMaxY * sc
|
|
|
|
let bodyEdge = isOutgoing ? bodyRect.maxX : bodyRect.minX
|
|
let bottom = bodyRect.maxY
|
|
let top = bottom - tailH
|
|
let dir: CGFloat = isOutgoing ? 1 : -1
|
|
|
|
func tp(_ svgX: CGFloat, _ svgY: CGFloat) -> CGPoint {
|
|
let dx = (svgStraightX - svgX) * sc * dir
|
|
return CGPoint(x: bodyEdge + dx, y: top + svgY * sc)
|
|
}
|
|
|
|
if isOutgoing {
|
|
path.move(to: tp(5.59961, 24.2305))
|
|
path.addCurve(to: tp(0, 33.0244),
|
|
controlPoint1: tp(5.42042, 28.0524), controlPoint2: tp(3.19779, 31.339))
|
|
path.addCurve(to: tp(2.6123, 33.2305),
|
|
controlPoint1: tp(0.851596, 33.1596), controlPoint2: tp(1.72394, 33.2305))
|
|
path.addCurve(to: tp(13.0293, 29.5596),
|
|
controlPoint1: tp(6.53776, 33.2305), controlPoint2: tp(10.1517, 31.8599))
|
|
path.addCurve(to: tp(7.57422, 23.1719),
|
|
controlPoint1: tp(10.7434, 27.898), controlPoint2: tp(8.86922, 25.7134))
|
|
path.addCurve(to: tp(5.6123, 4.2002),
|
|
controlPoint1: tp(5.61235, 19.3215), controlPoint2: tp(5.6123, 14.281))
|
|
path.addLine(to: tp(5.6123, 0))
|
|
path.addLine(to: tp(5.59961, 0))
|
|
path.addLine(to: tp(5.59961, 24.2305))
|
|
path.close()
|
|
} else {
|
|
path.move(to: tp(5.59961, 24.2305))
|
|
path.addLine(to: tp(5.59961, 0))
|
|
path.addLine(to: tp(5.6123, 0))
|
|
path.addLine(to: tp(5.6123, 4.2002))
|
|
path.addCurve(to: tp(7.57422, 23.1719),
|
|
controlPoint1: tp(5.6123, 14.281), controlPoint2: tp(5.61235, 19.3215))
|
|
path.addCurve(to: tp(13.0293, 29.5596),
|
|
controlPoint1: tp(8.86922, 25.7134), controlPoint2: tp(10.7434, 27.898))
|
|
path.addCurve(to: tp(2.6123, 33.2305),
|
|
controlPoint1: tp(10.1517, 31.8599), controlPoint2: tp(6.53776, 33.2305))
|
|
path.addCurve(to: tp(0, 33.0244),
|
|
controlPoint1: tp(1.72394, 33.2305), controlPoint2: tp(0.851596, 33.1596))
|
|
path.addCurve(to: tp(5.59961, 24.2305),
|
|
controlPoint1: tp(3.19779, 31.339), controlPoint2: tp(5.42042, 28.0524))
|
|
path.close()
|
|
}
|
|
}
|
|
|
|
private func cornerRadii(r: CGFloat, s: CGFloat)
|
|
-> (tl: CGFloat, tr: CGFloat, bl: CGFloat, br: CGFloat) {
|
|
switch position {
|
|
case .single: return (r, r, r, r)
|
|
case .top: return isOutgoing ? (r, r, r, s) : (r, r, s, r)
|
|
case .mid: return isOutgoing ? (r, s, r, s) : (s, r, s, r)
|
|
case .bottom: return isOutgoing ? (r, s, r, r) : (s, r, r, r)
|
|
}
|
|
}
|
|
|
|
// MARK: - Context Menu
|
|
|
|
func contextMenuInteraction(
|
|
_ interaction: UIContextMenuInteraction,
|
|
configurationForMenuAtLocation location: CGPoint
|
|
) -> UIContextMenuConfiguration? {
|
|
guard let message, let actions else { return nil }
|
|
|
|
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in
|
|
var items: [UIAction] = []
|
|
|
|
items.append(UIAction(title: "Copy", image: UIImage(systemName: "doc.on.doc")) { _ in
|
|
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)
|
|
})
|
|
items.append(UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { _ in
|
|
actions.onDelete(message)
|
|
})
|
|
|
|
return UIMenu(children: items)
|
|
}
|
|
}
|
|
|
|
// MARK: - Swipe to Reply
|
|
|
|
@objc private func handleSwipe(_ gesture: UIPanGestureRecognizer) {
|
|
let translation = gesture.translation(in: contentView)
|
|
|
|
switch gesture.state {
|
|
case .began:
|
|
swipeStartX = bubbleView.frame.origin.x
|
|
case .changed:
|
|
// Only allow swipe toward reply direction
|
|
let dx = isOutgoing ? min(translation.x, 0) : max(translation.x, 0)
|
|
let clamped = isOutgoing ? max(dx, -60) : min(dx, 60)
|
|
bubbleView.transform = CGAffineTransform(translationX: clamped, y: 0)
|
|
|
|
let progress = min(abs(clamped) / 50, 1)
|
|
replyIconView.alpha = progress
|
|
replyIconView.transform = CGAffineTransform(scaleX: progress, y: progress)
|
|
|
|
case .ended, .cancelled:
|
|
let dx = isOutgoing ? translation.x : translation.x
|
|
if abs(dx) > 50, let message, let actions {
|
|
// Haptic
|
|
let generator = UIImpactFeedbackGenerator(style: .medium)
|
|
generator.impactOccurred()
|
|
actions.onReply(message)
|
|
}
|
|
UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseOut) {
|
|
self.bubbleView.transform = .identity
|
|
self.replyIconView.alpha = 0
|
|
self.replyIconView.transform = .identity
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
// MARK: - Self-sizing
|
|
|
|
override func preferredLayoutAttributesFitting(
|
|
_ layoutAttributes: UICollectionViewLayoutAttributes
|
|
) -> UICollectionViewLayoutAttributes {
|
|
if let h = preCalculatedHeight {
|
|
let attrs = layoutAttributes.copy() as! UICollectionViewLayoutAttributes
|
|
attrs.size.height = h
|
|
return attrs
|
|
}
|
|
// Fallback: use automatic sizing
|
|
return super.preferredLayoutAttributesFitting(layoutAttributes)
|
|
}
|
|
|
|
func setPreCalculatedHeight(_ height: CGFloat?) {
|
|
preCalculatedHeight = height
|
|
}
|
|
|
|
override func prepareForReuse() {
|
|
super.prepareForReuse()
|
|
preCalculatedHeight = nil
|
|
message = nil
|
|
actions = nil
|
|
textLabel.text = nil
|
|
timestampLabel.text = nil
|
|
checkmarkView.image = nil
|
|
replyContainer.isHidden = true
|
|
bubbleView.transform = .identity
|
|
replyIconView.alpha = 0
|
|
}
|
|
}
|
|
|
|
// MARK: - UIGestureRecognizerDelegate
|
|
|
|
extension NativeTextBubbleCell: UIGestureRecognizerDelegate {
|
|
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true }
|
|
let velocity = pan.velocity(in: contentView)
|
|
// Only horizontal swipes (don't interfere with scroll)
|
|
return abs(velocity.x) > abs(velocity.y) * 1.5
|
|
}
|
|
}
|