// // 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 } 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 func setupRx() { } /// 收起所有浮层面板 func dismissAllPanels(excludeTextField: Bool = false) { let needsReset = !emojiPanelView.isHidden || !voiceRecordView.isHidden || textField.isFirstResponder guard needsReset else { return } stopVisibleEmojiAnimations() emojiPanelView.isHidden = true voiceRecordView.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.. 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 }() 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 }() override init(style: CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none backgroundColor = .clear contentView.addSubview(bubbleView) bubbleView.addSubview(contentLabel) contentView.addSubview(timeLabel) 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) .bottom(10) contentLabel.layoutChain .edgesVertical(10) .edgesHorzontal(20) } 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 { func configure(_ msg: ChatMessage) { timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? formatTime(msg.timestamp) : nil avatarView.image = msg.avatar nameLabel.text = msg.senderName contentLabel.text = msg.content } 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 nameLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 10, weight: .regular) label.textColor = UIColor(hexStr: "#666666") 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: "#CCEAFF") return v }() private let contentLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 14) 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(bubbleView) bubbleView.addSubview(contentLabel) 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) avatarView.layoutChain .topToBottomOfView(bubbleView, offset: -15) .left(12) .width(30).height(30) .bottom(10) nameLabel.layoutChain .leftToRightOfView(avatarView, offset: 5) .bottomToView(avatarView) contentLabel.layoutChain .edgesVertical(10) .edgesHorzontal(20) } 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 { 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 { 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 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 { 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 { 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 }() 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 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 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 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 }() 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 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") } }