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

1666 lines
67 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.

//
// GroupChatVC.swift
// QuickLocation
//
// Created by 八条 on 2026/6/4.
//
import UIKit
import RxSwift
import RxCocoa
import RxDataSources
import OpenIMSDK
import AVFoundation
import AudioToolbox
import Speech
import IQKeyboardManagerSwift
import MJRefresh
import CoreLocation
import SwiftyUserDefaults
#if !targetEnvironment(simulator)
import AMapNaviKit
import AMapSearchKit
#endif
final class GroupChatVC: BaseViewController {
override var isNavigationBarHidden: Bool { true }
fileprivate var rootView: GroupChatView!
private let viewModel = GroupChatViewModel()
private var msgListener: MessageListenerProxy?
/// mention 列表:首行 @所有人 + 成员
private var mentionRows: [(userId: String?, nickname: String)] = []
private var isMentionPickerVisible = false
private var groupRefreshWorkItem: DispatchWorkItem?
private var groupInfoRequestID = UUID()
/// 加载更早历史前记录,用于保持阅读位置
private var pendingOffsetRestore: (oldHeight: CGFloat, oldOffset: CGFloat)?
private var didSetupKeyboard = false
/// 首屏等行高落地后再贴底;用户手滑列表后取消
private var needsInitialBottomScroll = true
/// 在 table reload 前记录列表是否贴底,避免资料刷新把用户拉回底部。
private var shouldScrollAfterMessageReload = true
/// 非贴底刷新时保留最上方可见消息,防止整表重载改变阅读位置。
private var pendingMessageReloadAnchor: TableScrollAnchor?
private struct TableScrollAnchor {
let indexPath: IndexPath
let offsetFromViewportTop: CGFloat
}
// MARK: - Init
init(groupId: String) {
viewModel.groupId = groupId
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func loadView() {
rootView = GroupChatView(frame: UIScreen.main.bounds)
view = rootView
}
override func viewDidLoad() {
super.viewDidLoad()
fd_interactivePopDisabled = true
bindViewModel()
reactiveAction()
setupMessageListener()
setupVoiceRecording()
setupPanelDismiss()
setupChatLongPress()
setupMentionInput()
setupHistoryRefreshHeader()
// 并行加载:业务接口 + IM SDK 互不依赖,同时发起
requestGroupInfoByKey()
GroupIMService.shared.ensureLogin { [weak self] success in
guard let self = self, success else { return }
self.viewModel.loadMessages()
}
// 处理系统配置
guard let config = AppContextManager.shared.systemConfig else { return }
rootView.chatWarningView.isHidden = config.chatWarning.isEmpty
rootView.chatWarningLab.text = config.chatWarning
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
setupKeyboard()
IQKeyboardManager.shared.isEnabled = false
IQKeyboardManager.shared.resignOnTouchOutside = false
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
viewModel.markAsRead()
VoicePlayerManager.shared.stop()
if isVoiceRecording {
finishVoiceRecording(action: .discard)
} else if !rootView.voiceRecordView.isHidden {
rootView.voiceRecordView.reset()
}
IQKeyboardManager.shared.isEnabled = true
IQKeyboardManager.shared.resignOnTouchOutside = true
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
NotificationCenter.default.removeObserver(self)
}
// MARK: - Keyboard
private func setupKeyboard() {
guard !didSetupKeyboard else { return }
didSetupKeyboard = true
NotificationCenter.default.rx.notification(UIResponder.keyboardWillShowNotification)
.subscribe(onNext: { [weak self] noti in
guard let self = self,
self.isViewLoaded && self.view.window != nil,
let userInfo = noti.userInfo,
let frame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
else { return }
// 语音转文字预览自己抬界面,不要再顶聊天输入栏
guard self.rootView.voiceRecordView.isHidden else { return }
let height = frame.height
let duration = (userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double) ?? 0.25
self.rootView.dismissAllPanels(excludeTextField: true)
UIView.animate(withDuration: duration) {
self.rootView.bottomBar.layoutChain.bottom(height + 20)
}
self.scrollToBottom()
})
.disposed(by: disposeBag)
NotificationCenter.default.rx.notification(UIResponder.keyboardWillHideNotification)
.subscribe(onNext: { [weak self] noti in
guard let self = self,
self.isViewLoaded && self.view.window != nil
else { return }
guard self.rootView.voiceRecordView.isHidden else { return }
guard self.rootView.emojiPanelView.isHidden else { return }
let duration = (noti.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double) ?? 0.25
self.rootView.refreshComposer()
UIView.animate(withDuration: duration) {
self.rootView.bottomBar.layoutChain.bottom(kSafeBottomMargin)
self.rootView.layoutIfNeeded()
}
})
.disposed(by: disposeBag)
}
private func setupPanelDismiss() {
// 用户开始拖拽 tableview 时收起面板
rootView.tableView.panGestureRecognizer.rx.event
.filter { $0.state == .began }
.subscribe(onNext: { [weak self] _ in
guard let self = self, self.rootView.voiceRecordView.isHidden else { return }
self.rootView.dismissAllPanels()
self.bubbleMenu?.dismiss()
self.needsInitialBottomScroll = false
})
.disposed(by: disposeBag)
// 点击 cell 收起面板
rootView.tableView.rx.itemSelected
.subscribe(onNext: { [weak self] _ in
guard let self = self, self.rootView.voiceRecordView.isHidden else { return }
self.rootView.dismissAllPanels()
self.bubbleMenu?.dismiss()
})
.disposed(by: disposeBag)
}
// MARK: - Bindings
private func bindViewModel() {
rootView.tableView.rx.setDelegate(self)
.disposed(by: disposeBag)
let messages = viewModel.output.messages
.skip(1)
.observe(on: MainScheduler.asyncInstance)
.share()
messages
.do(onNext: { [weak self] _ in
guard let self else { return }
let tableView = self.rootView.tableView
let isInteracting = tableView.isTracking || tableView.isDragging || tableView.isDecelerating
self.shouldScrollAfterMessageReload = !isInteracting
&& (self.needsInitialBottomScroll || self.isNearBottom())
self.pendingMessageReloadAnchor = self.shouldScrollAfterMessageReload || self.viewModel.suppressAutoScroll
? nil
: self.captureVisibleAnchor()
})
.map { [ChatSectionModel(model: "", items: $0)] }
.bind(to: rootView.tableView.rx.items(dataSource: dataSource))
.disposed(by: disposeBag)
messages
.subscribe(onNext: { [weak self] _ in
guard let self = self else { return }
if self.viewModel.suppressAutoScroll {
self.viewModel.suppressAutoScroll = false
self.restoreOffsetAfterPrependIfNeeded()
return
}
self.rootView.tableView.mj_header?.isHidden = !self.viewModel.hasMoreHistory
guard self.shouldScrollAfterMessageReload else {
self.restoreVisibleAnchor(self.pendingMessageReloadAnchor)
self.pendingMessageReloadAnchor = nil
return
}
self.pendingMessageReloadAnchor = nil
self.scrollToBottom()
if self.needsInitialBottomScroll {
DispatchQueue.main.async { [weak self] in
guard let self, self.needsInitialBottomScroll else { return }
self.scrollToBottom()
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { [weak self] in
guard let self, self.needsInitialBottomScroll else { return }
self.scrollToBottom()
self.needsInitialBottomScroll = false
}
}
})
.disposed(by: disposeBag)
viewModel.output.pendingQuoteSummary
.observe(on: MainScheduler.instance)
.subscribe(onNext: { [weak self] summary in
guard let self = self else { return }
if let summary {
self.rootView.setQuoteBarVisible(true, summary: summary)
self.bringInputOverlaysToFront()
} else {
self.rootView.setQuoteBarVisible(false)
}
})
.disposed(by: disposeBag)
rootView.quoteBarCloseBtn.rx.tap
.subscribe(onNext: { [weak self] in
self?.viewModel.clearPendingQuote()
})
.disposed(by: disposeBag)
NotificationCenter.default.rx.notification(.RefreshUserConfigNotification)
.observe(on: MainScheduler.instance)
.subscribe(onNext: { [weak self] _ in
self?.scheduleGroupInfoRefresh()
})
.disposed(by: disposeBag)
NotificationCenter.default.rx.notification(.RefreshGroupInfoNotification)
.observe(on: MainScheduler.instance)
.subscribe(onNext: { [weak self] notification in
guard let self, self.shouldRefreshGroupInfo(for: notification) else { return }
self.scheduleGroupInfoRefresh()
})
.disposed(by: disposeBag)
NotificationCenter.default.rx.notification(.GroupChatHistoryDidClearNotification)
.observe(on: MainScheduler.instance)
.subscribe(onNext: { [weak self] notification in
guard let self,
let groupKey = notification.userInfo?[GroupDataChangeUserInfoKey.groupKey] as? String,
groupKey == self.viewModel.groupId else { return }
self.pendingOffsetRestore = nil
self.pendingMessageReloadAnchor = nil
self.needsInitialBottomScroll = false
self.rootView.tableView.mj_header?.endRefreshing()
self.viewModel.clearMessages()
})
.disposed(by: disposeBag)
let emojiItems = UIView.emojiFileNames.map { $0 }
Observable.just([SectionModel(model: "", items: emojiItems)])
.bind(to: rootView.emojiCollectionView.rx.items(dataSource: emojiDataSource))
.disposed(by: disposeBag)
}
private func bringInputOverlaysToFront() {
rootView.bringSubviewToFront(rootView.mentionPickerView)
rootView.bringSubviewToFront(rootView.quoteBar)
rootView.bringSubviewToFront(rootView.bottomBar)
rootView.bringSubviewToFront(rootView.voiceRecordView)
}
// MARK: - 顶部加载更早历史
private func setupHistoryRefreshHeader() {
let header = MJRefreshNormalHeader { [weak self] in
self?.loadOlderHistoryFromHeader()
}
header.addFeedbackGenerator()
header.lastUpdatedTimeLabel?.isHidden = true
header.setTitle("下拉加载更早消息", for: .idle)
header.setTitle("松开加载", for: .pulling)
header.setTitle("加载中...", for: .refreshing)
rootView.tableView.mj_header = header
}
private func loadOlderHistoryFromHeader() {
guard viewModel.hasMoreHistory else {
rootView.tableView.mj_header?.endRefreshing()
rootView.tableView.mj_header?.isHidden = true
return
}
let tableView = rootView.tableView
pendingOffsetRestore = (tableView.contentSize.height, tableView.contentOffset.y)
viewModel.loadOlderHistory { [weak self] result in
guard let self = self else { return }
self.rootView.tableView.mj_header?.endRefreshing()
if !result.hasMore {
self.rootView.tableView.mj_header?.isHidden = true
}
// suppressAutoScroll 路径里会 restoreOffset;若未 prepend 也清掉 pending
if result.prependedCount == 0 {
self.pendingOffsetRestore = nil
}
}
}
private func restoreOffsetAfterPrependIfNeeded() {
guard let pending = pendingOffsetRestore else { return }
pendingOffsetRestore = nil
let tableView = rootView.tableView
tableView.layoutIfNeeded()
let delta = tableView.contentSize.height - pending.oldHeight
guard delta > 0 else { return }
var offset = tableView.contentOffset
offset.y = max(0, pending.oldOffset + delta)
tableView.setContentOffset(offset, animated: false)
}
private func scrollToBottom() {
let tableView = rootView.tableView
tableView.layoutIfNeeded()
let inset = tableView.adjustedContentInset
let y = tableView.contentSize.height - tableView.bounds.height + inset.bottom
guard y > -inset.top else { return }
tableView.setContentOffset(CGPoint(x: 0, y: max(-inset.top, y)), animated: false)
}
private func isNearBottom(threshold: CGFloat = 40) -> Bool {
let tableView = rootView.tableView
let inset = tableView.adjustedContentInset
let maxY = tableView.contentSize.height - tableView.bounds.height + inset.bottom
return tableView.contentOffset.y >= maxY - threshold
}
private func captureVisibleAnchor() -> TableScrollAnchor? {
let tableView = rootView.tableView
tableView.layoutIfNeeded()
guard let indexPath = tableView.indexPathsForVisibleRows?.sorted().first else { return nil }
let rowTop = tableView.rectForRow(at: indexPath).minY
return TableScrollAnchor(
indexPath: indexPath,
offsetFromViewportTop: rowTop - tableView.contentOffset.y
)
}
private func restoreVisibleAnchor(_ anchor: TableScrollAnchor?) {
guard let anchor else { return }
let tableView = rootView.tableView
guard anchor.indexPath.section < tableView.numberOfSections,
anchor.indexPath.row < tableView.numberOfRows(inSection: anchor.indexPath.section) else { return }
tableView.layoutIfNeeded()
let inset = tableView.adjustedContentInset
let minY = -inset.top
let maxY = max(minY, tableView.contentSize.height - tableView.bounds.height + inset.bottom)
let rowTop = tableView.rectForRow(at: anchor.indexPath).minY
let targetY = min(max(rowTop - anchor.offsetFromViewportTop, minY), maxY)
tableView.setContentOffset(CGPoint(x: tableView.contentOffset.x, y: targetY), animated: false)
}
private func scrollToMessage(clientMsgID: String) {
viewModel.ensureMessageVisible(clientMsgID: clientMsgID) { [weak self] found in
guard let self = self else { return }
guard found, let indexPath = self.viewModel.indexPath(forClientMsgID: clientMsgID) else {
DLToast.showError(text: "无法定位到原消息")
return
}
self.rootView.tableView.layoutIfNeeded()
self.rootView.tableView.scrollToRow(at: indexPath, at: .middle, animated: true)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
if let cell = self.rootView.tableView.cellForRow(at: indexPath) as? TextSendMsgCell {
cell.flashHighlight()
} else if let cell = self.rootView.tableView.cellForRow(at: indexPath) as? TextReceivedMsgCell {
cell.flashHighlight()
} else if let cell = self.rootView.tableView.cellForRow(at: indexPath) {
let old = cell.contentView.backgroundColor
cell.contentView.backgroundColor = UIColor(hexStr: "#16B3FF").withAlphaComponent(0.12)
UIView.animate(withDuration: 0.8, delay: 0.3, options: []) {
cell.contentView.backgroundColor = old
}
}
}
}
}
private lazy var emojiDataSource: RxCollectionViewSectionedReloadDataSource<SectionModel<String, String>> = {
RxCollectionViewSectionedReloadDataSource<SectionModel<String, String>> { _, collectionView, indexPath, name in
let cell: EmojiPanelCell = collectionView.dequeueReusableCell(for: indexPath)
cell.configure(name: name)
return cell
}
}()
// MARK: - Message Listener
private func setupMessageListener() {
msgListener = MessageListenerProxy(
onMessage: { [weak self] msg in
guard let self = self, msg.groupID == self.viewModel.groupId else { return }
self.viewModel.onReceiveMessage(msg)
},
onRevoked: { [weak self] info in
self?.viewModel.onMessageRevoked(info)
}
)
OIMManager.callbacker.addAdvancedMsgListener(listener: msgListener!)
}
// MARK: - Long press (quote / revoke / avatar @)
private func setupChatLongPress() {
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleChatLongPress(_:)))
longPress.minimumPressDuration = 0.45
rootView.tableView.addGestureRecognizer(longPress)
}
@objc private func handleChatLongPress(_ gesture: UILongPressGestureRecognizer) {
guard gesture.state == .began else { return }
let point = gesture.location(in: rootView.tableView)
guard let indexPath = rootView.tableView.indexPathForRow(at: point),
let cell = rootView.tableView.cellForRow(at: indexPath) else { return }
let items = viewModel.currentMessages
guard indexPath.row < items.count,
let msg = items[indexPath.row].chatMessage else { return }
// 长按对方头像 → @
if !msg.isSelf, let avatar = avatarView(in: cell) {
let p = gesture.location(in: avatar)
if avatar.bounds.insetBy(dx: -6, dy: -6).contains(p) {
insertMention(userId: msg.senderId, nickname: msg.senderName, replaceTriggerAt: false)
return
}
}
showBubbleActionMenu(for: msg, from: cell)
}
private func avatarView(in cell: UITableViewCell) -> UIImageView? {
if let c = cell as? TextReceivedMsgCell { return c.avatarView }
if let c = cell as? EmojiReceivedMsgCell { return c.avatarView }
if let c = cell as? VoiceReceivedMsgCell { return c.avatarView }
if let c = cell as? ImageReceivedMsgCell { return c.avatarView }
return nil
}
private func menuAnchorView(in cell: UITableViewCell) -> UIView {
if let c = cell as? TextSendMsgCell { return c.menuAnchorView }
if let c = cell as? TextReceivedMsgCell { return c.menuAnchorView }
if let c = cell as? EmojiSendMsgCell { return c.menuAnchorView }
if let c = cell as? EmojiReceivedMsgCell { return c.menuAnchorView }
if let c = cell as? VoiceSendMsgCell { return c.menuAnchorView }
if let c = cell as? VoiceReceivedMsgCell { return c.menuAnchorView }
if let c = cell as? ImageSendMsgCell { return c.menuAnchorView }
if let c = cell as? ImageReceivedMsgCell { return c.menuAnchorView }
if let c = cell as? LocationSendMsgCell { return c.menuAnchorView }
if let c = cell as? LocationReceivedMsgCell { return c.menuAnchorView }
return cell.contentView
}
private weak var bubbleMenu: ChatBubbleActionMenu?
private func showBubbleActionMenu(for msg: ChatMessage, from cell: UITableViewCell) {
bubbleMenu?.dismiss()
rootView.dismissAllPanels()
let menu = ChatBubbleActionMenu()
bubbleMenu = menu
menu.onMention = { [weak self] in
guard let self else { return }
self.insertMention(userId: msg.senderId, nickname: msg.senderName, replaceTriggerAt: false)
}
menu.onReply = { [weak self] in
guard let self = self,
let oim = self.viewModel.oimMessage(forClientMsgID: msg.id) else {
DLToast.showError(text: "无法引用该消息")
return
}
self.viewModel.setPendingQuote(oim)
}
menu.onReport = {
ReceiveMessageReportPopView.show()
}
menu.onDismiss = { [weak self] in self?.bubbleMenu = nil }
let anchor = menuAnchorView(in: cell)
menu.show(in: rootView, anchor: anchor, canMention: !msg.isSelf, canReport: !msg.isSelf)
}
// MARK: - Mention
private func setupMentionInput() {
rootView.textField.delegate = self
rootView.mentionTableView.delegate = self
rootView.mentionTableView.dataSource = self
}
private func handleMentionTrigger() {
guard let text = rootView.textField.text,
let selected = rootView.textField.selectedTextRange else {
hideMentionPicker()
return
}
let cursor = rootView.textField.offset(from: rootView.textField.beginningOfDocument, to: selected.start)
let ns = text as NSString
let prefix = ns.substring(to: min(cursor, ns.length))
if prefix.hasSuffix("@") {
showMentionPicker()
} else if isMentionPickerVisible {
// 继续输入非空格时保持;遇到空格关闭
if prefix.last == " " || !prefix.contains("@") {
hideMentionPicker()
}
}
}
private func showMentionPicker() {
refreshMentionRows()
rootView.mentionTableView.reloadData()
rootView.mentionPickerView.isHidden = false
isMentionPickerVisible = true
bringInputOverlaysToFront()
rootView.layoutIfNeeded()
let bounds = rootView.mentionPickerView.bounds
rootView.mentionPickerView.layer.shadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: 12).cgPath
}
private func hideMentionPicker() {
rootView.mentionPickerView.isHidden = true
isMentionPickerVisible = false
}
private func refreshMentionRows() {
mentionRows = [(nil, "所有人")]
+ viewModel.mentionCandidates().map { ($0.user_id, $0.showName) }
if isMentionPickerVisible {
rootView.mentionTableView.reloadData()
}
}
private func insertMention(userId: String?, nickname: String, replaceTriggerAt: Bool = true) {
hideMentionPicker()
let insert: String
if let userId {
insert = viewModel.mentionUser(userId: userId, nickname: nickname)
} else {
viewModel.setPendingAtAll(true)
insert = "@所有人 "
}
var current = rootView.textField.text ?? ""
if replaceTriggerAt, let atIdx = current.lastIndex(of: "@") {
let after = current[current.index(after: atIdx)...]
if after.isEmpty || !after.contains(where: { $0.isWhitespace }) {
current = String(current[..<atIdx])
}
}
rootView.textField.text = current + insert
rootView.refreshComposer()
rootView.textField.becomeFirstResponder()
}
// MARK: - Actions
private func reactiveAction() {
rootView.backBtn.rx.tap
.subscribe(onNext: { _ in
AppRouter.shared.popOrDismiss()
})
.disposed(by: disposeBag)
// 审核
rootView.reviewBtn.rx.tap.subscribe(onNext: { _ in
AppRouter.push(Route.reviewMemberList, userInfo: ["groupId": self.viewModel.groupId])
}).disposed(by: disposeBag)
// 成员列表
rootView.memberBtn.rx.tap.subscribe(onNext: { _ in
AppRouter.push(Route.groupMemberList, userInfo: ["groupKey": self.viewModel.groupId])
}).disposed(by: disposeBag)
// 语音按钮:切换到按住说话
rootView.voiceBtn.rx.tap.subscribe(onNext: { [weak self] _ in
self?.enterVoiceInputMode()
})
.disposed(by: disposeBag)
rootView.keyboardBtn.rx.tap
.subscribe(onNext: { [weak self] _ in
guard let self = self else { return }
self.rootView.setVoiceInputMode(false)
self.rootView.textField.becomeFirstResponder()
})
.disposed(by: disposeBag)
// 表情按钮
rootView.emojiBtn.rx.tap
.subscribe(onNext: { [weak self] _ in
guard let self = self else { return }
self.showEmojiPanel()
})
.disposed(by: disposeBag)
// 表情面板点击
rootView.emojiCollectionView.rx.modelSelected(String.self)
.subscribe(onNext: { [weak self] name in
guard let self = self, let idx = UIView.emojiFileNames.firstIndex(of: name) else { return }
self.viewModel.input.sendMessage.onNext("js_emoji:\(idx)")
})
.disposed(by: disposeBag)
rootView.galleryBtn.rx.tap
.subscribe(onNext: { [weak self] in
guard let self else { return }
self.rootView.dismissAllPanels()
self.showAlbum()
})
.disposed(by: disposeBag)
rootView.cameraBtn.rx.tap
.subscribe(onNext: { [weak self] in
guard let self else { return }
self.rootView.dismissAllPanels()
self.showCamera()
})
.disposed(by: disposeBag)
rootView.locationBtn.rx.tap
.subscribe(onNext: { [weak self] in
self?.rootView.dismissAllPanels()
self?.showLocationPicker()
})
.disposed(by: disposeBag)
rootView.sendBtn.rx.tap
.subscribe(onNext: { [weak self] in
self?.sendCurrentTextMessage()
})
.disposed(by: disposeBag)
// 设置
rootView.settingBtn.rx.tap
.subscribe(onNext: { [weak self] _ in
guard let self = self else { return }
AppRouter.push(Route.groupSetting, userInfo: ["groupId": self.viewModel.groupId])
})
.disposed(by: disposeBag)
}
private func sendCurrentTextMessage() {
let text = rootView.textField.text ?? ""
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
viewModel.input.sendMessage.onNext(text)
rootView.textField.text = ""
rootView.refreshComposer()
hideMentionPicker()
}
// MARK: - 显示表情面板
private func showEmojiPanel() {
let show = self.rootView.emojiPanelView.isHidden
if !show {
self.rootView.stopVisibleEmojiAnimations()
}
self.rootView.emojiPanelView.isHidden = !show
let offset: CGFloat = show ? 220 : 0
UIView.animate(withDuration: 0.25) {
self.rootView.bottomBar.layoutChain.bottom(20 + offset)
}
// completion: { _ in
// let offset: CGFloat = self.rootView.tableView.contentSize.height
// self.rootView.tableView.setContentOffset(CGPointMake(0, offset), animated: false)
// }
scrollToBottom()
if show {
self.rootView.textField.resignFirstResponder()
DispatchQueue.main.async {
self.rootView.playVisibleEmojiAnimations()
self.rootView.preloadAdjacentEmojiPages()
}
}
}
// MARK: - 显示相册
private func showAlbum() {
let picker = PhotoPickerVC(maxCount: 9)
picker.onConfirm = { [weak self] images in
guard let self else { return }
for image in images {
self.sendImageMessage(image)
}
}
present(picker, animated: true)
}
private func showCamera() {
guard UIImagePickerController.isSourceTypeAvailable(.camera) else {
DLToast.showError(text: "当前设备不支持拍照")
return
}
let vc = CameraCaptureVC(mode: .photo)
vc.onPhoto = { [weak self] image in
self?.dismiss(animated: true) {
self?.sendImageMessage(image)
}
}
vc.modalPresentationStyle = .fullScreen
present(vc, animated: true)
}
private func showLocationPicker() {
let picker = LocationPickerViewController()
picker.onPickedLocation = { [weak self] location in
self?.viewModel.sendLocation(ChatLocationPayload(name: location.name,
address: location.address,
latitude: location.coordinate.latitude,
longitude: location.coordinate.longitude))
}
navigationController?.pushViewController(picker, animated: true)
}
private func showLocationDetail(_ location: ChatLocationPayload?) {
guard let location else {
DLToast.showError(text: "无法读取位置信息")
return
}
navigationController?.pushViewController(ChatLocationDetailVC(location: location), animated: true)
}
// MARK: - Voice Recording
private var isVoiceRecording = false
private let voiceCapture = ChatVoiceCapture()
private var recordFileURL: URL?
private var recordTimer: Timer?
private var recordDuration: Int = 0
private var currentVoiceHover: VoiceRecordView.HoverTarget = .none
private func enterVoiceInputMode() {
let status = AVCaptureDevice.authorizationStatus(for: .audio)
switch status {
case .authorized:
rootView.dismissAllPanels()
rootView.setVoiceInputMode(true)
requestSpeechAuthorizationIfNeeded()
case .notDetermined:
AVAudioSession.sharedInstance().requestRecordPermission { [weak self] granted in
DispatchQueue.main.async {
guard let self, granted else { return }
self.rootView.dismissAllPanels()
self.rootView.setVoiceInputMode(true)
self.requestSpeechAuthorizationIfNeeded()
}
}
default:
Permission.openAppSetting(title: "请开启麦克风权限",
message: "请在iPhone的“设置-隐私-麦克风”选项中允许\(kAppName)访问你的麦克风。")
}
}
private func setupVoiceRecording() {
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleVoiceLongPress(_:)))
longPress.minimumPressDuration = 0.18
longPress.allowableMovement = .greatestFiniteMagnitude
rootView.holdSpeakBtn.addGestureRecognizer(longPress)
rootView.voiceRecordView.onPreviewCancel = { [weak self] in
self?.discardPreview()
}
rootView.voiceRecordView.onPreviewSend = { [weak self] text in
self?.rootView.voiceRecordView.reset()
self?.rootView.restoreComposerPosition()
self?.viewModel.input.sendMessage.onNext(text)
}
voiceCapture.onLevel = { [weak self] level in
self?.rootView.voiceRecordView.pushLevel(level)
}
voiceCapture.onPartialText = { [weak self] text in
self?.rootView.voiceRecordView.updateRecognizedText(text)
}
}
@objc private func handleVoiceLongPress(_ gesture: UILongPressGestureRecognizer) {
let point = gesture.location(in: rootView.voiceRecordView)
switch gesture.state {
case .began:
startVoiceRecording()
case .changed:
guard isVoiceRecording else { return }
let target = rootView.voiceRecordView.hoverTarget(at: point)
if target != currentVoiceHover {
currentVoiceHover = target
AudioServicesPlaySystemSound(1519)
}
rootView.voiceRecordView.setHover(target)
case .ended:
guard isVoiceRecording else { return }
finishVoiceRecording(action: finishAction(for: currentVoiceHover))
case .cancelled, .failed:
if isVoiceRecording {
finishVoiceRecording(action: .discard)
}
default:
break
}
}
private enum VoiceFinishAction {
case discard
case sendVoice
case convert
}
private func finishAction(for hover: VoiceRecordView.HoverTarget) -> VoiceFinishAction {
switch hover {
case .cancel: return .discard
case .convert: return .convert
case .none: return .sendVoice
}
}
private func startVoiceRecording() {
guard !isVoiceRecording else { return }
AudioServicesPlaySystemSound(1519)
currentVoiceHover = .none
bringInputOverlaysToFront()
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("voice_\(UUID().uuidString).wav")
recordFileURL = url
let startCapture: (Bool) -> Void = { [weak self] enableSpeech in
guard let self else { return }
do {
try self.voiceCapture.start(fileURL: url, enableSpeech: enableSpeech)
self.isVoiceRecording = true
self.recordDuration = 0
self.rootView.voiceRecordView.beginRecording()
self.recordTimer?.invalidate()
self.recordTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { [weak self] _ in
self?.tickVoiceRecording()
}
RunLoop.main.add(self.recordTimer!, forMode: .common)
} catch {
self.dl.show(text: "无法开始录音")
try? FileManager.default.removeItem(at: url)
}
}
startCapture(SFSpeechRecognizer.authorizationStatus() == .authorized)
}
private func requestSpeechAuthorizationIfNeeded() {
guard SFSpeechRecognizer.authorizationStatus() == .notDetermined else { return }
SFSpeechRecognizer.requestAuthorization { _ in }
}
private func tickVoiceRecording() {
guard isVoiceRecording else { return }
recordDuration = voiceCapture.duration
rootView.voiceRecordView.updateDuration(recordDuration)
if recordDuration >= 60 {
finishVoiceRecording(action: finishAction(for: currentVoiceHover))
}
}
private func finishVoiceRecording(action: VoiceFinishAction) {
guard isVoiceRecording else {
if action == .discard {
rootView.voiceRecordView.reset()
}
return
}
isVoiceRecording = false
recordTimer?.invalidate()
recordTimer = nil
let duration = max(recordDuration, voiceCapture.duration)
voiceCapture.stop(finishRecognition: action == .convert)
let text = voiceCapture.latestText
let url = recordFileURL
switch action {
case .discard:
deleteRecordFile()
rootView.voiceRecordView.reset()
case .sendVoice:
rootView.voiceRecordView.reset()
guard let url, duration >= 1 else {
dl.show(text: "说话时间太短")
deleteRecordFile()
return
}
guard let durationMs = validVoiceDurationMs(at: url) else {
dl.show(text: "录音文件无效,请重试")
deleteRecordFile()
return
}
sendVoiceMessage(url: url, durationMs: durationMs)
case .convert:
deleteRecordFile()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in
guard let self, !self.isVoiceRecording, !self.rootView.voiceRecordView.isHidden else { return }
let latest = self.voiceCapture.latestText.isEmpty ? text : self.voiceCapture.latestText
self.rootView.voiceRecordView.showPreview(text: latest)
}
}
}
private func discardPreview() {
deleteRecordFile()
rootView.voiceRecordView.reset()
rootView.restoreComposerPosition()
}
private func deleteRecordFile() {
if let url = recordFileURL {
try? FileManager.default.removeItem(at: url)
}
recordFileURL = nil
}
private func validVoiceDurationMs(at url: URL) -> Int? {
guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
let fileSize = attributes[.size] as? NSNumber,
fileSize.intValue > 44,
let audioFile = try? AVAudioFile(forReading: url),
audioFile.length > 0,
audioFile.processingFormat.sampleRate > 0 else {
return nil
}
let duration = Double(audioFile.length) / audioFile.processingFormat.sampleRate
guard duration >= 0.5, duration.isFinite else { return nil }
return max(1_000, Int((duration * 1_000).rounded()))
}
private func sendVoiceMessage(url: URL, durationMs: Int) {
GroupIMService.shared.sendGroupMessage(groupId: viewModel.groupId,
makeMessage: {
OIMMessageInfo.createSoundMessage(fromFullPath: url.path, duration: durationMs)
},
onPrepared: { [weak self] message in
self?.viewModel.cacheMessage(message)
},
onSuccess: { [weak self] sentMessage in
DispatchQueue.main.async {
guard let self else { return }
sentMessage.soundElem?.soundPath = url.path
self.viewModel.onReceiveMessage(sentMessage)
}
},
onFailure: { [weak self] code, errMsg in
print("Voice send failed: \(code) \(errMsg ?? "")")
DispatchQueue.main.async {
let text = GroupIMService.shared.displaySendError(errMsg, fallback: "语音发送失败,请重试")
self?.dl.show(text: text)
}
})
}
// MARK: - API
private func shouldRefreshGroupInfo(for notification: Notification) -> Bool {
guard let groupKey = notification.userInfo?[GroupDataChangeUserInfoKey.groupKey] as? String,
!groupKey.isEmpty else { return true }
return groupKey == viewModel.groupId
}
private func scheduleGroupInfoRefresh() {
groupRefreshWorkItem?.cancel()
let work = DispatchWorkItem { [weak self] in
self?.requestGroupInfoByKey(showError: false)
}
groupRefreshWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: work)
}
private func requestGroupInfoByKey(showError: Bool = true) {
let requestID = UUID()
groupInfoRequestID = requestID
GroupService.groupUsers(groupKey: viewModel.groupId)
.subscribe(onNext: { [weak self] response in
guard let self, self.groupInfoRequestID == requestID else { return }
guard response.isValid(for: self.viewModel.groupId),
let model = response.model else {
if showError {
DLToast.showError(text: response.message ?? "获取圈子成员失败")
}
return
}
self.viewModel.groupModel = model
self.viewModel.memberList = GroupMemberSorter.sorted(
response.list,
groupKey: model.group_key,
currentUserId: AppContextManager.shared.userId
)
self.rootView.groupNameLabel.text = "\(model.name)(\(response.list.count))"
self.rootView.groupAvatarView.setGroupIcon(model)
self.rootView.reviewBtn.isHidden = !model.is_owner
self.refreshMentionRows()
// self.rootView.reviewDotView.isHidden = response.reviewCount == 0
}, onError: { [weak self] error in
guard let self, self.groupInfoRequestID == requestID else { return }
if showError {
DLToast.showError(error)
}
})
.disposed(by: disposeBag)
}
// MARK: - dataSource
private lazy var dataSource: RxTableViewSectionedReloadDataSource<ChatSectionModel> = {
RxTableViewSectionedReloadDataSource<ChatSectionModel> { [weak self] _, tableView, indexPath, item in
switch item {
case let .send(msg):
let cell: TextSendMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(msg)
cell.onQuoteTap = { [weak self] in
guard let id = msg.quotePreview?.quotedClientMsgID else { return }
self?.scrollToMessage(clientMsgID: id)
}
return cell
case let .received(msg):
let cell: TextReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(msg)
cell.onQuoteTap = { [weak self] in
guard let id = msg.quotePreview?.quotedClientMsgID else { return }
self?.scrollToMessage(clientMsgID: id)
}
return cell
case let .emojiSend(msg):
let cell: EmojiSendMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(msg)
return cell
case let .emojiReceived(msg):
let cell: EmojiReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(msg)
return cell
case let .voiceSend(msg):
let cell: VoiceSendMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(msg)
return cell
case let .voiceReceived(msg):
let cell: VoiceReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(msg)
return cell
case let .imageSend(msg):
let cell: ImageSendMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(msg)
cell.onImageTap = { [weak self] in
self?.showBigImage(imgUrlList: [msg.imageUrl], currentPage: 0, projectiveView: cell.photoView)
}
return cell
case let .imageReceived(msg):
let cell: ImageReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(msg)
cell.onImageTap = { [weak self] in
self?.showBigImage(imgUrlList: [msg.imageUrl], currentPage: 0, projectiveView: cell.photoView)
}
return cell
case let .locationSend(msg):
let cell: LocationSendMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(msg)
cell.onLocationTap = { [weak self] in
self?.showLocationDetail(msg.location)
}
return cell
case let .locationReceived(msg):
let cell: LocationReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(msg)
cell.onLocationTap = { [weak self] in
self?.showLocationDetail(msg.location)
}
return cell
case let .notification(text, showTime, timestamp),
let .revoked(text, showTime, timestamp):
let cell: NotificationMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(text, showTime: showTime, timestamp: timestamp)
return cell
}
}
}()
}
// MARK: - UITextViewDelegate / Mention table
extension GroupChatVC: UITextViewDelegate, UITableViewDataSource {
func textViewDidChange(_ textView: UITextView) {
rootView.refreshComposer()
handleMentionTrigger()
}
func textView(_ textView: UITextView,
shouldChangeTextIn range: NSRange,
replacementText text: String) -> Bool {
guard text == "\n", textView.markedTextRange == nil else { return true }
sendCurrentTextMessage()
return false
}
func textViewDidChangeSelection(_ textView: UITextView) {
guard textView.isFirstResponder else { return }
handleMentionTrigger()
}
func textViewDidEndEditing(_ textView: UITextView) {
hideMentionPicker()
}
func textViewDidBeginEditing(_ textView: UITextView) {
rootView.refreshComposer()
}
func textViewShouldEndEditing(_ textView: UITextView) -> Bool {
return true
}
func numberOfSections(in tableView: UITableView) -> Int { 1 }
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
tableView === rootView.mentionTableView ? mentionRows.count : 0
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "MentionCell", for: indexPath)
let row = mentionRows[indexPath.row]
cell.textLabel?.font = .systemFont(ofSize: 14)
cell.textLabel?.text = row.userId == nil ? "@所有人" : "@\(row.nickname)"
cell.selectionStyle = .default
return cell
}
}
// MARK: - UITableViewDelegate
extension GroupChatVC: UITableViewDelegate {
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
guard tableView === rootView.tableView,
viewModel.currentMessages.indices.contains(indexPath.row) else { return 160 }
switch viewModel.currentMessages[indexPath.row] {
case let .imageSend(msg), let .imageReceived(msg):
return ChatImageLayout.messageSize(width: msg.imageWidth, height: msg.imageHeight).height + 60
case .locationSend, .locationReceived:
return 208
case .emojiSend, .emojiReceived:
return 123
case .voiceSend, .voiceReceived:
return 99
case .send, .received:
return 100
case .notification, .revoked:
return 60
}
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
(cell as? EmojiSendMsgCell)?.playAnimation()
(cell as? EmojiReceivedMsgCell)?.playAnimation()
}
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
(cell as? EmojiSendMsgCell)?.stopAnimation()
(cell as? EmojiReceivedMsgCell)?.stopAnimation()
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if tableView === rootView.mentionTableView {
tableView.deselectRow(at: indexPath, animated: true)
let row = mentionRows[indexPath.row]
insertMention(userId: row.userId, nickname: row.nickname)
return
}
}
}
// MARK: - MessageListenerProxy
private class MessageListenerProxy: NSObject, OIMAdvancedMsgListener {
private let onMessage: (OIMMessageInfo) -> Void
private let onRevoked: (OIMMessageRevokedInfo) -> Void
init(onMessage: @escaping (OIMMessageInfo) -> Void,
onRevoked: @escaping (OIMMessageRevokedInfo) -> Void) {
self.onMessage = onMessage
self.onRevoked = onRevoked
}
func onRecvNewMessage(_ msg: OIMMessageInfo) {
onMessage(msg)
}
func onRecvMessageRevoked(_ messageRevoked: OIMMessageRevokedInfo) {
onRevoked(messageRevoked)
}
}
extension GroupChatVC {
private func sendImageMessage(_ image: UIImage) {
let sendImage = image.cgImage != nil ? image.fixOrientation() : image
guard let data = sendImage.jpegData(compressionQuality: 0.8) else { return }
let dir = NSTemporaryDirectory()
let filename = "img_\(Int(Date().timeIntervalSince1970)).jpg"
let fileURL = URL(fileURLWithPath: dir + filename)
try? data.write(to: fileURL)
let displaySize = ChatImageLayout.displaySize(width: sendImage.size.width, height: sendImage.size.height)
var localId = ""
GroupIMService.shared.sendGroupMessage(groupId: viewModel.groupId,
makeMessage: {
OIMMessageInfo.createImageMessage(fromFullPath: fileURL.path)
},
onPrepared: { [weak self] message in
guard let self else { return }
localId = message.clientMsgID ?? UUID().uuidString
self.viewModel.cacheMessage(message)
let localMessage = ChatMessage(
id: localId,
isSelf: true,
senderId: AppContextManager.shared.userId,
avatar: self.viewModel.getUserAvatar(id: AppContextManager.shared.userId),
headPic: self.viewModel.getUserHeadPic(id: AppContextManager.shared.userId),
senderName: AppContextManager.shared.name,
content: "",
voiceUrl: "",
imageUrl: fileURL.path,
imageWidth: displaySize.width,
imageHeight: displaySize.height,
timestamp: Date().timeIntervalSince1970,
showTime: false,
isUploading: true
)
self.viewModel.appendLocalMessage(.imageSend(localMessage))
},
onSuccess: { [weak self] sentMessage in
// 服务端返回后,更新本地消息为服务端图片URL,去掉loading
let networkUrl = sentMessage.pictureElem?.sourcePicture?.url
?? sentMessage.pictureElem?.bigPicture?.url
?? sentMessage.pictureElem?.sourcePath
?? ""
self?.viewModel.updateLocalMessage(id: localId) { chatMsg in
// 仅在服务端有URL时才替换,否则保留本地路径让图片仍可见
if !networkUrl.isEmpty {
chatMsg.imageUrl = networkUrl
}
chatMsg.isUploading = false
}
try? FileManager.default.removeItem(at: fileURL)
},
onFailure: { [weak self] code, errMsg in
print("Image send failed: \(code) \(errMsg ?? "")")
// 发送失败,隐藏 loading
self?.viewModel.updateLocalMessage(id: localId) { chatMsg in
chatMsg.isUploading = false
}
DispatchQueue.main.async {
let text = GroupIMService.shared.displaySendError(errMsg, fallback: "图片发送失败,请重试")
self?.dl.show(text: text)
}
})
}
}
// MARK: - 群聊位置详情
final class ChatLocationDetailVC: BaseViewController {
override var isNavigationBarHidden: Bool { true }
private let location: ChatLocationPayload
private var rootView: ChatLocationDetailView!
private var currentCoordinate: CLLocationCoordinate2D?
#if !targetEnvironment(simulator)
private let routeSearch = AMapSearchAPI()
private var routeOverlays: [MAPolyline] = []
#endif
init(location: ChatLocationPayload) {
self.location = location
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func loadView() {
rootView = ChatLocationDetailView(frame: UIScreen.main.bounds)
view = rootView
}
override func viewDidLoad() {
super.viewDidLoad()
rootView.backBtn.addTarget(self, action: #selector(handleBack), for: .touchUpInside)
rootView.nameLabel.text = location.name.isEmpty ? "位置" : location.name
rootView.addressLabel.text = location.address.isEmpty ? "暂无详细地址" : location.address
setupLocation()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
if isMovingFromParent || isBeingDismissed {
#if !targetEnvironment(simulator)
rootView.cleanupMap()
#endif
}
}
@objc private func handleBack() { navigationController?.popViewController(animated: true) }
private func setupLocation() {
if let latitude = Defaults[\.currentLatitude], let longitude = Defaults[\.currentLongitude] {
let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
if CLLocationCoordinate2DIsValid(coordinate) {
currentCoordinate = coordinate
let distance = CLLocation(latitude: latitude, longitude: longitude)
.distance(from: CLLocation(latitude: location.latitude, longitude: location.longitude))
rootView.distanceLabel.setDistance(distance)
}
}
if currentCoordinate == nil {
rootView.distanceLabel.setDistance(nil)
}
#if !targetEnvironment(simulator)
rootView.mapView.delegate = self
rootView.mapView.showsUserLocation = false
routeSearch?.delegate = self
rootView.mapView.setCenter(location.coordinate, animated: false)
rootView.mapView.setZoomLevel(18, animated: false)
addAnnotations()
requestRoutes()
#else
rootView.mapHintLabel.isHidden = false
#endif
}
#if !targetEnvironment(simulator)
private func addAnnotations() {
let target = MAPointAnnotation()
target.coordinate = location.coordinate
target.title = "target"
rootView.mapView.addAnnotation(target)
if let currentCoordinate {
let current = CurrentUserLocationAnnotation(coordinate: currentCoordinate)
rootView.mapView.addAnnotation(current)
rootView.mapView.showAnnotations([target, current], animated: true)
}
}
private func requestRoutes() {
guard let currentCoordinate else {
rootView.drivingLabel.setDuration(nil)
rootView.walkingLabel.setDuration(nil)
return
}
let origin = AMapGeoPoint.location(withLatitude: CGFloat(currentCoordinate.latitude), longitude: CGFloat(currentCoordinate.longitude))
let destination = AMapGeoPoint.location(withLatitude: CGFloat(location.latitude), longitude: CGFloat(location.longitude))
let driving = AMapDrivingRouteSearchRequest()
driving.origin = origin
driving.destination = destination
driving.strategy = 0
routeSearch?.aMapDrivingRouteSearch(driving)
let walking = AMapWalkingRouteSearchRequest()
walking.origin = origin
walking.destination = destination
routeSearch?.aMapWalkingRouteSearch(walking)
}
#endif
}
#if !targetEnvironment(simulator)
extension ChatLocationDetailVC: AMapSearchDelegate, MAMapViewDelegate {
func onRouteSearchDone(_ request: AMapRouteSearchBaseRequest!, response: AMapRouteSearchResponse!) {
guard let path = response.route?.paths?.first as? AMapPath else {
if request is AMapDrivingRouteSearchRequest {
rootView.drivingLabel.setDuration(nil)
} else if request is AMapWalkingRouteSearchRequest {
rootView.walkingLabel.setDuration(nil)
}
return
}
if request is AMapDrivingRouteSearchRequest {
rootView.drivingLabel.setDuration(TimeInterval(path.duration))
drawDrivingRoute(path)
} else if request is AMapWalkingRouteSearchRequest {
rootView.walkingLabel.setDuration(TimeInterval(path.duration))
}
}
func aMapSearchRequest(_ request: Any!, didFailWithError error: Error!) {
if request is AMapDrivingRouteSearchRequest {
rootView.drivingLabel.setDuration(nil)
} else if request is AMapWalkingRouteSearchRequest {
rootView.walkingLabel.setDuration(nil)
}
}
private func drawDrivingRoute(_ path: AMapPath) {
routeOverlays.forEach { rootView.mapView.remove($0) }
routeOverlays.removeAll()
var coordinates: [CLLocationCoordinate2D] = []
for step in path.steps {
guard let polyline = step.polyline else { continue }
for point in polyline.split(separator: ";") {
let values = point.split(separator: ",")
guard values.count == 2, let longitude = Double(values[0]), let latitude = Double(values[1]) else { continue }
coordinates.append(CLLocationCoordinate2D(latitude: latitude, longitude: longitude))
}
}
guard coordinates.count > 1 else { return }
var mutableCoordinates = coordinates
if let overlay = MAPolyline(coordinates: &mutableCoordinates, count: UInt(coordinates.count)) {
rootView.mapView.add(overlay)
routeOverlays.append(overlay)
rootView.layoutIfNeeded()
let padding = UIEdgeInsets(
top: kNaviHeight + 18,
left: 36,
bottom: ChatLocationDetailView.bottomCardHeight + 24,
right: 36
)
rootView.mapView.showOverlays(routeOverlays, edgePadding: padding, animated: true)
}
}
func mapView(_ mapView: MAMapView!, viewFor annotation: MAAnnotation!) -> MAAnnotationView! {
if annotation is CurrentUserLocationAnnotation {
let identifier = "ChatLocationDetailCurrent"
guard let view = (mapView.dequeueReusableAnnotationView(withIdentifier: identifier) as? CurrentUserLocationAnnotationView)
?? CurrentUserLocationAnnotationView(annotation: annotation, reuseIdentifier: identifier) else { return nil }
view.annotation = annotation
view.zIndex = 10
view.configure()
return view
}
guard annotation is MAPointAnnotation else { return nil }
let identifier = "ChatLocationDetailPin"
guard let view = mapView.dequeueReusableAnnotationView(withIdentifier: identifier)
?? MAAnnotationView(annotation: annotation, reuseIdentifier: identifier) else { return nil }
view.annotation = annotation
view.image = UIImage(named: "IM/location_pin")
view.centerOffset = CGPoint(x: 0, y: -28)
return view
}
func mapView(_ mapView: MAMapView!, rendererFor overlay: MAOverlay!) -> MAOverlayRenderer! {
guard let polyline = overlay as? MAPolyline else { return nil }
let renderer = MAPolylineRenderer(polyline: polyline)
renderer?.strokeColor = UIColor(hexStr: "#58EDFF")
renderer?.lineWidth = 4
return renderer
}
}
#endif
private final class ChatLocationDetailView: UIView {
static let bottomCardHeight = 153 + kSafeBottomMargin
#if !targetEnvironment(simulator)
lazy var mapView: MAMapView! = {
let map = MAMapView()
map.showsCompass = false
map.showsScale = false
map.zoomLevel = 15
return map
}()
#endif
let backBtn: UIButton = {
let button = UIButton(type: .custom)
button.setBackgroundImage(UIImage(named: "Common/back"), for: .normal)
button.backgroundColor = .clear
button.extendEdgeInsets = UIEdgeInsets(top: 20, left: 18, bottom: 20, right: 20)
return button
}()
let mapHintLabel: UILabel = {
let label = UILabel()
label.text = "请在真机查看地图"
label.font = .systemFont(ofSize: 13)
label.textColor = UIColor(hexStr: "#7F8794")
label.textAlignment = .center
label.isHidden = true
return label
}()
let nameLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 19, weight: .semibold)
label.textColor = UIColor(hexStr: "#293445")
return label
}()
let addressLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 15)
label.textColor = UIColor(hexStr: "#293445")
label.numberOfLines = 2
return label
}()
let distanceLabel = ChatLocationMetricLabel()
let drivingLabel = ChatLocationMetricLabel(iconName: "IM/location_driving")
let walkingLabel = ChatLocationMetricLabel(iconName: "IM/location_walking")
private let routeStack: UIStackView = {
let stack = UIStackView()
stack.axis = .horizontal
stack.alignment = .center
stack.distribution = .fill
stack.spacing = 8
return stack
}()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor(hexStr: "#EAF2F3")
setupUI()
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
private func setupUI() {
#if !targetEnvironment(simulator)
addSubview(mapView)
mapView.layoutChain.edges()
#endif
addSubview(mapHintLabel)
addSubview(backBtn)
let card = UIView()
card.backgroundColor = .white
card.layer.cornerRadius = 20
card.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
addSubview(card)
card.addSubview(nameLabel)
card.addSubview(addressLabel)
card.addSubview(routeStack)
routeStack.addArrangedSubview(distanceLabel)
routeStack.addArrangedSubview(drivingLabel)
routeStack.addArrangedSubview(walkingLabel)
backBtn.layoutChain.top(kStatusBarHeight - 4).left(11).width(47).height(47)
mapHintLabel.layoutChain.centerX().centerY().edgesHorzontal(40)
card.layoutChain.edgesHorzontal().bottom().height(Self.bottomCardHeight)
nameLabel.layoutChain.top(24).left(16).right(16)
addressLabel.layoutChain.topToBottomOfView(nameLabel, offset: 8).left(16).right(16)
routeStack.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
routeStack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
routeStack.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -16),
routeStack.topAnchor.constraint(equalTo: addressLabel.bottomAnchor, constant: 10),
routeStack.heightAnchor.constraint(equalToConstant: 30)
])
}
#if !targetEnvironment(simulator)
func cleanupMap() {
mapView?.delegate = nil
mapView?.removeFromSuperview()
mapView = nil
}
#endif
}
private final class ChatLocationMetricLabel: UILabel {
private let iconName: String?
private let textInsets = UIEdgeInsets(top: 5, left: 8, bottom: 5, right: 8)
init(iconName: String? = nil) {
self.iconName = iconName
super.init(frame: .zero)
backgroundColor = UIColor(hexStr: "#F7F7F7")
layer.cornerRadius = 8
clipsToBounds = true
numberOfLines = 1
setMetric(prefix: iconName == nil ? "距你 " : "", value: nil, suffix: "")
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func setDistance(_ distance: CLLocationDistance?) {
guard let distance else {
setMetric(prefix: "距你 ", value: nil, suffix: "")
return
}
if distance >= 1000 {
setMetric(prefix: "距你 ", value: String(format: "%.1f", distance / 1000), suffix: " 公里")
} else {
setMetric(prefix: "距你 ", value: "\(Int(distance))", suffix: " 米")
}
}
func setDuration(_ duration: TimeInterval?) {
guard let duration, duration > 0 else {
setMetric(prefix: "", value: nil, suffix: "")
return
}
let minutes = max(1, Int(ceil(duration / 60)))
if minutes >= 60 {
let hours = minutes / 60
let remainingMinutes = minutes % 60
if remainingMinutes > 0 {
setMetric(prefix: "",
value: "\(hours)",
suffix: " 小时 ",
secondaryValue: "\(remainingMinutes)",
secondarySuffix: " 分钟")
} else {
setMetric(prefix: "", value: "\(hours)", suffix: " 小时")
}
} else {
setMetric(prefix: "", value: "\(minutes)", suffix: " 分钟")
}
}
override func drawText(in rect: CGRect) {
super.drawText(in: rect.inset(by: textInsets))
}
override var intrinsicContentSize: CGSize {
let size = super.intrinsicContentSize
return CGSize(width: size.width + textInsets.left + textInsets.right,
height: size.height + textInsets.top + textInsets.bottom)
}
private func setMetric(prefix: String,
value: String?,
suffix: String,
secondaryValue: String? = nil,
secondarySuffix: String = "") {
let text = NSMutableAttributedString()
if let iconName, let image = UIImage(named: iconName) {
let attachment = NSTextAttachment()
attachment.image = image
attachment.bounds = CGRect(x: 0, y: -2, width: 14, height: 14)
text.append(NSAttributedString(attachment: attachment))
text.append(NSAttributedString(string: " "))
}
let normalAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 15),
.foregroundColor: UIColor(hexStr: "#293445")
]
text.append(NSAttributedString(string: prefix, attributes: normalAttributes))
if let value {
let highlightAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 15),
.foregroundColor: UIColor(hexStr: "#00AFFF")
]
text.append(NSAttributedString(string: value, attributes: highlightAttributes))
text.append(NSAttributedString(string: suffix, attributes: normalAttributes))
if let secondaryValue {
text.append(NSAttributedString(string: secondaryValue, attributes: highlightAttributes))
text.append(NSAttributedString(string: secondarySuffix, attributes: normalAttributes))
}
} else {
text.append(NSAttributedString(string: "--", attributes: normalAttributes))
}
attributedText = text
invalidateIntrinsicContentSize()
}
}