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

1908 lines
78 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 HXPHPicker
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 cameraPicker: ImagePicker?
///
private var pendingOffsetRestore: (oldHeight: CGFloat, oldOffset: 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
//
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()
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() {
//
NotificationCenter.default.rx.notification(UIResponder.keyboardWillShowNotification)
.subscribe(onNext: { [weak self] noti in
guard let self = self,
// VC VC
self.isViewLoaded && self.view.window != nil,
let userInfo = noti.userInfo,
let frame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
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)
}
private func setupPanelDismiss() {
// tableview
rootView.tableView.panGestureRecognizer.rx.event
.filter { $0.state == .began }
.subscribe(onNext: { [weak self] _ in
self?.rootView.dismissAllPanels()
self?.bubbleMenu?.dismiss()
})
.disposed(by: disposeBag)
// cell
rootView.tableView.rx.itemSelected
.subscribe(onNext: { [weak self] _ in
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)
}
// 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)
self.rootView.textField.becomeFirstResponder()
}
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?.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.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
guard let self = self else { return }
let status = AVCaptureDevice.authorizationStatus(for: .audio)
switch status {
case .authorized:
break
case .notDetermined:
AVAudioSession.sharedInstance().requestRecordPermission { granted in
guard granted else { return }
DispatchQueue.main.async {
self.rootView.dismissAllPanels()
self.showSpeakPanel()
}
}
return
default:
Permission.openAppSetting(title: "请开启麦克风权限",
message: "请在iPhone的“设置-隐私-麦克风”选项中允许\(kAppName)访问你的麦克风。")
return
}
self.rootView.dismissAllPanels()
self.showSpeakPanel()
})
.disposed(by: disposeBag)
//
rootView.voiceRecordView.keyboardBtn.rx.tap
.subscribe(onNext: { [weak self] _ in
guard let self = self else { return }
self.rootView.dismissAllPanels()
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.voiceRecordView.emojiBtn.rx.tap
.subscribe(onNext: { [weak self] _ in
guard let self = self else { return }
self.rootView.dismissAllPanels()
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)
Observable.merge(
rootView.galleryBtn.rx.tap.asObservable(),
rootView.voiceRecordView.addBtn.rx.tap.asObservable()
)
.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 showSpeakPanel() {
let show = rootView.voiceRecordView.isHidden
rootView.voiceRecordView.isHidden = !show
let offset: CGFloat = show ? 252 : 0
UIView.animate(withDuration: 0.25) {
self.rootView.bottomBar.layoutChain.bottom(show ? offset - self.rootView.bottomBar.dl.height : kSafeBottomMargin)
self.rootView.voiceRecordView.layoutChain.bottom(offset - 252)
}
scrollToBottom()
}
// 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 config = PhotoTools.getWXPickerConfig()
// 0
config.selectOptions = [.photo]
config.selectMode = .multiple
config.maximumSelectedCount = 9
// config.maximumSelectedPhotoFileSize = 5242880
config.allowSyncICloudWhenSelectPhoto = false
config.previewView.bottomView.editButtonHidden = true
config.photoList.allowAddCamera = true
config.photoList.camera.allowsEditing = false
let pickerController = PhotoPickerController(picker: config)
pickerController.pickerDelegate = self
pickerController.modalPresentationStyle = .fullScreen
self.present(pickerController, animated: true, completion: nil)
}
private func showCamera() {
guard UIImagePickerController.isSourceTypeAvailable(.camera) else {
DLToast.showError(text: "当前设备不支持拍照")
return
}
let picker = ImagePicker()
picker.imageDidPicked = { [weak self] image in
DispatchQueue.main.async {
self?.sendImageMessage(image)
}
}
cameraPicker = picker
picker.showCamera()
}
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 audioRecorder: AVAudioRecorder?
private var recordFileURL: URL?
private var recordTimer: Timer?
private var recordDuration: Int = 0
private func setupVoiceRecording() {
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleVoiceLongPress(_:)))
longPress.minimumPressDuration = 0.2
rootView.voiceRecordView.speakBtn.addGestureRecognizer(longPress)
}
@objc private func handleVoiceLongPress(_ gesture: UILongPressGestureRecognizer) {
let location = gesture.location(in: rootView.voiceRecordView)
let btnCenterX = rootView.voiceRecordView.speakBtn.center.x
let isCancel = location.x < btnCenterX - 100
switch gesture.state {
case .began:
AudioServicesPlaySystemSound(1519)
rootView.voiceRecordView.state = .recording
startRecording()
case .changed:
let wasRecording = rootView.voiceRecordView.state == .recording
rootView.voiceRecordView.state = isCancel ? .canceling : .recording
rootView.voiceRecordView.cancelBtn.isSelected = isCancel
if isCancel && wasRecording {
AudioServicesPlaySystemSound(1519)
}
case .ended:
stopRecording(cancel: isCancel)
default:
break
}
}
private func startRecording() {
let session = AVAudioSession.sharedInstance()
try? session.setCategory(.playAndRecord, mode: .default)
try? session.setActive(true)
let dir = NSTemporaryDirectory()
let filename = "voice_\(Int(Date().timeIntervalSince1970)).wav"
recordFileURL = URL(fileURLWithPath: dir + filename)
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatLinearPCM,
AVSampleRateKey: 8000,
AVNumberOfChannelsKey: 1,
AVLinearPCMBitDepthKey: 16,
AVLinearPCMIsFloatKey: false
]
guard let url = recordFileURL else { return }
audioRecorder = try? AVAudioRecorder(url: url, settings: settings)
audioRecorder?.record()
recordDuration = 0
recordTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
self?.recordDuration += 1
}
}
private func stopRecording(cancel: Bool) {
audioRecorder?.stop()
recordTimer?.invalidate()
recordTimer = nil
rootView.voiceRecordView.stopRotating()
try? AVAudioSession.sharedInstance().setActive(false)
if cancel {
self.rootView.voiceRecordView.cancelBtn.isSelected = false
if let url = recordFileURL { try? FileManager.default.removeItem(at: url) }
return
}
guard let url = recordFileURL, recordDuration >= 1 else {
self.dl.show(text: "说话时间太短")
if let url = recordFileURL { try? FileManager.default.removeItem(at: url) }
return
}
// Send voice message via OpenIM
let msg = OIMMessageInfo.createSoundMessage(fromFullPath: url.path, duration: recordDuration * 1000)
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)
}
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: - 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)
}
}
// MARK: - PhotoPickerControllerDelegate
extension GroupChatVC: PhotoPickerControllerDelegate {
///
/// - Parameters:
/// - pickerController: PhotoPickerController
/// - result:
/// result.photoAssets
/// result.isOriginal
func pickerController(_ pickerController: PhotoPickerController,
didFinishSelection result: PickerResult) {
result.getImage { (image, photoAsset, index) in
} completionHandler: { [weak self] (images) in
guard let self = self else { return }
for img in images {
self.sendImageMessage(img)
}
}
}
private func sendImageMessage(_ image: UIImage) {
guard let data = image.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 = Self.imageDisplaySize(w: image.size.width, h: image.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
// URLloading
// 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
}
})
}
private static func imageDisplaySize(w: CGFloat, h: CGFloat) -> CGSize {
guard w > 0, h > 0 else { return CGSize(width: 160, height: 160) }
let maxW: CGFloat = 200, maxH: CGFloat = 250, minW: CGFloat = 80
var dw = maxW, dh = dw * (h / w)
if dh > maxH { dh = maxH; dw = dh * (w / h) }
if dw < minW { dw = minW; dh = dw * (h / w) }
return CGSize(width: dw, height: dh)
}
///
/// - Parameter pickerController: PhotoPickerController
func pickerController(didCancel pickerController: PhotoPickerController) {
}
}
// 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)分钟"
}