578 lines
23 KiB
Swift
578 lines
23 KiB
Swift
import UIKit
|
|
|
|
/// Universal pure UIKit message cell — handles ALL message types.
|
|
/// Rosetta equivalent of Telegram's ChatMessageBubbleItemNode.
|
|
///
|
|
/// Architecture (Telegram pattern):
|
|
/// 1. `MessageCellLayout.calculate()` — runs on ANY thread (background-safe)
|
|
/// 2. `NativeMessageCell.apply(layout:)` — runs on main thread, just sets frames
|
|
/// 3. No SwiftUI, no UIHostingConfiguration, no self-sizing
|
|
///
|
|
/// Subviews are always present but hidden when not needed (no alloc/dealloc overhead).
|
|
final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDelegate {
|
|
|
|
// MARK: - Constants
|
|
|
|
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 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 forwardLabelFont = UIFont.systemFont(ofSize: 13, weight: .regular)
|
|
private static let forwardNameFont = UIFont.systemFont(ofSize: 14, weight: .semibold)
|
|
private static let fileNameFont = UIFont.systemFont(ofSize: 14, weight: .medium)
|
|
private static let fileSizeFont = UIFont.systemFont(ofSize: 12, weight: .regular)
|
|
|
|
// MARK: - Subviews (always present, hidden when unused)
|
|
|
|
// Bubble
|
|
private let bubbleView = UIView()
|
|
private let bubbleLayer = CAShapeLayer()
|
|
|
|
// Text
|
|
private let textLabel = UILabel()
|
|
|
|
// Timestamp + delivery
|
|
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()
|
|
|
|
// Photo
|
|
private let photoView = UIImageView()
|
|
private let photoPlaceholderView = UIView()
|
|
|
|
// File
|
|
private let fileContainer = UIView()
|
|
private let fileIconView = UIView()
|
|
private let fileNameLabel = UILabel()
|
|
private let fileSizeLabel = UILabel()
|
|
|
|
// Forward header
|
|
private let forwardLabel = UILabel()
|
|
private let forwardAvatarView = UIView()
|
|
private let forwardNameLabel = UILabel()
|
|
|
|
// Swipe-to-reply
|
|
private let replyIconView = UIImageView()
|
|
|
|
// MARK: - State
|
|
|
|
private var message: ChatMessage?
|
|
private var actions: MessageCellActions?
|
|
private var currentLayout: MessageCellLayout?
|
|
|
|
// 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
|
|
contentView.transform = CGAffineTransform(scaleX: 1, y: -1) // inverted scroll flip
|
|
|
|
// 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
|
|
bubbleView.addSubview(timestampLabel)
|
|
|
|
// Checkmark
|
|
checkmarkView.contentMode = .scaleAspectFit
|
|
bubbleView.addSubview(checkmarkView)
|
|
|
|
// Reply quote
|
|
replyBar.layer.cornerRadius = 1.5
|
|
replyContainer.addSubview(replyBar)
|
|
replyNameLabel.font = Self.replyNameFont
|
|
replyContainer.addSubview(replyNameLabel)
|
|
replyTextLabel.font = Self.replyTextFont
|
|
replyTextLabel.lineBreakMode = .byTruncatingTail
|
|
replyContainer.addSubview(replyTextLabel)
|
|
bubbleView.addSubview(replyContainer)
|
|
|
|
// Photo
|
|
photoView.contentMode = .scaleAspectFill
|
|
photoView.clipsToBounds = true
|
|
bubbleView.addSubview(photoView)
|
|
|
|
photoPlaceholderView.backgroundColor = UIColor.white.withAlphaComponent(0.1)
|
|
bubbleView.addSubview(photoPlaceholderView)
|
|
|
|
// File
|
|
fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2)
|
|
fileIconView.layer.cornerRadius = 20
|
|
fileContainer.addSubview(fileIconView)
|
|
fileNameLabel.font = Self.fileNameFont
|
|
fileNameLabel.textColor = .white
|
|
fileContainer.addSubview(fileNameLabel)
|
|
fileSizeLabel.font = Self.fileSizeFont
|
|
fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6)
|
|
fileContainer.addSubview(fileSizeLabel)
|
|
bubbleView.addSubview(fileContainer)
|
|
|
|
// Forward header
|
|
forwardLabel.font = Self.forwardLabelFont
|
|
forwardLabel.text = "Forwarded message"
|
|
forwardLabel.textColor = UIColor.white.withAlphaComponent(0.6)
|
|
bubbleView.addSubview(forwardLabel)
|
|
|
|
forwardAvatarView.backgroundColor = UIColor.white.withAlphaComponent(0.3)
|
|
forwardAvatarView.layer.cornerRadius = 10
|
|
bubbleView.addSubview(forwardAvatarView)
|
|
|
|
forwardNameLabel.font = Self.forwardNameFont
|
|
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)
|
|
replyIconView.alpha = 0
|
|
contentView.addSubview(replyIconView)
|
|
|
|
// Interactions
|
|
let contextMenu = UIContextMenuInteraction(delegate: self)
|
|
bubbleView.addInteraction(contextMenu)
|
|
|
|
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleSwipe(_:)))
|
|
pan.delegate = self
|
|
contentView.addGestureRecognizer(pan)
|
|
}
|
|
|
|
// MARK: - Configure + Apply Layout
|
|
|
|
/// Configure cell data (content). Does NOT trigger layout.
|
|
func configure(
|
|
message: ChatMessage,
|
|
timestamp: String,
|
|
actions: MessageCellActions,
|
|
replyName: String? = nil,
|
|
replyText: String? = nil,
|
|
forwardSenderName: String? = nil
|
|
) {
|
|
self.message = message
|
|
self.actions = actions
|
|
|
|
let isOutgoing = currentLayout?.isOutgoing ?? false
|
|
|
|
// Text (filter garbage/encrypted — UIKit path parity with SwiftUI)
|
|
textLabel.text = MessageCellLayout.isGarbageOrEncrypted(message.text) ? "" : message.text
|
|
|
|
// Timestamp
|
|
timestampLabel.text = timestamp
|
|
timestampLabel.textColor = isOutgoing
|
|
? UIColor.white.withAlphaComponent(0.55)
|
|
: UIColor.white.withAlphaComponent(0.6)
|
|
|
|
// Delivery
|
|
if isOutgoing {
|
|
checkmarkView.isHidden = false
|
|
switch message.deliveryStatus {
|
|
case .delivered:
|
|
checkmarkView.image = UIImage(systemName: "checkmark")?.withRenderingMode(.alwaysTemplate)
|
|
checkmarkView.tintColor = message.isRead ? .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 = .red
|
|
}
|
|
} else {
|
|
checkmarkView.isHidden = true
|
|
}
|
|
|
|
// Bubble color
|
|
bubbleLayer.fillColor = (isOutgoing ? Self.outgoingColor : Self.incomingColor).cgColor
|
|
|
|
// Reply quote
|
|
if let replyName {
|
|
replyContainer.isHidden = false
|
|
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)
|
|
} else {
|
|
replyContainer.isHidden = true
|
|
}
|
|
|
|
// Forward
|
|
if let forwardSenderName {
|
|
forwardLabel.isHidden = false
|
|
forwardAvatarView.isHidden = false
|
|
forwardNameLabel.isHidden = false
|
|
forwardNameLabel.text = forwardSenderName
|
|
} else {
|
|
forwardLabel.isHidden = true
|
|
forwardAvatarView.isHidden = true
|
|
forwardNameLabel.isHidden = true
|
|
}
|
|
|
|
// Photo placeholder (actual image loading handled separately)
|
|
photoView.isHidden = !(currentLayout?.hasPhoto ?? false)
|
|
photoPlaceholderView.isHidden = !(currentLayout?.hasPhoto ?? false)
|
|
|
|
// File
|
|
if let layout = currentLayout, layout.hasFile {
|
|
fileContainer.isHidden = false
|
|
let fileAtt = message.attachments.first { $0.type == .file }
|
|
fileNameLabel.text = fileAtt?.preview.components(separatedBy: "::").last ?? "File"
|
|
fileSizeLabel.text = ""
|
|
} else {
|
|
fileContainer.isHidden = true
|
|
}
|
|
}
|
|
|
|
/// Apply pre-calculated layout (main thread only — just sets frames).
|
|
/// This is the "apply" part of Telegram's asyncLayout pattern.
|
|
/// NOTE: Bubble X-position is recalculated in layoutSubviews() based on actual cell width.
|
|
func apply(layout: MessageCellLayout) {
|
|
currentLayout = layout
|
|
setNeedsLayout() // trigger layoutSubviews for correct X positioning
|
|
}
|
|
|
|
override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
guard let layout = currentLayout else { return }
|
|
|
|
let cellW = contentView.bounds.width
|
|
let tailW: CGFloat = layout.hasTail ? 6 : 0
|
|
let isTopOrSingle = (layout.position == .single || layout.position == .top)
|
|
let topPad: CGFloat = isTopOrSingle ? 6 : 2
|
|
|
|
// Bubble X: align to RIGHT for outgoing, LEFT for incoming
|
|
// This is computed from CELL WIDTH, not maxBubbleWidth
|
|
let bubbleX: CGFloat
|
|
if layout.isOutgoing {
|
|
bubbleX = cellW - layout.bubbleSize.width - tailW - 2
|
|
} else {
|
|
bubbleX = tailW + 2
|
|
}
|
|
|
|
bubbleView.frame = CGRect(
|
|
x: bubbleX, y: topPad,
|
|
width: layout.bubbleSize.width, height: layout.bubbleSize.height
|
|
)
|
|
bubbleLayer.frame = bubbleView.bounds
|
|
|
|
let shapeRect: CGRect
|
|
if layout.hasTail {
|
|
if layout.isOutgoing {
|
|
shapeRect = CGRect(x: 0, y: 0,
|
|
width: layout.bubbleSize.width + 6, height: layout.bubbleSize.height)
|
|
} else {
|
|
shapeRect = CGRect(x: -6, y: 0,
|
|
width: layout.bubbleSize.width + 6, height: layout.bubbleSize.height)
|
|
}
|
|
} else {
|
|
shapeRect = CGRect(origin: .zero, size: layout.bubbleSize)
|
|
}
|
|
bubbleLayer.path = BubblePathCache.shared.path(
|
|
size: shapeRect.size, origin: shapeRect.origin,
|
|
position: layout.position, isOutgoing: layout.isOutgoing, hasTail: layout.hasTail
|
|
)
|
|
|
|
// Text
|
|
textLabel.isHidden = layout.textSize == .zero
|
|
textLabel.frame = layout.textFrame
|
|
|
|
// Timestamp + checkmark
|
|
timestampLabel.frame = layout.timestampFrame
|
|
checkmarkView.frame = layout.checkmarkFrame
|
|
|
|
// Reply
|
|
replyContainer.isHidden = !layout.hasReplyQuote
|
|
if layout.hasReplyQuote {
|
|
replyContainer.frame = layout.replyContainerFrame
|
|
replyBar.frame = layout.replyBarFrame
|
|
replyNameLabel.frame = layout.replyNameFrame
|
|
replyTextLabel.frame = layout.replyTextFrame
|
|
}
|
|
|
|
// Photo
|
|
photoView.isHidden = !layout.hasPhoto
|
|
photoPlaceholderView.isHidden = !layout.hasPhoto
|
|
if layout.hasPhoto {
|
|
photoView.frame = layout.photoFrame
|
|
photoPlaceholderView.frame = layout.photoFrame
|
|
}
|
|
|
|
// File
|
|
fileContainer.isHidden = !layout.hasFile
|
|
if layout.hasFile {
|
|
fileContainer.frame = layout.fileFrame
|
|
fileIconView.frame = CGRect(x: 10, y: 8, width: 40, height: 40)
|
|
fileNameLabel.frame = CGRect(x: 60, y: 10, width: layout.fileFrame.width - 70, height: 17)
|
|
fileSizeLabel.frame = CGRect(x: 60, y: 30, width: layout.fileFrame.width - 70, height: 15)
|
|
}
|
|
|
|
// Forward
|
|
if layout.isForward {
|
|
forwardLabel.frame = layout.forwardHeaderFrame
|
|
forwardAvatarView.frame = layout.forwardAvatarFrame
|
|
forwardNameLabel.frame = layout.forwardNameFrame
|
|
}
|
|
|
|
// 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
|
|
)
|
|
}
|
|
|
|
// MARK: - Self-sizing (from pre-calculated layout)
|
|
|
|
override func preferredLayoutAttributesFitting(
|
|
_ layoutAttributes: UICollectionViewLayoutAttributes
|
|
) -> UICollectionViewLayoutAttributes {
|
|
// Always return concrete height — never fall to super (expensive self-sizing)
|
|
let attrs = layoutAttributes.copy() as! UICollectionViewLayoutAttributes
|
|
attrs.size.height = currentLayout?.totalHeight ?? 50
|
|
return attrs
|
|
}
|
|
|
|
// 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] = []
|
|
if !message.text.isEmpty {
|
|
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)
|
|
let isOutgoing = currentLayout?.isOutgoing ?? false
|
|
|
|
switch gesture.state {
|
|
case .changed:
|
|
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:
|
|
if abs(translation.x) > 50, let message, let actions {
|
|
UIImpactFeedbackGenerator(style: .medium).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: - Reuse
|
|
|
|
override func prepareForReuse() {
|
|
super.prepareForReuse()
|
|
message = nil
|
|
actions = nil
|
|
currentLayout = nil
|
|
textLabel.text = nil
|
|
timestampLabel.text = nil
|
|
checkmarkView.image = nil
|
|
photoView.image = nil
|
|
replyContainer.isHidden = true
|
|
fileContainer.isHidden = true
|
|
forwardLabel.isHidden = true
|
|
forwardAvatarView.isHidden = true
|
|
forwardNameLabel.isHidden = true
|
|
photoView.isHidden = true
|
|
photoPlaceholderView.isHidden = true
|
|
bubbleView.transform = .identity
|
|
replyIconView.alpha = 0
|
|
}
|
|
}
|
|
|
|
// MARK: - UIGestureRecognizerDelegate
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
// MARK: - Bubble Path Cache
|
|
|
|
/// Caches CGPath objects for bubble shapes to avoid recalculating Bezier paths every frame.
|
|
/// Telegram equivalent: PrincipalThemeEssentialGraphics caches bubble images.
|
|
final class BubblePathCache {
|
|
static let shared = BubblePathCache()
|
|
|
|
private var cache: [String: CGPath] = [:]
|
|
|
|
func path(
|
|
size: CGSize, origin: CGPoint,
|
|
position: BubblePosition, isOutgoing: Bool, hasTail: Bool
|
|
) -> CGPath {
|
|
let key = "\(Int(size.width))x\(Int(size.height))_\(Int(origin.x))_\(position)_\(isOutgoing)_\(hasTail)"
|
|
if let cached = cache[key] { return cached }
|
|
|
|
let rect = CGRect(origin: origin, size: size)
|
|
let path = makeBubblePath(in: rect, position: position, isOutgoing: isOutgoing, hasTail: hasTail)
|
|
cache[key] = path
|
|
|
|
// Evict if cache grows too large
|
|
if cache.count > 200 {
|
|
cache.removeAll()
|
|
}
|
|
|
|
return path
|
|
}
|
|
|
|
private func makeBubblePath(
|
|
in rect: CGRect, position: BubblePosition, isOutgoing: Bool, hasTail: Bool
|
|
) -> CGPath {
|
|
let r: CGFloat = 18, s: CGFloat = 8, tailW: CGFloat = 6
|
|
|
|
// Body rect
|
|
let bodyRect: CGRect
|
|
if hasTail {
|
|
bodyRect = isOutgoing
|
|
? CGRect(x: rect.minX, y: rect.minY, width: rect.width - tailW, height: rect.height)
|
|
: CGRect(x: rect.minX + tailW, y: rect.minY, width: rect.width - tailW, height: rect.height)
|
|
} else {
|
|
bodyRect = rect
|
|
}
|
|
|
|
// Corner radii
|
|
let (tl, tr, bl, br): (CGFloat, CGFloat, CGFloat, 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)
|
|
}
|
|
}()
|
|
|
|
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 = CGMutablePath()
|
|
|
|
// 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(tangent1End: CGPoint(x: bodyRect.maxX, y: bodyRect.minY),
|
|
tangent2End: CGPoint(x: bodyRect.maxX, y: bodyRect.minY + cTR), radius: cTR)
|
|
path.addLine(to: CGPoint(x: bodyRect.maxX, y: bodyRect.maxY - cBR))
|
|
path.addArc(tangent1End: CGPoint(x: bodyRect.maxX, y: bodyRect.maxY),
|
|
tangent2End: CGPoint(x: bodyRect.maxX - cBR, y: bodyRect.maxY), radius: cBR)
|
|
path.addLine(to: CGPoint(x: bodyRect.minX + cBL, y: bodyRect.maxY))
|
|
path.addArc(tangent1End: CGPoint(x: bodyRect.minX, y: bodyRect.maxY),
|
|
tangent2End: CGPoint(x: bodyRect.minX, y: bodyRect.maxY - cBL), radius: cBL)
|
|
path.addLine(to: CGPoint(x: bodyRect.minX, y: bodyRect.minY + cTL))
|
|
path.addArc(tangent1End: CGPoint(x: bodyRect.minX, y: bodyRect.minY),
|
|
tangent2End: CGPoint(x: bodyRect.minX + cTL, y: bodyRect.minY), radius: cTL)
|
|
path.closeSubpath()
|
|
|
|
// Figma SVG tail
|
|
if hasTail {
|
|
addFigmaTail(to: path, bodyRect: bodyRect, isOutgoing: isOutgoing)
|
|
}
|
|
|
|
return path
|
|
}
|
|
|
|
private func addFigmaTail(to path: CGMutablePath, bodyRect: CGRect, isOutgoing: Bool) {
|
|
let svgStraightX: CGFloat = 5.59961
|
|
let svgMaxY: CGFloat = 33.2305
|
|
let sc: CGFloat = 6 / 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), control1: tp(5.42042, 28.0524), control2: tp(3.19779, 31.339))
|
|
path.addCurve(to: tp(2.6123, 33.2305), control1: tp(0.851596, 33.1596), control2: tp(1.72394, 33.2305))
|
|
path.addCurve(to: tp(13.0293, 29.5596), control1: tp(6.53776, 33.2305), control2: tp(10.1517, 31.8599))
|
|
path.addCurve(to: tp(7.57422, 23.1719), control1: tp(10.7434, 27.898), control2: tp(8.86922, 25.7134))
|
|
path.addCurve(to: tp(5.6123, 4.2002), control1: tp(5.61235, 19.3215), control2: 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.closeSubpath()
|
|
} 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), control1: tp(5.6123, 14.281), control2: tp(5.61235, 19.3215))
|
|
path.addCurve(to: tp(13.0293, 29.5596), control1: tp(8.86922, 25.7134), control2: tp(10.7434, 27.898))
|
|
path.addCurve(to: tp(2.6123, 33.2305), control1: tp(10.1517, 31.8599), control2: tp(6.53776, 33.2305))
|
|
path.addCurve(to: tp(0, 33.0244), control1: tp(1.72394, 33.2305), control2: tp(0.851596, 33.1596))
|
|
path.addCurve(to: tp(5.59961, 24.2305), control1: tp(3.19779, 31.339), control2: tp(5.42042, 28.0524))
|
|
path.closeSubpath()
|
|
}
|
|
}
|
|
}
|