僅供參考
/// 聊天列表布局模式
enum ChatLayoutMode {
/// 單 section,多 row(默認)
case singleSection
/// 多 section,單 row
case multiSectionOneRow
/// 多 section, 多 row(按日期分組)
case multiSectionMultiRow
}
/// 聊天頁面通用助手類
class ChatTableViewHelper: NSObject {
weak var tableView: UITableView?
/// 當前布局模式(默認為單 section 模式)
var layoutMode: ChatLayoutMode = .singleSection
/// 距底部范圍內視為在底部
var bottomSafeRange: CGFloat = 20~
/// 是否在底部(用于控制新消息是否自動滾動)
private var isUserAtBottom: Bool = true
/// 是否允許新消息自動滾動到底部
private var shouldAutoScrollOnNewMessage: Bool = true
/// 滾動到頂部時的回調(用于加載更多歷史)
var onReachTop: (() -> Void)?
//MARK: -
///進入頁面首次加載數據調用
func reloadAndScrollToBottom(animated: Bool = false) {
guard let tableView = tableView else { return }
tableView.reloadData()
DispatchQueue.main.async {[weak self] in
guard let self = self else { return }
tableView.layoutIfNeeded()
self.scrollToBottom(animated: animated)
}
}
///滾動監聽(scrollViewDidScroll)
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard tableView != nil else { return }
let offsetY = scrollView.contentOffset.y
let contentHeight = scrollView.contentSize.height
let frameHeight = scrollView.frame.size.height
// 距底部 bottomSafeRange 點范圍內視為在底部
isUserAtBottom = offsetY >= contentHeight - frameHeight - bottomSafeRange
// 滑動到頂部觸發回調
if offsetY <= 0 {
onReachTop?()
}
}
/// 插入一條新消息(自動判斷是否需要滾動到底部)
func insertNewMessage(animated: Bool = true) {
guard let tableView = tableView else { return }
let shouldScroll = isUserAtBottom && shouldAutoScrollOnNewMessage
switch layoutMode {
case .singleSection:
let row = tableView.numberOfRows(inSection: 0)
let indexPath = IndexPath(row: row, section: 0)
tableView.performBatchUpdates {
tableView.insertRows(at: [indexPath], with: animated ? .automatic : .none)
} completion: { _ in
if shouldScroll {
self.scrollToBottom(animated: animated)
}
}
case .multiSectionOneRow:
let newSection = tableView.numberOfSections
let indexSet = IndexSet(integer: newSection)
tableView.performBatchUpdates {
tableView.insertSections(indexSet, with: animated ? .automatic : .none)
} completion: { [weak self] _ in
guard let self = self else { return }
if shouldScroll {
self.scrollToBottom(animated: animated)
}
}
case .multiSectionMultiRow:
let lastSection = max(tableView.numberOfSections - 1, 0)
let newRow = tableView.numberOfRows(inSection: lastSection)
let indexPath = IndexPath(row: newRow, section: lastSection)
tableView.performBatchUpdates {
tableView.insertRows(at: [indexPath], with: animated ? .automatic : .none)
} completion: { _ in
if shouldScroll {
self.scrollToBottom(animated: animated)
}
}
}
}
/// 根據布局模式插入歷史消息
func insertHistory(insertedCount: Int) {
switch layoutMode {
case .singleSection:
insertHistoryRows(insertedCount: insertedCount)
case .multiSectionOneRow:
insertHistorySections(insertedCount: insertedCount)
case .multiSectionMultiRow:
insertHistoryGroupedSections(insertedSectionCount: insertedCount)
}
}
}
//MARK: - 私有方法
private extension ChatTableViewHelper{
///滾動到底部(用于新消息自動滾動)
func scrollToBottom(animated: Bool) {
guard let tableView = tableView else { return }
DispatchQueue.main.async {
guard tableView.numberOfSections > 0 else { return }
let lastSection = tableView.numberOfSections - 1
let lastRow = tableView.numberOfRows(inSection: lastSection) - 1
guard lastRow >= 0 else { return }
let indexPath = IndexPath(row: lastRow, section: lastSection)
tableView.scrollToRow(at: indexPath, at: .bottom, animated: animated)
}
}
/// 插入歷史消息,并保持滾動位置(適用于單 section)
func insertHistoryRows(insertedCount: Int) {
guard insertedCount > 0 else { return }
guard let tableView = tableView else { return }
// 獲取當前第一個可視 cell 和偏移差
guard let firstVisibleIndexPath = tableView.indexPathsForVisibleRows?.first,
let firstCell = tableView.cellForRow(at: firstVisibleIndexPath) else {
tableView.reloadData()
return
}
let offset = tableView.contentOffset.y - firstCell.frame.origin.y
// 構造插入的 indexPaths
var indexPaths: [IndexPath] = []
for row in 0..<insertedCount {
indexPaths.append(IndexPath(row: row, section: 0))
}
// 插入數據并保持偏移位置
tableView.performBatchUpdates {
tableView.insertRows(at: indexPaths, with: .none)
} completion: { _ in
let newIndexPath = IndexPath(row: firstVisibleIndexPath.row + insertedCount, section: 0)
if newIndexPath.row < tableView.numberOfRows(inSection: 0) {
let rect = tableView.rectForRow(at: newIndexPath)
let newOffsetY = rect.origin.y + offset
tableView.setContentOffset(CGPoint(x: 0, y: newOffsetY), animated: false)
}
}
}
/// 插入歷史消息,并保持滾動位置(適用于每個 section 一個 row)
func insertHistorySections(insertedCount: Int) {
guard insertedCount > 0 else { return }
guard let tableView = tableView else { return }
guard let firstVisibleIndexPath = tableView.indexPathsForVisibleRows?.first,
let firstCell = tableView.cellForRow(at: firstVisibleIndexPath) else {
tableView.reloadData()
return
}
let offset = tableView.contentOffset.y - firstCell.frame.origin.y
let indexSet = IndexSet(integersIn: 0..<insertedCount)
tableView.performBatchUpdates {
tableView.insertSections(indexSet, with: .none)
} completion: { _ in
let newSection = firstVisibleIndexPath.section + insertedCount
let newIndexPath = IndexPath(row: 0, section: newSection)
if newSection < tableView.numberOfSections {
let rect = tableView.rectForRow(at: newIndexPath)
let newOffsetY = rect.origin.y + offset
tableView.setContentOffset(CGPoint(x: 0, y: newOffsetY), animated: false)
}
}
}
/// 插入歷史記錄(按日期分組的多 section,每個 section 多 row)
func insertHistoryGroupedSections(insertedSectionCount: Int) {
guard let tableView = tableView else { return }
guard let firstVisibleIndexPath = tableView.indexPathsForVisibleRows?.first,
let firstCell = tableView.cellForRow(at: firstVisibleIndexPath) else {
tableView.reloadData()
return
}
let offset = tableView.contentOffset.y - firstCell.frame.origin.y
let indexSet = IndexSet(integersIn: 0..<insertedSectionCount)
tableView.performBatchUpdates {
tableView.insertSections(indexSet, with: .none)
} completion: { _ in
let newSection = firstVisibleIndexPath.section + insertedSectionCount
let newIndexPath = IndexPath(row: firstVisibleIndexPath.row, section: newSection)
if newSection < tableView.numberOfSections,
tableView.numberOfRows(inSection: newSection) > newIndexPath.row {
let rect = tableView.rectForRow(at: newIndexPath)
let newOffsetY = rect.origin.y + offset
tableView.setContentOffset(CGPoint(x: 0, y: newOffsetY), animated: false)
}
}
}
}