// // GroupChatView.swift // QuickLocation // // Created by 八条 on 2026/6/4. // import UIKit import RxSwift import RxCocoa import Lottie import AVFoundation import SwiftDate import MapKit enum VoiceRecordState { case began case canceling case ended(URL?) // nil = cancelled } /// 聊天图片气泡尺寸:按原图宽高缩放,忽略 snapshot 的正方形裁剪。 enum ChatImageLayout { static let maxWidth: CGFloat = 200 static let maxHeight: CGFloat = 250 static let minWidth: CGFloat = 80 static let fallback = CGSize(width: 160, height: 120) static func displaySize(width: CGFloat, height: CGFloat) -> CGSize { guard width > 1, height > 1, width.isFinite, height.isFinite else { return fallback } var dw = maxWidth var dh = dw * (height / width) if dh > maxHeight { dh = maxHeight dw = dh * (width / height) } if dw < minWidth { dw = minWidth dh = dw * (height / width) } return CGSize(width: dw.rounded(), height: dh.rounded()) } } // MARK: - Message Model struct ChatMessage { let id: String let isSelf: Bool let senderId: String let avatar: UIImage let senderName: String let content: String let voiceUrl: String var imageUrl: String let imageWidth: CGFloat let imageHeight: CGFloat let timestamp: TimeInterval var showTime: Bool = false var isUploading: Bool = false var quotePreview: QuotePreview? = nil var atUserIDs: [String] = [] var atNicknames: [String] = [] var isAtAll: Bool = false var isCircleOwner: Bool = false var location: ChatLocationPayload? = nil func with(avatar: UIImage? = nil, showTime: Bool? = nil, isUploading: Bool? = nil, imageUrl: String? = nil, quotePreview: QuotePreview? = nil, isCircleOwner: Bool? = nil, location: ChatLocationPayload? = nil) -> ChatMessage { ChatMessage( id: id, isSelf: isSelf, senderId: senderId, avatar: avatar ?? self.avatar, senderName: senderName, content: content, voiceUrl: voiceUrl, imageUrl: imageUrl ?? self.imageUrl, imageWidth: imageWidth, imageHeight: imageHeight, timestamp: timestamp, showTime: showTime ?? self.showTime, isUploading: isUploading ?? self.isUploading, quotePreview: quotePreview ?? self.quotePreview, atUserIDs: atUserIDs, atNicknames: atNicknames, isAtAll: isAtAll, isCircleOwner: isCircleOwner ?? self.isCircleOwner, location: location ?? self.location ) } /// 正文 attributed(含 @ 高亮) func attributedContent(isOutgoing: Bool) -> NSAttributedString { let baseColor: UIColor = isOutgoing ? UIColor(hexStr: "#293445") : UIColor(hexStr: "#293445") let atColor: UIColor = isOutgoing ? UIColor(hexStr: "#0086C4") : UIColor(hexStr: "#0086C4") let result = NSMutableAttributedString( string: content, attributes: [ .font: UIFont.systemFont(ofSize: 14), .foregroundColor: baseColor ] ) var tokens = atNicknames.map { "@\($0)" } if isAtAll { tokens.append("@所有人") } for token in Set(tokens) where !token.isEmpty && token != "@" { var searchRange = NSRange(location: 0, length: result.length) while searchRange.location < result.length { let found = (result.string as NSString).range(of: token, options: [], range: searchRange) if found.location == NSNotFound { break } result.addAttribute(.foregroundColor, value: atColor, range: found) result.addAttribute(.font, value: UIFont.systemFont(ofSize: 14, weight: .semibold), range: found) let next = found.location + found.length searchRange = NSRange(location: next, length: result.length - next) } } return result } } class GroupChatView: UIView { var disposeBag = DisposeBag() /// For scrolling control from VC var scrollToBottom: (() -> Void)? /// bottomBar 底部约束 var bottomBarBottomConstraint: NSLayoutConstraint? /// 语音录制回调 var onVoiceRecordState: ((VoiceRecordState) -> Void)? var onLocationTap: (() -> Void)? private var emojiPlaybackGeneration = 0 private var quoteBarHeightConstraint: NSLayoutConstraint? func setQuoteBarVisible(_ visible: Bool, summary: String? = nil) { quoteBar.isHidden = !visible quoteBarHeightConstraint?.constant = visible ? 36 : 0 if let summary { quoteBarLabel.text = summary } UIView.animate(withDuration: 0.2) { self.layoutIfNeeded() } } private func setupRx() { } /// 收起所有浮层面板 private var isVoiceInputMode = false func setVoiceInputMode(_ enabled: Bool) { isVoiceInputMode = enabled if enabled { textField.resignFirstResponder() emojiPanelView.isHidden = true } refreshComposer() } func refreshComposer() { let hasText = !(textField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty if isVoiceInputMode { textField.isHidden = true emojiBtn.isHidden = true voiceBtn.isHidden = true sendBtn.isHidden = true holdSpeakBtn.isHidden = false keyboardBtn.isHidden = false } else { textField.isHidden = false emojiBtn.isHidden = false holdSpeakBtn.isHidden = true keyboardBtn.isHidden = true sendBtn.isHidden = !hasText voiceBtn.isHidden = hasText } layoutIfNeeded() } func dismissAllPanels(excludeTextField: Bool = false) { let needsReset = !emojiPanelView.isHidden || textField.isFirstResponder || !mentionPickerView.isHidden guard needsReset else { return } stopVisibleEmojiAnimations() emojiPanelView.isHidden = true mentionPickerView.isHidden = true if !excludeTextField { textField.resignFirstResponder() } restoreComposerPosition() } func restoreComposerPosition(animated: Bool = true) { let update = { self.bottomBar.layoutChain.bottom(kSafeBottomMargin) self.layoutIfNeeded() } if animated { UIView.animate(withDuration: 0.25, animations: update) } else { update() } } func playVisibleEmojiAnimations() { emojiPlaybackGeneration += 1 let generation = emojiPlaybackGeneration let indexPaths = emojiCollectionView.indexPathsForVisibleItems.sorted { $0.item < $1.item } for (offset, indexPath) in indexPaths.enumerated() { DispatchQueue.main.asyncAfter(deadline: .now() + Double(offset) * 0.02) { [weak self] in guard let self = self, self.emojiPlaybackGeneration == generation, !self.emojiCollectionView.isDragging, !self.emojiCollectionView.isDecelerating, let cell = self.emojiCollectionView.cellForItem(at: indexPath) as? EmojiPanelCell else { return } cell.playAnimation() } } } func stopVisibleEmojiAnimations() { emojiPlaybackGeneration += 1 emojiCollectionView.visibleCells.forEach { cell in (cell as? EmojiPanelCell)?.stopAnimation() } } func preloadAdjacentEmojiPages() { let items = UIView.emojiFileNames guard emojiCollectionView.bounds.width > 0, !items.isEmpty else { return } let currentPage = Int(round(emojiCollectionView.contentOffset.x / emojiCollectionView.bounds.width)) for page in [currentPage - 1, currentPage + 1] where page >= 0 { let start = page * Self.emojiPerPage guard start < items.count else { continue } let end = min(start + Self.emojiPerPage, items.count) items[start.. UIButton { let btn = UIButton(type: .custom) btn.setTitle(title, for: .normal) btn.setTitleColor(UIColor(hexStr: "#7F8794"), for: .normal) btn.setImage(UIImage(named: imageName), for: .normal) btn.titleLabel?.font = .systemFont(ofSize: 13) btn.backgroundColor = .white btn.cornerRadius = 10 return btn } lazy var bottomBar: UIView = { let view = UIView() view.backgroundColor = .clear view.layer.shadowColor = UIColor(hexStr: "#0F2846", alpha: 0.1).cgColor view.layer.shadowOffset = CGSize(width: 0, height: 0) view.layer.shadowOpacity = 1 view.layer.shadowRadius = 9 return view }() lazy var bottomBarCornerView: UIView = { let view = UIView() view.backgroundColor = .white view.cornerRadius = 13 return view }() lazy var voiceBtn: UIButton = { let btn = UIButton(type: .custom) btn.setImage(UIImage(named: "IM/voice_input"), for: .normal) return btn }() lazy var textField: UITextField = { let tf = UITextField() tf.font = .systemFont(ofSize: 16) tf.textColor = UIColor(hexStr: "#293445") tf.backgroundColor = .clear tf.placeholder = "发消息或者按住说话" tf.returnKeyType = .send tf.tintColor = UIColor(hexStr: "#16B3FF") return tf }() lazy var holdSpeakBtn: UIButton = { let btn = UIButton(type: .custom) btn.setTitle("按住 说话", for: .normal) btn.setTitleColor(UIColor(hexStr: "#293445"), for: .normal) btn.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) btn.isExclusiveTouch = true btn.isHidden = true return btn }() lazy var keyboardBtn: UIButton = { let btn = UIButton(type: .custom) btn.setImage(UIImage(named: "IM/keyboard"), for: .normal) btn.isHidden = true return btn }() lazy var emojiBtn: UIButton = { let btn = UIButton(type: .custom) btn.setImage(UIImage(named: "IM/emoji_input"), for: .normal) return btn }() lazy var sendBtn: UIButton = { let btn = UIButton(type: .custom) btn.setImage(UIImage(named: "IM/send"), for: .normal) btn.backgroundColor = UIColor(hexStr: "#16B3FF") btn.cornerRadius = 10 btn.isHidden = true return btn }() /// IM功能禁用 lazy var disableIMView: UIView = { let view = UIView() view.backgroundColor = .clear view.layer.shadowColor = UIColor(hexStr: "#0F2846", alpha: 0.1).cgColor view.layer.shadowOffset = CGSize(width: 0, height: 0) view.layer.shadowOpacity = 1 view.layer.shadowRadius = 9 view.isHidden = true let v = UIView() v.backgroundColor = .white v.cornerRadius = 25 view.addSubview(v) v.layoutChain.edges() let label = UILabel() label.text = "开通VIP后可发送消息" label.textColor = ThemeManager.shared.color.titleAuxColor label.font = .systemFont(ofSize: 15, weight: .medium) view.addSubview(label) label.layoutChain.centerX().centerY() return view }() /// 语音面板 lazy var voiceRecordView: VoiceRecordView = { let v = VoiceRecordView() v.isHidden = true return v }() // MARK: - 表情面板 lazy var emojiPanelView: UIView = { let v = UIView() v.backgroundColor = UIColor(hexStr: "#F5F6F8") v.isHidden = true return v }() private static let emojiCols = 4 private static let emojiRows = 3 private static let emojiPerPage = emojiCols * emojiRows // 12 lazy var emojiCollectionView: UICollectionView = { let layout = CollectionHFlowLayout() let hSpacing: CGFloat = (kScreenWidth - CGFloat(Self.emojiCols) * 50) / CGFloat(Self.emojiCols + 1) let vSpacing: CGFloat = (180 - CGFloat(Self.emojiRows) * 50) / CGFloat(Self.emojiRows + 1) layout.rows = Self.emojiRows layout.colums = Self.emojiCols layout.itemSize = CGSize(width: 50, height: 50) layout.hSpacing = hSpacing layout.vSpacing = vSpacing layout.sectionInset = UIEdgeInsets(top: vSpacing, left: hSpacing, bottom: vSpacing, right: hSpacing) let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) cv.backgroundColor = .clear cv.isPagingEnabled = true cv.showsHorizontalScrollIndicator = false cv.register(EmojiPanelCell.self, forCellWithReuseIdentifier: "EmojiPanelCell") cv.delegate = self return cv }() lazy var emojiPageControl: UIPageControl = { let pc = UIPageControl() pc.numberOfPages = (UIView.emojiFileNames.count + Self.emojiPerPage - 1) / Self.emojiPerPage pc.currentPageIndicatorTintColor = UIColor(hexStr: "#16B3FF") pc.pageIndicatorTintColor = UIColor(hexStr: "#D0D0D0") return pc }() override init(frame: CGRect) { super.init(frame: .zero) backgroundColor = UIColor(hexStr: "#F5F5F5") setupUI() setupRx() refreshComposer() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() if !mentionPickerView.isHidden { mentionPickerView.layer.shadowPath = UIBezierPath( roundedRect: mentionPickerView.bounds, cornerRadius: 12 ).cgPath } } } // MARK: - Quote block(气泡外侧下方) final class ChatQuoteBlockView: UIControl { private let lineView: UIView = { let v = UIView() v.backgroundColor = UIColor(hexStr: "#16B3FF").withAlphaComponent(0.55) return v }() private let label: UILabel = { let lab = UILabel() lab.font = .systemFont(ofSize: 11) lab.textColor = UIColor(hexStr: "#666666") lab.numberOfLines = 2 return lab }() override init(frame: CGRect) { super.init(frame: frame) cornerRadius = 4 backgroundColor = UIColor(hexStr: "#EEEEF0") isHidden = true // addSubview(lineView) addSubview(label) // lineView.layoutChain.left().top(2).bottom(2).width(2) label.layoutChain.edgesHorzontal(10).edgesVertical() // label.layoutChain.leftToRightOfView(lineView, offset: 6).right().top().bottom() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func configure(_ preview: QuotePreview?) { guard let preview else { isHidden = true return } isHidden = false let name = preview.senderName.isEmpty ? "" : "\(preview.senderName): " label.text = name + preview.summary label.textColor = UIColor(hexStr: "#666666") lineView.backgroundColor = UIColor(hexStr: "#16B3FF").withAlphaComponent(0.55) } } final class ChatSenderNameView: UIView { private let ownerLabel: ChatPaddingLabel = { let label = ChatPaddingLabel() label.text = "圈主" label.font = .systemFont(ofSize: 10, weight: .medium) label.textColor = UIColor(hexStr: "#16B3FF") label.backgroundColor = UIColor(hexStr: "#DFF6FF") label.cornerRadius = 6 label.clipsToBounds = true label.insets = UIEdgeInsets(top: 2, left: 6, bottom: 2, right: 6) return label }() private let nameLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 12) label.textColor = UIColor(hexStr: "#7B7B7B") label.lineBreakMode = .byTruncatingTail return label }() private var ownerWidthConstraint: NSLayoutConstraint! private var spacingConstraint: NSLayoutConstraint! override init(frame: CGRect) { super.init(frame: frame) addSubview(ownerLabel) addSubview(nameLabel) ownerLabel.translatesAutoresizingMaskIntoConstraints = false nameLabel.translatesAutoresizingMaskIntoConstraints = false ownerWidthConstraint = ownerLabel.widthAnchor.constraint(equalToConstant: 0) spacingConstraint = nameLabel.leadingAnchor.constraint(equalTo: ownerLabel.trailingAnchor) NSLayoutConstraint.activate([ ownerLabel.leadingAnchor.constraint(equalTo: leadingAnchor), ownerLabel.centerYAnchor.constraint(equalTo: centerYAnchor), ownerLabel.heightAnchor.constraint(equalToConstant: 20), ownerWidthConstraint, spacingConstraint, nameLabel.centerYAnchor.constraint(equalTo: centerYAnchor), nameLabel.trailingAnchor.constraint(equalTo: trailingAnchor), heightAnchor.constraint(equalToConstant: 20) ]) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func configure(name: String, isOwner: Bool) { nameLabel.text = name ownerLabel.isHidden = !isOwner ownerWidthConstraint.constant = isOwner ? ownerLabel.intrinsicContentSize.width : 0 spacingConstraint.constant = isOwner ? 6 : 0 } } private final class ChatPaddingLabel: UILabel { var insets = UIEdgeInsets.zero override func drawText(in rect: CGRect) { super.drawText(in: rect.inset(by: insets)) } override var intrinsicContentSize: CGSize { let size = super.intrinsicContentSize return CGSize(width: size.width + insets.left + insets.right, height: size.height + insets.top + insets.bottom) } } // MARK: - 气泡上方操作菜单 final class ChatBubbleActionMenu: UIView { var onMention: (() -> Void)? var onReply: (() -> Void)? var onDismiss: (() -> Void)? private let panel = UIView() private let arrow = ChatBubbleMenuArrowView() private let stack = UIStackView() private let dimView = UIControl() private let itemWidth: CGFloat = 48 private let panelHeight: CGFloat = 54 private let arrowWidth: CGFloat = 10 private let arrowHeight: CGFloat = 6 private let gap: CGFloat = 4 override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .clear dimView.backgroundColor = .clear dimView.addTarget(self, action: #selector(handleDismiss), for: .touchUpInside) addSubview(dimView) dimView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ dimView.topAnchor.constraint(equalTo: topAnchor), dimView.leadingAnchor.constraint(equalTo: leadingAnchor), dimView.trailingAnchor.constraint(equalTo: trailingAnchor), dimView.bottomAnchor.constraint(equalTo: bottomAnchor) ]) panel.backgroundColor = UIColor(hexStr: "#293445") panel.layer.cornerRadius = 12 panel.clipsToBounds = true addSubview(panel) stack.axis = .horizontal stack.alignment = .fill stack.distribution = .fillEqually stack.spacing = 0 panel.addSubview(stack) stack.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ stack.topAnchor.constraint(equalTo: panel.topAnchor), stack.leadingAnchor.constraint(equalTo: panel.leadingAnchor), stack.trailingAnchor.constraint(equalTo: panel.trailingAnchor), stack.bottomAnchor.constraint(equalTo: panel.bottomAnchor), panel.heightAnchor.constraint(equalToConstant: 54) ]) addSubview(arrow) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func show(in host: UIView, anchor: UIView, canMention: Bool) { removeFromSuperview() host.addSubview(self) frame = host.bounds autoresizingMask = [.flexibleWidth, .flexibleHeight] stack.arrangedSubviews.forEach { stack.removeArrangedSubview($0) $0.removeFromSuperview() } if canMention { stack.addArrangedSubview(makeButton(title: "TA", imageName: "IM/at", action: #selector(handleMention))) } stack.addArrangedSubview(makeButton(title: "回复", imageName: "IM/reply", action: #selector(handleReply))) let panelW = itemWidth * CGFloat(max(stack.arrangedSubviews.count, 1)) let panelH = panelHeight layoutIfNeeded() let anchorRect = anchor.convert(anchor.bounds, to: host) var panelX = anchorRect.midX - panelW / 2 panelX = max(12, min(panelX, host.bounds.width - panelW - 12)) var panelY = anchorRect.minY - gap - panelH - arrowHeight var arrowAbove = true if panelY < host.safeAreaInsets.top + 8 { panelY = anchorRect.maxY + gap + arrowHeight arrowAbove = false } panel.frame = CGRect(x: panelX, y: panelY, width: panelW, height: panelH) arrow.pointsUp = !arrowAbove let arrowX = min(max(anchorRect.midX - arrowWidth / 2, panelX + 12), panelX + panelW - arrowWidth - 12) if arrowAbove { arrow.frame = CGRect(x: arrowX, y: panel.frame.maxY - 1, width: arrowWidth, height: arrowHeight) } else { arrow.frame = CGRect(x: arrowX, y: panel.frame.minY - arrowHeight + 1, width: arrowWidth, height: arrowHeight) } alpha = 0 UIView.animate(withDuration: 0.15) { self.alpha = 1 } } private func makeButton(title: String, imageName: String, action: Selector) -> UIControl { let btn = UIControl() btn.addTarget(self, action: action, for: .touchUpInside) btn.translatesAutoresizingMaskIntoConstraints = false let icon = UIImageView(image: UIImage(named: imageName)?.withRenderingMode(.alwaysTemplate)) icon.tintColor = .white icon.contentMode = .scaleAspectFit icon.translatesAutoresizingMaskIntoConstraints = false let label = UILabel() label.text = title label.textColor = .white label.font = .systemFont(ofSize: 10, weight: .medium) label.textAlignment = .center label.translatesAutoresizingMaskIntoConstraints = false btn.addSubview(icon) btn.addSubview(label) NSLayoutConstraint.activate([ icon.topAnchor.constraint(equalTo: btn.topAnchor, constant: 8), icon.centerXAnchor.constraint(equalTo: btn.centerXAnchor), icon.widthAnchor.constraint(equalToConstant: 16), icon.heightAnchor.constraint(equalToConstant: 16), label.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 4), label.centerXAnchor.constraint(equalTo: btn.centerXAnchor), label.leadingAnchor.constraint(greaterThanOrEqualTo: btn.leadingAnchor, constant: 2), label.trailingAnchor.constraint(lessThanOrEqualTo: btn.trailingAnchor, constant: -2), label.bottomAnchor.constraint(lessThanOrEqualTo: btn.bottomAnchor, constant: -6) ]) return btn } @objc private func handleMention() { onMention?() dismiss() } @objc private func handleReply() { onReply?() dismiss() } @objc private func handleDismiss() { dismiss() } func dismiss() { UIView.animate(withDuration: 0.12, animations: { self.alpha = 0 }, completion: { _ in self.onDismiss?() self.removeFromSuperview() }) } } private final class ChatBubbleMenuArrowView: UIView { var pointsUp = false { didSet { setNeedsDisplay() } } override init(frame: CGRect) { super.init(frame: frame) isOpaque = false backgroundColor = .clear } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() setNeedsDisplay() } override func draw(_ rect: CGRect) { let path = UIBezierPath() if pointsUp { path.move(to: CGPoint(x: rect.midX, y: 0)) path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) path.addLine(to: CGPoint(x: 0, y: rect.maxY)) } else { path.move(to: CGPoint(x: 0, y: 0)) path.addLine(to: CGPoint(x: rect.maxX, y: 0)) path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY)) } path.close() UIColor(hexStr: "#293445").setFill() path.fill() } } private func chatFormatTime(_ t: TimeInterval) -> String { let date = Date(timeIntervalSince1970: t) if date.isToday { return date.toFormat("HH:mm") } if date.isYesterday { return date.toFormat("'昨天' HH:mm") } if Calendar.current.isDate(date, equalTo: Date(), toGranularity: .year) { return date.toFormat("M-d HH:mm") } return date.toFormat("yyyy-M-d HH:mm") } // MARK: - 发送的消息cell class TextSendMsgCell: UITableViewCell { var onQuoteTap: (() -> Void)? private(set) var messageId: String = "" var menuAnchorView: UIView { bubbleView } func configure(_ msg: ChatMessage) { messageId = msg.id timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil avatarView.image = msg.avatar senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner) contentLabel.attributedText = msg.attributedContent(isOutgoing: true) let hasQuote = msg.quotePreview != nil quoteBlock.configure(msg.quotePreview) quoteHeightConstraint?.constant = hasQuote ? 28 : 0 quoteTopConstraint?.constant = hasQuote ? 4 : 0 } func flashHighlight() { let old = contentView.backgroundColor contentView.backgroundColor = UIColor(hexStr: "#16B3FF").withAlphaComponent(0.12) UIView.animate(withDuration: 0.8, delay: 0.3, options: []) { self.contentView.backgroundColor = old } } private let timeLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 12) label.textColor = UIColor(hexStr: "#999999") label.textAlignment = .center return label }() let avatarView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill iv.cornerRadius = 10 iv.clipsToBounds = true iv.backgroundColor = UIColor(hexStr: "#E0E0E0") // iv.borderWidth = 2 // iv.borderColor = .white return iv }() private let bubbleView: UIView = { let v = UIView() v.backgroundColor = UIColor(hexStr: "#C9EEFF") v.cornerRadius = 10 v.layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner] return v }() private let senderNameView = ChatSenderNameView() private let contentLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 14, weight: .medium) label.textColor = UIColor(hexStr: "#293445") label.numberOfLines = 0 return label }() private let quoteBlock = ChatQuoteBlockView() private var quoteHeightConstraint: NSLayoutConstraint? private var quoteTopConstraint: NSLayoutConstraint? override init(style: CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none backgroundColor = .clear contentView.addSubview(timeLabel) contentView.addSubview(bubbleView) bubbleView.addSubview(contentLabel) contentView.addSubview(quoteBlock) contentView.addSubview(avatarView) contentView.addSubview(senderNameView) timeLabel.layoutChain.top().centerX() avatarView.layoutChain .topToBottomOfView(timeLabel, offset: 10) .right(12).width(38).height(38) senderNameView.translatesAutoresizingMaskIntoConstraints = false senderNameView.layoutChain .topToView(avatarView) .rightToLeftOfView(avatarView, offset: -9) // NSLayoutConstraint.activate([ // senderNameView.trailingAnchor.constraint(equalTo: avatarView.leadingAnchor, constant: -5), // senderNameView.bottomAnchor.constraint(equalTo: avatarView.bottomAnchor), // senderNameView.leadingAnchor.constraint(greaterThanOrEqualTo: contentView.leadingAnchor, constant: 80) // ]) bubbleView.layoutChain .topToBottomOfView(senderNameView, offset: 5) .rightToView(senderNameView) .left(60, relation: .greaterThanOrEqual) .height(30, relation: .greaterThanOrEqual) contentLabel.setContentHuggingPriority(.required, for: .horizontal) contentLabel.setContentCompressionResistancePriority(.required, for: .horizontal) contentLabel.translatesAutoresizingMaskIntoConstraints = false contentLabel.layoutChain .edgesVertical(13) .edgesHorzontal(12) quoteBlock.translatesAutoresizingMaskIntoConstraints = false quoteBlock.setContentHuggingPriority(.defaultLow, for: .horizontal) let qTop = quoteBlock.topAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: 4) let qH = quoteBlock.heightAnchor.constraint(equalToConstant: 0) quoteTopConstraint = qTop quoteHeightConstraint = qH NSLayoutConstraint.activate([ qTop, quoteBlock.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor), quoteBlock.leadingAnchor.constraint(greaterThanOrEqualTo: contentView.leadingAnchor, constant: 60), quoteBlock.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10), qH ]) quoteBlock.addTarget(self, action: #selector(handleQuoteTap), for: .touchUpInside) } @objc private func handleQuoteTap() { onQuoteTap?() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() } } // MARK: - 收到的消息cell class TextReceivedMsgCell: UITableViewCell { var onQuoteTap: (() -> Void)? private(set) var messageId: String = "" private(set) var senderId: String = "" private(set) var senderName: String = "" var menuAnchorView: UIView { bubbleView } func configure(_ msg: ChatMessage) { messageId = msg.id senderId = msg.senderId senderName = msg.senderName timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil avatarView.image = msg.avatar senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner) contentLabel.attributedText = msg.attributedContent(isOutgoing: false) let hasQuote = msg.quotePreview != nil quoteBlock.configure(msg.quotePreview) quoteHeightConstraint?.constant = hasQuote ? 28 : 0 quoteTopConstraint?.constant = hasQuote ? 4 : 0 } func flashHighlight() { let old = contentView.backgroundColor contentView.backgroundColor = UIColor(hexStr: "#16B3FF").withAlphaComponent(0.12) UIView.animate(withDuration: 0.8, delay: 0.3, options: []) { self.contentView.backgroundColor = old } } private let timeLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 12) label.textColor = UIColor(hexStr: "#999999") label.textAlignment = .center return label }() let avatarView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill iv.cornerRadius = 10 iv.clipsToBounds = true iv.backgroundColor = UIColor(hexStr: "#E0E0E0") // iv.borderWidth = 2 // iv.borderColor = .white iv.isUserInteractionEnabled = true return iv }() private let bubbleView: UIView = { let v = UIView() v.backgroundColor = UIColor(hexStr: "#FFFFFF") v.cornerRadius = 10 v.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner] return v }() private let senderNameView = ChatSenderNameView() private let contentLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 14) label.numberOfLines = 0 return label }() private let quoteBlock = ChatQuoteBlockView() private var quoteHeightConstraint: NSLayoutConstraint? private var quoteTopConstraint: NSLayoutConstraint? override init(style: CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none backgroundColor = .clear contentView.addSubview(timeLabel) contentView.addSubview(bubbleView) bubbleView.addSubview(contentLabel) contentView.addSubview(quoteBlock) contentView.addSubview(avatarView) contentView.addSubview(senderNameView) timeLabel.layoutChain.top().centerX() avatarView.layoutChain .topToBottomOfView(timeLabel, offset: 10) .left(12).width(38).height(38) senderNameView.translatesAutoresizingMaskIntoConstraints = false senderNameView.layoutChain .topToView(avatarView) .leftToRightOfView(avatarView, offset: 9) bubbleView.layoutChain .topToBottomOfView(senderNameView, offset: 5) .leftToView(senderNameView) .right(60, relation: .greaterThanOrEqual) .height(30, relation: .greaterThanOrEqual) contentLabel.setContentHuggingPriority(.required, for: .horizontal) contentLabel.setContentCompressionResistancePriority(.required, for: .horizontal) contentLabel.translatesAutoresizingMaskIntoConstraints = false contentLabel.layoutChain .edgesVertical(13) .edgesHorzontal(12) quoteBlock.translatesAutoresizingMaskIntoConstraints = false quoteBlock.setContentHuggingPriority(.defaultLow, for: .horizontal) // 引用在 bubbleView 下方 let qTop = quoteBlock.topAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: 4) let qH = quoteBlock.heightAnchor.constraint(equalToConstant: 0) quoteTopConstraint = qTop quoteHeightConstraint = qH NSLayoutConstraint.activate([ qTop, quoteBlock.leadingAnchor.constraint(equalTo: senderNameView.leadingAnchor), quoteBlock.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -60), quoteBlock.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10), qH ]) quoteBlock.addTarget(self, action: #selector(handleQuoteTap), for: .touchUpInside) } @objc private func handleQuoteTap() { onQuoteTap?() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() } } // MARK: - 通知消息cell final class NotificationMsgCell: UITableViewCell { func configure(_ text: NSAttributedString, showTime: Bool, timestamp: TimeInterval) { timeLabel.isHidden = !showTime timeLabel.text = showTime ? chatFormatTime(timestamp) : nil contentLabel.attributedText = text } private let timeLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 12) label.textColor = UIColor(hexStr: "#999999") label.textAlignment = .center return label }() private let contentLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 12) label.textColor = UIColor(hexStr: "#999999") label.textAlignment = .center label.numberOfLines = 0 return label }() override init(style: CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none backgroundColor = .clear contentView.addSubview(timeLabel) contentView.addSubview(contentLabel) timeLabel.layoutChain .top(10) .centerX() contentLabel.layoutChain .topToBottomOfView(timeLabel, offset: 8) .edgesHorzontal(40) .bottom(10) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } // MARK: - 发送的表情消息 final class EmojiSendMsgCell: UITableViewCell { var menuAnchorView: UIView { lottieView } private let timeLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 12) label.textColor = UIColor(hexStr: "#999999") label.textAlignment = .center return label }() let avatarView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill iv.cornerRadius = 10 iv.clipsToBounds = true iv.backgroundColor = UIColor(hexStr: "#E0E0E0") // iv.borderWidth = 2 // iv.borderColor = .white return iv }() private let senderNameView = ChatSenderNameView() private let lottieView: LottieAnimationView = { let v = LottieAnimationView() v.contentMode = .scaleAspectFit v.loopMode = .loop return v }() private var animationName: String? private var shouldPlayAnimation = false override init(style: CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none backgroundColor = .clear contentView.addSubview(timeLabel) contentView.addSubview(avatarView) contentView.addSubview(senderNameView) contentView.addSubview(lottieView) timeLabel.layoutChain.top().centerX() avatarView.layoutChain .topToBottomOfView(timeLabel, offset: 10) .right(12).width(38).height(38) senderNameView.translatesAutoresizingMaskIntoConstraints = false senderNameView.layoutChain .topToView(avatarView) .rightToLeftOfView(avatarView, offset: -9) avatarView.layoutChain .topToBottomOfView(timeLabel, offset: 14) .right(12) .width(30).height(30) lottieView.layoutChain .topToBottomOfView(senderNameView, offset: 5) .right(60) .width(60).height(60) .bottom(10) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func configure(_ msg: ChatMessage) { timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil avatarView.image = msg.avatar senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner) let index = Int(msg.content.replacingOccurrences(of: "js_emoji:", with: "")) ?? 0 guard Self.emojiFileNames.indices.contains(index) else { animationName = nil lottieView.animation = nil return } let name = Self.emojiFileNames[index] animationName = name shouldPlayAnimation = false lottieView.stop() lottieView.animation = nil lottieView.currentProgress = 0 EmojiPanelCell.loadAnimation(for: name) { [weak self] animation in guard let self = self, self.animationName == name else { return } self.lottieView.animation = animation self.lottieView.currentProgress = 0 if self.shouldPlayAnimation { self.lottieView.play() } } } func playAnimation() { shouldPlayAnimation = true lottieView.play() } func stopAnimation() { shouldPlayAnimation = false lottieView.stop() } override func prepareForReuse() { super.prepareForReuse() animationName = nil shouldPlayAnimation = false lottieView.stop() lottieView.animation = nil } } // MARK: - 收到的表情消息 final class EmojiReceivedMsgCell: UITableViewCell { var menuAnchorView: UIView { lottieView } private let timeLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 12) label.textColor = UIColor(hexStr: "#999999") label.textAlignment = .center return label }() let avatarView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill iv.cornerRadius = 10 iv.clipsToBounds = true iv.backgroundColor = UIColor(hexStr: "#E0E0E0") // iv.borderWidth = 2 // iv.borderColor = .white iv.isUserInteractionEnabled = true return iv }() private let senderNameView = ChatSenderNameView() private let lottieView: LottieAnimationView = { let v = LottieAnimationView() v.contentMode = .scaleAspectFit v.loopMode = .loop return v }() private var animationName: String? private var shouldPlayAnimation = false override init(style: CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none backgroundColor = .clear contentView.addSubview(timeLabel) contentView.addSubview(avatarView) contentView.addSubview(senderNameView) contentView.addSubview(lottieView) timeLabel.layoutChain.top().centerX() avatarView.layoutChain .topToBottomOfView(timeLabel, offset: 10) .left(12).width(38).height(38) senderNameView.translatesAutoresizingMaskIntoConstraints = false senderNameView.layoutChain .topToView(avatarView) .leftToRightOfView(avatarView, offset: 9) lottieView.layoutChain .topToBottomOfView(senderNameView, offset: 5) .left(60) .width(60).height(60) .bottom(10) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func configure(_ msg: ChatMessage) { timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil avatarView.image = msg.avatar senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner) let index = Int(msg.content.replacingOccurrences(of: "js_emoji:", with: "")) ?? 0 guard Self.emojiFileNames.indices.contains(index) else { animationName = nil lottieView.animation = nil return } let name = Self.emojiFileNames[index] animationName = name shouldPlayAnimation = false lottieView.stop() lottieView.animation = nil lottieView.currentProgress = 0 EmojiPanelCell.loadAnimation(for: name) { [weak self] animation in guard let self = self, self.animationName == name else { return } self.lottieView.animation = animation self.lottieView.currentProgress = 0 if self.shouldPlayAnimation { self.lottieView.play() } } } func playAnimation() { shouldPlayAnimation = true lottieView.play() } func stopAnimation() { shouldPlayAnimation = false lottieView.stop() } override func prepareForReuse() { super.prepareForReuse() animationName = nil shouldPlayAnimation = false lottieView.stop() lottieView.animation = nil } } // MARK: - UICollectionViewDelegate (page control) extension GroupChatView: UICollectionViewDelegate { func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { guard scrollView == emojiCollectionView else { return } stopVisibleEmojiAnimations() } func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { guard scrollView == emojiCollectionView, !decelerate else { return } playVisibleEmojiAnimations() preloadAdjacentEmojiPages() } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { guard scrollView == emojiCollectionView else { return } let page = Int(scrollView.contentOffset.x / scrollView.bounds.width) emojiPageControl.currentPage = page playVisibleEmojiAnimations() preloadAdjacentEmojiPages() } func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { guard collectionView == emojiCollectionView else { return } guard !collectionView.isDragging, !collectionView.isDecelerating else { return } (cell as? EmojiPanelCell)?.playAnimation() } func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { guard collectionView == emojiCollectionView else { return } (cell as? EmojiPanelCell)?.stopAnimation() } } // MARK: - 发送的语音消息 final class VoiceSendMsgCell: UITableViewCell, VoicePlaybackView { var menuAnchorView: UIView { bubbleView } private let timeLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 12) label.textColor = UIColor(hexStr: "#999999") label.textAlignment = .center return label }() let avatarView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill iv.cornerRadius = 10 iv.clipsToBounds = true iv.backgroundColor = UIColor(hexStr: "#E0E0E0") // iv.borderWidth = 2 // iv.borderColor = .white return iv }() private let senderNameView = ChatSenderNameView() private let bubbleView: UIView = { let v = UIView() v.backgroundColor = UIColor(hexStr: "#C9EEFF") v.cornerRadius = 10 v.layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner] return v }() private let playAnimation: LottieAnimationView = { let v = LottieAnimationView() if let path = Bundle.main.path(forResource: "message_voice_play", ofType: "json") { v.animation = LottieAnimation.filepath(path) } // v.loopMode = .loop v.contentMode = .scaleAspectFit return v }() private let durationLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 14) label.textColor = UIColor(hexStr: "#1A1A1A") return label }() override init(style: CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none backgroundColor = .clear contentView.addSubview(timeLabel) contentView.addSubview(bubbleView) bubbleView.addSubview(playAnimation) bubbleView.addSubview(durationLabel) contentView.addSubview(avatarView) contentView.addSubview(senderNameView) let tap = UITapGestureRecognizer(target: self, action: #selector(togglePlay)) bubbleView.addGestureRecognizer(tap) timeLabel.layoutChain.top().centerX() avatarView.layoutChain .topToBottomOfView(timeLabel, offset: 10) .right(12).width(38).height(38) senderNameView.translatesAutoresizingMaskIntoConstraints = false senderNameView.layoutChain .topToView(avatarView) .rightToLeftOfView(avatarView, offset: -9) bubbleView.layoutChain .topToBottomOfView(senderNameView, offset: 5) .rightToView(senderNameView) .width(105).height(39).bottom(10) playAnimation.layoutChain .right(36) .centerY() .width(20).height(20) durationLabel.layoutChain .rightToLeftOfView(playAnimation, offset: -4) .centerY() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func configure(_ msg: ChatMessage) { timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil avatarView.image = msg.avatar senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner) let dur = msg.content.int / 1000 durationLabel.text = dur > 0 ? "\(dur)''" : "" voiceUrl = msg.voiceUrl } private var voiceUrl: String? @objc private func togglePlay() { guard let url = voiceUrl, !url.isEmpty else { return } if VoicePlayerManager.shared.state == .playing, VoicePlayerManager.shared.isCurrent(url: url) { VoicePlayerManager.shared.pause() playAnimation.stop() } else { VoicePlayerManager.shared.play(urlString: url, playbackView: self) { [weak self] state in if state == .paused { self?.playAnimation.stop() } else { self?.playAnimation.play() } } onFinished: { [weak self] in self?.playAnimation.stop() } } } func play() { playAnimation.play() } func stop() { playAnimation.stop() } override func prepareForReuse() { super.prepareForReuse() playAnimation.stop() } override func layoutSubviews() { super.layoutSubviews() } } // MARK: - 收到的语音消息 final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView { var menuAnchorView: UIView { bubbleView } func configure(_ msg: ChatMessage) { timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil avatarView.image = msg.avatar senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner) let dur = msg.content.int / 1000 durationLabel.text = dur > 0 ? "\(dur)''" : "" voiceUrl = msg.voiceUrl } private var voiceUrl: String? @objc private func togglePlay() { guard let url = voiceUrl, !url.isEmpty else { return } if VoicePlayerManager.shared.state == .playing, VoicePlayerManager.shared.isCurrent(url: url) { VoicePlayerManager.shared.pause() playAnimation.stop() } else { VoicePlayerManager.shared.play(urlString: url, playbackView: self) { [weak self] state in if state == .paused { self?.playAnimation.stop() } else { self?.playAnimation.play() } } onFinished: { [weak self] in self?.playAnimation.stop() } } } func play() { playAnimation.play() } func stop() { playAnimation.stop() } private let timeLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 12) label.textColor = UIColor(hexStr: "#999999") label.textAlignment = .center return label }() let avatarView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill iv.cornerRadius = 10 iv.clipsToBounds = true iv.backgroundColor = UIColor(hexStr: "#E0E0E0") // iv.borderWidth = 2 // iv.borderColor = .white return iv }() private let senderNameView = ChatSenderNameView() private let bubbleView: UIView = { let v = UIView() v.backgroundColor = UIColor(hexStr: "#FFFFFF") v.cornerRadius = 10 v.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner] return v }() private let playAnimation: LottieAnimationView = { let v = LottieAnimationView() if let path = Bundle.main.path(forResource: "message_voice_play", ofType: "json") { v.animation = LottieAnimation.filepath(path) } v.loopMode = .loop v.contentMode = .scaleAspectFit v.transform = CGAffineTransform(rotationAngle: .pi) return v }() private let durationLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 14) label.textColor = UIColor(hexStr: "#1A1A1A") return label }() override init(style: CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none backgroundColor = .clear contentView.addSubview(timeLabel) contentView.addSubview(bubbleView) bubbleView.addSubview(playAnimation) bubbleView.addSubview(durationLabel) contentView.addSubview(avatarView) contentView.addSubview(senderNameView) let tap = UITapGestureRecognizer(target: self, action: #selector(togglePlay)) bubbleView.addGestureRecognizer(tap) timeLabel.layoutChain.top().centerX() avatarView.layoutChain .topToBottomOfView(timeLabel, offset: 10) .left(12).width(38).height(38) senderNameView.translatesAutoresizingMaskIntoConstraints = false senderNameView.layoutChain .topToView(avatarView) .leftToRightOfView(avatarView, offset: 9) bubbleView.layoutChain .topToBottomOfView(senderNameView, offset: 5) .leftToView(senderNameView) .width(105) .height(39) .bottom(10) playAnimation.layoutChain .left(36) .centerY() .width(20).height(20) durationLabel.layoutChain .leftToRightOfView(playAnimation, offset: 4) .centerY() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func prepareForReuse() { super.prepareForReuse() playAnimation.stop() } override func layoutSubviews() { super.layoutSubviews() } } // MARK: - 图片消息(收发共用尺寸,避免接收 cell 再走一套正方形逻辑) final class ImageSendMsgCell: ChatImageMsgCell { override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier, isOutgoing: true) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } final class ImageReceivedMsgCell: ChatImageMsgCell { override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier, isOutgoing: false) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } class ChatImageMsgCell: UITableViewCell { var menuAnchorView: UIView { photoView } var onImageTap: (() -> Void)? var onNeedRelayout: (() -> Void)? private let isOutgoing: Bool private var configuredId: String? func configure(_ msg: ChatMessage) { configuredId = msg.id timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil avatarView.image = msg.avatar senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner) applyPhotoSize(ChatImageLayout.displaySize(width: msg.imageWidth, height: msg.imageHeight)) loadPhoto(url: msg.imageUrl, messageId: msg.id, preferred: CGSize(width: msg.imageWidth, height: msg.imageHeight)) guard isOutgoing else { return } loadingView.isHidden = !msg.isUploading if msg.isUploading { loadingView.startAnimating() } else { loadingView.stopAnimating() } } private func loadPhoto(url: String, messageId: String, preferred: CGSize) { guard !url.isEmpty else { photoView.image = nil; return } if let localImage = UIImage(contentsOfFile: url) { photoView.image = localImage syncPhotoSizeIfNeeded(localImage.size, preferred: preferred) return } photoView.dl.setImage(with: url) { [weak self] image, _ in guard let self, self.configuredId == messageId, let image else { return } self.syncPhotoSizeIfNeeded(image.size, preferred: preferred) } } private func syncPhotoSizeIfNeeded(_ imageSize: CGSize, preferred: CGSize) { let size = ChatImageLayout.displaySize(width: imageSize.width, height: imageSize.height) let current = preferred.width > 1 && preferred.height > 1 ? ChatImageLayout.displaySize(width: preferred.width, height: preferred.height) : ChatImageLayout.fallback guard abs(size.width - current.width) > 2 || abs(size.height - current.height) > 2 else { return } applyPhotoSize(size) let id = configuredId DispatchQueue.main.async { [weak self] in guard let self, self.configuredId == id else { return } self.onNeedRelayout?() } } private func applyPhotoSize(_ size: CGSize) { photoView.layoutChain.width(size.width).height(size.height) } @objc private func onTap() { onImageTap?() } private let loadingView: UIActivityIndicatorView = { let v = UIActivityIndicatorView(style: UIActivityIndicatorView.Style.large) v.hidesWhenStopped = true v.color = UIColor(hexStr: "#16B3FF") return v }() private let timeLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 12) label.textColor = UIColor(hexStr: "#999999") label.textAlignment = .center return label }() let avatarView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill iv.cornerRadius = 10 iv.clipsToBounds = true iv.backgroundColor = UIColor(hexStr: "#E0E0E0") return iv }() private let senderNameView = ChatSenderNameView() lazy var photoView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill iv.cornerRadius = 8 iv.clipsToBounds = true iv.backgroundColor = UIColor(hexStr: "#F0F0F0") iv.isUserInteractionEnabled = true return iv }() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { self.isOutgoing = false super.init(style: style, reuseIdentifier: reuseIdentifier) setupUI() } init(style: UITableViewCell.CellStyle, reuseIdentifier: String?, isOutgoing: Bool) { self.isOutgoing = isOutgoing super.init(style: style, reuseIdentifier: reuseIdentifier) setupUI() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setupUI() { selectionStyle = .none backgroundColor = .clear contentView.addSubview(timeLabel) contentView.addSubview(photoView) contentView.addSubview(avatarView) contentView.addSubview(senderNameView) let tap = UITapGestureRecognizer(target: self, action: #selector(onTap)) photoView.addGestureRecognizer(tap) timeLabel.layoutChain.top().centerX() senderNameView.translatesAutoresizingMaskIntoConstraints = false if isOutgoing { photoView.addSubview(loadingView) loadingView.layoutChain.centerX().centerY() avatarView.layoutChain .topToBottomOfView(timeLabel, offset: 10) .right(12).width(38).height(38) senderNameView.layoutChain .topToView(avatarView) .rightToLeftOfView(avatarView, offset: -9) photoView.layoutChain .topToBottomOfView(senderNameView, offset: 5) .rightToView(senderNameView, offset: 0) .width(ChatImageLayout.fallback.width) .height(ChatImageLayout.fallback.height) .bottom(10) } else { loadingView.isHidden = true avatarView.layoutChain .topToBottomOfView(timeLabel, offset: 10) .left(12).width(38).height(38) senderNameView.layoutChain .topToView(avatarView) .leftToRightOfView(avatarView, offset: 9) photoView.layoutChain .topToBottomOfView(senderNameView, offset: 5) .leftToView(senderNameView) .width(ChatImageLayout.fallback.width) .height(ChatImageLayout.fallback.height) .bottom(10) } } override func prepareForReuse() { super.prepareForReuse() photoView.image = nil onImageTap = nil onNeedRelayout = nil configuredId = nil loadingView.stopAnimating() loadingView.isHidden = true } } // MARK: - 位置消息 final class LocationSendMsgCell: ChatLocationMsgCell { override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier, isOutgoing: true) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } final class LocationReceivedMsgCell: ChatLocationMsgCell { override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier, isOutgoing: false) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } class ChatLocationMsgCell: UITableViewCell { var onLocationTap: (() -> Void)? var menuAnchorView: UIView { cardView } private let isOutgoing: Bool private let timeLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 12) label.textColor = UIColor(hexStr: "#999999") label.textAlignment = .center return label }() private let avatarView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill iv.cornerRadius = 10 iv.clipsToBounds = true iv.backgroundColor = UIColor(hexStr: "#E0E0E0") // iv.borderWidth = 2 // iv.borderColor = .white return iv }() private let senderNameView = ChatSenderNameView() private let cardView: UIControl = { let view = UIControl() view.backgroundColor = .white view.cornerRadius = 10 view.clipsToBounds = true return view }() private let mapPreview = ChatLocationMapPreview() private let titleLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 14, weight: .bold) label.textColor = UIColor(hexStr: "#293445") return label }() private let addressLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 12, weight: .medium) label.textColor = UIColor(hexStr: "#AAAAAA") return label }() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { self.isOutgoing = false super.init(style: style, reuseIdentifier: reuseIdentifier) setupUI() } init(style: UITableViewCell.CellStyle, reuseIdentifier: String?, isOutgoing: Bool) { self.isOutgoing = isOutgoing super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none backgroundColor = .clear setupUI() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func configure(_ msg: ChatMessage) { timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil avatarView.image = msg.avatar senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner) let location = msg.location ?? ChatLocationPayload(name: msg.content, address: "", latitude: 0, longitude: 0) titleLabel.text = location.name.isEmpty ? "位置" : location.name addressLabel.text = location.address.isEmpty ? "暂无详细地址" : location.address mapPreview.configure(location: location) } private func setupUI() { selectionStyle = .none backgroundColor = .clear contentView.addSubview(timeLabel) contentView.addSubview(cardView) contentView.addSubview(avatarView) contentView.addSubview(senderNameView) cardView.addSubview(titleLabel) cardView.addSubview(addressLabel) cardView.addTarget(self, action: #selector(handleLocationTap), for: .touchUpInside) timeLabel.layoutChain.top().centerX() cardView.translatesAutoresizingMaskIntoConstraints = false avatarView.translatesAutoresizingMaskIntoConstraints = false senderNameView.translatesAutoresizingMaskIntoConstraints = false titleLabel.layoutChain .top(11) .left(10) addressLabel.layoutChain .topToBottomOfView(titleLabel, offset: 8) .edgesHorzontal(10) if isOutgoing { avatarView.layoutChain .topToBottomOfView(timeLabel, offset: 10) .right(12).width(38).height(38) senderNameView.layoutChain .topToView(avatarView) .rightToLeftOfView(avatarView, offset: -9) cardView.layoutChain .topToBottomOfView(senderNameView, offset: 5) .rightToView(senderNameView) .width(250) .height(148) .bottom(10) } else { avatarView.layoutChain .topToBottomOfView(timeLabel, offset: 10) .left(12).width(38).height(38) senderNameView.layoutChain .topToView(avatarView) .leftToRightOfView(avatarView, offset: 9) cardView.layoutChain .topToBottomOfView(senderNameView, offset: 5) .leftToView(senderNameView) .width(250) .height(148) .bottom(10) } } @objc private func handleLocationTap() { onLocationTap?() } } private final class ChatLocationMapPreview: UIView { private let imageView = UIImageView() private let pinView: UIView = { let view = UIView() view.backgroundColor = UIColor(hexStr: "#16B3FF") view.cornerRadius = 10 view.borderWidth = 3 view.borderColor = .white view.layer.shadowColor = UIColor.black.cgColor view.layer.shadowOpacity = 0.18 view.layer.shadowRadius = 3 view.layer.shadowOffset = CGSize(width: 0, height: 1) return view }() private var requestID = UUID() override init(frame: CGRect) { super.init(frame: frame) backgroundColor = UIColor(hexStr: "#E7F0E9") clipsToBounds = true imageView.contentMode = .scaleAspectFill addSubview(imageView) addSubview(pinView) imageView.translatesAutoresizingMaskIntoConstraints = false pinView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ imageView.topAnchor.constraint(equalTo: topAnchor), imageView.leadingAnchor.constraint(equalTo: leadingAnchor), imageView.trailingAnchor.constraint(equalTo: trailingAnchor), imageView.bottomAnchor.constraint(equalTo: bottomAnchor), pinView.centerXAnchor.constraint(equalTo: centerXAnchor), pinView.centerYAnchor.constraint(equalTo: centerYAnchor), pinView.widthAnchor.constraint(equalToConstant: 20), pinView.heightAnchor.constraint(equalToConstant: 20) ]) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func configure(location: ChatLocationPayload) { let identifier = UUID() requestID = identifier imageView.image = nil guard CLLocationCoordinate2DIsValid(location.coordinate), abs(location.latitude) > 0.0001 || abs(location.longitude) > 0.0001 else { return } let options = MKMapSnapshotter.Options() options.region = MKCoordinateRegion(center: location.coordinate, latitudinalMeters: 700, longitudinalMeters: 700) options.size = CGSize(width: 460, height: 168) options.scale = UIScreen.main.scale MKMapSnapshotter(options: options).start(with: .main) { [weak self] snapshot, _ in guard let self, self.requestID == identifier else { return } self.imageView.image = snapshot?.image } } }