iosswiftmkannotationcalloutview

Swift -How to Update Data in Custom MKAnnotation Callout?


I have a custom annotation for my mapView. I initially set the coordinate, title (eg. "first title"), subTitle (eg. "first address"), userId, and a distance (eg. 0 meters) property on it with some data. I add it to the mapView and to an array for later use. Everything works, it shows on the mapView, I press it and the callout shows that initial data.

I later get updated that the location for that callout has changed. I loop through the array and update the callout with new data for the coordinate, title (eg. "new title"), subTitle (eg. "new address"), and distance (eg. 100 meters) properties. I also animate the callout from it's original location to it's new location. The animation works fine and the callout moves from point A to point B.

The problem is when I tap the annotation the old data gets shown on the callout instead of the new data.

I use calloutAccessoryControlTapped to push on a new vc. When i put a breakpoint there the custom pin has all the new data. The error seems to happen with the callout.

How do I fix this?

I don't want to clear all the annotations from the mapView so that's not an option. I call mapView.removeAnnotation(customPin) and mapView.addAnnotation(customPin) which fixes the problem for that pin but there is a blink when the pin is removed and added back to the map and then when it animates to it's new location it looks choppy.

Custom Annotation

class CustomPin: NSObject, MKAnnotation {

    @objc dynamic var coordinate: CLLocationCoordinate2D
    var title: String?
    var subtitle: String?
    var userId: String?
    var distance: CLLocationDistance?

    init(coordinate: CLLocationCoordinate2D, title: String, subtitle: String, userId: String, distance: CLLocationDistance?) {

        self.coordinate = coordinate
        self.title = title
        self.subtitle = subtitle
        self.userId = userId
        self.distance = distance

        super.init()
    }
}

First time the annotation gets set with initial data

firstFunctionThatGetsTheInitialLocation(origLat, origLon) {

   let firstCoordinate = CLLocationCoordinate2DMake(origLat, origLon)   

   let distanceInMeters: CLLocationDistance = self.center.distance(from: anotherUsersLocation)

   let customPin = CustomPin(coordinate: firstCoordinate, title: "first title", subtitle: "first address", userId: "12345", distance: distance)

    DispatchQueue.main.async { [weak self] in

      self?.mapView.addAnnotation(customPin)

      self?.arrOfPins.append(customPin)
    }
}

Second time the annotation gets set with New Data

secondFunctionThatGetsTheNewLocation(newCoordinate: CLLocationCoordinate2D, newDistance: CLLocationDistance) {

    for pin in customPins {

        pin.title = "second title" // ** updates but the callout doesn't reflect it
        pin.subTitle = "second address" // ** updates but the callout doesn't reflect it
        pin.distance = newDistance // ** updates but the callout doesn't reflect it

       // calling these gives me the new data but the annotation blinks and moves really fast to it's new location
       // mapView.removeAnnotation(pin)
       // mapView.addAnnotation(pin)

        UIView.animate(withDuration: 1) {
            pin.coordinate = newCoordinate // this updates and animates to the new location with no problem
        }
    }
}

MapView viewFor annotation

func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {

    if annotation.isKind(of: MKUserLocation.self) { return nil }

    guard let annotation = annotation as? CustomPin else { return nil }

    let reuseIdentifier = "CustomPin"

    var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: reuseIdentifier)

    if annotationView == nil {
        annotationView = MKAnnotationView(annotation: annotation, reuseIdentifier: reuseIdentifier)
        annotationView?.canShowCallout = true
        annotationView?.calloutOffset = CGPoint(x: -5, y: 5)

        annotationView?.rightCalloutAccessoryView = UIButton(type: .detailDisclosure)

        annotationView?.image = UIImage(named: "chevronImage")

    } else {
        annotationView?.annotation = annotation
    }

    annotationView?.detailCalloutAccessoryView = nil
    annotationView?.detailCalloutAccessoryView = createCallOutWithDataFrom(customPin: annotation)

    return annotationView
}

Creation of UIView for Callout

func createCallOutWithDataFrom(customPin: CustomPin) -> UIView {

    let titleText = customPin.title
    let subTitleText = customPin.subTitle
    let distanceText = subTitle.distance // gets converted to a string

    // 1. create a UIView
    // 2. create some labels and add the text from the title, subTitle, and distance and add them as subViews to the UIView
    // 3. return the UIView
}

