Files
location-spoofer/App/MapViewRepresentable.swift
T

320 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import CoreLocation
import Foundation
import MapKit
import SwiftUI
import UIKit
enum MapZoomMath {
private static let minimumDelta = 0.000_05
private static let maximumLatitudeDelta = 170.0
private static let maximumLongitudeDelta = 360.0
static func scaledSpan(_ span: MKCoordinateSpan, factor: Double) -> MKCoordinateSpan {
guard factor.isFinite, factor > 0 else { return span }
return MKCoordinateSpan(
latitudeDelta: min(max(span.latitudeDelta * factor, minimumDelta), maximumLatitudeDelta),
longitudeDelta: min(max(span.longitudeDelta * factor, minimumDelta), maximumLongitudeDelta)
)
}
static func viewportScaleLabel(distanceMeters: CLLocationDistance) -> String {
let meters = max(0, distanceMeters)
if meters < 1_000 {
return "\(Int(meters.rounded())) m"
}
let kilometers = meters / 1_000
if kilometers < 10 {
return String(format: "%.1f km", kilometers)
}
return "\(Int(kilometers.rounded())) km"
}
}
struct MapViewRepresentable: UIViewRepresentable {
let selection: MapSelection
let cameraCommand: MapCameraCommand?
let onRealtimeLocationChanged: (CLLocation) -> Void
let onUserCenterChanged: (CLLocationCoordinate2D, CLLocationDistance) -> Void
let onViewportChanged: (CLLocationDistance) -> Void
let onMapTap: (CLLocationCoordinate2D) -> Void
let onUserZoomChanged: ((CLLocationDistance) -> Void)?
var onZoomIn: (() -> Void)?
var onZoomOut: (() -> Void)?
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
func makeUIView(context: Context) -> MKMapView {
let map = MKMapView()
map.delegate = context.coordinator
map.showsUserLocation = true
let initialDistance = ViewportStore.loadOrDefault()
map.setRegion(
MKCoordinateRegion(
center: selection.coordinate,
latitudinalMeters: initialDistance,
longitudinalMeters: initialDistance
),
animated: false
)
// Center pin — positioned relative to geographic center, not Auto Layout
let pinSize: CGFloat = 38
let sizeCfg = UIImage.SymbolConfiguration(pointSize: pinSize, weight: .semibold)
let paletteCfg = UIImage.SymbolConfiguration(paletteColors: [.white, .red])
let pinImage = UIImage(systemName: "mappin",
withConfiguration: sizeCfg.applying(paletteCfg))
let pin = UIImageView(image: pinImage)
pin.frame = CGRect(x: 0, y: 0, width: pinSize, height: pinSize)
pin.isUserInteractionEnabled = false
pin.layer.shadowColor = UIColor.black.cgColor
pin.layer.shadowOpacity = 0.28
pin.layer.shadowRadius = 5
pin.layer.shadowOffset = CGSize(width: 0, height: 3)
map.addSubview(pin)
context.coordinator.centerPin = pin
// Zoom controls — UIKit subviews inside MKMapView, move with the map
let zoomStack = UIStackView()
zoomStack.axis = .vertical
zoomStack.spacing = 0
zoomStack.translatesAutoresizingMaskIntoConstraints = false
zoomStack.alignment = .center
zoomStack.backgroundColor = UIColor.systemBackground.withAlphaComponent(0.8)
zoomStack.layer.cornerRadius = 13
zoomStack.layer.shadowColor = UIColor.black.cgColor
zoomStack.layer.shadowOpacity = 0.18
zoomStack.layer.shadowRadius = 7
zoomStack.layer.shadowOffset = CGSize(width: 0, height: 3)
let zoomInBtn = UIButton(type: .system)
zoomInBtn.setImage(UIImage(systemName: "plus", withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .bold)), for: .normal)
zoomInBtn.addTarget(context.coordinator, action: #selector(Coordinator.zoomInTapped), for: .touchUpInside)
zoomInBtn.heightAnchor.constraint(equalToConstant: 52).isActive = true
zoomInBtn.widthAnchor.constraint(equalToConstant: 52).isActive = true
let zoomLabel = UILabel()
let roundedDesc = UIFont.systemFont(ofSize: 9, weight: .semibold).fontDescriptor.withDesign(.rounded)
zoomLabel.font = roundedDesc.flatMap { UIFont(descriptor: $0, size: 9) } ?? .systemFont(ofSize: 9, weight: .semibold)
zoomLabel.textColor = .secondaryLabel
zoomLabel.textAlignment = .center
zoomLabel.adjustsFontSizeToFitWidth = true
zoomLabel.minimumScaleFactor = 0.7
zoomLabel.text = MapZoomMath.viewportScaleLabel(distanceMeters: ViewportStore.loadOrDefault())
zoomLabel.heightAnchor.constraint(equalToConstant: 28).isActive = true
context.coordinator.zoomLabel = zoomLabel
let zoomOutBtn = UIButton(type: .system)
zoomOutBtn.setImage(UIImage(systemName: "minus", withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .bold)), for: .normal)
zoomOutBtn.addTarget(context.coordinator, action: #selector(Coordinator.zoomOutTapped), for: .touchUpInside)
zoomOutBtn.heightAnchor.constraint(equalToConstant: 52).isActive = true
zoomOutBtn.widthAnchor.constraint(equalToConstant: 52).isActive = true
let sep1 = UIView(); sep1.translatesAutoresizingMaskIntoConstraints = false
sep1.heightAnchor.constraint(equalToConstant: 0.5).isActive = true
sep1.backgroundColor = .separator
sep1.widthAnchor.constraint(equalToConstant: 28).isActive = true
let sep2 = UIView(); sep2.translatesAutoresizingMaskIntoConstraints = false
sep2.heightAnchor.constraint(equalToConstant: 0.5).isActive = true
sep2.backgroundColor = .separator
sep2.widthAnchor.constraint(equalToConstant: 28).isActive = true
zoomStack.addArrangedSubview(zoomInBtn)
zoomStack.addArrangedSubview(sep1)
zoomStack.addArrangedSubview(zoomLabel)
zoomStack.addArrangedSubview(sep2)
zoomStack.addArrangedSubview(zoomOutBtn)
map.addSubview(zoomStack)
NSLayoutConstraint.activate([
zoomStack.leadingAnchor.constraint(equalTo: map.safeAreaLayoutGuide.leadingAnchor, constant: 16),
zoomStack.topAnchor.constraint(equalTo: map.safeAreaLayoutGuide.topAnchor, constant: 130),
])
let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(_:)))
tap.cancelsTouchesInView = false
map.addGestureRecognizer(tap)
context.coordinator.map = map
context.coordinator.setupKeyboardObservers()
return map
}
func updateUIView(_ map: MKMapView, context: Context) {
context.coordinator.parent = self
context.coordinator.consume(cameraCommand, on: map)
}
final class Coordinator: NSObject, MKMapViewDelegate {
var parent: MapViewRepresentable
weak var map: MKMapView?
private var lastConsumedCommandID: UInt64?
private var activeCameraCommandID: UInt64?
private var activeCommandIsZoom = false
private var regionChangeWasUserDriven = false
private var isPinchZoom = false
weak var zoomLabel: UILabel?
weak var centerPin: UIImageView?
private let pinSize: CGFloat = 38
// 蓝点实际大小从 MKUserLocationView 取,默认 20pt
private var userDotDiameter: CGFloat = 20
@objc func zoomInTapped() { parent.onZoomIn?() }
@objc func zoomOutTapped() { parent.onZoomOut?() }
init(parent: MapViewRepresentable) {
self.parent = parent
}
private func updatePinPosition(on mapView: MKMapView) {
guard let pin = centerPin else { return }
let centerPt = mapView.convert(mapView.centerCoordinate, toPointTo: mapView)
// pin 尖在底边,上移自身一半 + 蓝点半径对齐
pin.center = CGPoint(
x: centerPt.x,
y: centerPt.y - pinSize / 2 + 5
)
}
func setupKeyboardObservers() {
let nc = NotificationCenter.default
nc.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { [weak self] _ in
guard let self, let map = self.map else { return }
self.updatePinPosition(on: map)
}
nc.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { [weak self] _ in
guard let self, let map = self.map else { return }
self.updatePinPosition(on: map)
}
}
func consume(_ command: MapCameraCommand?, on map: MKMapView) {
guard let command, command.id != lastConsumedCommandID else { return }
lastConsumedCommandID = command.id
activeCameraCommandID = command.id
activeCommandIsZoom = command.kind.isZoom
regionChangeWasUserDriven = false
map.userTrackingMode = .none
let region: MKCoordinateRegion
switch command.kind {
case let .focus(coordinate, _):
// 保持当前 span 不变,只移动中心点,避免 latitudinalMeters
// 与 visibleVerticalDistance 之间因屏幕宽高比引入 2x 漂移
region = MKCoordinateRegion(center: coordinate, span: map.region.span)
case let .zoom(factor):
region = MKCoordinateRegion(
center: map.centerCoordinate,
span: MapZoomMath.scaledSpan(map.region.span, factor: factor)
)
}
map.setRegion(region, animated: true)
}
@objc func handleTap(_ gesture: UITapGestureRecognizer) {
guard gesture.state == .ended, let map else { return }
let point = gesture.location(in: map)
parent.onMapTap(map.convert(point, toCoordinateFrom: map))
}
func mapView(_ mapView: MKMapView, didAdd views: [MKAnnotationView]) {
for view in views where view.annotation is MKUserLocation {
// 取蓝点实际大小,隐藏精度圈
userDotDiameter = view.bounds.width
for sub in view.subviews where sub.bounds.width > userDotDiameter + 4 {
sub.isHidden = true
}
}
}
func mapView(_ mapView: MKMapView, didUpdate userLocation: MKUserLocation) {
guard let location = userLocation.location,
CLLocationCoordinate2DIsValid(location.coordinate),
location.horizontalAccuracy >= 0 else { return }
parent.onRealtimeLocationChanged(location)
}
func mapView(_ mapView: MKMapView, regionWillChangeAnimated animated: Bool) {
let activeRecognizers = ([mapView as UIView] + mapView.subviews)
.compactMap(\.gestureRecognizers)
.flatMap { $0 }
.filter { recognizer in
recognizer.state == .began || recognizer.state == .changed
}
let hasActiveGesture = !activeRecognizers.isEmpty
// Pinching updates the viewport/name granularity only. MapKit can
// keep its pan recognizer active during a pinch, so only a pure pan
// is allowed to replace the selected center coordinate.
let hasActivePan = activeRecognizers.contains { $0 is UIPanGestureRecognizer }
let hasActivePinch = activeRecognizers.contains { $0 is UIPinchGestureRecognizer }
regionChangeWasUserDriven = hasActivePan && !hasActivePinch
isPinchZoom = hasActivePinch
if hasActiveGesture {
activeCameraCommandID = nil
activeCommandIsZoom = false
}
}
func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
let distance = visibleVerticalDistance(in: mapView)
parent.onViewportChanged(distance)
zoomLabel?.text = MapZoomMath.viewportScaleLabel(distanceMeters: distance)
updatePinPosition(on: mapView)
// 推算瓦片类型:比较地图中心与蓝点的偏移
// 蓝点是 WGS-84,地图中心在 GCJ-02 瓦片上会有 ~596m 偏移
let mc = mapView.centerCoordinate
if let bl = mapView.userLocation.location?.coordinate {
let wgs = CoordinateConverter.gcj02ToWgs84(lat: mc.latitude, lon: mc.longitude)
let dGCJ = CoordinateConverter.distance(lat1: wgs.lat, lon1: wgs.lon, lat2: bl.latitude, lon2: bl.longitude)
let dRaw = CoordinateConverter.distance(lat1: mc.latitude, lon1: mc.longitude, lat2: bl.latitude, lon2: bl.longitude)
// GCJ→WGS 后更接近蓝点 = 地图是 GCJ-02;原始更接近 = 地图是 WGS-84
let type: CoordinateConverter.CoordType = dGCJ < dRaw ? .gcj02 : .wgs84
if type != CoordinateConverter.currentTileType {
CoordinateConverter.currentTileType = type
RuntimeLogger.info("APP", "坐标转换", "推算瓦片 → \(type.rawValue)", details: [
"dGCJ": String(format: "%.0fm", dGCJ), "dRaw": String(format: "%.0fm", dRaw)
])
}
} else {
// 无蓝点,用坐标地理边界兜底
CoordinateConverter.updateTileType(lat: mc.latitude, lon: mc.longitude)
}
// 同步蓝点坐标(避免 delegate 更新不及时导致 mapState.realtimeLocation 为 nil
if let ul = mapView.userLocation.location,
CLLocationCoordinate2DIsValid(ul.coordinate), ul.horizontalAccuracy >= 0 {
parent.onRealtimeLocationChanged(ul)
}
let userZoomed: Bool
if activeCameraCommandID != nil {
userZoomed = activeCommandIsZoom
activeCameraCommandID = nil
activeCommandIsZoom = false
} else if isPinchZoom {
userZoomed = true
isPinchZoom = false
} else {
userZoomed = false
}
if userZoomed {
parent.onUserZoomChanged?(distance)
} else if regionChangeWasUserDriven {
parent.onUserCenterChanged(mapView.centerCoordinate, distance)
}
regionChangeWasUserDriven = false
}
private func visibleVerticalDistance(in map: MKMapView) -> CLLocationDistance {
let centerX = map.bounds.midX
let north = map.convert(CGPoint(x: centerX, y: map.bounds.minY), toCoordinateFrom: map)
let south = map.convert(CGPoint(x: centerX, y: map.bounds.maxY), toCoordinateFrom: map)
let northLocation = CLLocation(latitude: north.latitude, longitude: north.longitude)
let southLocation = CLLocation(latitude: south.latitude, longitude: south.longitude)
let measured = northLocation.distance(from: southLocation)
return measured.isFinite && measured > 0 ? measured : 1_000
}
}
}