jsdw_ios/QuickLocation/Section/Group/GroupChat/GroupChatView.swift

2020 lines
69 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// GroupChatView.swift
// QuickLocation
//
// Created by on 2026/6/4.
//
import UIKit
import RxSwift
import RxCocoa
import Lottie
import AVFoundation
enum VoiceRecordState {
case began
case canceling
case ended(URL?) // nil = cancelled
}
// 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
func with(avatar: UIImage? = nil,
showTime: Bool? = nil,
isUploading: Bool? = nil,
imageUrl: String? = nil,
quotePreview: QuotePreview? = 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
)
}
/// attributed @
func attributedContent(isOutgoing: Bool) -> NSAttributedString {
let baseColor: UIColor = isOutgoing ? .white : UIColor(hexStr: "#333333")
let atColor: UIColor = isOutgoing ? UIColor(hexStr: "#E8F8FF") : UIColor(hexStr: "#16B3FF")
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)?
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() {
}
///
func dismissAllPanels(excludeTextField: Bool = false) {
let needsReset = !emojiPanelView.isHidden
|| !voiceRecordView.isHidden
|| textField.isFirstResponder
|| !mentionPickerView.isHidden
guard needsReset else { return }
stopVisibleEmojiAnimations()
emojiPanelView.isHidden = true
voiceRecordView.isHidden = true
mentionPickerView.isHidden = true
if !excludeTextField { textField.resignFirstResponder() }
UIView.animate(withDuration: 0.25) {
self.bottomBar.layoutChain.bottom(kSafeBottomMargin + 20)
self.voiceRecordView.layoutChain.bottom(-252)
}
}
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..<end].forEach { name in
EmojiPanelCell.loadAnimation(for: name) { _ in }
}
}
}
// MARK: - Setup
private func setupUI() {
backgroundColor = UIColor(hexStr: "#F5FBFB")
addSubview(navBgView)
addSubview(navBarView)
navBarView.addSubview(backBtn)
navBarView.addSubview(groupAvatarView)
navBarView.addSubview(groupNameLabel)
navBarView.addSubview(onlineStatusLabel)
navBarView.addSubview(rightIconsView)
rightIconsView.addSubview(reviewBtn)
rightIconsView.addSubview(memberBtn)
rightIconsView.addSubview(settingBtn)
rightIconsView.addSubview(reviewDotView)
addSubview(tableView)
addSubview(chatWarningView)
addSubview(quoteBar)
addSubview(mentionPickerView)
addSubview(bottomBar)
addSubview(disableIMView)
bottomBar.addSubview(bottomBarCornerView)
addSubview(voiceRecordView)
addSubview(emojiPanelView)
emojiPanelView.addSubview(emojiCollectionView)
emojiPanelView.addSubview(emojiPageControl)
bottomBar.addSubview(voiceBtn)
bottomBar.addSubview(textField)
bottomBar.addSubview(emojiBtn)
bottomBar.addSubview(addBtn)
bottomBar.addSubview(sendBtn)
quoteBar.addSubview(quoteBarLabel)
quoteBar.addSubview(quoteBarCloseBtn)
mentionPickerView.addSubview(mentionPickerContentView)
mentionPickerContentView.addSubview(mentionTableView)
navBgView.layoutChain
.edges(excludingEdge: .bottom)
.height(kNaviHeight)
navBarView.layoutChain
.edges(excludingEdge: .bottom)
.height(kNaviHeight)
backBtn.layoutChain
.top(kStatusBarHeight + 12)
.left(7)
.width(24).height(24)
groupAvatarView.layoutChain
.centerY(backBtn)
.leftToRightOfView(backBtn, offset: 5)
.width(32).height(32)
groupNameLabel.layoutChain
.topToView(groupAvatarView)
.leftToRightOfView(groupAvatarView, offset: 8)
onlineStatusLabel.layoutChain
.topToBottomOfView(groupNameLabel, offset: 2)
.leftToView(groupNameLabel)
rightIconsView.layoutChain
.centerY(backBtn)
.right(15)
.height(24)
reviewBtn.layoutChain
.left().centerY()
.width(24).height(24)
reviewDotView.layoutChain
.topToView(reviewBtn, offset: -2)
.leftToRightOfView(reviewBtn, offset: -6)
.width(8)
.height(8)
memberBtn.layoutChain
.leftToRightOfView(reviewBtn, offset: 20)
.centerY()
.width(24).height(24)
settingBtn.layoutChain
.leftToRightOfView(memberBtn, offset: 20)
.centerY()
.width(24).height(24)
.right()
tableView.layoutChain
.topToBottomOfView(navBarView)
.edgesHorzontal()
.bottomToTopOfView(bottomBar)
chatWarningView.layoutChain
.topToBottomOfView(navBarView)
.edgesHorzontal()
quoteBar.layoutChain
.edgesHorzontal(15)
.bottomToTopOfView(bottomBar, offset: -6)
quoteBarHeightConstraint = quoteBar.heightAnchor.constraint(equalToConstant: 0)
quoteBarHeightConstraint?.isActive = true
quoteBarLabel.layoutChain
.left(12).rightToLeftOfView(quoteBarCloseBtn, offset: -8)
.centerY()
quoteBarCloseBtn.layoutChain
.right(8).centerY()
.width(24).height(24)
mentionPickerView.layoutChain
.edgesHorzontal(15)
.height(180)
.bottomToTopOfView(quoteBar, offset: -6)
mentionPickerContentView.layoutChain.edges()
mentionTableView.layoutChain.edges()
bottomBar.layoutChain
.edgesHorzontal(15)
.height(50)
.bottom(kSafeBottomMargin + 20)
disableIMView.layoutChain
.edgesHorzontal(15)
.height(50)
.bottom(kSafeBottomMargin + 20)
bottomBarBottomConstraint = bottomBar.jh_constraint(.bottom, toAttribute: .bottom, otherView: bottomBar.superview, relation: .equal)
bottomBarCornerView.layoutChain.edges()
voiceBtn.layoutChain
.left(13).centerY()
.width(28).height(28)
sendBtn.layoutChain
.right(19).centerY()
.width(76).height(34)
addBtn.layoutChain
.rightToLeftOfView(sendBtn, offset: -10)
.centerY()
.width(28).height(28)
emojiBtn.layoutChain
.rightToLeftOfView(addBtn, offset: -10)
.centerY()
.width(28).height(28)
textField.layoutChain
.leftToRightOfView(voiceBtn, offset: 10)
.rightToLeftOfView(emojiBtn, offset: -10)
.centerY()
.height(36)
voiceRecordView.layoutChain
.edgesHorzontal()
.bottom(-252)
.height(252)
// .heightToWidth(252/375)
emojiPanelView.layoutChain
.edgesHorzontal()
.bottom()
.height(220)
emojiCollectionView.layoutChain
.edges(excludingEdge: .bottom)
emojiPageControl.layoutChain
.topToBottomOfView(emojiCollectionView)
.centerX()
.height(20)
.bottom()
}
// MARK: - Nav
lazy var navBgView: UIImageView = {
let iv = UIImageView()
iv.image = UIImage(named: "Common/navBar_bg_2")
iv.contentMode = .scaleAspectFill
return iv
}()
lazy var navBarView: UIView = {
let view = UIView()
view.backgroundColor = .clear
return view
}()
lazy var backBtn: UIButton = {
let btn = UIButton(type: .custom)
btn.setImage(UIImage(named: "Common/back"), for: .normal)
btn.extendEdgeInsets = UIEdgeInsets(top: 20, left: 10, bottom: 20, right: 40)
return btn
}()
lazy var groupAvatarView: UIImageView = {
let iv = UIImageView()
iv.contentMode = .scaleAspectFill
iv.cornerRadius = 16
iv.clipsToBounds = true
iv.backgroundColor = UIColor(hexStr: "#E0E0E0")
return iv
}()
lazy var groupNameLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 14, weight: .medium)
label.textColor = UIColor(hexStr: "#333333")
return label
}()
lazy var onlineStatusLabel: UILabel = {
let label = UILabel()
label.text = "今日活跃"
label.font = .systemFont(ofSize: 12, weight: .regular)
label.textColor = UIColor(hexStr: "#999999")
return label
}()
lazy var rightIconsView: UIView = {
let view = UIView()
view.backgroundColor = .clear
return view
}()
lazy var reviewBtn: UIButton = {
let btn = UIButton(type: .custom)
btn.setImage(UIImage(named: "IM/review"), for: .normal)
btn.isHidden = true
return btn
}()
lazy var reviewDotView: UIView = {
let view = UIView()
view.backgroundColor = .red
view.cornerRadius = 4
view.isHidden = true
return view
}()
lazy var memberBtn: UIButton = {
let btn = UIButton(type: .custom)
btn.setImage(UIImage(named: "IM/member"), for: .normal)
return btn
}()
lazy var settingBtn: UIButton = {
let btn = UIButton(type: .custom)
btn.setImage(UIImage(named: "IM/setting"), for: .normal)
return btn
}()
// MARK: - chatwarning
lazy var chatWarningView: UIView = {
let view = UIView()
view.backgroundColor = .black.withAlphaComponent(0.5)
view.isHidden = true
view.addSubview(closeBtn)
closeBtn.layoutChain
.right(10)
.height(10)
.width(10)
.centerY()
view.addSubview(chatWarningLab)
chatWarningLab.layoutChain
.edgesVertical(5)
.left(15)
.rightToLeftOfView(closeBtn, offset: -8)
return view
}()
lazy var closeBtn: UIButton = {
let btn = UIButton(type: .custom)
btn.backgroundColor = .clear
btn.setImage(UIImage(named: "Group/close"), for: .normal)
btn.extendEdgeInsets = UIEdgeInsets(top: 20, left: 30, bottom: 20, right: 10)
btn.rx.tap.subscribe(onNext: { _ in
self.chatWarningView.isHidden = true
}).disposed(by: disposeBag)
return btn
}()
lazy var chatWarningLab: UILabel = {
let label = UILabel()
label.textColor = .white
label.font = .systemFont(ofSize: 10, weight: .regular)
label.numberOfLines = 0
return label
}()
// MARK: - Message List
lazy var tableView: UITableView = {
let tv = UITableView(frame: .zero, style: .plain)
tv.backgroundColor = .clear
tv.separatorStyle = .none
tv.showsVerticalScrollIndicator = true
tv.register(TextSendMsgCell.self)
tv.register(TextReceivedMsgCell.self)
tv.register(EmojiSendMsgCell.self)
tv.register(EmojiReceivedMsgCell.self)
tv.register(VoiceSendMsgCell.self)
tv.register(VoiceReceivedMsgCell.self)
tv.register(ImageSendMsgCell.self)
tv.register(ImageReceivedMsgCell.self)
tv.register(NotificationMsgCell.self)
tv.rowHeight = UITableView.automaticDimension
tv.contentInset = UIEdgeInsets(top: 8, left: 0, bottom: 8, right: 0)
return tv
}()
// MARK: - Quote / Mention
lazy var quoteBar: UIView = {
let view = UIView()
view.backgroundColor = UIColor(hexStr: "#EEF6FB")
view.cornerRadius = 10
view.isHidden = true
return view
}()
lazy var quoteBarLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 12)
label.textColor = UIColor(hexStr: "#666666")
label.lineBreakMode = .byTruncatingTail
return label
}()
lazy var quoteBarCloseBtn: UIButton = {
let btn = UIButton(type: .custom)
btn.setTitle("", for: .normal)
btn.setTitleColor(UIColor(hexStr: "#999999"), for: .normal)
btn.titleLabel?.font = .systemFont(ofSize: 14)
return btn
}()
lazy var mentionPickerView: UIView = {
let view = UIView()
view.backgroundColor = .clear
view.isHidden = true
// clipsToBounds
view.layer.shadowColor = UIColor(hexStr: "#0F2846").cgColor
view.layer.shadowOpacity = 0.18
view.layer.shadowRadius = 12
view.layer.shadowOffset = CGSize(width: 0, height: 4)
view.layer.masksToBounds = false
return view
}()
lazy var mentionPickerContentView: UIView = {
let view = UIView()
view.backgroundColor = .white
view.layer.cornerRadius = 12
view.layer.borderWidth = 0.5
view.layer.borderColor = UIColor(hexStr: "#E5E5E5").cgColor
view.clipsToBounds = true
return view
}()
lazy var mentionTableView: UITableView = {
let tv = UITableView(frame: .zero, style: .plain)
tv.separatorStyle = .singleLine
tv.rowHeight = 44
tv.backgroundColor = .white
tv.register(UITableViewCell.self, forCellReuseIdentifier: "MentionCell")
return tv
}()
// MARK: - Bottom Bar
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 = 25
return view
}()
lazy var voiceBtn: UIButton = {
let btn = UIButton(type: .custom)
btn.setImage(UIImage(named: "IM/voice"), for: .normal)
return btn
}()
lazy var textField: UITextField = {
let tf = UITextField()
tf.font = .systemFont(ofSize: 14)
tf.backgroundColor = .white
tf.cornerRadius = 18
tf.placeholder = "输入消息..."
tf.returnKeyType = .send
tf.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 36))
tf.leftViewMode = .always
return tf
}()
lazy var emojiBtn: UIButton = {
let btn = UIButton(type: .custom)
btn.setImage(UIImage(named: "IM/emoji"), for: .normal)
return btn
}()
lazy var addBtn: UIButton = {
let btn = UIButton(type: .custom)
btn.setImage(UIImage(named: "IM/add"), for: .normal)
return btn
}()
lazy var sendBtn: UIButton = {
let btn = UIButton(type: .custom)
btn.setTitle("发送", for: .normal)
btn.setTitleColor(UIColor(hexStr: "#16B3FF"), for: .normal)
btn.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium)
btn.backgroundColor = .white
btn.borderWidth = 1
btn.borderColor = UIColor(hexStr: "#16B3FF")
btn.cornerRadius = 17
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 = .clear
setupUI()
setupRx()
}
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)
}
}
// MARK: -
final class ChatBubbleActionMenu: UIView {
var onQuote: (() -> Void)?
var onRevoke: (() -> Void)?
var onDismiss: (() -> Void)?
private let panel = UIView()
private let arrow = UIView()
private let stack = UIStackView()
private let dimView = UIControl()
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: "#333333")
panel.layer.cornerRadius = 10
panel.clipsToBounds = true
addSubview(panel)
stack.axis = .horizontal
stack.alignment = .fill
stack.distribution = .fill
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: 44)
])
arrow.backgroundColor = UIColor(hexStr: "#333333")
addSubview(arrow)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func show(in host: UIView, anchor: UIView, showRevoke: Bool) {
removeFromSuperview()
host.addSubview(self)
frame = host.bounds
autoresizingMask = [.flexibleWidth, .flexibleHeight]
stack.arrangedSubviews.forEach {
stack.removeArrangedSubview($0)
$0.removeFromSuperview()
}
let quoteBtn = makeButton(title: "引用", action: #selector(handleQuote))
quoteBtn.widthAnchor.constraint(equalToConstant: 72).isActive = true
stack.addArrangedSubview(quoteBtn)
if showRevoke {
let sep = UIView()
sep.backgroundColor = UIColor.white.withAlphaComponent(0.2)
sep.translatesAutoresizingMaskIntoConstraints = false
sep.widthAnchor.constraint(equalToConstant: 1).isActive = true
stack.addArrangedSubview(sep)
let revokeBtn = makeButton(title: "撤回", action: #selector(handleRevoke))
revokeBtn.widthAnchor.constraint(equalToConstant: 72).isActive = true
stack.addArrangedSubview(revokeBtn)
}
let panelW: CGFloat = showRevoke ? 145 : 72
let panelH: CGFloat = 44
let arrowSize: CGFloat = 8
let gap: CGFloat = 6
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 - arrowSize
var arrowAbove = true
if panelY < host.safeAreaInsets.top + 8 {
panelY = anchorRect.maxY + gap + arrowSize
arrowAbove = false
}
panel.frame = CGRect(x: panelX, y: panelY, width: panelW, height: panelH)
let arrowX = min(max(anchorRect.midX - arrowSize / 2, panelX + 10), panelX + panelW - arrowSize - 10)
if arrowAbove {
arrow.frame = CGRect(x: arrowX, y: panel.frame.maxY - 1, width: arrowSize, height: arrowSize)
} else {
arrow.frame = CGRect(x: arrowX, y: panel.frame.minY - arrowSize + 1, width: arrowSize, height: arrowSize)
}
arrow.transform = CGAffineTransform(rotationAngle: .pi / 4)
alpha = 0
UIView.animate(withDuration: 0.15) { self.alpha = 1 }
}
private func makeButton(title: String, action: Selector) -> UIButton {
let btn = UIButton(type: .system)
btn.setTitle(title, for: .normal)
btn.setTitleColor(.white, for: .normal)
btn.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
btn.addTarget(self, action: action, for: .touchUpInside)
btn.translatesAutoresizingMaskIntoConstraints = false
return btn
}
@objc private func handleQuote() {
onQuote?()
dismiss()
}
@objc private func handleRevoke() {
onRevoke?()
dismiss()
}
@objc private func handleDismiss() { dismiss() }
func dismiss() {
UIView.animate(withDuration: 0.12, animations: {
self.alpha = 0
}, completion: { _ in
self.onDismiss?()
self.removeFromSuperview()
})
}
}
private func chatFormatTime(_ t: TimeInterval) -> String {
let date = Date(timeIntervalSince1970: t)
let now = Date()
let calendar = Calendar.current
let f = DateFormatter()
if calendar.isDateInToday(date) { f.dateFormat = "HH:mm" }
else if calendar.isDateInYesterday(date) { f.dateFormat = "'昨天' HH:mm" }
else if calendar.isDate(date, equalTo: now, toGranularity: .year) { f.dateFormat = "M-d HH:mm" }
else { f.dateFormat = "yyyy-M-d HH:mm" }
return f.string(from: date)
}
// 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
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 = 15
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: "#16B3FF")
return v
}()
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)
timeLabel.layoutChain.top().centerX()
avatarView.layoutChain
.topToBottomOfView(timeLabel, offset: 14)
.right(12).width(30).height(30)
bubbleView.layoutChain
.topToBottomOfView(avatarView, offset: -15)
.rightToView(avatarView, offset: -13)
.left(60, relation: .greaterThanOrEqual)
.width(100, relation: .greaterThanOrEqual)
.height(30, relation: .greaterThanOrEqual)
contentLabel.translatesAutoresizingMaskIntoConstraints = false
quoteBlock.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
contentLabel.topAnchor.constraint(equalTo: bubbleView.topAnchor, constant: 10),
contentLabel.leadingAnchor.constraint(equalTo: bubbleView.leadingAnchor, constant: 20),
contentLabel.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor, constant: -20),
contentLabel.bottomAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: -10)
])
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.widthAnchor.constraint(lessThanOrEqualTo: bubbleView.widthAnchor),
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()
bubbleView.setNeedsLayout()
bubbleView.layoutIfNeeded()
bubbleView.setCornerRadius(corners: [.topLeft, .bottomLeft, .bottomRight],
withCornerRadii: CGSize(width: bubbleView.dl.height / 2, height: bubbleView.dl.height / 2))
}
}
// 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
nameLabel.text = msg.senderName
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
}()
private let nameLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 10, weight: .regular)
label.textColor = UIColor(hexStr: "#666666")
return label
}()
let avatarView: UIImageView = {
let iv = UIImageView()
iv.contentMode = .scaleAspectFill
iv.cornerRadius = 15
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: "#CCEAFF")
return v
}()
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(nameLabel)
timeLabel.layoutChain.top().centerX()
bubbleView.layoutChain
.topToBottomOfView(timeLabel, offset: 14)
.left(30)
.right(60, relation: .greaterThanOrEqual)
.width(100, relation: .greaterThanOrEqual)
.height(30, relation: .greaterThanOrEqual)
contentLabel.translatesAutoresizingMaskIntoConstraints = false
quoteBlock.translatesAutoresizingMaskIntoConstraints = false
avatarView.translatesAutoresizingMaskIntoConstraints = false
nameLabel.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
contentLabel.topAnchor.constraint(equalTo: bubbleView.topAnchor, constant: 10),
contentLabel.leadingAnchor.constraint(equalTo: bubbleView.leadingAnchor, constant: 20),
contentLabel.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor, constant: -20),
contentLabel.bottomAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: -10),
// +
avatarView.topAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: -15),
avatarView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12),
avatarView.widthAnchor.constraint(equalToConstant: 30),
avatarView.heightAnchor.constraint(equalToConstant: 30),
nameLabel.leadingAnchor.constraint(equalTo: avatarView.trailingAnchor, constant: 5),
nameLabel.bottomAnchor.constraint(equalTo: avatarView.bottomAnchor)
])
// senderName
let qTop = quoteBlock.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 4)
let qH = quoteBlock.heightAnchor.constraint(equalToConstant: 0)
quoteTopConstraint = qTop
quoteHeightConstraint = qH
NSLayoutConstraint.activate([
qTop,
quoteBlock.leadingAnchor.constraint(equalTo: nameLabel.leadingAnchor),
quoteBlock.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -60),
quoteBlock.widthAnchor.constraint(lessThanOrEqualTo: bubbleView.widthAnchor),
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()
bubbleView.setNeedsLayout()
bubbleView.layoutIfNeeded()
bubbleView.setCornerRadius(corners: [.topLeft, .topRight, .bottomRight],
withCornerRadii: CGSize(width: bubbleView.dl.height / 2, height: bubbleView.dl.height / 2))
}
}
// MARK: - cell
final class NotificationMsgCell: UITableViewCell {
func configure(_ text: NSAttributedString, showTime: Bool, timestamp: TimeInterval) {
timeLabel.isHidden = !showTime
timeLabel.text = showTime ? formatTime(timestamp) : nil
contentLabel.attributedText = text
}
private func formatTime(_ t: TimeInterval) -> String {
let date = Date(timeIntervalSince1970: t)
let now = Date()
let calendar = Calendar.current
let f = DateFormatter()
if calendar.isDateInToday(date) { f.dateFormat = "HH:mm" }
else if calendar.isDateInYesterday(date) { f.dateFormat = "'昨天' HH:mm" }
else if calendar.isDate(date, equalTo: now, toGranularity: .year) { f.dateFormat = "M-d HH:mm" }
else { f.dateFormat = "yyyy-M-d HH:mm" }
return f.string(from: date)
}
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
}()
private let avatarView: UIImageView = {
let iv = UIImageView()
iv.contentMode = .scaleAspectFill
iv.cornerRadius = 15
iv.clipsToBounds = true
iv.backgroundColor = UIColor(hexStr: "#E0E0E0")
iv.borderWidth = 2
iv.borderColor = .white
return iv
}()
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(lottieView)
timeLabel.layoutChain.top().centerX()
avatarView.layoutChain
.topToBottomOfView(timeLabel, offset: 14)
.right(12)
.width(30).height(30)
lottieView.layoutChain
.topToBottomOfView(avatarView, offset: -15)
.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 ? formatTime(msg.timestamp) : nil
avatarView.image = msg.avatar
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
}
private func formatTime(_ t: TimeInterval) -> String {
let date = Date(timeIntervalSince1970: t)
let now = Date()
let calendar = Calendar.current
let f = DateFormatter()
if calendar.isDateInToday(date) { f.dateFormat = "HH:mm" }
else if calendar.isDateInYesterday(date) { f.dateFormat = "'昨天' HH:mm" }
else if calendar.isDate(date, equalTo: now, toGranularity: .year) { f.dateFormat = "M-d HH:mm" }
else { f.dateFormat = "yyyy-M-d HH:mm" }
return f.string(from: date)
}
}
// 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 = 15
iv.clipsToBounds = true
iv.backgroundColor = UIColor(hexStr: "#E0E0E0")
iv.borderWidth = 2
iv.borderColor = .white
iv.isUserInteractionEnabled = true
return iv
}()
private let nameLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 10, weight: .regular)
label.textColor = UIColor(hexStr: "#666666")
return label
}()
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(nameLabel)
contentView.addSubview(lottieView)
timeLabel.layoutChain.top().centerX()
lottieView.layoutChain
.topToBottomOfView(timeLabel, offset: 14)
.left(60)
.width(60).height(60)
avatarView.layoutChain
.topToBottomOfView(lottieView, offset: -15)
.left(12)
.width(30).height(30)
.bottom(10)
nameLabel.layoutChain
.leftToRightOfView(avatarView, offset: 5)
.bottomToView(avatarView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(_ msg: ChatMessage) {
timeLabel.isHidden = !msg.showTime
timeLabel.text = msg.showTime ? formatTime(msg.timestamp) : nil
avatarView.image = msg.avatar
nameLabel.text = msg.senderName
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
}
private func formatTime(_ t: TimeInterval) -> String {
let date = Date(timeIntervalSince1970: t)
let now = Date()
let calendar = Calendar.current
let f = DateFormatter()
if calendar.isDateInToday(date) { f.dateFormat = "HH:mm" }
else if calendar.isDateInYesterday(date) { f.dateFormat = "'昨天' HH:mm" }
else if calendar.isDate(date, equalTo: now, toGranularity: .year) { f.dateFormat = "M-d HH:mm" }
else { f.dateFormat = "yyyy-M-d HH:mm" }
return f.string(from: date)
}
}
// 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
}()
private let avatarView: UIImageView = {
let iv = UIImageView()
iv.contentMode = .scaleAspectFill
iv.cornerRadius = 15
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: "#16B3FF")
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)
let tap = UITapGestureRecognizer(target: self, action: #selector(togglePlay))
bubbleView.addGestureRecognizer(tap)
timeLabel.layoutChain.top().centerX()
avatarView.layoutChain
.topToBottomOfView(timeLabel, offset: 14)
.right(12).width(30).height(30)
bubbleView.layoutChain
.topToBottomOfView(avatarView, offset: -15)
.rightToView(avatarView, offset: -13)
.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 ? formatTime(msg.timestamp) : nil
avatarView.image = msg.avatar
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()
}
private func formatTime(_ t: TimeInterval) -> String {
let date = Date(timeIntervalSince1970: t)
let now = Date()
let calendar = Calendar.current
let f = DateFormatter()
if calendar.isDateInToday(date) { f.dateFormat = "HH:mm" }
else if calendar.isDateInYesterday(date) { f.dateFormat = "'昨天' HH:mm" }
else if calendar.isDate(date, equalTo: now, toGranularity: .year) { f.dateFormat = "M-d HH:mm" }
else { f.dateFormat = "yyyy-M-d HH:mm" }
return f.string(from: date)
}
override func layoutSubviews() {
super.layoutSubviews()
bubbleView.setNeedsLayout()
bubbleView.layoutIfNeeded()
bubbleView.setCornerRadius(corners: [.topLeft , .bottomRight, .bottomLeft],
withCornerRadii: CGSize(width: bubbleView.dl.height / 2, height: bubbleView.dl.height / 2))
}
}
// MARK: -
final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView {
var menuAnchorView: UIView { bubbleView }
func configure(_ msg: ChatMessage) {
timeLabel.isHidden = !msg.showTime
timeLabel.text = msg.showTime ? formatTime(msg.timestamp) : nil
avatarView.image = msg.avatar
nameLabel.text = msg.senderName
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 func formatTime(_ t: TimeInterval) -> String {
let date = Date(timeIntervalSince1970: t)
let now = Date()
let calendar = Calendar.current
let f = DateFormatter()
if calendar.isDateInToday(date) { f.dateFormat = "HH:mm" }
else if calendar.isDateInYesterday(date) { f.dateFormat = "'昨天' HH:mm" }
else if calendar.isDate(date, equalTo: now, toGranularity: .year) { f.dateFormat = "M-d HH:mm" }
else { f.dateFormat = "yyyy-M-d HH:mm" }
return f.string(from: date)
}
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 = 15
iv.clipsToBounds = true
iv.backgroundColor = UIColor(hexStr: "#E0E0E0")
iv.borderWidth = 2
iv.borderColor = .white
iv.isUserInteractionEnabled = true
return iv
}()
private let nameLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 10, weight: .regular)
label.textColor = UIColor(hexStr: "#666666")
return label
}()
private let bubbleView: UIView = {
let v = UIView()
v.backgroundColor = UIColor(hexStr: "#CCEAFF")
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(nameLabel)
let tap = UITapGestureRecognizer(target: self, action: #selector(togglePlay))
bubbleView.addGestureRecognizer(tap)
timeLabel.layoutChain.top().centerX()
bubbleView.layoutChain
.topToBottomOfView(timeLabel, offset: 14)
.width(105)
.height(39)
avatarView.layoutChain
.topToBottomOfView(bubbleView, offset: -15)
.left(12)
.width(30).height(30)
.bottom(10)
bubbleView.layoutChain.leftToView(avatarView, offset: 13)
nameLabel.layoutChain
.leftToRightOfView(avatarView, offset: 5)
.bottomToView(avatarView)
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()
bubbleView.setNeedsLayout()
bubbleView.layoutIfNeeded()
bubbleView.setCornerRadius(corners: [.topLeft , .topRight, .bottomRight],
withCornerRadii: CGSize(width: bubbleView.dl.height / 2, height: bubbleView.dl.height / 2))
}
}
// MARK: -
final class ImageSendMsgCell: UITableViewCell {
var menuAnchorView: UIView { photoView }
var onImageTap: (() -> Void)?
func configure(_ msg: ChatMessage) {
timeLabel.isHidden = !msg.showTime
timeLabel.text = msg.showTime ? formatTime(msg.timestamp) : nil
avatarView.image = msg.avatar
if !msg.imageUrl.isEmpty {
//
if let localImage = UIImage(contentsOfFile: msg.imageUrl) {
photoView.image = localImage
} else {
photoView.dl.setImage(with: msg.imageUrl)
}
}
photoView.layoutChain.width(msg.imageWidth).height(msg.imageHeight)
loadingView.isHidden = !msg.isUploading
if msg.isUploading { loadingView.startAnimating() }
}
@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 static func displaySize(w: CGFloat, h: CGFloat) -> CGSize {
guard w > 0, h > 0 else { return CGSize(width: 160, height: 160) }
let maxW: CGFloat = 200, maxH: CGFloat = 250, minW: CGFloat = 80
var dw = maxW, dh = dw * (h / w)
if dh > maxH { dh = maxH; dw = dh * (w / h) }
if dw < minW { dw = minW; dh = dw * (h / w) }
return CGSize(width: dw, height: dh)
}
private func formatTime(_ t: TimeInterval) -> String {
let date = Date(timeIntervalSince1970: t)
let now = Date()
let calendar = Calendar.current
let f = DateFormatter()
if calendar.isDateInToday(date) { f.dateFormat = "HH:mm" }
else if calendar.isDateInYesterday(date) { f.dateFormat = "'昨天' HH:mm" }
else if calendar.isDate(date, equalTo: now, toGranularity: .year) { f.dateFormat = "M-d HH:mm" }
else { f.dateFormat = "yyyy-M-d HH:mm" }
return f.string(from: date)
}
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 = 15
iv.clipsToBounds = true
iv.backgroundColor = UIColor(hexStr: "#E0E0E0")
iv.borderWidth = 2
iv.borderColor = .white
return iv
}()
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: CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
backgroundColor = .clear
contentView.addSubview(timeLabel)
contentView.addSubview(photoView)
contentView.addSubview(avatarView)
let tap = UITapGestureRecognizer(target: self, action: #selector(onTap))
photoView.addGestureRecognizer(tap)
photoView.addSubview(loadingView)
loadingView.layoutChain.centerX().centerY()
timeLabel.layoutChain.top().centerX()
avatarView.layoutChain
.topToBottomOfView(timeLabel, offset: 14)
.right(12).width(30).height(30)
photoView.layoutChain
.topToBottomOfView(avatarView, offset: -15)
.rightToView(avatarView, offset: -13)
.bottom(10)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
// MARK: -
final class ImageReceivedMsgCell: UITableViewCell {
var menuAnchorView: UIView { photoView }
var onImageTap: (() -> Void)?
func configure(_ msg: ChatMessage) {
timeLabel.isHidden = !msg.showTime
timeLabel.text = msg.showTime ? formatTime(msg.timestamp) : nil
avatarView.image = msg.avatar
nameLabel.text = msg.senderName
if !msg.imageUrl.isEmpty {
photoView.dl.setImage(with: msg.imageUrl)
}
photoView.layoutChain.width(msg.imageWidth).height(msg.imageHeight)
}
@objc private func onTap() { onImageTap?() }
private static func displaySize(w: CGFloat, h: CGFloat) -> CGSize {
guard w > 0, h > 0 else { return CGSize(width: 160, height: 160) }
let maxW: CGFloat = 200, maxH: CGFloat = 250, minW: CGFloat = 80
var dw = maxW, dh = dw * (h / w)
if dh > maxH { dh = maxH; dw = dh * (w / h) }
if dw < minW { dw = minW; dh = dw * (h / w) }
return CGSize(width: dw, height: dh)
}
private func formatTime(_ t: TimeInterval) -> String {
let date = Date(timeIntervalSince1970: t)
let now = Date()
let calendar = Calendar.current
let f = DateFormatter()
if calendar.isDateInToday(date) { f.dateFormat = "HH:mm" }
else if calendar.isDateInYesterday(date) { f.dateFormat = "'昨天' HH:mm" }
else if calendar.isDate(date, equalTo: now, toGranularity: .year) { f.dateFormat = "M-d HH:mm" }
else { f.dateFormat = "yyyy-M-d HH:mm" }
return f.string(from: date)
}
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 = 15
iv.clipsToBounds = true
iv.backgroundColor = UIColor(hexStr: "#E0E0E0")
iv.borderWidth = 2
iv.borderColor = .white
iv.isUserInteractionEnabled = true
return iv
}()
private let nameLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 10, weight: .regular)
label.textColor = UIColor(hexStr: "#666666")
return label
}()
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: CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
backgroundColor = .clear
contentView.addSubview(timeLabel)
contentView.addSubview(nameLabel)
contentView.addSubview(photoView)
contentView.addSubview(avatarView)
let tap = UITapGestureRecognizer(target: self, action: #selector(onTap))
photoView.addGestureRecognizer(tap)
timeLabel.layoutChain.top().centerX()
photoView.layoutChain
.topToBottomOfView(timeLabel, offset: 14)
.width(160).height(160)
avatarView.layoutChain
.topToBottomOfView(photoView, offset: -15)
.left(12)
.width(30).height(30)
.bottom(10)
photoView.layoutChain.leftToView(avatarView, offset: 13)
nameLabel.layoutChain
.leftToRightOfView(avatarView, offset: 5)
.bottomToView(avatarView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}