Solution

  • There are a few issues:

    1. You need to use the @objc dynamic qualifier for any properties you want to observe. The standard callout performs Key-Value Observation (KVO) on title and subtitle. (And the annotation view observes changes to coordinate.)

    2. If you want to observe userid and distance, you have to make those @objc dynamic as well. Note, you’ll have to make distance be non-optional to make that observable:

      var distance: CLLocationDistance
      

      So:

      class CustomAnnotation: NSObject, MKAnnotation {
          // standard MKAnnotation properties
      
          @objc dynamic var coordinate: CLLocationCoordinate2D
          @objc dynamic var title: String?
          @objc dynamic var subtitle: String?
      
          // additional custom properties
      
          @objc dynamic var userId: String
          @objc dynamic var distance: CLLocationDistance
      
          init(coordinate: CLLocationCoordinate2D, title: String, subtitle: String, userId: String, distance: CLLocationDistance) {
              self.userId = userId
              self.distance = distance
              self.coordinate = coordinate
              self.title = title
              self.subtitle = subtitle
      
              super.init()
          }
      }
      
    3. Like I said, the standard callout observes title and subtitle. While you have to make the annotation properties observable, if you’re going to build your own detailCalloutAccessoryView, you’re going to have to do your own KVO:

      class CustomAnnotationView: MKMarkerAnnotationView {
          private let customClusteringIdentifier = "..."
      
          override init(annotation: MKAnnotation?, reuseIdentifier: String?) {
              super.init(annotation: annotation, reuseIdentifier: reuseIdentifier)
              canShowCallout = true
              detailCalloutAccessoryView = createCallOutWithDataFrom(customAnnotation: annotation as? CustomAnnotation)
              clusteringIdentifier = customClusteringIdentifier
          }
      
          required init?(coder aDecoder: NSCoder) {
              fatalError("init(coder:) has not been implemented")
          }
      
          deinit {
              removeAnyObservers()
          }
      
          override var annotation: MKAnnotation? {
              didSet {
                  removeAnyObservers()
                  clusteringIdentifier = customClusteringIdentifier
                  if let customAnnotation = annotation as? CustomAnnotation {
                      updateAndAddObservers(for: customAnnotation)
                  }
              }
          }
      
          private var subtitleObserver: NSKeyValueObservation?
          private var userObserver: NSKeyValueObservation?
          private var distanceObserver: NSKeyValueObservation?
      
          private let subtitleLabel: UILabel = {
              let label = UILabel()
              label.translatesAutoresizingMaskIntoConstraints = false
              return label
          }()
      
          private let userLabel: UILabel = {
              let label = UILabel()
              label.translatesAutoresizingMaskIntoConstraints = false
              return label
          }()
      
          private let distanceLabel: UILabel = {
              let label = UILabel()
              label.translatesAutoresizingMaskIntoConstraints = false
              return label
          }()
      }
      
      private extension CustomAnnotationView {
          func updateAndAddObservers(for customAnnotation: CustomAnnotation) {
              subtitleLabel.text = customAnnotation.subtitle
              subtitleObserver = customAnnotation.observe(\.subtitle) { [weak self] customAnnotation, _ in
                  self?.subtitleLabel.text = customAnnotation.subtitle
              }
      
              userLabel.text = customAnnotation.userId
              userObserver = customAnnotation.observe(\.userId) { [weak self] customAnnotation, _ in
                  self?.userLabel.text = customAnnotation.userId
              }
      
              distanceLabel.text = "\(customAnnotation.distance) meters"
              distanceObserver = customAnnotation.observe(\.distance) { [weak self] customAnnotation, _ in
                  self?.distanceLabel.text = "\(customAnnotation.distance) meters"
              }
          }
      
          func removeAnyObservers() {
              subtitleObserver = nil
              userObserver = nil
              distanceObserver = nil
          }
      
          func createCallOutWithDataFrom(customAnnotation: CustomAnnotation?) -> UIView {
              let view = UIView()
              view.translatesAutoresizingMaskIntoConstraints = false
              view.addSubview(subtitleLabel)
              view.addSubview(userLabel)
              view.addSubview(distanceLabel)
      
              NSLayoutConstraint.activate([
                  subtitleLabel.topAnchor.constraint(equalTo: view.topAnchor),
                  subtitleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor),
                  subtitleLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor),
                  subtitleLabel.bottomAnchor.constraint(equalTo: userLabel.topAnchor),
      
                  userLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor),
                  userLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor),
                  userLabel.bottomAnchor.constraint(equalTo: distanceLabel.topAnchor),
      
                  distanceLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor),
                  distanceLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor),
                  distanceLabel.bottomAnchor.constraint(equalTo: view.bottomAnchor)
              ])
      
              if let customAnnotation = customAnnotation {
                  updateAndAddObservers(for: customAnnotation)
              }
      
              return view
          }
      }
      

    That yields: animated callout changes