Files
suixinkan_uikit/suixinkan/UI/PunchPoint/PunchPointDetailViewController.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
}
}