Meshtastic-Apple/Meshtastic/Views/Map/Custom/MapViewSwiftUI.swift

488 lines
18 KiB
Swift
Raw Normal View History

//
// MapViewSwitUI.swift
// Meshtastic
//
2023-01-13 22:30:10 -08:00
// Copyright(c) Josh Pirihi & Garth Vander Houwen 1/16/22.
import SwiftUI
import MapKit
func degreesToRadians(_ number: Double) -> Double {
return number * .pi / 180
}
struct MapViewSwiftUI: UIViewRepresentable {
2023-01-20 21:49:54 -08:00
var onLongPress: (_ waypointCoordinate: CLLocationCoordinate2D) -> Void
2023-01-16 23:16:57 -08:00
var onWaypointEdit: (_ waypointId: Int ) -> Void
2023-01-11 21:39:14 -08:00
let mapView = MKMapView()
2023-02-21 21:57:22 -08:00
let positions: [PositionEntity]
let waypoints: [WaypointEntity]
let mapViewType: MKMapType
let centeringMode: CenteringMode
let centerOnPositionsOnly: Bool
2023-02-22 16:12:34 -08:00
@AppStorage("meshMapRecentering") private var recenter = false
2023-02-24 21:14:08 -08:00
@AppStorage("meshMapUserTrackingMode") private var userTrackingModeId = 0
// Offline Maps
//make this view dependent on the UserDefault that is updated when importing a new map file
@AppStorage("lastUpdatedLocalMapFile") private var lastUpdatedLocalMapFile = 0
@State private var loadedLastUpdatedLocalMapFile = 0
var customMapOverlay: CustomMapOverlay?
@State private var presentCustomMapOverlayHash: CustomMapOverlay?
var overlays: [Overlay] = []
2023-01-13 15:58:20 -08:00
let dynamicRegion: Bool = true
func makeUIView(context: Context) -> MKMapView {
2023-01-13 22:30:10 -08:00
// Parameters
2023-02-21 21:57:22 -08:00
mapView.mapType = mapViewType
2023-01-14 11:26:32 -08:00
mapView.addAnnotations(waypoints)
2023-02-21 21:57:22 -08:00
// Logic to manage the map centering options
switch centeringMode {
case .allAnnotations:
mapView.addAnnotations(positions)
mapView.fitAllAnnotations()
case .allPositions:
mapView.fit(annotations: positions, andShow: true)
}
2023-02-21 21:57:22 -08:00
2023-01-13 22:30:10 -08:00
// Other MKMapView Settings
2023-02-22 20:31:08 -08:00
mapView.showsUserLocation = false
2023-02-21 19:03:11 -08:00
mapView.preferredConfiguration.elevationStyle = .realistic
2023-01-13 22:30:10 -08:00
mapView.isPitchEnabled = true
mapView.isRotateEnabled = true
mapView.isScrollEnabled = true
mapView.isZoomEnabled = true
mapView.showsBuildings = true
mapView.showsScale = true
2023-01-13 22:30:10 -08:00
mapView.showsTraffic = true
2023-02-21 21:57:22 -08:00
2023-02-22 13:16:33 -08:00
#if targetEnvironment(macCatalyst)
// Show the default always visible compass and the mac only controls
mapView.showsCompass = true
2023-01-13 22:30:10 -08:00
mapView.showsZoomControls = true
2023-02-21 19:03:11 -08:00
mapView.showsPitchControl = true
2023-02-22 13:16:33 -08:00
#else
#if os(iOS)
// Hide the default compass that only appears when you are not going north and instead always show the compass in the bottom right corner of the map
mapView.showsCompass = false
let compassButton = MKCompassButton(mapView: mapView) // Make a new compass
compassButton.compassVisibility = .visible // Make it visible
mapView.addSubview(compassButton) // Add it to the view
compassButton.translatesAutoresizingMaskIntoConstraints = false
compassButton.trailingAnchor.constraint(equalTo: mapView.trailingAnchor, constant: -5).isActive = true
compassButton.bottomAnchor.constraint(equalTo: mapView.bottomAnchor, constant: -25).isActive = true
2023-02-24 08:46:26 -08:00
2023-02-22 13:16:33 -08:00
#endif
#endif
mapView.delegate = context.coordinator
return mapView
}
func updateUIView(_ mapView: MKMapView, context: Context) {
mapView.mapType = mapViewType
2023-01-13 09:40:52 -08:00
if self.customMapOverlay != self.presentCustomMapOverlayHash || self.loadedLastUpdatedLocalMapFile != self.lastUpdatedLocalMapFile {
mapView.removeOverlays(mapView.overlays)
if self.customMapOverlay != nil {
2023-02-22 13:16:33 -08:00
2023-01-13 09:40:52 -08:00
let fileManager = FileManager.default
let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
let tilePath = documentsDirectory.appendingPathComponent("offline_map.mbtiles", isDirectory: false).path
if fileManager.fileExists(atPath: tilePath) {
print("Loading local map file")
if let overlay = LocalMBTileOverlay(mbTilePath: tilePath) {
overlay.canReplaceMapContent = false//customMapOverlay.canReplaceMapContent
mapView.addOverlay(overlay)
}
} else {
print("Couldn't find a local map file to load")
}
}
DispatchQueue.main.async {
self.presentCustomMapOverlayHash = self.customMapOverlay
self.loadedLastUpdatedLocalMapFile = self.lastUpdatedLocalMapFile
}
}
DispatchQueue.main.async {
let annotationCount = waypoints.count + positions.count
if annotationCount != mapView.annotations.count {
mapView.removeAnnotations(mapView.annotations)
mapView.addAnnotations(waypoints)
mapView.setUserTrackingMode(UserTrackingModes(rawValue: userTrackingModeId )?.MKUserTrackingModeValue() ?? MKUserTrackingMode.none, animated: true)
mapView.showsUserLocation = true
}
2023-02-22 08:09:38 -08:00
switch centeringMode {
case .allAnnotations:
if annotationCount != mapView.annotations.count {
mapView.addAnnotations(positions)
if recenter {
mapView.fitAllAnnotations()
}
}
case .allPositions:
if annotationCount != mapView.annotations.count {
2023-02-24 21:14:08 -08:00
if recenter {
mapView.fit(annotations: positions, andShow: true)
} else {
mapView.addAnnotations(positions)
2023-02-24 21:14:08 -08:00
}
}
2023-02-21 21:57:22 -08:00
}
}
}
func makeCoordinator() -> MapCoordinator {
2023-01-11 21:39:14 -08:00
return Coordinator(self)
}
2023-01-11 21:39:14 -08:00
final class MapCoordinator: NSObject, MKMapViewDelegate, UIGestureRecognizerDelegate {
var parent: MapViewSwiftUI
var longPressRecognizer = UILongPressGestureRecognizer()
2023-01-13 09:40:52 -08:00
var overlays: [Overlay] = []
2023-01-11 21:39:14 -08:00
init(_ parent: MapViewSwiftUI) {
self.parent = parent
super.init()
self.longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(longPressHandler))
2023-01-21 07:28:50 -08:00
self.longPressRecognizer.minimumPressDuration = 0.5
//self.longPressRecognizer.numberOfTouchesRequired = 1
2023-01-21 07:28:50 -08:00
self.longPressRecognizer.cancelsTouchesInView = true
self.longPressRecognizer.delegate = self
self.parent.mapView.addGestureRecognizer(longPressRecognizer)
2023-01-13 09:40:52 -08:00
self.overlays = []
2023-01-11 21:39:14 -08:00
}
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
switch annotation {
case _ as MKClusterAnnotation:
2023-02-21 19:07:23 -08:00
let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: "nodeGroup") as? MKMarkerAnnotationView ?? MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: "WaypointGroup")
2023-02-24 21:14:08 -08:00
annotationView.markerTintColor = .brown
2023-02-21 19:07:23 -08:00
annotationView.displayPriority = .defaultLow
2023-01-16 17:40:28 -08:00
annotationView.tag = -1
return annotationView
case let positionAnnotation as PositionEntity:
let reuseID = String(positionAnnotation.nodePosition?.num ?? 0) + "-" + String(positionAnnotation.time?.timeIntervalSince1970 ?? 0)
let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: "node") as? MKMarkerAnnotationView ?? MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: reuseID )
2023-01-16 17:40:28 -08:00
annotationView.tag = -1
annotationView.canShowCallout = true
if positionAnnotation.latest {
annotationView.markerTintColor = .systemRed
annotationView.displayPriority = .required
annotationView.titleVisibility = .visible
}
else {
annotationView.markerTintColor = UIColor(.indigo)
annotationView.displayPriority = .defaultHigh
annotationView.titleVisibility = .adaptive
}
2023-02-21 19:07:23 -08:00
annotationView.tag = -1
2023-02-21 19:03:11 -08:00
annotationView.canShowCallout = true
2023-01-21 07:28:50 -08:00
annotationView.titleVisibility = .adaptive
let leftIcon = UIImageView(image: annotationView.glyphText?.image())
leftIcon.backgroundColor = UIColor(.indigo)
annotationView.leftCalloutAccessoryView = leftIcon
let subtitle = UILabel()
subtitle.text = "Long Name: \(positionAnnotation.nodePosition?.user?.longName ?? "Unknown") \n"
subtitle.text! += "Latitude: \(String(format: "%.5f", positionAnnotation.coordinate.latitude)) \n"
subtitle.text! += "Longitude: \(String(format: "%.5f", positionAnnotation.coordinate.longitude)) \n"
let distanceFormatter = MKDistanceFormatter()
subtitle.text! += "Altitude: \(distanceFormatter.string(fromDistance: Double(positionAnnotation.altitude))) \n"
if positionAnnotation.nodePosition?.metadata != nil {
2023-02-21 19:03:11 -08:00
if DeviceRoles(rawValue: Int(positionAnnotation.nodePosition!.metadata?.role ?? 0)) == DeviceRoles.client ||
DeviceRoles(rawValue: Int(positionAnnotation.nodePosition!.metadata?.role ?? 0)) == DeviceRoles.clientMute ||
DeviceRoles(rawValue: Int(positionAnnotation.nodePosition!.metadata?.role ?? 0)) == DeviceRoles.routerClient{
annotationView.glyphImage = UIImage(systemName: "flipphone")
} else if DeviceRoles(rawValue: Int(positionAnnotation.nodePosition!.metadata?.role ?? 0)) == DeviceRoles.repeater {
annotationView.glyphImage = UIImage(systemName: "repeat")
} else if DeviceRoles(rawValue: Int(positionAnnotation.nodePosition!.metadata?.role ?? 0)) == DeviceRoles.router {
annotationView.glyphImage = UIImage(systemName: "wifi.router.fill")
} else if DeviceRoles(rawValue: Int(positionAnnotation.nodePosition!.metadata?.role ?? 0)) == DeviceRoles.tracker {
annotationView.glyphImage = UIImage(systemName: "location.viewfinder")
2023-02-24 21:14:08 -08:00
} else if DeviceRoles(rawValue: Int(positionAnnotation.nodePosition!.metadata?.role ?? 0)) == DeviceRoles.sensor {
annotationView.glyphImage = UIImage(systemName: "sensor")
2023-02-21 19:03:11 -08:00
}
let pf = PositionFlags(rawValue: Int(positionAnnotation.nodePosition?.metadata?.positionFlags ?? 3))
if pf.contains(.Satsinview) {
subtitle.text! += "Sats in view: \(String(positionAnnotation.satsInView)) \n"
2023-02-05 18:36:35 -08:00
}
if pf.contains(.SeqNo) {
subtitle.text! += "Sequence: \(String(positionAnnotation.seqNo)) \n"
}
if pf.contains(.Speed) {
let formatter = MeasurementFormatter()
formatter.locale = Locale.current
subtitle.text! += "Speed: \(formatter.string(from: Measurement(value: Double(positionAnnotation.speed), unit: UnitSpeed.kilometersPerHour))) \n"
}
if pf.contains(.Heading){
if parent.userTrackingModeId != 2 {
annotationView.glyphImage = UIImage(systemName: "location.north.fill")?.rotate(radians: Float(degreesToRadians(Double(positionAnnotation.heading))))
subtitle.text! += "Heading: \(String(positionAnnotation.heading)) \n"
} else {
annotationView.glyphImage = UIImage(systemName: "flipphone")
}
2023-02-05 18:36:35 -08:00
}
} else {
2023-02-21 19:03:11 -08:00
annotationView.glyphImage = UIImage(systemName: "flipphone")
}
subtitle.text! += positionAnnotation.time?.formatted() ?? "Unknown \n"
subtitle.numberOfLines = 0
annotationView.detailCalloutAccessoryView = subtitle
2023-02-05 18:36:35 -08:00
let detailsIcon = UIButton(type: .detailDisclosure)
detailsIcon.setImage(UIImage(systemName: "info.square"), for: .normal)
annotationView.rightCalloutAccessoryView = detailsIcon
return annotationView
2023-01-13 22:30:10 -08:00
case let waypointAnnotation as WaypointEntity:
let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: "waypoint") as? MKMarkerAnnotationView ?? MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: String(waypointAnnotation.id))
2023-01-16 17:40:28 -08:00
annotationView.tag = Int(waypointAnnotation.id)
2023-01-16 23:16:57 -08:00
annotationView.isEnabled = true
2023-01-13 22:30:10 -08:00
annotationView.canShowCallout = true
2023-01-14 11:26:32 -08:00
if waypointAnnotation.icon == 0 {
2023-01-15 08:49:17 -08:00
annotationView.glyphText = "📍"
2023-01-14 11:26:32 -08:00
} else {
2023-01-15 08:49:17 -08:00
annotationView.glyphText = String(UnicodeScalar(Int(waypointAnnotation.icon)) ?? "📍")
2023-01-14 11:26:32 -08:00
}
2023-01-13 22:30:10 -08:00
annotationView.clusteringIdentifier = "waypointGroup"
annotationView.markerTintColor = UIColor(.accentColor)
annotationView.displayPriority = .defaultHigh
2023-01-21 07:28:50 -08:00
annotationView.titleVisibility = .adaptive
let leftIcon = UIImageView(image: annotationView.glyphText?.image())
leftIcon.backgroundColor = UIColor(.accentColor)
annotationView.leftCalloutAccessoryView = leftIcon
2023-01-18 16:57:44 -08:00
let subtitle = UILabel()
2023-02-06 10:26:04 -08:00
if waypointAnnotation.longDescription?.count ?? 0 > 0 {
subtitle.text = (waypointAnnotation.longDescription ?? "") + "\n"
}
else {
subtitle.text = ""
}
if waypointAnnotation.created != nil {
subtitle.text! += "Created: \(waypointAnnotation.created?.formatted() ?? "Unknown") \n"
}
if waypointAnnotation.lastUpdated != nil {
subtitle.text! += "Updated: \(waypointAnnotation.lastUpdated?.formatted() ?? "Unknown") \n"
}
if waypointAnnotation.expire != nil {
subtitle.text! += "Expires: \(waypointAnnotation.expire?.formatted() ?? "Unknown") \n"
}
2023-01-18 16:57:44 -08:00
subtitle.numberOfLines = 0
annotationView.detailCalloutAccessoryView = subtitle
2023-01-18 16:57:44 -08:00
let editIcon = UIButton(type: .detailDisclosure)
editIcon.setImage(UIImage(systemName: "square.and.pencil"), for: .normal)
annotationView.rightCalloutAccessoryView = editIcon
2023-01-13 22:30:10 -08:00
return annotationView
default: return nil
}
}
2023-01-11 21:39:14 -08:00
2023-01-16 23:16:57 -08:00
func mapView(_ mapView: MKMapView, annotationView view: MKAnnotationView, calloutAccessoryControlTapped control: UIControl) {
2023-01-18 16:57:44 -08:00
// Only Allow Edit for waypoint annotations with a id
if view.tag > 0 {
parent.onWaypointEdit(view.tag)
}
2023-01-16 17:40:28 -08:00
}
@objc func longPressHandler(_ gesture: UILongPressGestureRecognizer) {
2023-01-26 16:19:51 -08:00
if gesture.state != UIGestureRecognizer.State.ended {
return
} else if gesture.state != UIGestureRecognizer.State.began {
// Screen Position - CGPoint
let location = longPressRecognizer.location(in: self.parent.mapView)
// Map Coordinate - CLLocationCoordinate2D
let coordinate = self.parent.mapView.convert(location, toCoordinateFrom: self.parent.mapView)
let annotation = MKPointAnnotation()
annotation.title = "Dropped Pin"
annotation.coordinate = coordinate
parent.mapView.addAnnotation(annotation)
UINotificationFeedbackGenerator().notificationOccurred(.success)
2023-01-20 21:49:54 -08:00
parent.onLongPress(coordinate)
}
2023-01-13 09:40:52 -08:00
}
public func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
2023-02-22 13:16:33 -08:00
2023-01-13 09:40:52 -08:00
if let index = self.overlays.firstIndex(where: { overlay_ in overlay_.shape.hash == overlay.hash }) {
2023-02-22 13:16:33 -08:00
2023-01-13 09:40:52 -08:00
let unwrappedOverlay = self.overlays[index]
if let circleOverlay = unwrappedOverlay.shape as? MKCircle {
let renderer = MKCircleRenderer(circle: circleOverlay)
renderer.fillColor = unwrappedOverlay.fillColor
renderer.strokeColor = unwrappedOverlay.strokeColor
renderer.lineWidth = unwrappedOverlay.lineWidth
return renderer
} else if let polygonOverlay = unwrappedOverlay.shape as? MKPolygon {
let renderer = MKPolygonRenderer(polygon: polygonOverlay)
renderer.fillColor = unwrappedOverlay.fillColor
renderer.strokeColor = unwrappedOverlay.strokeColor
renderer.lineWidth = unwrappedOverlay.lineWidth
return renderer
} else if let multiPolygonOverlay = unwrappedOverlay.shape as? MKMultiPolygon {
let renderer = MKMultiPolygonRenderer(multiPolygon: multiPolygonOverlay)
renderer.fillColor = unwrappedOverlay.fillColor
renderer.strokeColor = unwrappedOverlay.strokeColor
renderer.lineWidth = unwrappedOverlay.lineWidth
return renderer
} else if let polyLineOverlay = unwrappedOverlay.shape as? MKPolyline {
let renderer = MKPolylineRenderer(polyline: polyLineOverlay)
renderer.fillColor = unwrappedOverlay.fillColor
renderer.strokeColor = unwrappedOverlay.strokeColor
renderer.lineWidth = unwrappedOverlay.lineWidth
return renderer
} else if let multiPolylineOverlay = unwrappedOverlay.shape as? MKMultiPolyline {
let renderer = MKMultiPolylineRenderer(multiPolyline: multiPolylineOverlay)
renderer.fillColor = unwrappedOverlay.fillColor
renderer.strokeColor = unwrappedOverlay.strokeColor
renderer.lineWidth = unwrappedOverlay.lineWidth
return renderer
} else {
return MKOverlayRenderer()
}
} else if let tileOverlay = overlay as? MKTileOverlay {
return MKTileOverlayRenderer(tileOverlay: tileOverlay)
} else {
return MKOverlayRenderer()
}
}
}
/// is supposed to be located in the folder with the map name
public struct DefaultTile: Hashable {
let tileName: String
let tileType: String
2023-01-13 09:40:52 -08:00
public init(tileName: String, tileType: String) {
self.tileName = tileName
self.tileType = tileType
}
}
2023-01-13 09:40:52 -08:00
public struct CustomMapOverlay: Equatable, Hashable {
let mapName: String
let tileType: String
var canReplaceMapContent: Bool
var minimumZoomLevel: Int?
var maximumZoomLevel: Int?
let defaultTile: DefaultTile?
2023-01-13 09:40:52 -08:00
public init(
mapName: String,
tileType: String,
canReplaceMapContent: Bool = true, // false for transparent tiles
minimumZoomLevel: Int? = nil,
maximumZoomLevel: Int? = nil,
defaultTile: DefaultTile? = nil
) {
self.mapName = mapName
self.tileType = tileType
self.canReplaceMapContent = canReplaceMapContent
self.minimumZoomLevel = minimumZoomLevel
self.maximumZoomLevel = maximumZoomLevel
self.defaultTile = defaultTile
}
public init?(
mapName: String?,
tileType: String,
canReplaceMapContent: Bool = true, // false for transparent tiles
minimumZoomLevel: Int? = nil,
maximumZoomLevel: Int? = nil,
defaultTile: DefaultTile? = nil
) {
if (mapName == nil || mapName! == "") {
return nil
}
self.mapName = mapName!
self.tileType = tileType
self.canReplaceMapContent = canReplaceMapContent
self.minimumZoomLevel = minimumZoomLevel
self.maximumZoomLevel = maximumZoomLevel
self.defaultTile = defaultTile
}
}
2023-01-13 09:40:52 -08:00
public class CustomMapOverlaySource: MKTileOverlay {
2023-01-13 09:40:52 -08:00
// requires folder: tiles/{mapName}/z/y/y,{tileType}
private var parent: MapViewSwiftUI
private let mapName: String
private let tileType: String
private let defaultTile: DefaultTile?
2023-01-13 09:40:52 -08:00
public init(
parent: MapViewSwiftUI,
mapName: String,
tileType: String,
defaultTile: DefaultTile?
) {
self.parent = parent
self.mapName = mapName
self.tileType = tileType
self.defaultTile = defaultTile
super.init(urlTemplate: "")
}
2023-01-13 09:40:52 -08:00
public override func url(forTilePath path: MKTileOverlayPath) -> URL {
if let tileUrl = Bundle.main.url(
forResource: "\(path.y)",
withExtension: self.tileType,
subdirectory: "tiles/\(self.mapName)/\(path.z)/\(path.x)",
localization: nil
) {
return tileUrl
} else if let defaultTile = self.defaultTile, let defaultTileUrl = Bundle.main.url(
forResource: defaultTile.tileName,
withExtension: defaultTile.tileType,
subdirectory: "tiles/\(self.mapName)",
localization: nil
) {
return defaultTileUrl
} else {
let urlstring = self.mapName+"\(path.z)/\(path.x)/\(path.y).png"
return URL(string: urlstring)!
}
}
}
public struct Overlay {
public static func == (lhs: MapViewSwiftUI.Overlay, rhs: MapViewSwiftUI.Overlay) -> Bool {
// maybe to use in the future for comparison of full array
lhs.shape.coordinate.latitude == rhs.shape.coordinate.latitude &&
lhs.shape.coordinate.longitude == rhs.shape.coordinate.longitude &&
lhs.fillColor == rhs.fillColor
}
2023-01-13 09:40:52 -08:00
var shape: MKOverlay
var fillColor: UIColor?
var strokeColor: UIColor?
var lineWidth: CGFloat
2023-01-13 09:40:52 -08:00
public init(
shape: MKOverlay,
fillColor: UIColor? = nil,
strokeColor: UIColor? = nil,
lineWidth: CGFloat = 0
) {
self.shape = shape
self.fillColor = fillColor
self.strokeColor = strokeColor
self.lineWidth = lineWidth
}
}
}