404 lines
12 KiB
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))
|
|
}
|
|
}
|