1966 lines
80 KiB
Swift
1966 lines
80 KiB
Swift
//
|
||
// 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 pendingOffsetRestore: (oldHeight: CGFloat, oldOffset: CGFloat)?
|
||
private var didSetupKeyboard = false
|
||
|
||
// 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
|
||
|
||
// 非圈主 会员权益拦截
|
||
let isCircleOwner = viewModel.groupId.contains(AppContextManager.shared.userId)
|
||
let canSend = isCircleOwner || AppContextManager.shared.vip > 1
|
||
rootView.disableIMView.isHidden = canSend
|
||
rootView.quickActionBar.isUserInteractionEnabled = canSend
|
||
rootView.quickActionBar.alpha = canSend ? 1 : 0.35
|
||
}
|
||
|
||
override func viewWillDisappear(_ animated: Bool) {
|
||
super.viewWillDisappear(animated)
|
||
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
|
||
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()
|
||
})
|
||
.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)
|
||
|
||
viewModel.output.messages
|
||
.skip(1)
|
||
.map { [ChatSectionModel(model: "", items: $0)] }
|
||
.observe(on: MainScheduler.asyncInstance)
|
||
.bind(to: rootView.tableView.rx.items(dataSource: dataSource))
|
||
.disposed(by: disposeBag)
|
||
|
||
viewModel.output.messages
|
||
.skip(1)
|
||
.observe(on: MainScheduler.asyncInstance)
|
||
.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
|
||
self.scrollToBottom()
|
||
})
|
||
.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)
|
||
|
||
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.disableIMView)
|
||
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 count = dataSource.sectionModels.first?.items.count ?? 0
|
||
guard count > 0 else { return }
|
||
DispatchQueue.main.async {
|
||
self.rootView.tableView.layoutIfNeeded()
|
||
let indexPath = IndexPath(row: count - 1, section: 0)
|
||
self.rootView.tableView.scrollToRow(at: indexPath, at: .bottom, 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.onDismiss = { [weak self] in self?.bubbleMenu = nil }
|
||
let anchor = menuAnchorView(in: cell)
|
||
menu.show(in: rootView, anchor: anchor, canMention: !msg.isSelf)
|
||
}
|
||
|
||
// MARK: - Mention
|
||
private func setupMentionInput() {
|
||
rootView.textField.delegate = self
|
||
rootView.mentionTableView.delegate = self
|
||
rootView.mentionTableView.dataSource = self
|
||
rootView.textField.rx.controlEvent(.editingChanged)
|
||
.subscribe(onNext: { [weak self] in
|
||
self?.rootView.refreshComposer()
|
||
self?.handleMentionTrigger()
|
||
})
|
||
.disposed(by: disposeBag)
|
||
}
|
||
|
||
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() {
|
||
mentionRows = [(nil, "所有人")] + viewModel.mentionCandidates().map { ($0.user_id, $0.nick_name) }
|
||
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 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 = self else { return }
|
||
self.rootView.dismissAllPanels()
|
||
self.showAlbum()
|
||
})
|
||
.disposed(by: disposeBag)
|
||
|
||
rootView.cameraBtn.rx.tap
|
||
.subscribe(onNext: { [weak self] in
|
||
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
|
||
guard let self = self else { return }
|
||
let text = self.rootView.textField.text ?? ""
|
||
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||
self.viewModel.input.sendMessage.onNext(text)
|
||
self.rootView.textField.text = ""
|
||
self.hideMentionPicker()
|
||
})
|
||
.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)
|
||
}
|
||
|
||
// 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
|
||
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 = ChatLocationPickerVC()
|
||
picker.onPickedLocation = { [weak self] location in
|
||
self?.viewModel.sendLocation(location)
|
||
}
|
||
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 = URL(fileURLWithPath: NSTemporaryDirectory() + "voice_\(Int(Date().timeIntervalSince1970)).caf")
|
||
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
|
||
}
|
||
sendVoiceMessage(url: url, durationMs: duration * 1000)
|
||
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 sendVoiceMessage(url: URL, durationMs: Int) {
|
||
let msg = OIMMessageInfo.createSoundMessage(fromFullPath: url.path, duration: durationMs)
|
||
viewModel.cacheMessage(msg)
|
||
OIMManager.manager.sendMessage(msg,
|
||
recvID: "",
|
||
groupID: viewModel.groupId,
|
||
offlinePushInfo: nil,
|
||
onSuccess: { [weak self] _ in
|
||
self?.viewModel.onReceiveMessage(msg)
|
||
},
|
||
onProgress: nil as OIMNumberCallback?,
|
||
onFailure: { code, errMsg in
|
||
print("Voice send failed: \(code) \(errMsg ?? "")")
|
||
})
|
||
}
|
||
|
||
// MARK: - API
|
||
private func requestGroupInfoByKey() {
|
||
GroupService.groupInfoByKey(viewModel.groupId).subscribe { response in
|
||
guard let model = response.model else { return }
|
||
self.viewModel.groupModel = model
|
||
self.viewModel.memberList = response.list
|
||
self.rootView.groupNameLabel.text = "\(model.name)(\(response.list.count))"
|
||
self.rootView.groupAvatarView.image = model.groupIcon
|
||
self.rootView.reviewBtn.isHidden = !model.is_owner
|
||
// self.rootView.reviewDotView.isHidden = response.reviewCount == 0
|
||
}.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)
|
||
}
|
||
cell.onNeedRelayout = { [weak tableView] in
|
||
UIView.performWithoutAnimation {
|
||
tableView?.beginUpdates()
|
||
tableView?.endUpdates()
|
||
}
|
||
}
|
||
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)
|
||
}
|
||
cell.onNeedRelayout = { [weak tableView] in
|
||
UIView.performWithoutAnimation {
|
||
tableView?.beginUpdates()
|
||
tableView?.endUpdates()
|
||
}
|
||
}
|
||
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: - UITextFieldDelegate / Mention table
|
||
extension GroupChatVC: UITextFieldDelegate, UITableViewDataSource {
|
||
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||
let text = textField.text ?? ""
|
||
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return true }
|
||
viewModel.input.sendMessage.onNext(text)
|
||
textField.text = ""
|
||
hideMentionPicker()
|
||
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, 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)
|
||
|
||
let msg = OIMMessageInfo.createImageMessage(fromFullPath: fileURL.path)
|
||
// 使用 SDK 的 clientMsgID 作为本地消息 ID,方便后续与监听器去重
|
||
let localId = msg.clientMsgID ?? UUID().uuidString
|
||
viewModel.cacheMessage(msg)
|
||
|
||
// 立即显示本地图片(带 loading)
|
||
let localMsg = ChatMessage(
|
||
id: localId,
|
||
isSelf: true,
|
||
senderId: AppContextManager.shared.userId,
|
||
avatar: viewModel.getUserAvatar(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
|
||
)
|
||
viewModel.appendLocalMessage(.imageSend(localMsg))
|
||
OIMManager.manager.sendMessage(msg,
|
||
recvID: "",
|
||
groupID: viewModel.groupId,
|
||
offlinePushInfo: nil,
|
||
onSuccess: { [weak self] returnedMsg in
|
||
// 服务端返回后,更新本地消息为服务端图片URL,去掉loading
|
||
// 注意:returnedMsg 是 SDK 回填服务端数据后的新 OIMMessageInfo,包含完整 URL
|
||
let networkUrl = returnedMsg?.pictureElem?.sourcePicture?.url
|
||
?? returnedMsg?.pictureElem?.bigPicture?.url
|
||
?? returnedMsg?.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)
|
||
},
|
||
onProgress: nil as OIMNumberCallback?,
|
||
onFailure: { [weak self] code, errMsg in
|
||
print("Image send failed: \(code) \(errMsg ?? "")")
|
||
// 发送失败,隐藏 loading
|
||
self?.viewModel.updateLocalMessage(id: localId) { chatMsg in
|
||
chatMsg.isUploading = false
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// MARK: - 群聊位置选择
|
||
private struct ChatLocationSuggestion {
|
||
let name: String
|
||
let address: String
|
||
let coordinate: CLLocationCoordinate2D
|
||
}
|
||
|
||
private final class ChatLocationSuggestionCell: UITableViewCell {
|
||
private let nameLabel: UILabel = {
|
||
let label = UILabel()
|
||
label.font = .systemFont(ofSize: 16, weight: .medium)
|
||
label.textColor = UIColor(hexStr: "#1E2F4A")
|
||
label.lineBreakMode = .byTruncatingTail
|
||
return label
|
||
}()
|
||
private let distanceLabel: UILabel = {
|
||
let label = UILabel()
|
||
label.font = .systemFont(ofSize: 12, weight: .bold)
|
||
label.textColor = UIColor(hexStr: "#50BCEF")
|
||
label.textAlignment = .center
|
||
label.backgroundColor = UIColor(hexStr: "#E3F6FF")
|
||
label.layer.cornerRadius = 6
|
||
label.clipsToBounds = true
|
||
return label
|
||
}()
|
||
private let addressLabel: UILabel = {
|
||
let label = UILabel()
|
||
label.font = .systemFont(ofSize: 14)
|
||
label.textColor = UIColor(hexStr: "#A2A2A2")
|
||
label.lineBreakMode = .byTruncatingTail
|
||
return label
|
||
}()
|
||
private let checkmarkView: UIImageView = {
|
||
let view = UIImageView(image: UIImage(systemName: "checkmark"))
|
||
view.tintColor = UIColor(hexStr: "#16B3FF")
|
||
view.contentMode = .scaleAspectFit
|
||
return view
|
||
}()
|
||
|
||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||
backgroundColor = .white
|
||
selectionStyle = .none
|
||
contentView.addSubview(nameLabel)
|
||
contentView.addSubview(distanceLabel)
|
||
contentView.addSubview(addressLabel)
|
||
contentView.addSubview(checkmarkView)
|
||
}
|
||
|
||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||
|
||
func configure(location: ChatLocationSuggestion, distanceText: String?, isSelected: Bool) {
|
||
nameLabel.text = location.name.isEmpty ? "已选位置" : location.name
|
||
addressLabel.text = location.address.isEmpty ? "暂无详细地址" : location.address
|
||
distanceLabel.text = distanceText
|
||
distanceLabel.isHidden = distanceText == nil
|
||
checkmarkView.isHidden = !isSelected
|
||
setNeedsLayout()
|
||
}
|
||
|
||
override func layoutSubviews() {
|
||
super.layoutSubviews()
|
||
let horizontalInset: CGFloat = 32
|
||
let checkWidth: CGFloat = 24
|
||
let rightInset: CGFloat = 20
|
||
let availableWidth = max(0, contentView.bounds.width - horizontalInset - checkWidth - rightInset)
|
||
let distanceText = distanceLabel.text ?? ""
|
||
let distanceWidth = distanceLabel.isHidden ? 0 : max(58, (distanceText as NSString).size(withAttributes: [.font: distanceLabel.font as Any]).width + 14)
|
||
let nameWidth = max(0, availableWidth - (distanceLabel.isHidden ? 0 : distanceWidth + 8))
|
||
|
||
nameLabel.frame = CGRect(x: horizontalInset, y: 11, width: nameWidth, height: 24)
|
||
distanceLabel.frame = CGRect(x: nameLabel.frame.maxX + 8, y: 13, width: distanceWidth, height: 20)
|
||
addressLabel.frame = CGRect(x: horizontalInset, y: 42, width: availableWidth, height: 20)
|
||
checkmarkView.frame = CGRect(x: contentView.bounds.width - rightInset - checkWidth, y: 26, width: checkWidth, height: checkWidth)
|
||
}
|
||
}
|
||
|
||
#if !targetEnvironment(simulator)
|
||
private final class ChatCurrentLocationAnnotation: NSObject, MAAnnotation {
|
||
var coordinate: CLLocationCoordinate2D
|
||
|
||
init(coordinate: CLLocationCoordinate2D) {
|
||
self.coordinate = coordinate
|
||
super.init()
|
||
}
|
||
}
|
||
|
||
private final class ChatCurrentLocationAnnotationView: MAAnnotationView {
|
||
private let avatarContainer: UIView = {
|
||
let view = UIView()
|
||
view.backgroundColor = .white
|
||
view.layer.cornerRadius = 14
|
||
view.layer.shadowColor = UIColor.black.cgColor
|
||
view.layer.shadowOffset = CGSize(width: 0, height: 2)
|
||
view.layer.shadowRadius = 5
|
||
view.layer.shadowOpacity = 0.16
|
||
return view
|
||
}()
|
||
private let avatarImageView: UIImageView = {
|
||
let view = UIImageView()
|
||
view.contentMode = .scaleAspectFill
|
||
view.clipsToBounds = true
|
||
view.layer.cornerRadius = 10
|
||
return view
|
||
}()
|
||
private let statusOuterView: UIView = {
|
||
let view = UIView()
|
||
view.backgroundColor = .white
|
||
view.layer.cornerRadius = 8
|
||
view.layer.shadowColor = UIColor.black.cgColor
|
||
view.layer.shadowOffset = CGSize(width: 0, height: 1)
|
||
view.layer.shadowRadius = 2
|
||
view.layer.shadowOpacity = 0.16
|
||
return view
|
||
}()
|
||
private let statusInnerView: UIView = {
|
||
let view = UIView()
|
||
view.backgroundColor = UIColor(hexStr: "#56DC42")
|
||
view.layer.cornerRadius = 5
|
||
return view
|
||
}()
|
||
|
||
override init!(annotation: MAAnnotation!, reuseIdentifier: String!) {
|
||
super.init(annotation: annotation, reuseIdentifier: reuseIdentifier)
|
||
backgroundColor = .clear
|
||
canShowCallout = false
|
||
isUserInteractionEnabled = false
|
||
bounds = CGRect(x: 0, y: 0, width: 56, height: 72)
|
||
centerOffset = CGPoint(x: 0, y: -29)
|
||
|
||
addSubview(avatarContainer)
|
||
avatarContainer.addSubview(avatarImageView)
|
||
addSubview(statusOuterView)
|
||
statusOuterView.addSubview(statusInnerView)
|
||
|
||
avatarContainer.frame = CGRect(x: 0, y: 0, width: 56, height: 56)
|
||
avatarImageView.frame = avatarContainer.bounds.insetBy(dx: 4, dy: 4)
|
||
statusOuterView.frame = CGRect(x: 20, y: 57, width: 16, height: 16)
|
||
statusInnerView.frame = statusOuterView.bounds.insetBy(dx: 3, dy: 3)
|
||
}
|
||
|
||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||
|
||
func configure() {
|
||
let avatar = AppContextManager.shared.avaterIcon
|
||
avatarImageView.image = avatar.size == .zero ? UIImage(named: "Common/default_avatar") : avatar
|
||
}
|
||
}
|
||
#endif
|
||
|
||
final class ChatLocationPickerVC: BaseViewController {
|
||
override var isNavigationBarHidden: Bool { true }
|
||
|
||
var onPickedLocation: ((ChatLocationPayload) -> Void)?
|
||
private var rootView: ChatLocationPickerView!
|
||
private var currentCoordinate: CLLocationCoordinate2D?
|
||
private var selectedLocation: ChatLocationSuggestion?
|
||
private var suggestions: [ChatLocationSuggestion] = []
|
||
private var searchWorkItem: DispatchWorkItem?
|
||
|
||
#if !targetEnvironment(simulator)
|
||
private let searchAPI = AMapSearchAPI()
|
||
private var currentLocationAnnotation: ChatCurrentLocationAnnotation?
|
||
#endif
|
||
|
||
override func loadView() {
|
||
rootView = ChatLocationPickerView(frame: UIScreen.main.bounds)
|
||
view = rootView
|
||
}
|
||
|
||
override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
rootView.resultTableView.dataSource = self
|
||
rootView.resultTableView.delegate = self
|
||
rootView.backBtn.addTarget(self, action: #selector(handleBack), for: .touchUpInside)
|
||
rootView.confirmBtn.addTarget(self, action: #selector(confirm), for: .touchUpInside)
|
||
rootView.recenterBtn.addTarget(self, action: #selector(recenterToCurrentLocation), for: .touchUpInside)
|
||
rootView.searchField.addTarget(self, action: #selector(searchChanged), for: .editingChanged)
|
||
rootView.searchField.addTarget(self, action: #selector(search), for: .editingDidEndOnExit)
|
||
setupMap()
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
@objc private func confirm() {
|
||
guard let selectedLocation else {
|
||
DLToast.showError(text: "请选择位置")
|
||
return
|
||
}
|
||
onPickedLocation?(ChatLocationPayload(name: selectedLocation.name,
|
||
address: selectedLocation.address,
|
||
latitude: selectedLocation.coordinate.latitude,
|
||
longitude: selectedLocation.coordinate.longitude))
|
||
navigationController?.popViewController(animated: true)
|
||
}
|
||
|
||
@objc private func searchChanged() {
|
||
searchWorkItem?.cancel()
|
||
let workItem = DispatchWorkItem { [weak self] in self?.search() }
|
||
searchWorkItem = workItem
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: workItem)
|
||
}
|
||
|
||
@objc private func search() {
|
||
let keyword = rootView.searchField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||
guard !keyword.isEmpty else {
|
||
loadNearbyPOIs(around: selectedLocation?.coordinate ?? currentCoordinate)
|
||
return
|
||
}
|
||
#if !targetEnvironment(simulator)
|
||
let request = AMapPOIKeywordsSearchRequest()
|
||
request.keywords = keyword
|
||
request.city = ""
|
||
request.offset = 20
|
||
if let coordinate = selectedLocation?.coordinate ?? currentCoordinate {
|
||
request.location = AMapGeoPoint.location(withLatitude: CGFloat(coordinate.latitude),
|
||
longitude: CGFloat(coordinate.longitude))
|
||
}
|
||
searchAPI?.aMapPOIKeywordsSearch(request)
|
||
#else
|
||
rootView.emptyLabel.isHidden = false
|
||
rootView.emptyLabel.text = "模拟器无法搜索地图地点"
|
||
#endif
|
||
}
|
||
|
||
@objc private func recenterToCurrentLocation() {
|
||
guard let currentCoordinate else {
|
||
DLToast.showError(text: "暂无当前位置")
|
||
return
|
||
}
|
||
#if !targetEnvironment(simulator)
|
||
rootView.mapView.setCenter(currentCoordinate, animated: true)
|
||
#endif
|
||
selectMapCenterLocation(currentCoordinate, placeholderName: "当前位置")
|
||
}
|
||
|
||
private func setupMap() {
|
||
if let latitude = Defaults[\.currentLatitude], let longitude = Defaults[\.currentLongitude] {
|
||
let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
|
||
if CLLocationCoordinate2DIsValid(coordinate) {
|
||
currentCoordinate = coordinate
|
||
}
|
||
}
|
||
#if !targetEnvironment(simulator)
|
||
searchAPI?.delegate = self
|
||
rootView.mapView.delegate = self
|
||
rootView.mapView.showsUserLocation = false
|
||
let coordinate = currentCoordinate ?? CLLocationCoordinate2D(latitude: 39.9042, longitude: 116.4074)
|
||
rootView.mapView.setCenter(coordinate, animated: false)
|
||
rootView.mapView.setZoomLevel(15, animated: false)
|
||
if let currentCoordinate {
|
||
addCurrentLocationAnnotation(at: currentCoordinate)
|
||
selectMapCenterLocation(currentCoordinate, placeholderName: "当前位置")
|
||
}
|
||
#else
|
||
rootView.emptyLabel.isHidden = false
|
||
rootView.emptyLabel.text = "请在真机上选择地图位置"
|
||
#endif
|
||
}
|
||
|
||
private func loadNearbyPOIs(around coordinate: CLLocationCoordinate2D?) {
|
||
#if !targetEnvironment(simulator)
|
||
guard let coordinate, CLLocationCoordinate2DIsValid(coordinate) else {
|
||
rootView.emptyLabel.isHidden = false
|
||
rootView.emptyLabel.text = "暂无当前位置,可直接在地图上选择"
|
||
return
|
||
}
|
||
let request = AMapPOIAroundSearchRequest()
|
||
request.location = AMapGeoPoint.location(withLatitude: CGFloat(coordinate.latitude),
|
||
longitude: CGFloat(coordinate.longitude))
|
||
request.radius = 1500
|
||
request.offset = 20
|
||
searchAPI?.aMapPOIAroundSearch(request)
|
||
#endif
|
||
}
|
||
|
||
private func updateSuggestions(_ values: [ChatLocationSuggestion]) {
|
||
suggestions = values
|
||
keepSelectedLocationInSuggestions()
|
||
rootView.resultTableView.reloadData()
|
||
rootView.emptyLabel.isHidden = !suggestions.isEmpty
|
||
}
|
||
|
||
private func select(_ location: ChatLocationSuggestion, recenterMap: Bool) {
|
||
selectedLocation = location
|
||
keepSelectedLocationInSuggestions()
|
||
rootView.selectedNameLabel.text = location.name.isEmpty ? "已选位置" : location.name
|
||
rootView.selectedAddressLabel.text = location.address.isEmpty ? "暂无详细地址" : location.address
|
||
rootView.selectedPanel.isHidden = false
|
||
rootView.confirmBtn.isEnabled = true
|
||
rootView.emptyLabel.isHidden = true
|
||
rootView.resultTableView.reloadData()
|
||
#if !targetEnvironment(simulator)
|
||
if recenterMap {
|
||
rootView.mapView.setCenter(location.coordinate, animated: true)
|
||
}
|
||
#endif
|
||
}
|
||
|
||
private func selectMapCenterLocation(_ coordinate: CLLocationCoordinate2D, placeholderName: String = "正在获取地点...") {
|
||
guard CLLocationCoordinate2DIsValid(coordinate) else { return }
|
||
let placeholder = ChatLocationSuggestion(name: placeholderName,
|
||
address: "正在获取地址...",
|
||
coordinate: coordinate)
|
||
select(placeholder, recenterMap: false)
|
||
#if !targetEnvironment(simulator)
|
||
requestReverseGeocode(for: coordinate)
|
||
loadNearbyPOIs(around: coordinate)
|
||
#endif
|
||
}
|
||
|
||
private func keepSelectedLocationInSuggestions() {
|
||
guard let selectedLocation else { return }
|
||
if let index = suggestions.firstIndex(where: { coordinatesMatch($0.coordinate, selectedLocation.coordinate) }) {
|
||
suggestions[index] = selectedLocation
|
||
} else {
|
||
suggestions.insert(selectedLocation, at: 0)
|
||
}
|
||
}
|
||
|
||
private func coordinatesMatch(_ lhs: CLLocationCoordinate2D, _ rhs: CLLocationCoordinate2D) -> Bool {
|
||
abs(lhs.latitude - rhs.latitude) < 0.000_001 && abs(lhs.longitude - rhs.longitude) < 0.000_001
|
||
}
|
||
|
||
private func distanceText(to coordinate: CLLocationCoordinate2D) -> String? {
|
||
guard let selectedLocation else { return nil }
|
||
let distance = CLLocation(latitude: selectedLocation.coordinate.latitude, longitude: selectedLocation.coordinate.longitude)
|
||
.distance(from: CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude))
|
||
if distance < 100 {
|
||
return "100m内"
|
||
}
|
||
if distance < 1000 {
|
||
return "\(Int(distance.rounded()))m"
|
||
}
|
||
return String(format: "%.1fkm", distance / 1000)
|
||
}
|
||
|
||
#if !targetEnvironment(simulator)
|
||
private func requestReverseGeocode(for coordinate: CLLocationCoordinate2D) {
|
||
let request = AMapReGeocodeSearchRequest()
|
||
request.location = AMapGeoPoint.location(withLatitude: CGFloat(coordinate.latitude), longitude: CGFloat(coordinate.longitude))
|
||
request.requireExtension = true
|
||
searchAPI?.aMapReGoecodeSearch(request)
|
||
}
|
||
|
||
private func addCurrentLocationAnnotation(at coordinate: CLLocationCoordinate2D) {
|
||
if let currentLocationAnnotation {
|
||
currentLocationAnnotation.coordinate = coordinate
|
||
return
|
||
}
|
||
let annotation = ChatCurrentLocationAnnotation(coordinate: coordinate)
|
||
currentLocationAnnotation = annotation
|
||
rootView.mapView.addAnnotation(annotation)
|
||
}
|
||
#endif
|
||
}
|
||
|
||
extension ChatLocationPickerVC: UITableViewDataSource, UITableViewDelegate {
|
||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { suggestions.count }
|
||
|
||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||
guard let cell = tableView.dequeueReusableCell(withIdentifier: "ChatLocationSuggestionCell", for: indexPath) as? ChatLocationSuggestionCell else {
|
||
return UITableViewCell()
|
||
}
|
||
let value = suggestions[indexPath.row]
|
||
let isSelected = selectedLocation.map { coordinatesMatch($0.coordinate, value.coordinate) } ?? false
|
||
cell.configure(location: value, distanceText: distanceText(to: value.coordinate), isSelected: isSelected)
|
||
return cell
|
||
}
|
||
|
||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||
tableView.deselectRow(at: indexPath, animated: true)
|
||
rootView.searchField.resignFirstResponder()
|
||
let location = suggestions[indexPath.row]
|
||
select(location, recenterMap: true)
|
||
}
|
||
}
|
||
|
||
#if !targetEnvironment(simulator)
|
||
extension ChatLocationPickerVC: AMapSearchDelegate, MAMapViewDelegate {
|
||
func onPOISearchDone(_ request: AMapPOISearchBaseRequest!, response: AMapPOISearchResponse!) {
|
||
if let aroundRequest = request as? AMapPOIAroundSearchRequest,
|
||
let point = aroundRequest.location {
|
||
let coordinate = CLLocationCoordinate2D(latitude: point.latitude, longitude: point.longitude)
|
||
guard let selectedLocation, coordinatesMatch(selectedLocation.coordinate, coordinate) else { return }
|
||
} else if let keywordRequest = request as? AMapPOIKeywordsSearchRequest,
|
||
let point = keywordRequest.location {
|
||
let coordinate = CLLocationCoordinate2D(latitude: point.latitude, longitude: point.longitude)
|
||
guard let selectedLocation, coordinatesMatch(selectedLocation.coordinate, coordinate) else { return }
|
||
}
|
||
let values = response.pois.compactMap { poi -> ChatLocationSuggestion? in
|
||
guard let point = poi.location else { return nil }
|
||
let coordinate = CLLocationCoordinate2D(latitude: point.latitude, longitude: point.longitude)
|
||
guard CLLocationCoordinate2DIsValid(coordinate) else { return nil }
|
||
return ChatLocationSuggestion(name: poi.name ?? "",
|
||
address: poi.address ?? poi.district ?? "",
|
||
coordinate: coordinate)
|
||
}
|
||
updateSuggestions(values)
|
||
}
|
||
|
||
func onReGeocodeSearchDone(_ request: AMapReGeocodeSearchRequest!, response: AMapReGeocodeSearchResponse!) {
|
||
guard let point = request.location else { return }
|
||
let coordinate = CLLocationCoordinate2D(latitude: point.latitude, longitude: point.longitude)
|
||
guard let selectedLocation, coordinatesMatch(selectedLocation.coordinate, coordinate) else { return }
|
||
let poi = response.regeocode?.pois?.first
|
||
let location = ChatLocationSuggestion(name: poi?.name ?? response.regeocode?.formattedAddress ?? "已选位置",
|
||
address: poi?.address ?? response.regeocode?.formattedAddress ?? "",
|
||
coordinate: coordinate)
|
||
select(location, recenterMap: false)
|
||
}
|
||
|
||
func aMapSearchRequest(_ request: Any!, didFailWithError error: Error!) {
|
||
if let reGeocodeRequest = request as? AMapReGeocodeSearchRequest,
|
||
let point = reGeocodeRequest.location {
|
||
let coordinate = CLLocationCoordinate2D(latitude: point.latitude, longitude: point.longitude)
|
||
if let selectedLocation, coordinatesMatch(selectedLocation.coordinate, coordinate) {
|
||
select(ChatLocationSuggestion(name: "已选位置", address: "", coordinate: coordinate), recenterMap: false)
|
||
}
|
||
return
|
||
}
|
||
rootView.emptyLabel.isHidden = false
|
||
rootView.emptyLabel.text = "地点加载失败,请重试或在地图上选择"
|
||
}
|
||
|
||
func mapView(_ mapView: MAMapView!, regionDidChangeAnimated animated: Bool, wasUserAction: Bool) {
|
||
guard wasUserAction else { return }
|
||
let center = CGPoint(x: mapView.bounds.midX, y: mapView.bounds.midY)
|
||
let coordinate = mapView.convert(center, toCoordinateFrom: mapView)
|
||
selectMapCenterLocation(coordinate)
|
||
}
|
||
|
||
func mapView(_ mapView: MAMapView!, viewFor annotation: MAAnnotation!) -> MAAnnotationView! {
|
||
guard annotation is ChatCurrentLocationAnnotation else { return nil }
|
||
let identifier = "ChatCurrentLocation"
|
||
guard let view = (mapView.dequeueReusableAnnotationView(withIdentifier: identifier) as? ChatCurrentLocationAnnotationView)
|
||
?? ChatCurrentLocationAnnotationView(annotation: annotation, reuseIdentifier: identifier) else { return nil }
|
||
view.annotation = annotation
|
||
view.zIndex = 10
|
||
view.configure()
|
||
return view
|
||
}
|
||
}
|
||
#endif
|
||
|
||
private final class ChatLocationPickerView: UIView {
|
||
#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 recenterBtn: UIButton = {
|
||
let button = UIButton(type: .custom)
|
||
button.setImage(UIImage(named: "IM/location_recenter"), for: .normal)
|
||
button.backgroundColor = .white
|
||
button.layer.cornerRadius = 10
|
||
button.layer.shadowColor = UIColor.black.cgColor
|
||
button.layer.shadowOffset = CGSize(width: 0, height: 2)
|
||
button.layer.shadowRadius = 6
|
||
button.layer.shadowOpacity = 0.12
|
||
return button
|
||
}()
|
||
let centerPinView: UIImageView = {
|
||
let view = UIImageView(image: UIImage(named: "IM/location_pin"))
|
||
view.contentMode = .scaleAspectFit
|
||
view.isUserInteractionEnabled = false
|
||
return view
|
||
}()
|
||
let searchField: UITextField = {
|
||
let field = UITextField()
|
||
field.placeholder = "搜索地点、商圈或道路"
|
||
field.font = .systemFont(ofSize: 14)
|
||
field.clearButtonMode = .whileEditing
|
||
field.returnKeyType = .search
|
||
field.backgroundColor = UIColor(hexStr: "#F3F3F3")
|
||
field.cornerRadius = 11
|
||
field.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 16, height: 36))
|
||
field.leftViewMode = .always
|
||
return field
|
||
}()
|
||
let resultTableView: UITableView = {
|
||
let table = UITableView(frame: .zero, style: .plain)
|
||
table.backgroundColor = .white
|
||
table.separatorColor = UIColor(hexStr: "#EEEEEE")
|
||
table.rowHeight = 74
|
||
table.register(ChatLocationSuggestionCell.self, forCellReuseIdentifier: "ChatLocationSuggestionCell")
|
||
table.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: kSafeBottomMargin, right: 0)
|
||
return table
|
||
}()
|
||
let emptyLabel: UILabel = {
|
||
let label = UILabel()
|
||
label.font = .systemFont(ofSize: 13)
|
||
label.textColor = UIColor(hexStr: "#8A8A8A")
|
||
label.textAlignment = .center
|
||
label.numberOfLines = 0
|
||
label.isHidden = true
|
||
return label
|
||
}()
|
||
let selectedPanel: UIView = {
|
||
let view = UIView()
|
||
view.backgroundColor = .white
|
||
view.layer.cornerRadius = 24
|
||
view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||
view.clipsToBounds = true
|
||
return view
|
||
}()
|
||
let selectedNameLabel: UILabel = {
|
||
let label = UILabel()
|
||
label.font = .systemFont(ofSize: 16, weight: .semibold)
|
||
label.textColor = UIColor(hexStr: "#282828")
|
||
return label
|
||
}()
|
||
let selectedAddressLabel: UILabel = {
|
||
let label = UILabel()
|
||
label.font = .systemFont(ofSize: 12)
|
||
label.textColor = UIColor(hexStr: "#8A8A8A")
|
||
label.lineBreakMode = .byTruncatingTail
|
||
return label
|
||
}()
|
||
let confirmBtn: UIButton = {
|
||
let button = UIButton(type: .custom)
|
||
button.setTitle("确认", for: .normal)
|
||
button.setTitleColor(.white, for: .normal)
|
||
button.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
|
||
button.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal)
|
||
button.cornerRadius = 8
|
||
return button
|
||
}()
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
backgroundColor = UIColor(hexStr: "#EFF7F8")
|
||
setupUI()
|
||
}
|
||
|
||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||
|
||
private func setupUI() {
|
||
#if !targetEnvironment(simulator)
|
||
addSubview(mapView)
|
||
mapView.layoutChain.edges(excludingEdge: .bottom).bottom(250 + kSafeBottomMargin)
|
||
addSubview(centerPinView)
|
||
#endif
|
||
addSubview(backBtn)
|
||
addSubview(recenterBtn)
|
||
addSubview(confirmBtn)
|
||
addSubview(selectedPanel)
|
||
selectedPanel.addSubview(searchField)
|
||
selectedPanel.addSubview(resultTableView)
|
||
selectedPanel.addSubview(emptyLabel)
|
||
selectedPanel.addSubview(selectedNameLabel)
|
||
selectedPanel.addSubview(selectedAddressLabel)
|
||
|
||
backBtn.layoutChain.top(kStatusBarHeight + 12).left(18).width(32).height(32)
|
||
recenterBtn.layoutChain.left(13).bottomToTopOfView(selectedPanel, offset: -24).width(38).height(38)
|
||
confirmBtn.layoutChain.right(14).width(50).height(30).centerY(backBtn)
|
||
selectedPanel.layoutChain.edgesHorzontal().bottom().height(290)
|
||
searchField.layoutChain.top(13).edgesHorzontal(15).height(38)
|
||
resultTableView.layoutChain.topToBottomOfView(searchField, offset: 13).edgesHorzontal(0).bottom()
|
||
emptyLabel.layoutChain.centerX().centerY().edgesHorzontal(40)
|
||
selectedNameLabel.isHidden = true
|
||
selectedAddressLabel.isHidden = true
|
||
#if !targetEnvironment(simulator)
|
||
centerPinView.layoutChain.centerX(mapView).centerY(mapView, offset: -28).width(34).height(56)
|
||
#endif
|
||
}
|
||
|
||
#if !targetEnvironment(simulator)
|
||
func cleanupMap() {
|
||
mapView?.delegate = nil
|
||
mapView?.removeFromSuperview()
|
||
mapView = nil
|
||
}
|
||
#endif
|
||
}
|
||
|
||
// 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.text = "距离你约 \(chatDistanceText(distance))"
|
||
}
|
||
}
|
||
if currentCoordinate == nil {
|
||
rootView.distanceLabel.text = "暂未获取当前位置"
|
||
}
|
||
#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 = ChatCurrentLocationAnnotation(coordinate: currentCoordinate)
|
||
rootView.mapView.addAnnotation(current)
|
||
rootView.mapView.showAnnotations([target, current], animated: true)
|
||
}
|
||
}
|
||
|
||
private func requestRoutes() {
|
||
guard let currentCoordinate else {
|
||
rootView.drivingLabel.text = "驾车 暂无当前位置"
|
||
rootView.walkingLabel.text = "步行 暂无当前位置"
|
||
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 { return }
|
||
let text = chatDurationText(TimeInterval(path.duration))
|
||
if request is AMapDrivingRouteSearchRequest {
|
||
rootView.drivingLabel.text = "驾车 约\(text)"
|
||
drawDrivingRoute(path)
|
||
} else if request is AMapWalkingRouteSearchRequest {
|
||
rootView.walkingLabel.text = "步行 约\(text)"
|
||
}
|
||
}
|
||
|
||
func aMapSearchRequest(_ request: Any!, didFailWithError error: Error!) {
|
||
if request is AMapDrivingRouteSearchRequest {
|
||
rootView.drivingLabel.text = "驾车 路线加载失败"
|
||
} else if request is AMapWalkingRouteSearchRequest {
|
||
rootView.walkingLabel.text = "步行 路线加载失败"
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
func mapView(_ mapView: MAMapView!, viewFor annotation: MAAnnotation!) -> MAAnnotationView! {
|
||
if annotation is ChatCurrentLocationAnnotation {
|
||
let identifier = "ChatLocationDetailCurrent"
|
||
guard let view = (mapView.dequeueReusableAnnotationView(withIdentifier: identifier) as? ChatCurrentLocationAnnotationView)
|
||
?? ChatCurrentLocationAnnotationView(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 {
|
||
#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: "#262626")
|
||
return label
|
||
}()
|
||
let addressLabel: UILabel = {
|
||
let label = UILabel()
|
||
label.font = .systemFont(ofSize: 13)
|
||
label.textColor = UIColor(hexStr: "#858585")
|
||
label.numberOfLines = 2
|
||
return label
|
||
}()
|
||
let distanceLabel: UILabel = {
|
||
let label = UILabel()
|
||
label.font = .systemFont(ofSize: 13)
|
||
label.textColor = UIColor(hexStr: "#16B3FF")
|
||
label.textAlignment = .center
|
||
label.backgroundColor = UIColor(hexStr: "#F4F5F6")
|
||
label.cornerRadius = 8
|
||
return label
|
||
}()
|
||
let drivingLabel: UILabel = routeLabel(icon: "驾", color: UIColor(hexStr: "#16B3FF"))
|
||
let walkingLabel: UILabel = routeLabel(icon: "步", color: UIColor(hexStr: "#21B573"))
|
||
private let routeStack: UIStackView = {
|
||
let stack = UIStackView()
|
||
stack.axis = .horizontal
|
||
stack.alignment = .fill
|
||
stack.distribution = .fillEqually
|
||
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 + 12).left(18).width(32).height(32)
|
||
mapHintLabel.layoutChain.centerX().centerY().edgesHorzontal(40)
|
||
card.layoutChain.edgesHorzontal().bottom().height(210 + kSafeBottomMargin)
|
||
nameLabel.layoutChain.top(22).left(20).right(20)
|
||
addressLabel.layoutChain.topToBottomOfView(nameLabel, offset: 8).left(20).right(20)
|
||
routeStack.layoutChain.topToBottomOfView(addressLabel, offset: 18).edgesHorzontal(20).height(32)
|
||
}
|
||
|
||
#if !targetEnvironment(simulator)
|
||
func cleanupMap() {
|
||
mapView?.delegate = nil
|
||
mapView?.removeFromSuperview()
|
||
mapView = nil
|
||
}
|
||
#endif
|
||
|
||
private static func routeLabel(icon: String, color: UIColor) -> UILabel {
|
||
let label = UILabel()
|
||
label.text = "\(icon) 路线计算中"
|
||
label.font = .systemFont(ofSize: 12, weight: .medium)
|
||
label.textAlignment = .center
|
||
label.textColor = color
|
||
label.backgroundColor = color.withAlphaComponent(0.1)
|
||
label.cornerRadius = 8
|
||
return label
|
||
}
|
||
}
|
||
|
||
private func chatDistanceText(_ distance: CLLocationDistance) -> String {
|
||
distance >= 1000 ? String(format: "%.1fkm", distance / 1000) : "\(Int(distance))m"
|
||
}
|
||
|
||
private func chatDurationText(_ duration: TimeInterval) -> String {
|
||
let minutes = max(1, Int(ceil(duration / 60)))
|
||
return minutes >= 60 ? "\(minutes / 60)小时\(minutes % 60)分钟" : "\(minutes)分钟"
|
||
}
|