953 lines
38 KiB
Swift
953 lines
38 KiB
Swift
//
|
||
// PunchPointDetailViewController.swift
|
||
// suixinkan
|
||
//
|
||
|
||
import CoreLocation
|
||
import Kingfisher
|
||
import Photos
|
||
import SnapKit
|
||
import UIKit
|
||
|
||
#if targetEnvironment(simulator)
|
||
import MapKit
|
||
#endif
|
||
|
||
/// 打卡点详情页,对齐 Android `PunchPointDetailScreen`。
|
||
final class PunchPointDetailViewController: BaseViewController {
|
||
private let viewModel: PunchPointDetailViewModel
|
||
private let api: any PunchPointAPIProtocol
|
||
|
||
private let mapView = PunchPointMapView()
|
||
private let zoomStack = UIStackView()
|
||
private let zoomInButton = PunchPointMapControlButton(assetName: "punch_point_zoom_in")
|
||
private let zoomOutButton = PunchPointMapControlButton(assetName: "punch_point_zoom_out")
|
||
private let locateButton = PunchPointMapControlButton(assetName: "punch_point_map_location")
|
||
private let cardView = UIView()
|
||
private let scrollView = UIScrollView()
|
||
private let contentStack = UIStackView()
|
||
private let bottomBar = UIView()
|
||
private let editButton = UIButton(type: .system)
|
||
private let emptyContainer = UIStackView()
|
||
private let emptyLabel = UILabel()
|
||
private let retryButton = UIButton(type: .system)
|
||
|
||
var onChanged: (() -> Void)?
|
||
|
||
/// 初始化打卡点详情页。
|
||
init(
|
||
punchPointId: Int64,
|
||
viewModel: PunchPointDetailViewModel? = nil,
|
||
api: (any PunchPointAPIProtocol)? = nil
|
||
) {
|
||
self.viewModel = viewModel ?? PunchPointDetailViewModel(punchPointId: punchPointId)
|
||
self.api = api ?? NetworkServices.shared.punchPointAPI
|
||
super.init(nibName: nil, bundle: nil)
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func setupNavigationBar() {
|
||
title = "打卡点详情"
|
||
}
|
||
|
||
override func setupUI() {
|
||
view.backgroundColor = AppColor.pageBackground
|
||
|
||
zoomStack.axis = .vertical
|
||
zoomStack.spacing = 0
|
||
zoomStack.addArrangedSubview(zoomInButton)
|
||
zoomStack.addArrangedSubview(zoomOutButton)
|
||
cardView.backgroundColor = .white
|
||
cardView.layer.cornerRadius = 12
|
||
cardView.clipsToBounds = true
|
||
scrollView.showsVerticalScrollIndicator = false
|
||
contentStack.axis = .vertical
|
||
contentStack.spacing = 12
|
||
bottomBar.backgroundColor = .white
|
||
editButton.setTitle("去编辑", for: .normal)
|
||
editButton.setTitleColor(.white, for: .normal)
|
||
editButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
||
editButton.backgroundColor = AppColor.primary
|
||
editButton.layer.cornerRadius = 12
|
||
|
||
emptyContainer.axis = .vertical
|
||
emptyContainer.alignment = .center
|
||
emptyContainer.spacing = 12
|
||
emptyLabel.text = "暂未获取到打卡点信息"
|
||
emptyLabel.textColor = AppColor.textSecondary
|
||
emptyLabel.font = .systemFont(ofSize: 15)
|
||
retryButton.setTitle("重新加载", for: .normal)
|
||
retryButton.setTitleColor(.white, for: .normal)
|
||
retryButton.backgroundColor = AppColor.primary
|
||
retryButton.layer.cornerRadius = 8
|
||
emptyContainer.addArrangedSubview(emptyLabel)
|
||
emptyContainer.addArrangedSubview(retryButton)
|
||
emptyContainer.isHidden = true
|
||
|
||
view.addSubview(mapView)
|
||
view.addSubview(zoomStack)
|
||
view.addSubview(locateButton)
|
||
view.addSubview(cardView)
|
||
cardView.addSubview(scrollView)
|
||
scrollView.addSubview(contentStack)
|
||
view.addSubview(bottomBar)
|
||
bottomBar.addSubview(editButton)
|
||
view.addSubview(emptyContainer)
|
||
}
|
||
|
||
override func setupConstraints() {
|
||
mapView.snp.makeConstraints { make in
|
||
make.top.equalTo(view.safeAreaLayoutGuide)
|
||
make.leading.trailing.equalToSuperview()
|
||
make.height.equalToSuperview().multipliedBy(0.5)
|
||
}
|
||
zoomStack.snp.makeConstraints { make in
|
||
make.trailing.equalToSuperview().inset(16)
|
||
make.bottom.equalTo(locateButton.snp.top).offset(-12)
|
||
}
|
||
locateButton.snp.makeConstraints { make in
|
||
make.trailing.equalToSuperview().inset(16)
|
||
make.bottom.equalTo(cardView.snp.top).offset(-16)
|
||
make.size.equalTo(40)
|
||
}
|
||
zoomInButton.snp.makeConstraints { make in
|
||
make.size.equalTo(40)
|
||
}
|
||
zoomOutButton.snp.makeConstraints { make in
|
||
make.size.equalTo(40)
|
||
}
|
||
bottomBar.snp.makeConstraints { make in
|
||
make.leading.trailing.bottom.equalToSuperview()
|
||
}
|
||
editButton.snp.makeConstraints { make in
|
||
make.top.equalToSuperview().offset(16)
|
||
make.leading.trailing.equalToSuperview().inset(16)
|
||
make.height.equalTo(48)
|
||
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-20)
|
||
}
|
||
cardView.snp.makeConstraints { make in
|
||
make.leading.trailing.equalToSuperview().inset(16)
|
||
make.bottom.equalTo(bottomBar.snp.top)
|
||
make.height.lessThanOrEqualToSuperview().multipliedBy(0.45)
|
||
make.top.greaterThanOrEqualTo(mapView.snp.top).offset(60)
|
||
}
|
||
scrollView.snp.makeConstraints { make in
|
||
make.edges.equalToSuperview()
|
||
}
|
||
contentStack.snp.makeConstraints { make in
|
||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 16, left: 12, bottom: 12, right: 12))
|
||
make.width.equalTo(scrollView.snp.width).offset(-24)
|
||
}
|
||
emptyContainer.snp.makeConstraints { make in
|
||
make.center.equalToSuperview()
|
||
}
|
||
retryButton.snp.makeConstraints { make in
|
||
make.width.equalTo(118)
|
||
make.height.equalTo(40)
|
||
}
|
||
}
|
||
|
||
override func bindActions() {
|
||
viewModel.onStateChange = { [weak self] in
|
||
Task { @MainActor in self?.applyViewModel() }
|
||
}
|
||
viewModel.onShowMessage = { [weak self] message in
|
||
Task { @MainActor in self?.showToast(message) }
|
||
}
|
||
zoomInButton.addTarget(self, action: #selector(zoomInTapped), for: .touchUpInside)
|
||
zoomOutButton.addTarget(self, action: #selector(zoomOutTapped), for: .touchUpInside)
|
||
locateButton.addTarget(self, action: #selector(locateTapped), for: .touchUpInside)
|
||
retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
|
||
editButton.addTarget(self, action: #selector(editTapped), for: .touchUpInside)
|
||
}
|
||
|
||
override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
Task { await viewModel.load(api: api) }
|
||
}
|
||
|
||
@MainActor
|
||
private func applyViewModel() {
|
||
viewModel.isLoading && viewModel.detail == nil ? showLoading() : hideLoading()
|
||
emptyContainer.isHidden = viewModel.detail != nil || viewModel.isLoading
|
||
guard let detail = viewModel.detail else {
|
||
cardView.isHidden = true
|
||
bottomBar.isHidden = true
|
||
return
|
||
}
|
||
cardView.isHidden = false
|
||
bottomBar.isHidden = false
|
||
if let region = detail.region {
|
||
let coordinate = CLLocationCoordinate2D(latitude: region.lat, longitude: region.lot)
|
||
mapView.updatePunchPointMarker(
|
||
coordinate: coordinate,
|
||
imageURL: detail.guideImages.first,
|
||
fitWithUserLocation: true
|
||
)
|
||
}
|
||
rebuildContent(detail)
|
||
}
|
||
|
||
private func rebuildContent(_ detail: PunchPointDetail) {
|
||
contentStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||
contentStack.addArrangedSubview(PunchPointReadOnlyField(title: "*打卡点名称", value: detail.name))
|
||
let coordinateText = detail.region.map { "\($0.lat),\($0.lot)" } ?? "--"
|
||
contentStack.addArrangedSubview(PunchPointReadOnlyField(
|
||
title: "*打卡点坐标",
|
||
value: coordinateText,
|
||
showsLocationIcon: true
|
||
))
|
||
contentStack.addArrangedSubview(PunchPointReadOnlyField(title: "*打卡点地址", value: detail.displayAddress.nonEmptyTrimmed ?? "--"))
|
||
if let description = detail.description?.nonEmptyTrimmed {
|
||
contentStack.addArrangedSubview(PunchPointReadOnlyField(title: "打卡点描述", value: description, multiline: true))
|
||
}
|
||
contentStack.addArrangedSubview(makeImageSection(detail.guideImages))
|
||
contentStack.addArrangedSubview(PunchPointRowField(title: "负责人", value: detail.creator?.nonEmptyTrimmed ?? "--"))
|
||
contentStack.addArrangedSubview(PunchPointDivider())
|
||
contentStack.addArrangedSubview(PunchPointRowField(title: "负责人手机号", value: PunchPointDisplayFormatter.maskedPhone(detail.creatorPhone)))
|
||
contentStack.addArrangedSubview(PunchPointDivider())
|
||
contentStack.addArrangedSubview(makeStatusRow(detail))
|
||
contentStack.addArrangedSubview(PunchPointDivider())
|
||
contentStack.addArrangedSubview(PunchPointRowField(title: "审核人", value: detail.auditor?.nonEmptyTrimmed ?? "--"))
|
||
contentStack.addArrangedSubview(PunchPointDivider())
|
||
contentStack.addArrangedSubview(PunchPointRowField(title: "审核时间", value: detail.auditTime?.nonEmptyTrimmed ?? "--"))
|
||
contentStack.addArrangedSubview(PunchPointDivider())
|
||
contentStack.addArrangedSubview(PunchPointRowField(title: "审核备注", value: detail.auditRemark?.nonEmptyTrimmed ?? "--"))
|
||
contentStack.addArrangedSubview(makeQRSection(detail.mpQrcode))
|
||
}
|
||
|
||
private func makeImageSection(_ urls: [String]) -> UIView {
|
||
let section = UIStackView()
|
||
section.axis = .vertical
|
||
section.spacing = 8
|
||
let title = UILabel()
|
||
title.attributedText = requiredTitle("*上传图片 (最多9张)")
|
||
section.addArrangedSubview(title)
|
||
guard !urls.isEmpty else {
|
||
let empty = UILabel()
|
||
empty.text = "暂无图片"
|
||
empty.font = .systemFont(ofSize: 12)
|
||
empty.textColor = AppColor.textSecondary
|
||
section.addArrangedSubview(empty)
|
||
return section
|
||
}
|
||
let scroll = UIScrollView()
|
||
scroll.showsHorizontalScrollIndicator = false
|
||
let row = UIStackView()
|
||
row.axis = .horizontal
|
||
row.spacing = 12
|
||
scroll.addSubview(row)
|
||
row.snp.makeConstraints { make in
|
||
make.edges.equalToSuperview()
|
||
make.height.equalTo(90)
|
||
}
|
||
urls.enumerated().forEach { index, urlText in
|
||
let imageView = UIImageView()
|
||
imageView.contentMode = .scaleAspectFill
|
||
imageView.clipsToBounds = true
|
||
imageView.layer.cornerRadius = 10
|
||
imageView.isUserInteractionEnabled = true
|
||
imageView.tag = index
|
||
if let url = URL(string: urlText) {
|
||
imageView.kf.setImage(with: url, placeholder: UIImage(named: "SplashLogo"))
|
||
} else {
|
||
imageView.image = UIImage(named: "SplashLogo")
|
||
}
|
||
imageView.snp.makeConstraints { make in
|
||
make.width.equalTo(120)
|
||
}
|
||
imageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(imageTapped(_:))))
|
||
row.addArrangedSubview(imageView)
|
||
}
|
||
scroll.snp.makeConstraints { make in
|
||
make.height.equalTo(90)
|
||
}
|
||
section.addArrangedSubview(scroll)
|
||
return section
|
||
}
|
||
|
||
private func makeStatusRow(_ detail: PunchPointDetail) -> UIView {
|
||
let row = UIStackView()
|
||
row.axis = .horizontal
|
||
row.alignment = .center
|
||
row.distribution = .equalSpacing
|
||
let label = UILabel()
|
||
label.text = "审核状态"
|
||
label.font = .systemFont(ofSize: 14)
|
||
label.textColor = UIColor(hex: 0x2E3746)
|
||
let chip = PunchPointStatusChip()
|
||
chip.apply(PunchPointDisplayFormatter.auditStatus(detail.status), compact: false)
|
||
row.addArrangedSubview(label)
|
||
row.addArrangedSubview(chip)
|
||
row.isLayoutMarginsRelativeArrangement = true
|
||
row.layoutMargins = UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0)
|
||
return row
|
||
}
|
||
|
||
private func makeQRSection(_ qrCode: String?) -> UIView {
|
||
let section = UIStackView()
|
||
section.axis = .vertical
|
||
section.alignment = .center
|
||
section.spacing = 12
|
||
let title = UILabel()
|
||
title.text = "打卡点二维码"
|
||
title.font = .systemFont(ofSize: 14)
|
||
title.textColor = UIColor(hex: 0x2E3746)
|
||
title.textAlignment = .left
|
||
section.addArrangedSubview(title)
|
||
title.snp.makeConstraints { make in
|
||
make.width.equalTo(section.snp.width)
|
||
}
|
||
guard let qrCode = qrCode?.nonEmptyTrimmed else {
|
||
let empty = UILabel()
|
||
empty.text = "暂无二维码"
|
||
empty.font = .systemFont(ofSize: 12)
|
||
empty.textColor = AppColor.textSecondary
|
||
section.addArrangedSubview(empty)
|
||
return section
|
||
}
|
||
let imageView = UIImageView()
|
||
imageView.contentMode = .scaleAspectFill
|
||
imageView.clipsToBounds = true
|
||
imageView.layer.cornerRadius = 16
|
||
if let url = URL(string: qrCode) {
|
||
imageView.kf.setImage(with: url, placeholder: UIImage(named: "SplashLogo"))
|
||
} else {
|
||
imageView.image = UIImage(named: "SplashLogo")
|
||
}
|
||
imageView.snp.makeConstraints { make in
|
||
make.size.equalTo(180)
|
||
}
|
||
let download = UIButton(type: .system)
|
||
download.setTitle("下载二维码", for: .normal)
|
||
download.setTitleColor(AppColor.primary, for: .normal)
|
||
download.titleLabel?.font = .systemFont(ofSize: 14)
|
||
download.backgroundColor = AppColor.primaryLight
|
||
download.layer.cornerRadius = 8
|
||
download.snp.makeConstraints { make in
|
||
make.height.equalTo(38)
|
||
make.width.equalTo(128)
|
||
}
|
||
download.addAction(UIAction { [weak self] _ in
|
||
self?.downloadQRCode(qrCode)
|
||
}, for: .touchUpInside)
|
||
section.addArrangedSubview(imageView)
|
||
section.addArrangedSubview(download)
|
||
return section
|
||
}
|
||
|
||
private func downloadQRCode(_ urlText: String) {
|
||
guard let url = URL(string: urlText) else {
|
||
showToast("二维码地址无效")
|
||
return
|
||
}
|
||
Task {
|
||
do {
|
||
let authorization = await PHPhotoLibrary.requestAuthorization(for: .addOnly)
|
||
guard authorization == .authorized || authorization == .limited else {
|
||
await MainActor.run { self.presentPhotoPermissionAlert() }
|
||
return
|
||
}
|
||
let (data, _) = try await URLSession.shared.data(from: url)
|
||
guard let image = UIImage(data: data) else { throw APIError.decodeFailed("二维码图片无效") }
|
||
try await PHPhotoLibrary.shared().performChanges {
|
||
PHAssetChangeRequest.creationRequestForAsset(from: image)
|
||
}
|
||
await MainActor.run { self.showToast("已保存到相册") }
|
||
} catch {
|
||
await MainActor.run { self.showToast(error.localizedDescription.isEmpty ? "保存失败" : error.localizedDescription) }
|
||
}
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private func presentPhotoPermissionAlert() {
|
||
let alert = UIAlertController(
|
||
title: "无法保存二维码",
|
||
message: "相册权限未授予,请在设置中开启",
|
||
preferredStyle: .alert
|
||
)
|
||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||
alert.addAction(UIAlertAction(title: "去设置", style: .default) { _ in
|
||
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
||
UIApplication.shared.open(url)
|
||
})
|
||
present(alert, animated: true)
|
||
}
|
||
|
||
private func requiredTitle(_ text: String) -> NSAttributedString {
|
||
let mutable = NSMutableAttributedString(string: text, attributes: [
|
||
.font: UIFont.systemFont(ofSize: 14, weight: .medium),
|
||
.foregroundColor: UIColor(hex: 0x2E3746),
|
||
])
|
||
if text.hasPrefix("*") {
|
||
mutable.addAttribute(.foregroundColor, value: AppColor.danger, range: NSRange(location: 0, length: 1))
|
||
}
|
||
return mutable
|
||
}
|
||
|
||
@objc private func imageTapped(_ gesture: UITapGestureRecognizer) {
|
||
guard let imageView = gesture.view, let urls = viewModel.detail?.guideImages else { return }
|
||
presentImagePreview(imageURLs: urls, startIndex: imageView.tag)
|
||
}
|
||
|
||
@objc private func zoomInTapped() {
|
||
mapView.zoomIn()
|
||
}
|
||
|
||
@objc private func zoomOutTapped() {
|
||
mapView.zoomOut()
|
||
}
|
||
|
||
@objc private func locateTapped() {
|
||
Task {
|
||
do {
|
||
let snapshot = try await LocationProvider.shared.requestSnapshot()
|
||
let coordinate = CLLocationCoordinate2D(
|
||
latitude: snapshot.latitude,
|
||
longitude: snapshot.longitude
|
||
)
|
||
await MainActor.run { self.mapView.setCenter(coordinate, zoomLevel: 16) }
|
||
} catch LocationProviderError.permissionDenied {
|
||
await MainActor.run { self.presentLocationPermissionAlert() }
|
||
} catch {
|
||
await MainActor.run { self.showToast("定位失败") }
|
||
}
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private func presentLocationPermissionAlert() {
|
||
let alert = UIAlertController(
|
||
title: "无法定位",
|
||
message: "定位权限未授予,请在设置中开启",
|
||
preferredStyle: .alert
|
||
)
|
||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||
alert.addAction(UIAlertAction(title: "去设置", style: .default) { _ in
|
||
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
||
UIApplication.shared.open(url)
|
||
})
|
||
present(alert, animated: true)
|
||
}
|
||
|
||
@objc private func retryTapped() {
|
||
Task { await viewModel.load(api: api) }
|
||
}
|
||
|
||
@objc private func editTapped() {
|
||
guard let detail = viewModel.detail else { return }
|
||
let controller = PunchPointFormViewController(mode: .edit(id: detail.id), initialDetail: detail)
|
||
controller.onSubmitSuccess = { [weak self] in
|
||
guard let self else { return }
|
||
self.onChanged?()
|
||
Task { await self.viewModel.load(api: self.api) }
|
||
}
|
||
navigationController?.pushViewController(controller, animated: true)
|
||
}
|
||
}
|
||
|
||
/// 高德地图容器,供打卡点详情与表单复用。
|
||
/// 打卡点地图视图,模拟器使用 MapKit 提供 marker、缩放、定位与点击选点行为。
|
||
#if targetEnvironment(simulator)
|
||
final class PunchPointMapView: UIView, MKMapViewDelegate {
|
||
var onMapTap: ((CLLocationCoordinate2D) -> Void)?
|
||
|
||
private let mapView: MKMapView
|
||
private var punchPointAnnotation: MKPointAnnotation?
|
||
private var selectedAnnotation: MKPointAnnotation?
|
||
private var punchPointImageURL: URL?
|
||
private var fitWithUserLocation = false
|
||
|
||
override init(frame: CGRect) {
|
||
mapView = MKMapView(frame: .zero)
|
||
super.init(frame: frame)
|
||
mapView.delegate = self
|
||
mapView.showsUserLocation = true
|
||
mapView.showsCompass = false
|
||
mapView.showsScale = false
|
||
mapView.pointOfInterestFilter = .includingAll
|
||
addSubview(mapView)
|
||
mapView.snp.makeConstraints { make in
|
||
make.edges.equalToSuperview()
|
||
}
|
||
|
||
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(mapTapped(_:)))
|
||
tapGesture.cancelsTouchesInView = false
|
||
mapView.addGestureRecognizer(tapGesture)
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
/// 更新原打卡点标记,可使用首张图片作为圆角 Marker。
|
||
func updatePunchPointMarker(
|
||
coordinate: CLLocationCoordinate2D?,
|
||
imageURL: String? = nil,
|
||
fitWithUserLocation: Bool = false
|
||
) {
|
||
if let punchPointAnnotation {
|
||
mapView.removeAnnotation(punchPointAnnotation)
|
||
self.punchPointAnnotation = nil
|
||
}
|
||
punchPointImageURL = imageURL.flatMap(URL.init(string:))
|
||
self.fitWithUserLocation = fitWithUserLocation
|
||
guard let coordinate, CLLocationCoordinate2DIsValid(coordinate) else { return }
|
||
let annotation = MKPointAnnotation()
|
||
annotation.coordinate = coordinate
|
||
annotation.title = "punch_point_original"
|
||
punchPointAnnotation = annotation
|
||
mapView.addAnnotation(annotation)
|
||
if fitWithUserLocation {
|
||
fitPunchPointAndUserLocation()
|
||
}
|
||
}
|
||
|
||
/// 兼容仅需更新普通 Marker 的调用。
|
||
func updateMarker(coordinate: CLLocationCoordinate2D?) {
|
||
updatePunchPointMarker(coordinate: coordinate)
|
||
}
|
||
|
||
/// 更新地图点击产生的新位置 Marker。
|
||
func updateSelectedMarker(coordinate: CLLocationCoordinate2D?) {
|
||
if let selectedAnnotation {
|
||
mapView.removeAnnotation(selectedAnnotation)
|
||
self.selectedAnnotation = nil
|
||
}
|
||
guard let coordinate, CLLocationCoordinate2DIsValid(coordinate) else { return }
|
||
let annotation = MKPointAnnotation()
|
||
annotation.coordinate = coordinate
|
||
annotation.title = "punch_point_selected"
|
||
selectedAnnotation = annotation
|
||
mapView.addAnnotation(annotation)
|
||
}
|
||
|
||
/// 设置地图中心点。
|
||
func setCenter(_ coordinate: CLLocationCoordinate2D, zoomLevel: Double = 15, animated: Bool = true) {
|
||
let delta = min(max(0.012 * pow(2, 15 - zoomLevel), 0.0005), 120)
|
||
let region = MKCoordinateRegion(
|
||
center: coordinate,
|
||
span: MKCoordinateSpan(latitudeDelta: delta, longitudeDelta: delta)
|
||
)
|
||
mapView.setRegion(region, animated: animated)
|
||
}
|
||
|
||
/// 放大地图。
|
||
func zoomIn() {
|
||
updateZoom(scale: 0.5)
|
||
}
|
||
|
||
/// 缩小地图。
|
||
func zoomOut() {
|
||
updateZoom(scale: 2)
|
||
}
|
||
|
||
/// 居中到用户当前位置。
|
||
func centerOnUserLocation() {
|
||
guard let coordinate = mapView.userLocation.location?.coordinate,
|
||
CLLocationCoordinate2DIsValid(coordinate),
|
||
coordinate.latitude != 0 || coordinate.longitude != 0 else { return }
|
||
setCenter(coordinate)
|
||
}
|
||
|
||
/// 同时展示原打卡点、用户位置和新选位置。
|
||
func fitAllMarkers(edgePadding: UIEdgeInsets = UIEdgeInsets(top: 80, left: 56, bottom: 80, right: 56)) {
|
||
var annotations: [any MKAnnotation] = []
|
||
if let punchPointAnnotation { annotations.append(punchPointAnnotation) }
|
||
if let selectedAnnotation { annotations.append(selectedAnnotation) }
|
||
if mapView.userLocation.location != nil { annotations.append(mapView.userLocation) }
|
||
guard !annotations.isEmpty else { return }
|
||
if annotations.count == 1, let annotation = annotations.first {
|
||
setCenter(annotation.coordinate, zoomLevel: 16)
|
||
} else {
|
||
var mapRect = MKMapRect.null
|
||
for annotation in annotations {
|
||
let point = MKMapPoint(annotation.coordinate)
|
||
mapRect = mapRect.union(MKMapRect(
|
||
origin: point,
|
||
size: MKMapSize(width: 1, height: 1)
|
||
))
|
||
}
|
||
mapView.setVisibleMapRect(mapRect, edgePadding: edgePadding, animated: true)
|
||
}
|
||
}
|
||
|
||
func mapView(_ mapView: MKMapView, viewFor annotation: any MKAnnotation) -> MKAnnotationView? {
|
||
if annotation is MKUserLocation { return nil }
|
||
let isOriginal = (annotation as AnyObject) === punchPointAnnotation
|
||
let reuseIdentifier = isOriginal ? "PunchPointImageMarker" : "PunchPointSelectedMarker"
|
||
let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: reuseIdentifier)
|
||
?? MKAnnotationView(annotation: annotation, reuseIdentifier: reuseIdentifier)
|
||
annotationView.annotation = annotation
|
||
annotationView.canShowCallout = false
|
||
annotationView.centerOffset = CGPoint(x: 0, y: -12)
|
||
|
||
if isOriginal, let punchPointImageURL {
|
||
annotationView.image = UIImage(named: "punch_point_marker")
|
||
KingfisherManager.shared.retrieveImage(with: punchPointImageURL) { result in
|
||
guard case let .success(value) = result else { return }
|
||
Task { @MainActor in
|
||
guard annotationView.annotation === annotation else { return }
|
||
annotationView.image = Self.roundedMarkerImage(value.image)
|
||
}
|
||
}
|
||
} else {
|
||
annotationView.image = UIImage(named: "punch_point_marker")
|
||
}
|
||
return annotationView
|
||
}
|
||
|
||
func mapView(_ mapView: MKMapView, didUpdate userLocation: MKUserLocation) {
|
||
guard userLocation.location != nil else { return }
|
||
fitPunchPointAndUserLocation()
|
||
}
|
||
|
||
private func fitPunchPointAndUserLocation() {
|
||
guard fitWithUserLocation else { return }
|
||
fitAllMarkers(edgePadding: UIEdgeInsets(top: 80, left: 50, bottom: 80, right: 50))
|
||
}
|
||
|
||
private static func roundedMarkerImage(_ source: UIImage) -> UIImage {
|
||
let size = CGSize(width: 40, height: 40)
|
||
return UIGraphicsImageRenderer(size: size).image { _ in
|
||
UIBezierPath(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: 6).addClip()
|
||
source.draw(in: CGRect(origin: .zero, size: size))
|
||
}
|
||
}
|
||
|
||
@objc private func mapTapped(_ gesture: UITapGestureRecognizer) {
|
||
guard gesture.state == .ended else { return }
|
||
let point = gesture.location(in: mapView)
|
||
onMapTap?(mapView.convert(point, toCoordinateFrom: mapView))
|
||
}
|
||
|
||
private func updateZoom(scale: Double) {
|
||
let current = mapView.region
|
||
let span = MKCoordinateSpan(
|
||
latitudeDelta: min(max(current.span.latitudeDelta * scale, 0.0005), 120),
|
||
longitudeDelta: min(max(current.span.longitudeDelta * scale, 0.0005), 120)
|
||
)
|
||
mapView.setRegion(MKCoordinateRegion(center: current.center, span: span), animated: true)
|
||
}
|
||
}
|
||
#else
|
||
/// 打卡点地图视图,真机封装高德地图 marker、缩放、定位与点击选点行为。
|
||
final class PunchPointMapView: UIView, MAMapViewDelegate {
|
||
var onMapTap: ((CLLocationCoordinate2D) -> Void)?
|
||
|
||
private let mapView: MAMapView
|
||
private var punchPointAnnotation: MAPointAnnotation?
|
||
private var selectedAnnotation: MAPointAnnotation?
|
||
private var punchPointImageURL: URL?
|
||
private var fitWithUserLocation = false
|
||
|
||
override init(frame: CGRect) {
|
||
_ = AMapBootstrap.configureIfNeeded()
|
||
mapView = MAMapView(frame: .zero)
|
||
super.init(frame: frame)
|
||
mapView.delegate = self
|
||
mapView.showsUserLocation = true
|
||
mapView.showsCompass = false
|
||
mapView.showsScale = false
|
||
mapView.touchPOIEnabled = true
|
||
mapView.zoomLevel = 15
|
||
addSubview(mapView)
|
||
mapView.snp.makeConstraints { make in
|
||
make.edges.equalToSuperview()
|
||
}
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
/// 更新原打卡点标记,可使用首张图片作为 Android 同款圆角 Marker。
|
||
func updatePunchPointMarker(
|
||
coordinate: CLLocationCoordinate2D?,
|
||
imageURL: String? = nil,
|
||
fitWithUserLocation: Bool = false
|
||
) {
|
||
if let punchPointAnnotation {
|
||
mapView.removeAnnotation(punchPointAnnotation)
|
||
self.punchPointAnnotation = nil
|
||
}
|
||
punchPointImageURL = imageURL.flatMap(URL.init(string:))
|
||
self.fitWithUserLocation = fitWithUserLocation
|
||
guard let coordinate, CLLocationCoordinate2DIsValid(coordinate) else { return }
|
||
let annotation = MAPointAnnotation()
|
||
annotation.coordinate = coordinate
|
||
annotation.title = "punch_point_original"
|
||
punchPointAnnotation = annotation
|
||
mapView.addAnnotation(annotation)
|
||
if fitWithUserLocation {
|
||
fitPunchPointAndUserLocation()
|
||
}
|
||
}
|
||
|
||
/// 兼容仅需更新普通 Marker 的调用。
|
||
func updateMarker(coordinate: CLLocationCoordinate2D?) {
|
||
updatePunchPointMarker(coordinate: coordinate)
|
||
}
|
||
|
||
/// 更新地图点击产生的新位置 Marker。
|
||
func updateSelectedMarker(coordinate: CLLocationCoordinate2D?) {
|
||
if let selectedAnnotation {
|
||
mapView.removeAnnotation(selectedAnnotation)
|
||
self.selectedAnnotation = nil
|
||
}
|
||
guard let coordinate, CLLocationCoordinate2DIsValid(coordinate) else { return }
|
||
let annotation = MAPointAnnotation()
|
||
annotation.coordinate = coordinate
|
||
annotation.title = "punch_point_selected"
|
||
selectedAnnotation = annotation
|
||
mapView.addAnnotation(annotation)
|
||
}
|
||
|
||
/// 设置地图中心点。
|
||
func setCenter(_ coordinate: CLLocationCoordinate2D, zoomLevel: Double = 15, animated: Bool = true) {
|
||
mapView.setCenter(coordinate, animated: animated)
|
||
mapView.setZoomLevel(zoomLevel, animated: animated)
|
||
}
|
||
|
||
/// 放大地图。
|
||
func zoomIn() {
|
||
mapView.setZoomLevel(mapView.zoomLevel + 1, animated: true)
|
||
}
|
||
|
||
/// 缩小地图。
|
||
func zoomOut() {
|
||
mapView.setZoomLevel(mapView.zoomLevel - 1, animated: true)
|
||
}
|
||
|
||
/// 居中到用户当前位置。
|
||
func centerOnUserLocation() {
|
||
guard let coordinate = mapView.userLocation.location?.coordinate,
|
||
CLLocationCoordinate2DIsValid(coordinate),
|
||
coordinate.latitude != 0 || coordinate.longitude != 0 else { return }
|
||
setCenter(coordinate)
|
||
}
|
||
|
||
/// 同时展示原打卡点、用户位置和新选位置,并留出 Android 同款边距。
|
||
func fitAllMarkers(edgePadding: UIEdgeInsets = UIEdgeInsets(top: 80, left: 56, bottom: 80, right: 56)) {
|
||
var annotations: [MAAnnotation] = []
|
||
if let punchPointAnnotation { annotations.append(punchPointAnnotation) }
|
||
if let selectedAnnotation { annotations.append(selectedAnnotation) }
|
||
if let userLocation = mapView.userLocation,
|
||
let location = userLocation.location,
|
||
CLLocationCoordinate2DIsValid(location.coordinate) {
|
||
annotations.append(userLocation)
|
||
}
|
||
guard !annotations.isEmpty else { return }
|
||
if annotations.count == 1, let annotation = annotations.first {
|
||
setCenter(annotation.coordinate, zoomLevel: 16)
|
||
} else {
|
||
mapView.showAnnotations(annotations, edgePadding: edgePadding, animated: true)
|
||
}
|
||
}
|
||
|
||
private func fitPunchPointAndUserLocation() {
|
||
guard fitWithUserLocation else { return }
|
||
fitAllMarkers(edgePadding: UIEdgeInsets(top: 80, left: 50, bottom: 80, right: 50))
|
||
}
|
||
|
||
func mapView(_ mapView: MAMapView!, viewFor annotation: MAAnnotation!) -> MAAnnotationView! {
|
||
if annotation is MAUserLocation { return nil }
|
||
let isOriginal = annotation === punchPointAnnotation
|
||
let reuseIdentifier = isOriginal ? "PunchPointImageMarker" : "PunchPointSelectedMarker"
|
||
let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: reuseIdentifier)
|
||
?? MAAnnotationView(annotation: annotation, reuseIdentifier: reuseIdentifier)
|
||
annotationView?.annotation = annotation
|
||
annotationView?.canShowCallout = false
|
||
annotationView?.centerOffset = CGPoint(x: 0, y: -12)
|
||
|
||
if isOriginal, let punchPointImageURL {
|
||
annotationView?.image = UIImage(named: "punch_point_marker")
|
||
KingfisherManager.shared.retrieveImage(with: punchPointImageURL) { result in
|
||
guard case let .success(value) = result else { return }
|
||
Task { @MainActor in
|
||
guard annotationView?.annotation === annotation else { return }
|
||
annotationView?.image = Self.roundedMarkerImage(value.image)
|
||
}
|
||
}
|
||
} else {
|
||
annotationView?.image = UIImage(named: "punch_point_marker")
|
||
}
|
||
return annotationView
|
||
}
|
||
|
||
func mapView(_ mapView: MAMapView!, didUpdate userLocation: MAUserLocation!, updatingLocation: Bool) {
|
||
guard updatingLocation, userLocation.location != nil else { return }
|
||
fitPunchPointAndUserLocation()
|
||
}
|
||
|
||
private static func roundedMarkerImage(_ source: UIImage) -> UIImage {
|
||
let size = CGSize(width: 40, height: 40)
|
||
return UIGraphicsImageRenderer(size: size).image { _ in
|
||
UIBezierPath(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: 6).addClip()
|
||
source.draw(in: CGRect(origin: .zero, size: size))
|
||
}
|
||
}
|
||
|
||
func mapView(_ mapView: MAMapView!, didSingleTappedAt coordinate: CLLocationCoordinate2D) {
|
||
onMapTap?(coordinate)
|
||
}
|
||
|
||
func mapView(_ mapView: MAMapView!, didTouchPois pois: [Any]!) {
|
||
guard let poi = pois.first as? MATouchPoi else { return }
|
||
onMapTap?(poi.coordinate)
|
||
}
|
||
}
|
||
#endif
|
||
|
||
/// 打卡点地图控制按钮。
|
||
/// 地图悬浮控制按钮。
|
||
final class PunchPointMapControlButton: UIButton {
|
||
init(assetName: String) {
|
||
super.init(frame: .zero)
|
||
setImage(UIImage(named: assetName), for: .normal)
|
||
backgroundColor = .white
|
||
layer.cornerRadius = 12
|
||
clipsToBounds = true
|
||
imageView?.contentMode = .scaleAspectFit
|
||
accessibilityLabel = switch assetName {
|
||
case "punch_point_zoom_in": "放大地图"
|
||
case "punch_point_zoom_out": "缩小地图"
|
||
default: "定位当前位置"
|
||
}
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
}
|
||
|
||
/// 打卡点只读字段。
|
||
/// 详情页只读字段块。
|
||
final class PunchPointReadOnlyField: UIView {
|
||
init(title: String, value: String, multiline: Bool = false, showsLocationIcon: Bool = false) {
|
||
super.init(frame: .zero)
|
||
let stack = UIStackView()
|
||
stack.axis = .vertical
|
||
stack.spacing = 8
|
||
let titleLabel = UILabel()
|
||
titleLabel.attributedText = Self.requiredTitle(title)
|
||
let valueContainer = UIView()
|
||
valueContainer.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||
valueContainer.layer.cornerRadius = 8
|
||
let valueLabel = UILabel()
|
||
valueLabel.text = value.nonEmptyTrimmed ?? "--"
|
||
valueLabel.font = .systemFont(ofSize: 14)
|
||
valueLabel.textColor = UIColor(hex: 0x4B5563)
|
||
valueLabel.numberOfLines = multiline ? 0 : 2
|
||
stack.addArrangedSubview(titleLabel)
|
||
stack.addArrangedSubview(valueContainer)
|
||
valueContainer.addSubview(valueLabel)
|
||
if showsLocationIcon {
|
||
let icon = UIImageView(image: UIImage(named: "punch_point_location"))
|
||
icon.contentMode = .scaleAspectFit
|
||
valueContainer.addSubview(icon)
|
||
icon.snp.makeConstraints { make in
|
||
make.trailing.equalToSuperview().inset(12)
|
||
make.centerY.equalToSuperview()
|
||
make.size.equalTo(20)
|
||
}
|
||
valueLabel.snp.makeConstraints { make in
|
||
make.top.bottom.equalToSuperview().inset(12)
|
||
make.leading.equalToSuperview().offset(12)
|
||
make.trailing.lessThanOrEqualTo(icon.snp.leading).offset(-8)
|
||
}
|
||
} else {
|
||
valueLabel.snp.makeConstraints { make in
|
||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12))
|
||
}
|
||
}
|
||
valueContainer.snp.makeConstraints { make in
|
||
make.height.greaterThanOrEqualTo(multiline ? 72 : 46)
|
||
}
|
||
addSubview(stack)
|
||
stack.snp.makeConstraints { make in
|
||
make.edges.equalToSuperview()
|
||
}
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
private static func requiredTitle(_ text: String) -> NSAttributedString {
|
||
let mutable = NSMutableAttributedString(string: text, attributes: [
|
||
.font: UIFont.systemFont(ofSize: 14, weight: .medium),
|
||
.foregroundColor: UIColor(hex: 0x2E3746),
|
||
])
|
||
if text.hasPrefix("*") {
|
||
mutable.addAttribute(.foregroundColor, value: AppColor.danger, range: NSRange(location: 0, length: 1))
|
||
}
|
||
return mutable
|
||
}
|
||
}
|
||
|
||
/// 打卡点左右字段。
|
||
/// 详情页横向键值字段。
|
||
final class PunchPointRowField: UIView {
|
||
init(title: String, value: String) {
|
||
super.init(frame: .zero)
|
||
let titleLabel = UILabel()
|
||
titleLabel.text = title
|
||
titleLabel.font = .systemFont(ofSize: 14)
|
||
titleLabel.textColor = UIColor(hex: 0x2E3746)
|
||
let valueLabel = UILabel()
|
||
valueLabel.text = value.nonEmptyTrimmed ?? "--"
|
||
valueLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||
valueLabel.textColor = .black
|
||
valueLabel.textAlignment = .right
|
||
valueLabel.numberOfLines = 2
|
||
addSubview(titleLabel)
|
||
addSubview(valueLabel)
|
||
titleLabel.snp.makeConstraints { make in
|
||
make.leading.top.bottom.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0))
|
||
}
|
||
valueLabel.snp.makeConstraints { make in
|
||
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(12)
|
||
make.trailing.centerY.equalToSuperview()
|
||
}
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
}
|
||
|
||
/// 打卡点详情分割线。
|
||
/// 详情卡片内分割线。
|
||
final class PunchPointDivider: UIView {
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
backgroundColor = UIColor(hex: 0xF0F0F0)
|
||
snp.makeConstraints { make in
|
||
make.height.equalTo(1)
|
||
}
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
}
|
||
|
||
private extension String {
|
||
var nonEmptyTrimmed: String? {
|
||
let text = trimmingCharacters(in: .whitespacesAndNewlines)
|
||
return text.isEmpty ? nil : text
|
||
}
|
||
}
|