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

404 lines
12 KiB
Swift

//
// 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))
}
}