// // 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> = { RxCollectionViewSectionedReloadDataSource> { _, 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[..= 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 = { RxTableViewSectionedReloadDataSource { [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 // 服务端返回后,更新本地消息为服务端图片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 } }) } 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)分钟" }