// // 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