jsdw_ios/QuickLocation/Section/Map/LocationPicker/LocationPickerViewControlle...

637 lines
26 KiB
Swift

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