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