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

2313 lines
79 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
import SwiftDate
import MapKit
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
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() {
}
///
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)
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() {
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(quickActionBar)
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(sendBtn)
quickActionBar.addSubview(galleryBtn)
quickActionBar.addSubview(cameraBtn)
quickActionBar.addSubview(locationBtn)
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 + 8)
.left(7)
.width(32).height(32)
groupAvatarView.layoutChain
.centerY(backBtn)
.leftToRightOfView(backBtn, offset: 5)
.width(30).height(30)
groupNameLabel.layoutChain
.leftToRightOfView(groupAvatarView, offset: 8)
.centerY(groupAvatarView)
// onlineStatusLabel.layoutChain
// .topToBottomOfView(groupNameLabel, offset: 2)
// .leftToView(groupNameLabel)
rightIconsView.layoutChain
.centerY(backBtn)
.right()
.height(32)
// reviewBtn.layoutChain
// .left().centerY()
// .width(24).height(24)
// reviewDotView.layoutChain
// .topToView(reviewBtn, offset: -2)
// .leftToRightOfView(reviewBtn, offset: -6)
// .width(8)
// .height(8)
settingBtn.layoutChain
.right(14)
.centerY()
.width(32).height(32)
memberBtn.layoutChain
.left()
.centerY()
.width(32).height(32)
.rightToLeftOfView(settingBtn, offset: -10)
tableView.layoutChain
.topToBottomOfView(navBarView)
.edgesHorzontal()
.bottomToTopOfView(quickActionBar)
chatWarningView.layoutChain
.topToBottomOfView(navBarView)
.edgesHorzontal()
quoteBar.layoutChain
.edgesHorzontal(15)
.bottomToTopOfView(quickActionBar, 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()
quickActionBar.translatesAutoresizingMaskIntoConstraints = false
galleryBtn.translatesAutoresizingMaskIntoConstraints = false
cameraBtn.translatesAutoresizingMaskIntoConstraints = false
locationBtn.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
quickActionBar.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10),
quickActionBar.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10),
quickActionBar.bottomAnchor.constraint(equalTo: bottomBar.topAnchor, constant: -10),
quickActionBar.heightAnchor.constraint(equalToConstant: 40),
galleryBtn.leadingAnchor.constraint(equalTo: quickActionBar.leadingAnchor),
galleryBtn.topAnchor.constraint(equalTo: quickActionBar.topAnchor),
galleryBtn.bottomAnchor.constraint(equalTo: quickActionBar.bottomAnchor),
cameraBtn.leadingAnchor.constraint(equalTo: galleryBtn.trailingAnchor, constant: 11),
cameraBtn.topAnchor.constraint(equalTo: quickActionBar.topAnchor),
cameraBtn.bottomAnchor.constraint(equalTo: quickActionBar.bottomAnchor),
locationBtn.leadingAnchor.constraint(equalTo: cameraBtn.trailingAnchor, constant: 11),
locationBtn.trailingAnchor.constraint(equalTo: quickActionBar.trailingAnchor),
locationBtn.topAnchor.constraint(equalTo: quickActionBar.topAnchor),
locationBtn.bottomAnchor.constraint(equalTo: quickActionBar.bottomAnchor),
galleryBtn.widthAnchor.constraint(equalTo: cameraBtn.widthAnchor),
cameraBtn.widthAnchor.constraint(equalTo: locationBtn.widthAnchor)
])
bottomBar.layoutChain
.edgesHorzontal(10)
.height(50)
.bottom(kSafeBottomMargin)
disableIMView.layoutChain
.edgesHorzontal(15)
.height(50)
.bottom(kSafeBottomMargin)
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(10).centerY()
.width(56).height(40)
emojiBtn.layoutChain
.rightToLeftOfView(sendBtn, offset: -8)
.centerY()
.width(30).height(30)
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
iv.isHidden = true
return iv
}()
lazy var navBarView: UIView = {
let view = UIView()
view.backgroundColor = UIColor(hexStr: "#F5F5F5")
let line = UIView()
line.backgroundColor = UIColor(hexStr: "#E6E6E6")
view.addSubview(line)
line.layoutChain
.edgesHorzontal()
.height(0.5)
.bottom()
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 = 8
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)
btn.backgroundColor = .white
btn.cornerRadius = 11
return btn
}()
lazy var settingBtn: UIButton = {
let btn = UIButton(type: .custom)
btn.setImage(UIImage(named: "IM/setting"), for: .normal)
btn.backgroundColor = .white
btn.cornerRadius = 11
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(LocationSendMsgCell.self)
tv.register(LocationReceivedMsgCell.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 quickActionBar: UIView = {
let view = UIView()
view.backgroundColor = .clear
return view
}()
lazy var galleryBtn: UIButton = makeQuickActionButton(title: " 照片", imageName: "IM/gallery")
lazy var cameraBtn: UIButton = makeQuickActionButton(title: " 拍照", imageName: "IM/camera")
lazy var locationBtn: UIButton = makeQuickActionButton(title: " 位置", imageName: "IM/location")
private func makeQuickActionButton(title: String, imageName: String) -> 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: 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_input"), 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.setImage(UIImage(named: "IM/send"), for: .normal)
btn.backgroundColor = UIColor(hexStr: "#18B5F4")
btn.cornerRadius = 14
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()
}
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)
.width(100, relation: .greaterThanOrEqual)
.height(30, relation: .greaterThanOrEqual)
contentLabel.translatesAutoresizingMaskIntoConstraints = false
contentLabel.layoutChain
.edgesVertical(13)
.edgesHorzontal(12)
quoteBlock.translatesAutoresizingMaskIntoConstraints = false
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()
}
}
// 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)
.width(100, relation: .greaterThanOrEqual)
.height(30, relation: .greaterThanOrEqual)
contentLabel.translatesAutoresizingMaskIntoConstraints = false
contentLabel.layoutChain
.edgesVertical(13)
.edgesHorzontal(12)
quoteBlock.translatesAutoresizingMaskIntoConstraints = false
// 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.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()
}
}
// 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: -
final class ImageSendMsgCell: UITableViewCell {
var menuAnchorView: UIView { photoView }
var onImageTap: (() -> Void)?
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)
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 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()
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)
contentView.addSubview(senderNameView)
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: 10)
.right(12).width(38).height(38)
senderNameView.translatesAutoresizingMaskIntoConstraints = false
senderNameView.layoutChain
.topToView(avatarView)
.rightToLeftOfView(avatarView, offset: -9)
photoView.layoutChain
.topToBottomOfView(senderNameView, offset: 5)
.rightToView(senderNameView, offset: 0)
.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 ? chatFormatTime(msg.timestamp) : nil
avatarView.image = msg.avatar
senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner)
if !msg.imageUrl.isEmpty {
photoView.dl.setImage(with: msg.imageUrl)
}
photoView.layoutChain.width(msg.imageWidth).height(msg.imageHeight)
}
@objc private func onTap() { onImageTap?() }
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()
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(senderNameView)
contentView.addSubview(photoView)
contentView.addSubview(avatarView)
let tap = UITapGestureRecognizer(target: self, action: #selector(onTap))
photoView.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)
photoView.layoutChain
.topToBottomOfView(senderNameView, offset: 5)
.leftToView(senderNameView)
.width(160).height(160)
.bottom(10)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
// 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
}
}
}