From 536e735f3b7a136e98c1f43a70c3fbf5cef88c00 Mon Sep 17 00:00:00 2001 From: linshujie Date: Mon, 14 Sep 2026 18:30:37 +0800 Subject: [PATCH] =?UTF-8?q?-=20=E4=BD=8D=E7=BD=AE=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E5=99=A8=20=E9=80=9A=E7=94=A8=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- QuickLocation.xcodeproj/project.pbxproj | 32 +- QuickLocation/Manager/App/ApiManager.swift | 14 +- QuickLocation/Manager/URL/URLManager.swift | 4 +- .../Section/Group/GroupChat/GroupChatVC.swift | 747 ++---------------- .../Group/GroupChat/GroupChatView.swift | 99 ++- .../LocationPicker/LocationPickerView.swift | 156 ++++ .../LocationPickerViewController.swift | 636 +++++++++++++++ .../Section/Mine/Account/AccountVC.swift | 17 +- .../Mine/MyProfile/AvatarIconListVC.swift | 6 + .../Mine/MyProfile/AvatarIconListView.swift | 3 + .../PigeonMessage/PigeonMessageVC.swift | 6 +- .../CreateSchedule/CreateScheduleVC.swift | 40 +- .../LocationPicker/LocationPickerVC.swift | 360 --------- .../LocationPicker/LocationPickerView.swift | 192 ----- 14 files changed, 1023 insertions(+), 1289 deletions(-) create mode 100644 QuickLocation/Section/Map/LocationPicker/LocationPickerView.swift create mode 100644 QuickLocation/Section/Map/LocationPicker/LocationPickerViewController.swift delete mode 100644 QuickLocation/Section/Schedule/LocationPicker/LocationPickerVC.swift delete mode 100644 QuickLocation/Section/Schedule/LocationPicker/LocationPickerView.swift diff --git a/QuickLocation.xcodeproj/project.pbxproj b/QuickLocation.xcodeproj/project.pbxproj index aa1c8ebe..ee834d27 100644 --- a/QuickLocation.xcodeproj/project.pbxproj +++ b/QuickLocation.xcodeproj/project.pbxproj @@ -247,8 +247,6 @@ 30D74ABD2FEA67EA0050EB2C /* CreateScheduleVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30D74ABC2FEA67EA0050EB2C /* CreateScheduleVC.swift */; }; 30D74ABF2FEA67F30050EB2C /* CreateScheduleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30D74ABE2FEA67F30050EB2C /* CreateScheduleView.swift */; }; 30D74AC12FEA6EEF0050EB2C /* CreateSchedulePopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30D74AC02FEA6EEF0050EB2C /* CreateSchedulePopView.swift */; }; - 30D74BF32FEB6F5B0050EB2C /* LocationPickerVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30D74BF02FEB6F5B0050EB2C /* LocationPickerVC.swift */; }; - 30D74BF42FEB6F5B0050EB2C /* LocationPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30D74BF12FEB6F5B0050EB2C /* LocationPickerView.swift */; }; 30D74D1F2FEBB09B0050EB2C /* CreateScheduleVM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30D74D1E2FEBB09B0050EB2C /* CreateScheduleVM.swift */; }; 30D87CDB2FDFA9EE00E958FD /* MQTTService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30D87CDA2FDFA9EE00E958FD /* MQTTService.swift */; }; 30D87CDD2FDFF07500E958FD /* InteractionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30D87CDC2FDFF07500E958FD /* InteractionView.swift */; }; @@ -381,6 +379,8 @@ 80F749310651808BF00F95CF /* DeviceActivityMonitorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64156B4A0E806901AB223FA /* DeviceActivityMonitorExtension.swift */; }; 830C70B1B53657BB020721DA /* AppRestrictManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AEEA6CEA0CEAAB42C22752B /* AppRestrictManager.swift */; }; 89647BC98E0FF342511D42CF /* FamilyControls.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4CD6B965A124276661A84C4C /* FamilyControls.framework */; }; + A1B2C30331DC000100000001 /* LocationPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C30131DC000100000001 /* LocationPickerViewController.swift */; }; + A1B2C30431DC000100000001 /* LocationPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C30231DC000100000001 /* LocationPickerView.swift */; }; A1B2C3D42FDABC8C009215C1 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = A1B2C3D52FDABC8C009215C1 /* PrivacyInfo.xcprivacy */; }; A42DCB2A66E694B7CC50033B /* AppRestrictCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89519661B70A0ADEC2A76032 /* AppRestrictCell.swift */; }; A55D8DE8A1F679EBF7B562D1 /* AppCatalogStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47922567FCD34DBE9186F8FD /* AppCatalogStore.swift */; }; @@ -690,8 +690,6 @@ 30D74ABC2FEA67EA0050EB2C /* CreateScheduleVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateScheduleVC.swift; sourceTree = ""; }; 30D74ABE2FEA67F30050EB2C /* CreateScheduleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateScheduleView.swift; sourceTree = ""; }; 30D74AC02FEA6EEF0050EB2C /* CreateSchedulePopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateSchedulePopView.swift; sourceTree = ""; }; - 30D74BF02FEB6F5B0050EB2C /* LocationPickerVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPickerVC.swift; sourceTree = ""; }; - 30D74BF12FEB6F5B0050EB2C /* LocationPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPickerView.swift; sourceTree = ""; }; 30D74D1E2FEBB09B0050EB2C /* CreateScheduleVM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateScheduleVM.swift; sourceTree = ""; }; 30D87CDA2FDFA9EE00E958FD /* MQTTService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MQTTService.swift; sourceTree = ""; }; 30D87CDC2FDFF07500E958FD /* InteractionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionView.swift; sourceTree = ""; }; @@ -828,6 +826,8 @@ 88CC9D32980864EAD9F72004 /* DeviceActivity.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = DeviceActivity.framework; path = System/Library/Frameworks/DeviceActivity.framework; sourceTree = SDKROOT; }; 89519661B70A0ADEC2A76032 /* AppRestrictCell.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AppRestrictCell.swift; path = AppRestrict/AppRestrictCell.swift; sourceTree = ""; }; 98E91E51811235EAD0985421 /* DeviceActivityMonitorExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DeviceActivityMonitorExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + A1B2C30131DC000100000001 /* LocationPickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPickerViewController.swift; sourceTree = ""; }; + A1B2C30231DC000100000001 /* LocationPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPickerView.swift; sourceTree = ""; }; A1B2C3D52FDABC8C009215C1 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; AC46E80B848E4012D192E4CE /* LockDistractView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LockDistractView.swift; sourceTree = ""; }; BE3D39F325FC7624C1D914D6 /* ITunesSearchService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ITunesSearchService.swift; path = AppRestrict/ITunesSearchService.swift; sourceTree = ""; }; @@ -1356,6 +1356,7 @@ 305A76322FCA8C7000227D26 /* CircleMember.swift */, 305A76332FCA8C7000227D26 /* MemberAnnotation.swift */, 305A76342FCA8C7000227D26 /* MemberAnnotationView.swift */, + A1B2C30531DC000100000001 /* LocationPicker */, 30D87D032FE1336300E958FD /* Navigation */, ); path = Map; @@ -1832,7 +1833,6 @@ 30D74AB32FEA25B90050EB2C /* ViewedModel.swift */, 30D74AB92FEA37AD0050EB2C /* ScheduleModel.swift */, 30D74ABB2FEA67CE0050EB2C /* CreateSchedule */, - 30D74BF22FEB6F5B0050EB2C /* LocationPicker */, 30BF300A2FED09A300D9CB52 /* ScheduleDetail */, 30A87A492FEE1CB20095E7C6 /* ItineraryDetail */, 30A87A4E2FEE4E390095E7C6 /* ScheduleHistory */, @@ -1853,15 +1853,6 @@ path = CreateSchedule; sourceTree = ""; }; - 30D74BF22FEB6F5B0050EB2C /* LocationPicker */ = { - isa = PBXGroup; - children = ( - 30D74BF02FEB6F5B0050EB2C /* LocationPickerVC.swift */, - 30D74BF12FEB6F5B0050EB2C /* LocationPickerView.swift */, - ); - path = LocationPicker; - sourceTree = ""; - }; 30D87CD52FDF9F1900E958FD /* MQTT */ = { isa = PBXGroup; children = ( @@ -2155,6 +2146,15 @@ name = iOS; sourceTree = ""; }; + A1B2C30531DC000100000001 /* LocationPicker */ = { + isa = PBXGroup; + children = ( + A1B2C30131DC000100000001 /* LocationPickerViewController.swift */, + A1B2C30231DC000100000001 /* LocationPickerView.swift */, + ); + path = LocationPicker; + sourceTree = ""; + }; B07D45692FCECE07570D9B73 /* Pods */ = { isa = PBXGroup; children = ( @@ -2441,8 +2441,8 @@ 305A76942FCA8C7000227D26 /* UploadImageCell.swift in Sources */, 305A76952FCA8C7000227D26 /* CornerRadiusCell.swift in Sources */, 305A76962FCA8C7000227D26 /* CornerRadiusFooterView.swift in Sources */, - 30D74BF32FEB6F5B0050EB2C /* LocationPickerVC.swift in Sources */, - 30D74BF42FEB6F5B0050EB2C /* LocationPickerView.swift in Sources */, + A1B2C30331DC000100000001 /* LocationPickerViewController.swift in Sources */, + A1B2C30431DC000100000001 /* LocationPickerView.swift in Sources */, 30C4C0162FDB91B8009215C1 /* CheckPermissionVC.swift in Sources */, 55C4C0242FDB91C8009215C2 /* CheckPermissionGuideVC.swift in Sources */, 305A76972FCA8C7000227D26 /* CornerRadiusHeaderView.swift in Sources */, diff --git a/QuickLocation/Manager/App/ApiManager.swift b/QuickLocation/Manager/App/ApiManager.swift index 7e596bc2..5554515b 100644 --- a/QuickLocation/Manager/App/ApiManager.swift +++ b/QuickLocation/Manager/App/ApiManager.swift @@ -115,7 +115,7 @@ extension ApiManager { case GatewayStatusCode.failure.rawValue, GatewayStatusCode.noAuthority.rawValue: // 退出当前界面 handlePopView(message, handle) case GatewayStatusCode.needVip.rawValue: - handleNeedVip(message ?? "") + handleNeedVip(message) default: // 接口没返回code的时候取success字段 if success != nil { @@ -172,8 +172,16 @@ extension ApiManager { GeYanSdk.deletePreResultCache() AppContextManager.shared.deleteAccount() GroupIMService.shared.logout() - DLToast.showError(text: msg ?? "登录失效,请重新登录!") { - AppDelegate.shared.showMainViewController() + DLToast.showError(text: msg ?? "登录失效,请重新登录!") { [weak self] in + guard let self else { return } + SystemService.userConfig().subscribe(onNext: { response in + guard let model = response.model else { return } + Defaults[\.loginToken] = model.token + AppContextManager.shared.systemConfig = model.config + DLToast.dismiss { + AppDelegate.shared.showMainViewController() + } + }, onError: { _ in }).disposed(by: self.disposeBag) } } } diff --git a/QuickLocation/Manager/URL/URLManager.swift b/QuickLocation/Manager/URL/URLManager.swift index d8b9c877..e160b8f6 100644 --- a/QuickLocation/Manager/URL/URLManager.swift +++ b/QuickLocation/Manager/URL/URLManager.swift @@ -50,11 +50,11 @@ extension DefaultsKeys { func setupNetworkMode() { #if DEBUG - setupApiEnv(0) + setupApiEnv(1) openIM_API = "https://imapi.zuom8.cn" // http://38.207.176.65:10002 openIM_WS = "ws://imws.zuom8.cn" // ws://38.207.176.65:10010 #elseif AdHoc - setupApiEnv(0) + setupApiEnv(1) openIM_API = "https://imapi.zuom8.cn" openIM_WS = "ws://imws.zuom8.cn" #else diff --git a/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift b/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift index 1669b826..226a3b6d 100644 --- a/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift +++ b/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift @@ -513,12 +513,6 @@ final class GroupChatVC: BaseViewController { rootView.textField.delegate = self rootView.mentionTableView.delegate = self rootView.mentionTableView.dataSource = self - rootView.textField.rx.controlEvent(.editingChanged) - .subscribe(onNext: { [weak self] in - self?.rootView.refreshComposer() - self?.handleMentionTrigger() - }) - .disposed(by: disposeBag) } private func handleMentionTrigger() { @@ -670,13 +664,7 @@ final class GroupChatVC: BaseViewController { rootView.sendBtn.rx.tap .subscribe(onNext: { [weak self] in - guard let self = self else { return } - let text = self.rootView.textField.text ?? "" - guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } - self.viewModel.input.sendMessage.onNext(text) - self.rootView.textField.text = "" - self.rootView.refreshComposer() - self.hideMentionPicker() + self?.sendCurrentTextMessage() }) .disposed(by: disposeBag) @@ -688,6 +676,15 @@ final class GroupChatVC: BaseViewController { }) .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() { @@ -745,9 +742,12 @@ final class GroupChatVC: BaseViewController { } private func showLocationPicker() { - let picker = ChatLocationPickerVC() + let picker = LocationPickerViewController() picker.onPickedLocation = { [weak self] location in - self?.viewModel.sendLocation(location) + self?.viewModel.sendLocation(ChatLocationPayload(name: location.name, + address: location.address, + latitude: location.coordinate.latitude, + longitude: location.coordinate.longitude)) } navigationController?.pushViewController(picker, animated: true) } @@ -1123,15 +1123,35 @@ final class GroupChatVC: BaseViewController { }() } -// 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 = "" +// 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 } @@ -1281,681 +1301,6 @@ extension GroupChatVC { } } -// 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() { - avatarImageView.setHeadPic(AppContextManager.shared.head_pic) - } -} -#endif - -final class ChatLocationPickerVC: BaseViewController { - override var isNavigationBarHidden: Bool { true } - - private let collapsedResultPanelHeight: CGFloat = 290 - var onPickedLocation: ((ChatLocationPayload) -> Void)? - private var rootView: ChatLocationPickerView! - private var currentCoordinate: CLLocationCoordinate2D? - private var selectedLocation: ChatLocationSuggestion? - private var suggestions: [ChatLocationSuggestion] = [] - private var searchWorkItem: DispatchWorkItem? - private var isResultPanelExpanded = false - private var keyboardManagerWasEnabled = true - private var keyboardManagerResignOnTouchOutside = true - - #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) - setupKeyboard() - setupMap() - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - keyboardManagerWasEnabled = IQKeyboardManager.shared.isEnabled - keyboardManagerResignOnTouchOutside = IQKeyboardManager.shared.resignOnTouchOutside - IQKeyboardManager.shared.isEnabled = false - IQKeyboardManager.shared.resignOnTouchOutside = false - view.transform = .identity - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - IQKeyboardManager.shared.isEnabled = keyboardManagerWasEnabled - IQKeyboardManager.shared.resignOnTouchOutside = keyboardManagerResignOnTouchOutside - } - - 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 - } - - private func setupKeyboard() { - NotificationCenter.default.rx.notification(UIResponder.keyboardWillShowNotification) - .observe(on: MainScheduler.instance) - .subscribe(onNext: { [weak self] notification in - guard let self, - self.isViewLoaded, - self.view.window != nil, - self.rootView.searchField.isFirstResponder else { return } - let duration = (notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double) ?? 0.25 - self.setResultPanelExpanded(true, duration: duration) - }) - .disposed(by: disposeBag) - - } - - private func setResultPanelExpanded(_ expanded: Bool, duration: TimeInterval = 0.25) { - guard isResultPanelExpanded != expanded else { return } - isResultPanelExpanded = expanded - let height = expanded ? view.bounds.height * 2 / 3 : collapsedResultPanelHeight - rootView.setResultPanelHeight(height, duration: duration) - } - - private func collapseResultPanel() { - setResultPanelExpanded(false) - rootView.searchField.resignFirstResponder() - } - - @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 scrollViewWillBeginDragging(_ scrollView: UIScrollView) { - guard scrollView === rootView.resultTableView else { return } - rootView.searchField.resignFirstResponder() - } - - 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) - collapseResultPanel() - 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 } - collapseResultPanel() - let center = CGPoint(x: mapView.bounds.midX, y: mapView.bounds.midY) - let coordinate = mapView.convert(center, toCoordinateFrom: mapView) - selectMapCenterLocation(coordinate) - } - - func mapView(_ mapView: MAMapView!, mapWillMoveByUser wasUserAction: Bool) { - guard wasUserAction else { return } - collapseResultPanel() - } - - func mapView(_ mapView: MAMapView!, didSingleTappedAt coordinate: CLLocationCoordinate2D) { - collapseResultPanel() - } - - 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 { - private var resultPanelHeightConstraint: NSLayoutConstraint? - - #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) - resultPanelHeightConstraint = selectedPanel.layoutChain.constraint - 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 - } - - func setResultPanelHeight(_ height: CGFloat, duration: TimeInterval) { - guard let resultPanelHeightConstraint, - abs(resultPanelHeightConstraint.constant - height) > 0.5 else { return } - layoutIfNeeded() - resultPanelHeightConstraint.constant = height - UIView.animate(withDuration: duration, - delay: 0, - options: [.beginFromCurrentState, .curveEaseInOut]) { - self.layoutIfNeeded() - } - } - - #if !targetEnvironment(simulator) - func cleanupMap() { - mapView?.delegate = nil - mapView?.removeFromSuperview() - mapView = nil - } - #endif -} - // MARK: - 群聊位置详情 final class ChatLocationDetailVC: BaseViewController { override var isNavigationBarHidden: Bool { true } @@ -2032,7 +1377,7 @@ final class ChatLocationDetailVC: BaseViewController { target.title = "target" rootView.mapView.addAnnotation(target) if let currentCoordinate { - let current = ChatCurrentLocationAnnotation(coordinate: currentCoordinate) + let current = CurrentUserLocationAnnotation(coordinate: currentCoordinate) rootView.mapView.addAnnotation(current) rootView.mapView.showAnnotations([target, current], animated: true) } @@ -2115,10 +1460,10 @@ extension ChatLocationDetailVC: AMapSearchDelegate, MAMapViewDelegate { } func mapView(_ mapView: MAMapView!, viewFor annotation: MAAnnotation!) -> MAAnnotationView! { - if annotation is ChatCurrentLocationAnnotation { + if annotation is CurrentUserLocationAnnotation { let identifier = "ChatLocationDetailCurrent" - guard let view = (mapView.dequeueReusableAnnotationView(withIdentifier: identifier) as? ChatCurrentLocationAnnotationView) - ?? ChatCurrentLocationAnnotationView(annotation: annotation, reuseIdentifier: identifier) else { return nil } + 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() diff --git a/QuickLocation/Section/Group/GroupChat/GroupChatView.swift b/QuickLocation/Section/Group/GroupChat/GroupChatView.swift index faedf0f7..081a307e 100644 --- a/QuickLocation/Section/Group/GroupChat/GroupChatView.swift +++ b/QuickLocation/Section/Group/GroupChat/GroupChatView.swift @@ -134,6 +134,56 @@ struct ChatMessage { } } +final class ChatComposerTextView: UITextView { + private let placeholderLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 16) + label.textColor = UIColor.placeholderText + label.isUserInteractionEnabled = false + return label + }() + + var placeholder: String? { + didSet { + placeholderLabel.text = placeholder + updatePlaceholderVisibility() + } + } + + override init(frame: CGRect, textContainer: NSTextContainer?) { + super.init(frame: frame, textContainer: textContainer) + addSubview(placeholderLabel) + placeholderLabel.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + placeholderLabel.leadingAnchor.constraint(equalTo: leadingAnchor), + placeholderLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor), + placeholderLabel.topAnchor.constraint(equalTo: topAnchor, constant: 8) + ]) + NotificationCenter.default.addObserver( + self, + selector: #selector(textDidChange), + name: UITextView.textDidChangeNotification, + object: self + ) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + func updatePlaceholderVisibility() { + placeholderLabel.isHidden = !text.isEmpty + } + + @objc private func textDidChange() { + updatePlaceholderVisibility() + } +} + class GroupChatView: UIView { var disposeBag = DisposeBag() @@ -147,6 +197,12 @@ class GroupChatView: UIView { var onLocationTap: (() -> Void)? private var emojiPlaybackGeneration = 0 private var quoteBarHeightConstraint: NSLayoutConstraint? + private var bottomBarHeightConstraint: NSLayoutConstraint? + private var textFieldHeightConstraint: NSLayoutConstraint? + + private let minimumBottomBarHeight: CGFloat = 50 + private let minimumTextFieldHeight: CGFloat = 36 + private let maximumTextLines: CGFloat = 4 func setQuoteBarVisible(_ visible: Bool, summary: String? = nil) { quoteBar.isHidden = !visible @@ -186,6 +242,7 @@ class GroupChatView: UIView { func refreshComposer() { let hasText = !(textField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + textField.updatePlaceholderVisibility() if isVoiceInputMode { updateHoldSpeakAppearance() textField.isHidden = true @@ -202,9 +259,36 @@ class GroupChatView: UIView { sendBtn.isHidden = !hasText voiceBtn.isHidden = hasText } + updateComposerHeight() layoutIfNeeded() } + private func updateComposerHeight() { + let fittingWidth = textField.bounds.width + guard fittingWidth > 0 else { return } + + let maximumTextHeight = ceil( + textField.font!.lineHeight * maximumTextLines + + textField.textContainerInset.top + + textField.textContainerInset.bottom + ) + let fittingHeight = textField.sizeThatFits( + CGSize(width: fittingWidth, height: .greatestFiniteMagnitude) + ).height + let textHeight = isVoiceInputMode + ? minimumTextFieldHeight + : min(maximumTextHeight, max(minimumTextFieldHeight, ceil(fittingHeight))) + let bottomBarHeight = max(minimumBottomBarHeight, textHeight + 14) + + textField.isScrollEnabled = !isVoiceInputMode && fittingHeight > maximumTextHeight + if abs((textFieldHeightConstraint?.constant ?? 0) - textHeight) > 0.5 { + textFieldHeightConstraint?.constant = textHeight + } + if abs((bottomBarHeightConstraint?.constant ?? 0) - bottomBarHeight) > 0.5 { + bottomBarHeightConstraint?.constant = bottomBarHeight + } + } + private func updateHoldSpeakAppearance() { var configuration = UIButton.Configuration.plain() configuration.title = "按住 说话" @@ -434,8 +518,9 @@ class GroupChatView: UIView { bottomBar.layoutChain .edgesHorzontal(10) - .height(50) .bottom(kSafeBottomMargin) + bottomBarHeightConstraint = bottomBar.heightAnchor.constraint(equalToConstant: minimumBottomBarHeight) + bottomBarHeightConstraint?.isActive = true disableIMView.layoutChain .edgesHorzontal(15) @@ -467,7 +552,8 @@ class GroupChatView: UIView { .left(16) .rightToLeftOfView(emojiBtn, offset: -10) .centerY() - .height(36) + textFieldHeightConstraint = textField.heightAnchor.constraint(equalToConstant: minimumTextFieldHeight) + textFieldHeightConstraint?.isActive = true holdSpeakBtn.layoutChain .left(16) @@ -750,14 +836,18 @@ class GroupChatView: UIView { return btn }() - lazy var textField: UITextField = { - let tf = UITextField() + lazy var textField: ChatComposerTextView = { + let tf = ChatComposerTextView() tf.font = .systemFont(ofSize: 16) tf.textColor = UIColor(hexStr: "#293445") tf.backgroundColor = .clear tf.placeholder = "发消息或者按住说话" tf.returnKeyType = .send tf.tintColor = UIColor(hexStr: "#16B3FF") + tf.textContainerInset = UIEdgeInsets(top: 8, left: 0, bottom: 8, right: 0) + tf.textContainer.lineFragmentPadding = 0 + tf.isScrollEnabled = false + tf.showsVerticalScrollIndicator = false return tf }() @@ -879,6 +969,7 @@ class GroupChatView: UIView { override func layoutSubviews() { super.layoutSubviews() + updateComposerHeight() if !mentionPickerView.isHidden { mentionPickerView.layer.shadowPath = UIBezierPath( roundedRect: mentionPickerView.bounds, diff --git a/QuickLocation/Section/Map/LocationPicker/LocationPickerView.swift b/QuickLocation/Section/Map/LocationPicker/LocationPickerView.swift new file mode 100644 index 00000000..2fec540f --- /dev/null +++ b/QuickLocation/Section/Map/LocationPicker/LocationPickerView.swift @@ -0,0 +1,156 @@ +// +// LocationPickerView.swift +// QuickLocation +// + +import UIKit +#if !targetEnvironment(simulator) +import AMapNaviKit +#endif + +final class LocationPickerView: UIView { + static let cellIdentifier = "LocationSuggestionCell" + + private var resultPanelHeightConstraint: NSLayoutConstraint? + + #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(LocationSuggestionCell.self, forCellReuseIdentifier: cellIdentifier) + 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 confirmBtn: UIButton = { + let button = UIButton(type: .custom) + button.setTitle("确认", for: .normal) + button.setTitleColor(.white, for: .normal) + button.setTitleColor(UIColor.white.withAlphaComponent(0.6), for: .disabled) + button.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) + button.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) + button.cornerRadius = 8 + button.isEnabled = false + return button + }() + private let resultPanel: UIView = { + let view = UIView() + view.backgroundColor = .white + view.layer.cornerRadius = 24 + view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + view.clipsToBounds = true + return view + }() + + 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(resultPanel) + resultPanel.addSubview(searchField) + resultPanel.addSubview(resultTableView) + resultPanel.addSubview(emptyLabel) + + backBtn.layoutChain.top(kStatusBarHeight + 12).left(18).width(32).height(32) + recenterBtn.layoutChain.left(13).bottomToTopOfView(resultPanel, offset: -24).width(38).height(38) + confirmBtn.layoutChain.right(14).width(50).height(30).centerY(backBtn) + resultPanel.layoutChain.edgesHorzontal().bottom().height(290) + resultPanelHeightConstraint = resultPanel.layoutChain.constraint + searchField.layoutChain.top(13).edgesHorzontal(15).height(38) + resultTableView.layoutChain.topToBottomOfView(searchField, offset: 13).edgesHorzontal().bottom() + emptyLabel.layoutChain.centerX().centerY().edgesHorzontal(40) + #if !targetEnvironment(simulator) + centerPinView.layoutChain.centerX(mapView).centerY(mapView, offset: -28).width(34).height(56) + #endif + } + + func setResultPanelHeight(_ height: CGFloat, duration: TimeInterval) { + guard let resultPanelHeightConstraint, + abs(resultPanelHeightConstraint.constant - height) > 0.5 else { return } + layoutIfNeeded() + resultPanelHeightConstraint.constant = height + UIView.animate(withDuration: duration, + delay: 0, + options: [.beginFromCurrentState, .curveEaseInOut]) { + self.layoutIfNeeded() + } + } + + #if !targetEnvironment(simulator) + func cleanupMap() { + mapView?.delegate = nil + mapView?.removeFromSuperview() + mapView = nil + } + #endif +} diff --git a/QuickLocation/Section/Map/LocationPicker/LocationPickerViewController.swift b/QuickLocation/Section/Map/LocationPicker/LocationPickerViewController.swift new file mode 100644 index 00000000..256ad751 --- /dev/null +++ b/QuickLocation/Section/Map/LocationPicker/LocationPickerViewController.swift @@ -0,0 +1,636 @@ +// +// LocationPickerViewController.swift +// QuickLocation +// + +import UIKit +import CoreLocation +import IQKeyboardManagerSwift +import RxSwift +import RxCocoa +import SwiftyUserDefaults +#if !targetEnvironment(simulator) +import AMapNaviKit +import AMapSearchKit +#endif + +struct PickedLocation { + let name: String + let address: String + let coordinate: CLLocationCoordinate2D + var province: String + var city: String + var district: String + var street: String + var country: String + var formatted_address: String + + init(name: String, + address: String, + coordinate: CLLocationCoordinate2D, + province: String = "", + city: String = "", + district: String = "", + street: String = "", + country: String = "", + formatted_address: String = "") { + self.name = name + self.address = address + self.coordinate = coordinate + self.province = province + self.city = city + self.district = district + self.street = street + self.country = country + self.formatted_address = formatted_address + } +} + +final class LocationSuggestionCell: 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: PickedLocation, 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) +final class CurrentUserLocationAnnotation: NSObject, MAAnnotation { + var coordinate: CLLocationCoordinate2D + + init(coordinate: CLLocationCoordinate2D) { + self.coordinate = coordinate + super.init() + } +} + +final class CurrentUserLocationAnnotationView: 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() { + avatarImageView.setHeadPic(AppContextManager.shared.head_pic) + } +} +#endif + +final class LocationPickerViewController: BaseViewController { + override var isNavigationBarHidden: Bool { true } + + var initialLocation: PickedLocation? + var onPickedLocation: ((PickedLocation) -> Void)? + + private let collapsedResultPanelHeight: CGFloat = 290 + private var rootView: LocationPickerView! + private var currentCoordinate: CLLocationCoordinate2D? + private var selectedLocation: PickedLocation? + private var suggestions: [PickedLocation] = [] + private var searchWorkItem: DispatchWorkItem? + private var isResultPanelExpanded = false + private var keyboardManagerWasEnabled = true + private var keyboardManagerResignOnTouchOutside = true + + #if !targetEnvironment(simulator) + private let searchAPI = AMapSearchAPI() + private var currentLocationAnnotation: CurrentUserLocationAnnotation? + #endif + + override func loadView() { + rootView = LocationPickerView(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) + setupKeyboard() + setupMap() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + keyboardManagerWasEnabled = IQKeyboardManager.shared.isEnabled + keyboardManagerResignOnTouchOutside = IQKeyboardManager.shared.resignOnTouchOutside + IQKeyboardManager.shared.isEnabled = false + IQKeyboardManager.shared.resignOnTouchOutside = false + view.transform = .identity + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + IQKeyboardManager.shared.isEnabled = keyboardManagerWasEnabled + IQKeyboardManager.shared.resignOnTouchOutside = keyboardManagerResignOnTouchOutside + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + if isMovingFromParent || isBeingDismissed { + #if !targetEnvironment(simulator) + rootView.cleanupMap() + #endif + } + } + + @objc private func handleBack() { + close() + } + + @objc private func confirm() { + guard let selectedLocation, + CLLocationCoordinate2DIsValid(selectedLocation.coordinate) else { + DLToast.showError(text: "请选择位置") + return + } + onPickedLocation?(selectedLocation) + close() + } + + private func close() { + view.endEditing(true) + if presentingViewController != nil { + dismiss(animated: true) + } else { + 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 + } + + private func setupKeyboard() { + NotificationCenter.default.rx.notification(UIResponder.keyboardWillShowNotification) + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] notification in + guard let self, + self.isViewLoaded, + self.view.window != nil, + self.rootView.searchField.isFirstResponder else { return } + let duration = (notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double) ?? 0.25 + self.setResultPanelExpanded(true, duration: duration) + }) + .disposed(by: disposeBag) + } + + private func setResultPanelExpanded(_ expanded: Bool, duration: TimeInterval = 0.25) { + guard isResultPanelExpanded != expanded else { return } + isResultPanelExpanded = expanded + let height = expanded ? view.bounds.height * 2 / 3 : collapsedResultPanelHeight + rootView.setResultPanelHeight(height, duration: duration) + } + + private func collapseResultPanel() { + setResultPanelExpanded(false) + rootView.searchField.resignFirstResponder() + } + + @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 + } + } + + let validInitialLocation = initialLocation.flatMap { location in + CLLocationCoordinate2DIsValid(location.coordinate) ? location : nil + } + + #if !targetEnvironment(simulator) + searchAPI?.delegate = self + rootView.mapView.delegate = self + rootView.mapView.showsUserLocation = false + let coordinate = validInitialLocation?.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) + } + if let validInitialLocation { + select(validInitialLocation, recenterMap: false) + requestReverseGeocode(for: validInitialLocation.coordinate) + loadNearbyPOIs(around: validInitialLocation.coordinate) + } else if let currentCoordinate { + selectMapCenterLocation(currentCoordinate, placeholderName: "当前位置") + } + #else + if let validInitialLocation { + select(validInitialLocation, recenterMap: false) + } + 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: [PickedLocation]) { + suggestions = values + keepSelectedLocationInSuggestions() + rootView.resultTableView.reloadData() + rootView.emptyLabel.isHidden = !suggestions.isEmpty + } + + private func select(_ location: PickedLocation, recenterMap: Bool) { + guard CLLocationCoordinate2DIsValid(location.coordinate) else { return } + selectedLocation = location + keepSelectedLocationInSuggestions() + 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 } + select(PickedLocation(name: placeholderName, + address: "正在获取地址...", + coordinate: coordinate), + 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 = CurrentUserLocationAnnotation(coordinate: coordinate) + currentLocationAnnotation = annotation + rootView.mapView.addAnnotation(annotation) + } + + private func location(from poi: AMapPOI) -> PickedLocation? { + guard let point = poi.location else { return nil } + let coordinate = CLLocationCoordinate2D(latitude: point.latitude, longitude: point.longitude) + guard CLLocationCoordinate2DIsValid(coordinate) else { return nil } + return PickedLocation(name: poi.name ?? "", + address: poi.address ?? poi.district ?? "", + coordinate: coordinate, + province: poi.province ?? "", + city: poi.city ?? "", + district: poi.district ?? "", + formatted_address: poi.address ?? "") + } + #endif +} + +extension LocationPickerViewController: UITableViewDataSource, UITableViewDelegate { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + suggestions.count + } + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + guard scrollView === rootView.resultTableView else { return } + rootView.searchField.resignFirstResponder() + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell(withIdentifier: LocationPickerView.cellIdentifier, + for: indexPath) as? LocationSuggestionCell else { + return UITableViewCell() + } + let location = suggestions[indexPath.row] + let isSelected = selectedLocation.map { coordinatesMatch($0.coordinate, location.coordinate) } ?? false + cell.configure(location: location, + distanceText: distanceText(to: location.coordinate), + isSelected: isSelected) + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + collapseResultPanel() + let location = suggestions[indexPath.row] + select(location, recenterMap: true) + rootView.searchField.text = location.name + #if !targetEnvironment(simulator) + requestReverseGeocode(for: location.coordinate) + #endif + } +} + +#if !targetEnvironment(simulator) +extension LocationPickerViewController: 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 } + } + updateSuggestions(response.pois.compactMap { self.location(from: $0) }) + } + + 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 reGeocode = response.regeocode + let poi = reGeocode?.pois?.first + let component = reGeocode?.addressComponent + let formattedAddress = reGeocode?.formattedAddress ?? "" + let hasResolvedName = !selectedLocation.name.isEmpty + && selectedLocation.name != "当前位置" + && selectedLocation.name != "正在获取地点..." + let hasResolvedAddress = !selectedLocation.address.isEmpty + && selectedLocation.address != "正在获取地址..." + let name = hasResolvedName + ? selectedLocation.name + : (poi?.name ?? (formattedAddress.isEmpty ? "已选位置" : formattedAddress)) + let address = hasResolvedAddress + ? selectedLocation.address + : (poi?.address ?? formattedAddress) + + select(PickedLocation(name: name, + address: address, + coordinate: coordinate, + province: component?.province ?? poi?.province ?? selectedLocation.province, + city: component?.city ?? poi?.city ?? selectedLocation.city, + district: component?.district ?? poi?.district ?? selectedLocation.district, + street: component?.streetNumber?.street ?? selectedLocation.street, + country: component?.country ?? selectedLocation.country, + formatted_address: formattedAddress.isEmpty + ? (selectedLocation.formatted_address.isEmpty ? address : selectedLocation.formatted_address) + : formattedAddress), + 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) { + let name = selectedLocation.name == "正在获取地点..." ? "已选位置" : selectedLocation.name + let address = selectedLocation.address == "正在获取地址..." ? "" : selectedLocation.address + select(PickedLocation(name: name, + address: address, + coordinate: coordinate, + province: selectedLocation.province, + city: selectedLocation.city, + district: selectedLocation.district, + street: selectedLocation.street, + country: selectedLocation.country, + formatted_address: selectedLocation.formatted_address), + recenterMap: false) + } + return + } + rootView.emptyLabel.isHidden = false + rootView.emptyLabel.text = "地点加载失败,请重试或在地图上选择" + } + + func mapView(_ mapView: MAMapView!, regionDidChangeAnimated animated: Bool, wasUserAction: Bool) { + guard wasUserAction else { return } + collapseResultPanel() + let center = CGPoint(x: mapView.bounds.midX, y: mapView.bounds.midY) + selectMapCenterLocation(mapView.convert(center, toCoordinateFrom: mapView)) + } + + func mapView(_ mapView: MAMapView!, mapWillMoveByUser wasUserAction: Bool) { + guard wasUserAction else { return } + collapseResultPanel() + } + + func mapView(_ mapView: MAMapView!, didSingleTappedAt coordinate: CLLocationCoordinate2D) { + collapseResultPanel() + } + + func mapView(_ mapView: MAMapView!, viewFor annotation: MAAnnotation!) -> MAAnnotationView! { + guard annotation is CurrentUserLocationAnnotation else { return nil } + let identifier = "CurrentUserLocation" + 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 + } +} +#endif diff --git a/QuickLocation/Section/Mine/Account/AccountVC.swift b/QuickLocation/Section/Mine/Account/AccountVC.swift index 0155464c..85859927 100644 --- a/QuickLocation/Section/Mine/Account/AccountVC.swift +++ b/QuickLocation/Section/Mine/Account/AccountVC.swift @@ -10,6 +10,7 @@ import RxSwift import RxCocoa import RxDataSources import Kingfisher +import SwiftyUserDefaults #if !targetEnvironment(simulator) import GeYanSdk import RxGesture @@ -162,14 +163,24 @@ class AccountVC: BaseViewController { // MARK: - API private func requestLogout() { - DLToast.showLoading() UserService.logout().subscribe(onNext: { response in - DLToast.dismiss() NotificationCenter.default.post(name: .invalidatePopupQueue, object: nil) GeYanSdk.deletePreResultCache() AppContextManager.shared.deleteAccount() GroupIMService.shared.logout() - AppDelegate.shared.showMainViewController() + self.requestUserConfig() + }, onError: { _ in }).disposed(by: disposeBag) + } + + private func requestUserConfig() { + DLToast.showLoading() + SystemService.userConfig().subscribe(onNext: { response in + guard let model = response.model else { return } + Defaults[\.loginToken] = model.token + AppContextManager.shared.systemConfig = model.config + DLToast.dismiss { + AppDelegate.shared.showMainViewController() + } }, onError: { _ in }).disposed(by: disposeBag) } diff --git a/QuickLocation/Section/Mine/MyProfile/AvatarIconListVC.swift b/QuickLocation/Section/Mine/MyProfile/AvatarIconListVC.swift index 511ffb4d..0846056a 100644 --- a/QuickLocation/Section/Mine/MyProfile/AvatarIconListVC.swift +++ b/QuickLocation/Section/Mine/MyProfile/AvatarIconListVC.swift @@ -24,6 +24,7 @@ class AvatarIconListVC: BaseViewController { override func viewDidLoad() { super.viewDidLoad() rootView.configure(headPic: iconIndex) + rootView.saveBtn.isHidden = true rootView.saveBtn.rx.tap.subscribe(onNext: { [weak self] _ in guard let self else { return } @@ -35,6 +36,10 @@ class AvatarIconListVC: BaseViewController { rootView.onBadgeTap = { [weak self] in self?.showUploadPicker() } + + rootView.onIconSelected = { [weak self] in + self?.rootView.saveBtn.isHidden = false + } } private func showUploadPicker() { @@ -82,6 +87,7 @@ class AvatarIconListVC: BaseViewController { .subscribe(onNext: { [weak self] url in guard let self else { return } self.rootView.setPreviewImage(image) + self.rootView.saveBtn.isHidden = true self.onSelectIcon?(url) }, onError: { error in DLToast.show(error, fallbackText: "更换失败") diff --git a/QuickLocation/Section/Mine/MyProfile/AvatarIconListView.swift b/QuickLocation/Section/Mine/MyProfile/AvatarIconListView.swift index 12d5005a..3c40a841 100644 --- a/QuickLocation/Section/Mine/MyProfile/AvatarIconListView.swift +++ b/QuickLocation/Section/Mine/MyProfile/AvatarIconListView.swift @@ -27,6 +27,7 @@ class AvatarIconListView: UIView { let saveBtn = UIButton(type: .custom) var onBadgeTap: (() -> Void)? + var onIconSelected: (() -> Void)? private var iconImgList: [UIImage] = [] private let collectionLayout = CollectionHFlowLayout() @@ -97,6 +98,7 @@ class AvatarIconListView: UIView { saveBtn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) saveBtn.layer.cornerRadius = 28 saveBtn.clipsToBounds = true + saveBtn.isHidden = true saveBtn.layoutChain .edgesHorzontal(30) .bottom(kSafeBottomMargin + 13) @@ -281,6 +283,7 @@ extension AvatarIconListView: UICollectionViewDelegate, UICollectionViewDataSour func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { selectedIndex = indexPath.item + 1 + onIconSelected?() } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { diff --git a/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift b/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift index 43cdceba..c3f7332e 100644 --- a/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift +++ b/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift @@ -611,7 +611,6 @@ final class PigeonMessageVC: BaseViewController { ) } .subscribe(onNext: { [weak self] _ in - DLToast.dismiss() guard let self else { return } self.selectedTemplate = nil self.selectedVoiceTemplate = nil @@ -620,8 +619,9 @@ final class PigeonMessageVC: BaseViewController { self.cleanupAudio(deleteFile: true) self.rootView.clearVoiceDraft() self.recordPigeonSendSuccess() - self.refreshGroupVip() - DLToast.show(text: "发送成功") + DLToast.show(text: "发送成功") { + self.refreshGroupVip() + } }, onError: { error in DLToast.dismiss() DLToast.show(error) diff --git a/QuickLocation/Section/Schedule/CreateSchedule/CreateScheduleVC.swift b/QuickLocation/Section/Schedule/CreateSchedule/CreateScheduleVC.swift index 25087ed7..a5a6af89 100644 --- a/QuickLocation/Section/Schedule/CreateSchedule/CreateScheduleVC.swift +++ b/QuickLocation/Section/Schedule/CreateSchedule/CreateScheduleVC.swift @@ -222,6 +222,7 @@ class CreateScheduleVC: BaseViewController, MAMapViewDelegate { } private func showTimePicker(for pointIndex: Int) { + view.endEditing(true) let picker = BRDatePickerView(pickerMode: .HM) picker.title = "选择到达时间" if pointIndex < viewModel.pointsRelay.value.count, @@ -247,14 +248,14 @@ class CreateScheduleVC: BaseViewController, MAMapViewDelegate { self.viewModel.pointsRelay.accept(list) } } - picker.show() + showSchedulePicker(picker) } private func showLocationPicker(for pointIndex: Int, item: SchedulePointItem) { let coord = item.latitude != 0 || item.longitude != 0 ? CLLocationCoordinate2D(latitude: item.latitude, longitude: item.longitude) : kCLLocationCoordinate2DInvalid - let vc = LocationPickerVC() + let vc = LocationPickerViewController() vc.modalPresentationStyle = .fullScreen if !item.locationName.isEmpty { vc.initialLocation = PickedLocation( @@ -437,6 +438,7 @@ class CreateScheduleVC: BaseViewController, MAMapViewDelegate { // MARK: - Date / Time Picker private func showDatePicker() { + view.endEditing(true) let picker = BRDatePickerView(pickerMode: .YMD) picker.title = "选择日期" picker.minDate = Date() @@ -450,7 +452,7 @@ class CreateScheduleVC: BaseViewController, MAMapViewDelegate { picker.resultBlock = { [weak self] date, _ in if let d = date { self?.viewModel.selectedDate.accept(d) } } - picker.show() + showSchedulePicker(picker) } private func makeSchedulePickerStyle() -> BRPickerStyle { @@ -477,11 +479,11 @@ class CreateScheduleVC: BaseViewController, MAMapViewDelegate { style.cancelBorderStyle = .fill style.cancelBtnFrame = CGRect(x: width - 16 - 28, y: 14, width: 28, height: 28) - style.pickerColor = .white + style.pickerColor = .clear style.pickerHeight = 230 style.rowHeight = 44 style.clearPickerNewStyle = true - style.selectRowColor = UIColor(hexStr: "#E3F6FF") + style.separatorColor = .clear style.selectRowTextColor = UIColor(hexStr: "#16B3FF") style.selectRowTextFont = .systemFont(ofSize: 18, weight: .medium) style.pickerTextColor = UIColor(hexStr: "#333333") @@ -490,6 +492,34 @@ class CreateScheduleVC: BaseViewController, MAMapViewDelegate { return style } + private func showSchedulePicker(_ picker: BRDatePickerView) { + picker.show() + + guard let alertView = picker.alertView, + let pickerView = firstSubview(of: UIPickerView.self, in: alertView) else { return } + let selectedBackgroundView = UIView() + selectedBackgroundView.translatesAutoresizingMaskIntoConstraints = false + selectedBackgroundView.backgroundColor = UIColor(hexStr: "#E3F6FF") + selectedBackgroundView.layer.cornerRadius = 12 + selectedBackgroundView.isUserInteractionEnabled = false + alertView.insertSubview(selectedBackgroundView, belowSubview: pickerView) + + NSLayoutConstraint.activate([ + selectedBackgroundView.leadingAnchor.constraint(equalTo: alertView.leadingAnchor, constant: 30), + selectedBackgroundView.trailingAnchor.constraint(equalTo: alertView.trailingAnchor, constant: -30), + selectedBackgroundView.centerYAnchor.constraint(equalTo: pickerView.centerYAnchor), + selectedBackgroundView.heightAnchor.constraint(equalToConstant: picker.pickerStyle?.rowHeight ?? 44) + ]) + } + + private func firstSubview(of type: View.Type, in view: UIView) -> View? { + if let match = view as? View { return match } + for subview in view.subviews { + if let match = firstSubview(of: type, in: subview) { return match } + } + return nil + } + private func makeSchedulePickerFooter(onDone: @escaping () -> Void) -> UIView { let width = UIScreen.main.bounds.width let footer = UIView(frame: CGRect(x: 0, y: 0, width: width, height: 78)) diff --git a/QuickLocation/Section/Schedule/LocationPicker/LocationPickerVC.swift b/QuickLocation/Section/Schedule/LocationPicker/LocationPickerVC.swift deleted file mode 100644 index e2b85d55..00000000 --- a/QuickLocation/Section/Schedule/LocationPicker/LocationPickerVC.swift +++ /dev/null @@ -1,360 +0,0 @@ -// -// LocationPickerVC.swift -// QuickLocation -// -// Created by 八条 on 2026/6/24. -// - -import UIKit -import RxSwift -import RxCocoa -import CoreLocation -import SwiftyUserDefaults -#if !targetEnvironment(simulator) -import AMapNaviKit -import AMapSearchKit -#endif - -struct PickedLocation { - let name: String - let address: String - let coordinate: CLLocationCoordinate2D - var province: String = "" - var city: String = "" - var district: String = "" - var street: String = "" - var country: String = "" - var formatted_address: String = "" -} - -class LocationPickerVC: BaseViewController { - - override var isNavigationBarHidden: Bool { true } - - var onPickedLocation: ((PickedLocation) -> Void)? - var initialLocation: PickedLocation? - - fileprivate var rootView: LocationPickerView! - - override func loadView() { - rootView = LocationPickerView(frame: UIScreen.main.bounds) - view = rootView - } - - private var isShowPoi: Bool = false - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - // 页面被 pop/dismiss 时清理地图资源 - if isMovingFromParent || isBeingDismissed { - #if !targetEnvironment(simulator) - rootView.cleanupMap() - #endif - } - } - - override func viewDidLoad() { - super.viewDidLoad() - setupMap() - setupKeyboard() - setupSearch() - // 如果有初始位置,显示在底部面板 - if let loc = initialLocation { - selectedLocation = loc - rootView.bottomView.isHidden = false - rootView.poiNameLab.text = loc.name - rootView.poiAddressLab.text = loc.address - if CLLocationCoordinate2DIsValid(loc.coordinate) { - rootView.mapView.setCenter(loc.coordinate, animated: false) - rootView.mapView.setZoomLevel(19, animated: false) - addLocationAnnotation(coordinate: loc.coordinate) - } - } - } - - // MARK: - Keyboard - private var keyboardHeight: CGFloat = 0 - - private func setupKeyboard() { - NotificationCenter.default.rx.notification(UIResponder.keyboardWillShowNotification) - .subscribe(onNext: { [weak self] noti in - guard let self = self, - let frame = noti.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect - else { return } - self.keyboardHeight = frame.height - }) - .disposed(by: disposeBag) - } - - // MARK: - Search - #if !targetEnvironment(simulator) - private let searchAPI = AMapSearchAPI() - private var searchPoiRequest = AMapPOIKeywordsSearchRequest() - #endif - private var selectedLocation: PickedLocation? - private var searchKeyword: String = "" - private var poiItems: [AMapPOI] = [] - private var maxTableHeight: CGFloat = 300 - private var currentLocation: CLLocationCoordinate2D? - - private func setupSearch() { - #if !targetEnvironment(simulator) - searchAPI?.delegate = self - - searchPoiRequest.city = "" - searchPoiRequest.offset = 10 - if let loc = currentLocation { - searchPoiRequest.location = AMapGeoPoint.location(withLatitude: CGFloat(loc.latitude), longitude: CGFloat(loc.longitude)) - } - #endif - - rootView.resultTableView.delegate = self - rootView.resultTableView.dataSource = self - - // 输入联想 - rootView.searchField.rx.text.orEmpty - .debounce(.milliseconds(400), scheduler: MainScheduler.instance) -// .filter { $0.count >= 2 } - .subscribe(onNext: { [weak self] keyword in - self?.poiSearch(keyword: keyword) - }) - .disposed(by: disposeBag) - rootView.searchField.rx.controlEvent(.editingDidBegin) - .subscribe(onNext: { - self.isShowPoi = false - }) - .disposed(by: disposeBag) - - rootView.searchField.rx.controlEvent(.editingDidEndOnExit) - .subscribe(onNext: { [weak self] _ in - guard let self = self, !self.poiItems.isEmpty else { return } - self.selectPOI(at: 0) - self.rootView.searchField.resignFirstResponder() - self.rootView.resultTableView.isHidden = true - }) - .disposed(by: disposeBag) - - // 搜索按钮 → 选中第一条联想 - rootView.searchBtn.rx.tap - .subscribe(onNext: { [weak self] _ in - guard let self = self, !self.poiItems.isEmpty else { return } - self.selectPOI(at: 0) - self.rootView.searchField.resignFirstResponder() - self.rootView.resultTableView.isHidden = true - }) - .disposed(by: disposeBag) - - rootView.backBtn.rx.tap - .subscribe(onNext: { [weak self] _ in - self?.dismiss(animated: true) - }) - .disposed(by: disposeBag) - - rootView.confirmBtn.rx.tap - .subscribe(onNext: { [weak self] _ in - guard let self = self, let loc = self.selectedLocation else { return } - self.onPickedLocation?(loc) - self.dismiss(animated: true) - }) - .disposed(by: disposeBag) - } - - private func poiSearch(keyword: String) { - searchKeyword = keyword - #if !targetEnvironment(simulator) - searchPoiRequest.keywords = keyword - searchAPI?.aMapPOIKeywordsSearch(searchPoiRequest) - #endif - } - - private func selectPOI(at index: Int) { - guard index < poiItems.count else { return } - selectLocation(poi: poiItems[index]) - } - - private func selectLocation(poi: AMapPOI) { - let coord = CLLocationCoordinate2D(latitude: CGFloat(poi.location.latitude), longitude: CGFloat(poi.location.longitude)) - guard CLLocationCoordinate2DIsValid(coord) else { return } - isShowPoi = true - selectedLocation = PickedLocation( - name: poi.name ?? "", - address: poi.address ?? "", - coordinate: coord, - province: poi.province ?? "", - city: poi.city ?? "", - district: poi.district ?? "", - street: "", - formatted_address: poi.address ?? "" - ) - rootView.resultTableView.isHidden = true - rootView.bottomView.isHidden = false - rootView.poiNameLab.text = poi.name - rootView.poiAddressLab.text = poi.address - rootView.mapView.setCenter(coord, animated: true) - rootView.mapView.setZoomLevel(19, animated: true) - addLocationAnnotation(coordinate: coord) - // 逆地理编码补充 street - #if !targetEnvironment(simulator) - let regeo = AMapReGeocodeSearchRequest() - regeo.location = AMapGeoPoint.location(withLatitude: CGFloat(coord.latitude), - longitude: CGFloat(coord.longitude)) - regeo.requireExtension = true - searchAPI?.aMapReGoecodeSearch(regeo) - #endif - } - - private func updateTableHeight(itemCount: Int) { - let contentH = CGFloat(itemCount) * 50 - let searchBarBottom = kStatusBarHeight + 8 + 44 + 10 - let bottomOffset: CGFloat = keyboardHeight > 0 ? keyboardHeight : 200 - let available = kScreenHeight - searchBarBottom - bottomOffset - 10 - maxTableHeight = max(80, available) - let h = min(contentH, maxTableHeight) - rootView.resultTableView.layoutChain.height(h) - } - - // MARK: - Map - private func setupMap() { - #if !targetEnvironment(simulator) - rootView.mapView.delegate = self - rootView.mapView.showsUserLocation = false - if let lat = Defaults[\.currentLatitude], let lon = Defaults[\.currentLongitude] { - let coord = CLLocationCoordinate2D(latitude: lat, longitude: lon) - if CLLocationCoordinate2DIsValid(coord) { - currentLocation = coord - rootView.mapView.setCenter(coord, animated: false) - rootView.mapView.setZoomLevel(18, animated: false) - } - } - #endif - } - - private func addLocationAnnotation(coordinate: CLLocationCoordinate2D) { - #if !targetEnvironment(simulator) - for ann in rootView.mapView.annotations?.compactMap({ $0 as? MAPointAnnotation }) ?? [] { - rootView.mapView.removeAnnotation(ann) - } - let ann = MAPointAnnotation() - ann.coordinate = coordinate - rootView.mapView.addAnnotation(ann) - #endif - } -} - -#if !targetEnvironment(simulator) -// MARK: - AMapSearchDelegate -extension LocationPickerVC: AMapSearchDelegate { - func onPOISearchDone(_ request: AMapPOISearchBaseRequest!, response: AMapPOISearchResponse!) { - // POI ID 搜索(来自地图点击)直接选中 - if request is AMapPOIIDSearchRequest, let poi = response.pois?.first { - selectLocation(poi: poi) - return - } - - // 关键字搜索 - guard response.pois.count > 0, isShowPoi == false else { return } - poiItems = response.pois - rootView.resultTableView.isHidden = false - rootView.resultTableView.reloadData() - updateTableHeight(itemCount: poiItems.count) - } - - func onReGeocodeSearchDone(_ request: AMapReGeocodeSearchRequest!, response: AMapReGeocodeSearchResponse!) { - guard let regeo = response.regeocode else { return } - let address = regeo.formattedAddress ?? "" - // 取第一个 POI 名称作为地点名,没有则取街道/区 - let poiName = regeo.pois?.first?.name - ?? regeo.pois?.first?.address - ?? regeo.pois?.first?.district - ?? "" - selectedLocation = PickedLocation( - name: poiName, - address: regeo.pois?.first?.address ?? "", - coordinate: selectedLocation?.coordinate ?? kCLLocationCoordinate2DInvalid, - province: regeo.pois?.first?.province ?? "", - city: regeo.pois?.first?.city ?? "", - district: regeo.pois?.first?.district ?? "", - street: regeo.addressComponent?.streetNumber?.street ?? "", - formatted_address: regeo.pois?.first?.address ?? "" - ) - rootView.bottomView.isHidden = false - rootView.poiNameLab.text = poiName - rootView.poiAddressLab.text = address - } - - func aMapSearchRequest(_ request: Any!, didFailWithError error: Error!) { - print("AMapSearch error: \(error.localizedDescription)") - } -} - -// MARK: - MAMapViewDelegate -extension LocationPickerVC: MAMapViewDelegate { - func mapView(_ mapView: MAMapView!, didSingleTappedAt coordinate: CLLocationCoordinate2D) { - isShowPoi = true - selectedLocation = PickedLocation( - name: "", - address: "", - coordinate: coordinate - ) - addLocationAnnotation(coordinate: coordinate) - rootView.mapView.setCenter(coordinate, animated: true) - // 逆地理编码获取地址 - #if !targetEnvironment(simulator) - let regeo = AMapReGeocodeSearchRequest() - regeo.location = AMapGeoPoint.location(withLatitude: CGFloat(coordinate.latitude), - longitude: CGFloat(coordinate.longitude)) - regeo.requireExtension = true - searchAPI?.aMapReGoecodeSearch(regeo) - #endif - } - - func mapView(_ mapView: MAMapView!, viewFor annotation: MAAnnotation!) -> MAAnnotationView! { - if annotation is MAUserLocation { return nil } - guard annotation is MAPointAnnotation else { return nil } - let identifier = "LocationPin" - var view = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) - if view == nil { - view = MAAnnotationView(annotation: annotation, reuseIdentifier: identifier) - } else { - view?.annotation = annotation - } - view?.image = UIImage(named: "IM/location_pin") - view?.centerOffset = CGPoint(x: 0, y: -30) - return view - } -} - -// MARK: - UITableViewDataSource / Delegate -extension LocationPickerVC: UITableViewDataSource, UITableViewDelegate { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - poiItems.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "cell") ?? UITableViewCell(style: .subtitle, reuseIdentifier: "cell") - let poi = poiItems[indexPath.row] - let name = poi.name ?? "" - if !searchKeyword.isEmpty, let range = name.lowercased().range(of: searchKeyword.lowercased()) { - let attr = NSMutableAttributedString(string: name) - attr.addAttribute(.foregroundColor, value: UIColor(hexStr: "#16B3FF"), range: NSRange(range, in: name)) - cell.textLabel?.attributedText = attr - } else { - cell.textLabel?.text = name - } - cell.textLabel?.font = .systemFont(ofSize: 14) - cell.detailTextLabel?.text = poi.address - cell.detailTextLabel?.font = .systemFont(ofSize: 12) - cell.detailTextLabel?.textColor = .gray - let arrow = UIImageView(image: UIImage(named: "Schedule/arrow")) - arrow.frame = CGRect(x: 0, y: 0, width: 10, height: 10) - cell.accessoryView = arrow - return cell - } - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) - rootView.searchField.resignFirstResponder() - selectPOI(at: indexPath.row) - rootView.searchField.text = poiItems[indexPath.row].name - } -} -#endif diff --git a/QuickLocation/Section/Schedule/LocationPicker/LocationPickerView.swift b/QuickLocation/Section/Schedule/LocationPicker/LocationPickerView.swift deleted file mode 100644 index d6c2ef8d..00000000 --- a/QuickLocation/Section/Schedule/LocationPicker/LocationPickerView.swift +++ /dev/null @@ -1,192 +0,0 @@ -// -// LocationPickerView.swift -// QuickLocation -// -// Created by 八条 on 2026/6/24. -// - -import UIKit -import RxSwift -import RxCocoa -#if !targetEnvironment(simulator) -import AMapNaviKit -#endif - -class LocationPickerView: UIView { - - var disposeBag = DisposeBag() - - private func setupUI() { - #if !targetEnvironment(simulator) - addSubview(mapView) - #endif - addSubview(topBgView) - topBgView.addSubview(backBtn) - topBgView.addSubview(searchField) - topBgView.addSubview(searchBtn) - addSubview(resultTableView) - addSubview(bottomView) - bottomView.addSubview(poiNameLab) - bottomView.addSubview(poiAddressLab) - bottomView.addSubview(confirmBtn) - - mapView.layoutChain - .edges() - - topBgView.layoutChain - .top(kStatusBarHeight + 8) - .edgesHorzontal(15) - .height(44) - - backBtn.layoutChain - .left().centerY() - .width(36).height(36) - - searchField.layoutChain - .leftToRightOfView(backBtn, offset: 8) - .centerY() - .right(60) - .height(36) - - searchBtn.layoutChain - .right(2).centerY() - .width(80).height(36) - - resultTableView.layoutChain - .topToBottomOfView(topBgView, offset: 10) - .edgesHorzontal(15) - - bottomView.layoutChain - .edgesHorzontal() - .bottom() - .height(160 + kSafeBottomMargin) - - poiNameLab.layoutChain - .top(16).left(16).right(16) - - poiAddressLab.layoutChain - .topToBottomOfView(poiNameLab, offset: 8) - .left(16).right(16) - - confirmBtn.layoutChain - .edgesHorzontal(30) - .height(44) - .bottom(kSafeBottomMargin + 16) - } - - // MARK: - Views - #if !targetEnvironment(simulator) - lazy var mapView: MAMapView! = { - let mv = MAMapView() - mv.zoomLevel = 18 - mv.showsUserLocation = false - mv.showsCompass = false -// DispatchQueue.main.async { mv.logoCenter = CGPoint(x: mv.bounds.width - 55, y: kNaviHeight) } - return mv - }() - #endif - - lazy var topBgView: UIView = { - let v = UIView() - v.backgroundColor = .white - v.cornerRadius = 22 - v.layer.shadowColor = UIColor.black.withAlphaComponent(0.1).cgColor - v.layer.shadowOffset = .zero - v.layer.shadowRadius = 6 - v.layer.shadowOpacity = 1 - return v - }() - - lazy var backBtn: UIButton = { - let btn = UIButton(type: .custom) - btn.setImage(UIImage(named: "Common/back"), for: .normal) - btn.extendEdgeInsets = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8) - return btn - }() - - lazy var searchField: UITextField = { - let tf = UITextField() - tf.placeholder = "搜索地点、公园、商圈、道路" - tf.font = .systemFont(ofSize: 14) - tf.clearButtonMode = .whileEditing - tf.returnKeyType = .search - return tf - }() - - lazy var searchBtn: UIButton = { - let btn = UIButton(type: .custom) - btn.setTitle("搜索", for: .normal) - btn.setTitleColor(.white, for: .normal) - btn.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) - btn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) - btn.cornerRadius = 18 - return btn - }() - - lazy var resultTableView: UITableView = { - let tv = UITableView(frame: .zero, style: .plain) - tv.backgroundColor = .white - tv.isHidden = true - tv.register(UITableViewCell.self, forCellReuseIdentifier: "cell") - tv.estimatedRowHeight = 60 - tv.cornerRadius = 10 - return tv - }() - - lazy var bottomView: UIView = { - let v = UIView() - v.backgroundColor = .white - v.isHidden = true - v.layer.cornerRadius = 16 - v.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] - v.layer.shadowColor = UIColor.black.withAlphaComponent(0.1).cgColor - v.layer.shadowOffset = CGSize(width: 0, height: -2) - v.layer.shadowRadius = 10 - v.layer.shadowOpacity = 1 - return v - }() - - lazy var poiNameLab: UILabel = { - let l = UILabel() - l.font = .systemFont(ofSize: 16, weight: .semibold) - l.textColor = UIColor(hexStr: "#333333") - return l - }() - - lazy var poiAddressLab: UILabel = { - let l = UILabel() - l.font = .systemFont(ofSize: 13) - l.textColor = UIColor(hexStr: "#999999") - l.numberOfLines = 2 - return l - }() - - lazy var confirmBtn: UIButton = { - let btn = UIButton(type: .custom) - btn.setTitle("确定位置", for: .normal) - btn.setTitleColor(.white, for: .normal) - btn.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) - btn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) - btn.cornerRadius = 22 - return btn - }() - - - #if !targetEnvironment(simulator) - func cleanupMap() { - mapView?.delegate = nil - mapView?.removeFromSuperview() - mapView = nil - } - #endif - - override init(frame: CGRect) { - super.init(frame: frame) - backgroundColor = .white - setupUI() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -}