ios – Can a polyline be bound to an annotation?


I am creating a map app for an autonomous robot. I am having difficulty with annotations and polylines moving together when dragging the annotation. Currently the annotation will move and then after a brief pause the polyline will move and catch up to it.

I want both to move in unison with a “rubber band” behavior. I have not been able to find a tutorial or example of dragging an annotation that has polylines connected to it when dragging. Thanks for any help/guidance you may give.

// LAMErtkAnnotationManager.swift
// LAME-RTK
// Created: 11/19/2024

import Foundation
import MapboxMaps
import CoreData
import Turf
import UIKit

// MARK: - Notification Extension

extension Notification.Name {
    /// Notification posted when an annotation's coordinate is updated.
    static let didUpdateAnnotation = Notification.Name("didUpdateAnnotation")
}

// MARK: - LAMErtkAnnotationManager

/// Manages annotations (drag handles) and polylines on the Mapbox map.
/// Ensures that dragging an annotation updates the corresponding polyline in real-time.
class LAMErtkAnnotationManager: NSObject, AnnotationInteractionDelegate {

    // MARK: - Properties

    /// The MapView instance where annotations and polylines are managed.
    var mapView: MapView

    /// The Core Data context for fetching and saving GPS data points.
    var viewContext: NSManagedObjectContext

    /// Manager for point annotations (drag handles).
    var pointAnnotationManager: PointAnnotationManager

    /// Manager for polyline annotations.
    var polylineAnnotationManager: PolylineAnnotationManager

    /// Dictionary mapping `zoneID` to its corresponding polyline annotation.
    private var polylinesByZone: [String: PolylineAnnotation] = [:]

    /// Dictionary to keep track of the last known coordinates of annotations.
    /// Used to detect changes during dragging.
    private var annotationCoordinates: [String: CLLocationCoordinate2D] = [:]

    /// Timer to periodically check for annotation coordinate changes.
    /// Since MapboxMaps SDK lacks direct drag event delegates, this timer facilitates synchronization.
    private var annotationCheckTimer: Timer?

    // MARK: - Initialization

    /// Initializes the `LAMErtkAnnotationManager` with the provided `MapView` and `NSManagedObjectContext`.
    ///
    /// - Parameters:
    ///   - mapView: The `MapView` instance where annotations and polylines are managed.
    ///   - viewContext: The Core Data context for fetching and saving GPS data points.
    init(mapView: MapView, viewContext: NSManagedObjectContext) {
        self.mapView = mapView
        self.viewContext = viewContext
        self.pointAnnotationManager = mapView.annotations.makePointAnnotationManager()
        self.polylineAnnotationManager = mapView.annotations.makePolylineAnnotationManager()
        super.init()

        // Assign the delegate for interaction events.
        self.pointAnnotationManager.delegate = self
        self.polylineAnnotationManager.delegate = self

        print("***** LAMErtkAnnotationManager initialized.")

        // Initialize stored annotation coordinates.
        initializeAnnotationCoordinates()

        // Start the timer to check for annotation updates every second.
        annotationCheckTimer = Timer.scheduledTimer(timeInterval: 1.0,
                                                   target: self,
                                                   selector: #selector(checkForAnnotationUpdates),
                                                   userInfo: nil,
                                                   repeats: true)

        // Observe annotation update notifications.
        NotificationCenter.default.addObserver(self,
                                               selector: #selector(handleAnnotationUpdate(notification:)),
                                               name: .didUpdateAnnotation,
                                               object: nil)
    }

    deinit {
        // Invalidate the timer and remove observers to prevent memory leaks.
        annotationCheckTimer?.invalidate()
        NotificationCenter.default.removeObserver(self)
    }

    // MARK: - Annotation Management

    /// Clears all point annotations and polylines from the map.
    func clearAllAnnotations() {
        pointAnnotationManager.annotations.removeAll()
        polylineAnnotationManager.annotations.removeAll()
        polylinesByZone.removeAll()
        annotationCoordinates.removeAll()
        print("***** All annotations cleared.")
    }

    /// Loads all point annotations and corresponding polylines from Core Data.
    func loadAllAnnotations() {
        clearAllAnnotations()
        loadPointAnnotations()
        loadPolylines()
        print("***** All annotations loaded.")
    }

    /// Loads point annotations (drag handles) from Core Data.
    private func loadPointAnnotations() {
        let fetchRequest: NSFetchRequest = GPSDataPoint.fetchRequest()
        fetchRequest.sortDescriptors = [
            NSSortDescriptor(key: "zoneID", ascending: true),
            NSSortDescriptor(key: "order", ascending: true)
        ]

        do {
            let gpsDataPoints = try viewContext.fetch(fetchRequest)
            pointAnnotationManager.annotations = gpsDataPoints.compactMap { point -> PointAnnotation? in
                guard let zoneID = point.zoneID else { return nil }
                let coordinate = CLLocationCoordinate2D(latitude: point.latitude, longitude: point.longitude)
                var annotation = PointAnnotation(coordinate: coordinate)
                annotation.isDraggable = true
                annotation.customData = ["zoneID": .string(zoneID)]
                annotation.iconImage = "diamondFill" // Set the icon image to "diamondFill"

                // Store the initial coordinates.
                annotationCoordinates[zoneID] = coordinate

                return annotation
            }
            print("***** Loaded \(pointAnnotationManager.annotations.count) point annotations.")
        } catch {
            print("***** Error fetching point annotations: \(error.localizedDescription)")
        }
    }

