// // GroupChatView.swift // QuickLocation // // Created by 八条 on 2026/6/4. // import UIKit import RxSwift import RxCocoa // MARK: - Message Model struct ChatMessage { let id: String let isSelf: Bool let senderName: String let content: String let timestamp: TimeInterval var showTime: Bool = false } class GroupChatView: UIView { var disposeBag = DisposeBag() // MARK: - Data var messages: [ChatMessage] = [] { didSet { tableView.reloadData() } } // MARK: - Setup private func setupUI() { backgroundColor = UIColor(hexStr: "#F5FBFB") addSubview(navBgView) addSubview(navBarView) navBarView.addSubview(backBtn) navBarView.addSubview(groupAvatarView) navBarView.addSubview(groupNameLabel) navBarView.addSubview(onlineStatusLabel) navBarView.addSubview(rightIconsView) rightIconsView.addSubview(reviewBtn) rightIconsView.addSubview(memberBtn) rightIconsView.addSubview(settingBtn) addSubview(tableView) addSubview(bottomBar) bottomBar.addSubview(voiceBtn) bottomBar.addSubview(textField) bottomBar.addSubview(emojiBtn) bottomBar.addSubview(addBtn) bottomBar.addSubview(sendBtn) navBgView.layoutChain .edges(excludingEdge: .bottom) .height(kNaviHeight) navBarView.layoutChain .edges(excludingEdge: .bottom) .height(kNaviHeight) backBtn.layoutChain .top(kStatusBarHeight + 12) .left(7) .width(24).height(24) groupAvatarView.layoutChain .centerY(backBtn) .leftToRightOfView(backBtn, offset: 5) .width(32).height(32) groupNameLabel.layoutChain .topToView(groupAvatarView) .leftToRightOfView(groupAvatarView, offset: 8) onlineStatusLabel.layoutChain .topToBottomOfView(groupNameLabel, offset: 2) .leftToView(groupNameLabel) rightIconsView.layoutChain .centerY(backBtn) .right(15) .height(24) reviewBtn.layoutChain .left().centerY() .width(24).height(24) memberBtn.layoutChain .leftToRightOfView(reviewBtn, offset: 20) .centerY() .width(24).height(24) settingBtn.layoutChain .leftToRightOfView(memberBtn, offset: 20) .centerY() .width(24).height(24) .right() tableView.layoutChain .topToBottomOfView(navBarView) .edgesHorzontal() .bottomToTopOfView(bottomBar) bottomBar.layoutChain .edgesHorzontal() .height(56) .bottom(kSafeBottomMargin) voiceBtn.layoutChain .left(12).centerY() .width(28).height(28) sendBtn.layoutChain .right(12).centerY() .width(52).height(32) addBtn.layoutChain .rightToLeftOfView(sendBtn, offset: -10) .centerY() .width(28).height(28) emojiBtn.layoutChain .rightToLeftOfView(addBtn, offset: -10) .centerY() .width(28).height(28) textField.layoutChain .leftToRightOfView(voiceBtn, offset: 10) .rightToLeftOfView(emojiBtn, offset: -10) .centerY() .height(36) } // MARK: - Nav lazy var navBgView: UIImageView = { let iv = UIImageView() iv.image = UIImage(named: "Common/navBar_bg_2") iv.contentMode = .scaleAspectFill return iv }() lazy var navBarView: UIView = { let view = UIView() view.backgroundColor = .clear return view }() lazy var backBtn: UIButton = { let btn = UIButton(type: .custom) btn.setImage(UIImage(named: "Common/back"), for: .normal) btn.extendEdgeInsets = UIEdgeInsets(top: 20, left: 10, bottom: 20, right: 40) return btn }() lazy var groupAvatarView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill iv.cornerRadius = 16 iv.clipsToBounds = true iv.backgroundColor = UIColor(hexStr: "#E0E0E0") return iv }() lazy var groupNameLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 14, weight: .medium) label.textColor = UIColor(hexStr: "#333333") return label }() lazy var onlineStatusLabel: UILabel = { let label = UILabel() label.text = "今日活跃" label.font = .systemFont(ofSize: 12, weight: .regular) label.textColor = UIColor(hexStr: "#999999") return label }() lazy var rightIconsView: UIView = { let view = UIView() view.backgroundColor = .clear return view }() lazy var reviewBtn: UIButton = { let btn = UIButton(type: .custom) btn.setImage(UIImage(named: "IM/review"), for: .normal) return btn }() lazy var memberBtn: UIButton = { let btn = UIButton(type: .custom) btn.setImage(UIImage(named: "IM/member"), for: .normal) return btn }() lazy var settingBtn: UIButton = { let btn = UIButton(type: .custom) btn.setImage(UIImage(named: "IM/setting"), for: .normal) return btn }() // MARK: - Message List lazy var tableView: UITableView = { let tv = UITableView(frame: .zero, style: .plain) tv.backgroundColor = .clear tv.separatorStyle = .none tv.showsVerticalScrollIndicator = false tv.register(TextMsgCell.self) tv.estimatedRowHeight = 60 tv.rowHeight = UITableView.automaticDimension tv.contentInset = UIEdgeInsets(top: 8, left: 0, bottom: 8, right: 0) tv.delegate = self tv.dataSource = self return tv }() // MARK: - Bottom Bar lazy var bottomBar: UIView = { let view = UIView() view.backgroundColor = UIColor(hexStr: "#F7F8F9") return view }() lazy var voiceBtn: UIButton = { let btn = UIButton(type: .custom) btn.setImage(UIImage(named: "IM/voice"), for: .normal) return btn }() lazy var textField: UITextField = { let tf = UITextField() tf.font = .systemFont(ofSize: 14) tf.backgroundColor = .white tf.cornerRadius = 18 tf.placeholder = "输入消息..." tf.returnKeyType = .send tf.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 36)) tf.leftViewMode = .always return tf }() lazy var emojiBtn: UIButton = { let btn = UIButton(type: .custom) btn.setImage(UIImage(named: "IM/emoji"), for: .normal) return btn }() lazy var addBtn: UIButton = { let btn = UIButton(type: .custom) btn.setImage(UIImage(named: "IM/add"), for: .normal) return btn }() lazy var sendBtn: UIButton = { let btn = UIButton(type: .custom) btn.setTitle("发送", for: .normal) btn.setTitleColor(.white, for: .normal) btn.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium) btn.backgroundColor = UIColor(hexStr: "#16B3FF") btn.cornerRadius = 16 return btn }() override init(frame: CGRect) { super.init(frame: .zero) setupUI() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } // MARK: - UITableViewDelegate, UITableViewDataSource extension GroupChatView: UITableViewDelegate, UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return messages.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell: TextMsgCell = tableView.dequeueReusableCell(for: indexPath) cell.configure(messages[indexPath.row]) return cell } } // MARK: - TextMsgCell final class TextMsgCell: UITableViewCell { private let timeLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 11) label.textColor = UIColor(hexStr: "#B0B0B0") label.textAlignment = .center return label }() private let avatarView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill iv.cornerRadius = 18 iv.clipsToBounds = true return iv }() private let bubbleView: UIView = { let v = UIView() v.cornerRadius = 8 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(avatarView) contentView.addSubview(bubbleView) bubbleView.addSubview(contentLabel) timeLabel.frame = CGRect(x: 0, y: 12, width: kScreenWidth, height: 16) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func configure(_ msg: ChatMessage) { timeLabel.isHidden = !msg.showTime if msg.showTime { timeLabel.text = formatTime(msg.timestamp) } contentLabel.text = msg.content let showTime = msg.showTime let topY: CGFloat = showTime ? 34 : 10 let screenW = kScreenWidth let maxBubbleW = screenW - 100 let contentW = min(msg.content.boundingSize(font: contentLabel.font, maxWidth: maxBubbleW - 24).width + 24, maxBubbleW) if msg.isSelf { avatarView.isHidden = true bubbleView.backgroundColor = UIColor(hexStr: "#16B3FF") contentLabel.textColor = .white let bubbleH = msg.content.boundingSize(font: contentLabel.font, maxWidth: maxBubbleW - 24).height + 20 bubbleView.frame = CGRect(x: screenW - 12 - contentW, y: topY, width: contentW, height: bubbleH) contentLabel.frame = CGRect(x: 12, y: 10, width: contentW - 24, height: bubbleH - 20) } else { avatarView.isHidden = false avatarView.image = UIImage(named: "GroupIcon1") bubbleView.backgroundColor = .white contentLabel.textColor = UIColor(hexStr: "#1A1A1A") avatarView.frame = CGRect(x: 12, y: topY, width: 36, height: 36) let bubbleH = msg.content.boundingSize(font: contentLabel.font, maxWidth: maxBubbleW - 24).height + 20 bubbleView.frame = CGRect(x: 56, y: topY, width: contentW, height: bubbleH) contentLabel.frame = CGRect(x: 12, y: 10, width: contentW - 24, height: bubbleH - 20) } } override func sizeThatFits(_ size: CGSize) -> CGSize { // This is handled by rowHeight = automaticDimension + proper intrinsic sizing guard let text = contentLabel.text, !text.isEmpty else { return CGSize(width: size.width, height: 60) } let showTime = !timeLabel.isHidden let topY: CGFloat = showTime ? 34 : 10 let maxBubbleW = size.width - 100 let textH = text.boundingSize(font: contentLabel.font, maxWidth: maxBubbleW - 24).height return CGSize(width: size.width, height: topY + textH + 20 + 10) } private func formatTime(_ t: TimeInterval) -> String { let date = Date(timeIntervalSince1970: t) let f = DateFormatter() f.dateFormat = "M-d HH:mm" return f.string(from: date) } } // MARK: - String bounding helper private extension String { func boundingSize(font: UIFont, maxWidth: CGFloat) -> CGSize { let rect = (self as NSString).boundingRect( with: CGSize(width: maxWidth, height: .greatestFiniteMagnitude), options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: [.font: font], context: nil ) return CGSize(width: ceil(rect.width), height: ceil(rect.height)) } }