Swift -How to Update Data in Custom MKAnnotation Callout?
Asked Answered
N

2

3

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
}
Newman answered 18/5, 2019 at 23:13 Comment(0)
V
7

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

Vulgarian answered 18/5, 2019 at 23:18 Comment(22)
Hey there! I actually tried that earlier. I read one of your other answers about adding it to coordinate and that's when it started animating correctly. But when I tried adding that to the other 2 properties it didn't work. I also tried adding it to the distance property but it said Property cannot be marked @objc because its type cannot be represented in Objective-C I'm guessing because it's of type CLLocationDistance. I just tried again and the same old data is showingNewman
I was suggesting updating title and subtitle only, not userid or distance. MKAnnotationView does KVO for coordinate, title, and subtitle only.Vulgarian
It did't work, I'll add it to my question so you can see what I did. I also just tried this https://mcmap.net/q/673233/-custom-mkannotation-not-moving-when-coordinate-set and it didn't work. Seems to only work when I remove it and add it againNewman
Maybe because I'm using an Annotation and not an AnnotationView?Newman
No, it’s because you are creating your own detailCalloutAccessoryView, and unlike the standard callout, you aren’t doing KVO of these properties. See revised answer.Vulgarian
I walked out the house, when i get back i'll try it out. I noticed there is a small blink on the names under the annotations, are you adding and removing them? It looks like it happens right after the call out name is changedNewman
I’m adding !!! to all the titles of all of the annotations and MKMarkerAnnotationView shows titles under the annotation view, not just the callout. I was just illustrating that by making these properties, you can enjoy KVO all over the place. But that additional animation is not relevant to your example.Vulgarian
lol, ok thanks. I use kvo in reg vcs when observing changes to label text. I also used it with avfoundation. I've never used it for anything else though. The dynamic property is something I definitely never saw beforeNewman
what's clusteringIdentifier = customClusteringIdentifier there are 4 references to it and I can't find anything else about it. I get the error "Use of unresolved identifier 'customClusteringIdentifier'; did you mean 'clusteringIdentifier'? Replace 'customClusteringIdentifier' with 'clusteringIdentifier'Newman
customClusteringIdentifier is just some unique string constant. Set it to whatever you want. We just want to avoid littering our code with the same string literal repeated in different places.Vulgarian
Your answer works, thanks!!! I have 1 more question. I was using a custom image from my annotationView?.image = UIImage(named: "customImage") , it still shows up but so does a pin. How do i get rid of the pin so that only the custom image shows?Newman
I subclassed MKMarkerAnnotationView, but you can subclass MKAnnotationView instead. And I’d put the setting of the image inside the init of that subclass, not in your viewFor method. Frankly, if targeting iOS 11 and later, you’d generally just mapView.register(CustomAnnotationView.self, forAnnotationViewWithReuseIdentifier: MKMapViewDefaultAnnotationViewReuseIdentifier), put all the annotation view configuration code in the MKAnnotionView subclass (like I have above), and then you can delete the viewFor method altogether. See https://mcmap.net/q/673234/-how-to-fix-custom-point-annotations-from-disappearing-from-overlap.Vulgarian
Ok thanks, I can figure it out from here. This helped tremendously, I really appreciate it.I was working on this for like 4 hours trying to get the callout to update. Enjoy your evening and have a nice weekend! Cheers :)Newman
Hey Rob, I have a quick question. The observers observe the property and then in their closure they update the label with the value from the property they're observing. I understand all of that, but right above the observer it's label also gets set with value from the property eg. userLabel.text = customAnnotation.userId why do you need that line first/beforehand when the observer is going to change it anyway in it's closure [weak self] customAnnotation, _ in self?.userLabel.text = customAnnotation.userId (shouldn't this be the actual decision maker)Newman
But the observer is only called when the value later changes, but doesn’t capture the initial value. You can, of course, use the .initial option for of the options parameter to the observer(_:options:changeHandler:) method. Then you wouldn’t need to manually set the initial value. But many observer systems (such as the native Swift one) don’t support this .initial pattern, so I’m just used to the “initialize and then add observer for changes” pattern that I’m used to above. But like much of this, there are many ways to skin the cat.Vulgarian
Oh ok, I thought it was called at the initial value set also. That answers the question, thanksNewman
I had to come back here because what I didn't foresee in my project was clustering. I started to read the Apple docs and saw there's a clusteringIdentifier which says "To group annotations into a cluster, set the clusteringIdentifier property to the same value on each annotation view in the group". I remembered you set that property. On your map two of the Starbuck annotations has a 2 & 3. Is that property all you needed to add to get the pins to cluster like that? developer.apple.com/documentation/mapkit/mkannotationview/…Newman
@LanceSamaria - Yep. The only trick is to make sure you set the clusteringIdentifier both in init and in the annotation observer, as shown above.Vulgarian
Thanks! You saved me a TON of research. I started looking at different pods and I was "ehhhhhhh...". This is a really strong answer, it covers plenty of ground and pretty much everything someone needs for a custom annotation and callout. I wish more people would come across because it should have a ton of upvotes. Anyhow thanks, I really appreciate it :)Newman
did you have a github to download this project? After you helped me with this it was working for months. I put the project to the side and updated to Xcode 11 last month. I just started back on this project and my pins no longer cluster and they disappear. Im baffled. If you still have link to this can you post it so I can have something to referenceNewman
I found out what the problem is. Setting the clusteringIdentifier both in init and in the annotation observer like in your example and the comment you left above no longer works. It causes my annotations to disappear. If there is only 1 on the map it will show but if there are two or more they all disappear. It's weird. Once I comment the lines out everything works fine (except clustering).Newman
MapKit question going begging here! stackoverflow.com/questions/78632823Gametophyte
G
0

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).

Gametophyte answered 17/6 at 15:31 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.