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
}
Lance Samaria
  • 17,576
  • 18
  • 108
  • 256

1 Answers1

5

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

Rob
  • 415,655
  • 72
  • 787
  • 1,044
  • 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 showing – Lance Samaria May 18 '19 at 23:32
  • I was suggesting updating `title` and `subtitle` only, not `userid` or `distance`. `MKAnnotationView` does KVO for `coordinate`, `title`, and `subtitle` only. – Rob May 18 '19 at 23:37
  • It did't work, I'll add it to my question so you can see what I did. I also just tried this https://stackoverflow.com/a/34475756/4833705 and it didn't work. Seems to only work when I remove it and add it again – Lance Samaria May 18 '19 at 23:44
  • Maybe because I'm using an Annotation and not an AnnotationView? – Lance Samaria May 18 '19 at 23:45
  • 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. – Rob May 19 '19 at 01:22
  • 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 changed – Lance Samaria May 19 '19 at 02:09
  • 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. – Rob May 19 '19 at 02:32
  • 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 before – Lance Samaria May 19 '19 at 02:35
  • 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' – Lance Samaria May 19 '19 at 04:05
  • `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. – Rob May 19 '19 at 04:18
  • 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? – Lance Samaria May 19 '19 at 04:22
  • 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://stackoverflow.com/a/55820111/1271826. – Rob May 19 '19 at 04:29
  • 1
    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 :) – Lance Samaria May 19 '19 at 04:32
  • 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) – Lance Samaria May 19 '19 at 14:22
  • 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. – Rob May 19 '19 at 18:07
  • Oh ok, I thought it was called at the initial value set also. That answers the question, thanks – Lance Samaria May 19 '19 at 18:10
  • 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? https://developer.apple.com/documentation/mapkit/mkannotationview/decluttering_a_map_with_mapkit_annotation_clustering – Lance Samaria Jul 14 '19 at 08:40
  • @LanceSamaria - Yep. The only trick is to make sure you set the `clusteringIdentifier` both in `init` and in the `annotation` observer, as shown above. – Rob Jul 14 '19 at 19:17
  • 2
    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 :) – Lance Samaria Jul 14 '19 at 23:22
  • 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 reference – Lance Samaria Dec 05 '19 at 18:14
  • 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). – Lance Samaria Dec 05 '19 at 22:55