Lance Samaria
Lance Samaria

Reputation: 19572

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
}

Upvotes: 3

Views: 2088

Answers (2)

Fattie
Fattie

Reputation: 12582

Quick trick for simpler cases ...

Here's a quick trick to get you home earlier for most cases.

Your annotation has many custom fields

class CatAnnotation: MKPointAnnotation {
    var popularityColor: UIColor = .purple // default
    .. many more
}

You want the annotation views to respond when you, say, change the mode of the map.

var fancyMapMode = false

@IBAction func tapChangeMapMode() {
    let ourAnnotations = mapView.annotations.compactMap{$0 as? CatAnnotation}
    if fancyMapMode {
        for i in 0..<ourAnnotations.count {
            // plain defaults ...
            ourAnnotations[i].popularityColor = .purple
            .. many more, back to defaults
            ourAnnotations[i].title = default title
        }
    }
    else {
        for i in 0..<ourAnnotations.count {
            // fancy special values ...
            ourAnnotations[i].popularityColor = .. % color
            .. many more
            ourAnnotations[i].title = default title + "\n#\(i) popular!!"
        }
    }
    fancyMapMode = !fancyMapMode
}

So you're changing all your special colors, views etc.

Typically the title will also change. Glory be, the title is already KVO'able.

Just add this in your custom annotation view(s):

class CatAnnotationView: MKMarkerAnnotationView {
    
    private var titleObs: NSKeyValueObservation?
    override var annotation: (any MKAnnotation)? {
        didSet {
            if let ca = annotation as? CatAnnotation {
                titleObs = ca.observe(\.title){ [weak self] _,_ in
                    self?.yourCustomImage.tintColor = ca.popularity
                    .. make as many changes as you want,
                    .. simply reading ca.yourValues
                }
            }
        }
    }

Downside: In your code to set all your custom values for the markers you simply have to set the .title = change last.

Downside: If you're not changing the title, bummer. A cheap trick is just add/remove a newline on the end (which indeed is completely harmless on the map view).

Upvotes: 0

Rob
Rob

Reputation: 437372

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

Upvotes: 7

Related Questions