    /// Loads polylines from Core Data, grouping them by `zoneID`.
    private func loadPolylines() {
        let fetchRequest: NSFetchRequest = GPSDataPoint.fetchRequest()
        fetchRequest.sortDescriptors = [
            NSSortDescriptor(key: "zoneID", ascending: true),
            NSSortDescriptor(key: "order", ascending: true)
        ]

        do {
            let gpsDataPoints = try viewContext.fetch(fetchRequest)
            let groupedPoints = Dictionary(grouping: gpsDataPoints, by: { $0.zoneID ?? "default" })

            for (zoneID, points) in groupedPoints {
                let coordinates = points.map { CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude) }
                if let lineString = try? LineString(coordinates) {
                    var polyline = PolylineAnnotation(lineString: lineString)
                    polyline.lineColor = StyleColor(.systemBlue)
                    polyline.lineWidth = 3.0
                    polyline.customData = ["zoneID": .string(zoneID)]
                    polylineAnnotationManager.annotations.append(polyline)
                    polylinesByZone[zoneID] = polyline
                    print("***** Added polyline for zoneID: \(zoneID) with \(coordinates.count) points.")
                }
            }
        } catch {
            print("***** Error fetching polylines: \(error.localizedDescription)")
        }
    }

    /// Initializes the stored coordinates from current annotations.
    private func initializeAnnotationCoordinates() {
        for annotation in pointAnnotationManager.annotations {
            if let zoneIDValue = annotation.customData["zoneID"],
               case let .string(zoneID) = zoneIDValue {
                annotationCoordinates[zoneID] = annotation.coordinate
            }
        }
    }

    // MARK: - Dynamic Polyline Updates with Turf

    /// Updates the polyline associated with a given `zoneID` using Turf.
    ///
    /// - Parameter zoneID: The `zoneID` whose polyline should be updated.
    func updatePolyline(for zoneID: String) {
        guard var polyline = polylinesByZone[zoneID] else { return }
        let updatedCoordinates = pointAnnotationManager.annotations.compactMap { annotation -> CLLocationCoordinate2D? in
            guard let annotationZoneID = annotation.customData["zoneID"],
                  case let .string(annotationZone) = annotationZoneID,
                  annotationZone == zoneID else { return nil }
            return annotation.coordinate
        }
        guard !updatedCoordinates.isEmpty else { return }

        // Use Turf to validate and create a new LineString.
        if let updatedLineString = try? LineString(updatedCoordinates) {
            polyline.lineString = updatedLineString
            polylineAnnotationManager.annotations = polylineAnnotationManager.annotations.map { $0.id == polyline.id ? polyline : $0 }
            print("***** Updated polyline for zoneID: \(zoneID) with \(updatedCoordinates.count) points.")
        } else {
            print("***** Error creating LineString for zoneID: \(zoneID).")
        }
    }

    // MARK: - AnnotationInteractionDelegate

    /// Handles tap interactions on annotations.
    ///
    /// - Parameters:
    ///   - manager: The `AnnotationManager` instance.
    ///   - annotations: The list of annotations that were tapped.
    func annotationManager(_ manager: AnnotationManager, didDetectTappedAnnotations annotations: [Annotation]) {
        for annotation in annotations {
            if let point = annotation as? PointAnnotation,
               let zoneIDValue = point.customData["zoneID"],
               case let .string(zoneID) = zoneIDValue {
                updatePolyline(for: zoneID)
            }
        }
    }

    // MARK: - Handling Annotation Drags

    /// Handles updates to annotations when they are dragged.
    /// Since MapboxMaps SDK doesn't provide direct drag event delegates, this method is triggered via notifications.
    ///
    /// - Parameter notification: The notification containing the updated annotation.
    @objc private func handleAnnotationUpdate(notification: Notification) {
        guard let userInfo = notification.userInfo,
              let updatedAnnotation = userInfo["annotation"] as? PointAnnotation,
              let zoneIDValue = updatedAnnotation.customData["zoneID"] else { return }

        // Use pattern matching to extract the string from JSONValue
        guard case let .string(zoneID) = zoneIDValue else { return }

        let newCoordinate = updatedAnnotation.coordinate
        print("***** Annotation for zoneID: \(zoneID) moved to new coordinate: \(newCoordinate)")

        // Update the corresponding GPSDataPoint in Core Data.
        let fetchRequest: NSFetchRequest = GPSDataPoint.fetchRequest()
        fetchRequest.predicate = NSPredicate(format: "zoneID == %@", zoneID)
        fetchRequest.sortDescriptors = [NSSortDescriptor(key: "order", ascending: true)]
        fetchRequest.fetchLimit = 1

        do {
            if let gpsDataPoint = try viewContext.fetch(fetchRequest).first {
                gpsDataPoint.latitude = newCoordinate.latitude
                gpsDataPoint.longitude = newCoordinate.longitude
                try viewContext.save()
                print("***** Updated GPSDataPoint for zoneID: \(zoneID) with new coordinates.")
            }
        } catch {
            print("***** Error updating GPSDataPoint for zoneID: \(zoneID): \(error.localizedDescription)")
        }

        // Update the corresponding polyline.
        updatePolyline(for: zoneID)
    }

    // MARK: - Timer for Checking Annotation Updates

    /// Periodically checks for changes in annotation coordinates to detect drags.
    @objc private func checkForAnnotationUpdates() {
        for annotation in pointAnnotationManager.annotations {
            if let zoneIDValue = annotation.customData["zoneID"],
               case let .string(zoneID) = zoneIDValue,
               let oldCoordinate = annotationCoordinates[zoneID],
               oldCoordinate.latitude != annotation.coordinate.latitude ||
               oldCoordinate.longitude != annotation.coordinate.longitude {

                // Update the stored coordinate.
                annotationCoordinates[zoneID] = annotation.coordinate

                // Post a notification with the updated annotation.
                NotificationCenter.default.post(name: .didUpdateAnnotation,
                                                object: nil,
                                                userInfo: ["annotation": annotation])
            }
        }
    }
}


Discover more from TrendyShopToBuy

Subscribe to get the latest posts sent to your email.

Latest articles

spot_imgspot_img

Related articles

Leave a Reply

spot_imgspot_img

Discover more from TrendyShopToBuy

Subscribe now to keep reading and get access to the full archive.

Continue reading