skip to Main Content

I have a MKMapView that is being handled with a SwiftUI UIViewRepresentable. The problem is that my clustering is failing to work as expected. My code produces the following series of problems.

  • Map loads, clusterable annotations cluster as expected.
  • Zooming in on a cluster de-clusters them.
  • Zooming out, they re-cluster, however if I zoom out even further, additional annotions are not clusterd with the group.
  • Zooming back in after encountering this bug, results in no more clustering of any kind.

Cluster Annotation View

final class ClusterLocationAnnotationView: MKAnnotationView {
        static let identifier = "cluster-location-annotation-identifier"

        // Omitted irrelevant `UILabel` and other views. 
        
        override init(annotation: MKAnnotation?, reuseIdentifier: String?) {
            super.init(annotation: annotation, reuseIdentifier: reuseIdentifier)
            displayPriority = .defaultHigh
            collisionMode = .rectangle
            clusteringIdentifier = "location-detail-cluster-id"
            canShowCallout = false
            setupUI()
        }
        
        // Removed Required Init
        
        private func setupUI() {
            addSubview(backgroundView)
            addSubview(label)
            addSubview(offersLabel)
            
            label.translatesAutoresizingMaskIntoConstraints = false
            NSLayoutConstraint.activate([
                label.centerXAnchor.constraint(equalTo: backgroundView.centerXAnchor),
                label.centerYAnchor.constraint(equalTo: backgroundView.centerYAnchor)
            ])
            
            offersLabel.translatesAutoresizingMaskIntoConstraints = false
            NSLayoutConstraint.activate([
                offersLabel.topAnchor.constraint(equalTo: backgroundView.bottomAnchor, constant: 4), // adjust the constant as needed
                offersLabel.centerXAnchor.constraint(equalTo: backgroundView.centerXAnchor)
            ])
        }
        
        // MARK: - Configure Cluster
        func configure(appConfig: AppConfig, clusterLocations: [LocationDetail]) {
            backgroundView.backgroundColor = UIColor(cgColor: appConfig.secondaryColor.cgColor ?? UIColor.black.cgColor)
            backgroundView.layer.cornerRadius = appConfig.globalCornerRadii
            
            label.text = clusterLocations.count.description
            label.textColor = UIColor(cgColor: appConfig.secondaryColor.contrastingColor().cgColor ?? UIColor.white.cgColor)
            
            // Set number of locationDetails that contain an offer, to the text, eg. offersLabel.text = locationsWithOffersCount
            var offerCount = 0
            
            for location in clusterLocations {
                offerCount += appConfig.hasOffer(for: location) ? 1 : 0
            }
            
            offersLabel.text = "(offerCount) Offer(s) Available"
        }

Annotation View

    final class LocationAnnotationView: MKAnnotationView {
        static let identifier = "location-annotation-identifier"

        // Removed irrelevant views.
        
        override init(annotation: MKAnnotation?, reuseIdentifier: String?) {
            super.init(annotation: annotation, reuseIdentifier: reuseIdentifier)
            displayPriority = .defaultHigh
            collisionMode = .rectangle
            clusteringIdentifier = "location-detail-cluster-id"
            canShowCallout = true
            setupUI()
        }
        
        //Removed Required INIT
        
        private func setupUI() {
            addSubview(backgroundView)
            addSubview(imageView)
            addSubview(label)

            backgroundView.translatesAutoresizingMaskIntoConstraints = false
            NSLayoutConstraint.activate([
                backgroundView.centerXAnchor.constraint(equalTo: self.centerXAnchor),
                backgroundView.centerYAnchor.constraint(equalTo: self.centerYAnchor),
                backgroundView.widthAnchor.constraint(equalToConstant: 40),
                backgroundView.heightAnchor.constraint(equalToConstant: 40)
            ])

            imageView.translatesAutoresizingMaskIntoConstraints = false
            NSLayoutConstraint.activate([
                imageView.centerXAnchor.constraint(equalTo: backgroundView.centerXAnchor),
                imageView.centerYAnchor.constraint(equalTo: backgroundView.centerYAnchor),
                imageView.widthAnchor.constraint(equalToConstant: 30),
                imageView.heightAnchor.constraint(equalToConstant: 30)
            ])

            label.translatesAutoresizingMaskIntoConstraints = false
            NSLayoutConstraint.activate([
                label.topAnchor.constraint(equalTo: backgroundView.bottomAnchor, constant: 4),
                label.centerXAnchor.constraint(equalTo: backgroundView.centerXAnchor)
            ])
        }

        func configure(with location: LocationDetail, and category: Category, appConfig: AppConfig) {
            backgroundView.backgroundColor = UIColor(cgColor: appConfig.secondaryColor.cgColor ?? UIColor.black.cgColor)
            backgroundView.layer.cornerRadius = appConfig.globalCornerRadii
            
            imageView.image = Iconoir.uiImage(from: category.iconName)?.withRenderingMode(.alwaysTemplate)
            imageView.tintColor = UIColor(cgColor: appConfig.secondaryColor.contrastingColor().cgColor ?? UIColor.white.cgColor)
            
            self.label.text = location.locationName
        }
    }

Coordinator MKMapViewDelegate Functions

        func mapView(_ mapView: MKMapView, clusterAnnotationForMemberAnnotations memberAnnotations: [MKAnnotation]) -> MKClusterAnnotation {
            return MKClusterAnnotation(memberAnnotations: memberAnnotations)
        }
        
        func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
            let appConfig = parent.appConfig
            
            if let cluster = annotation as? MKClusterAnnotation {
                let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: ClusterLocationAnnotationView.identifier, for: cluster) as? ClusterLocationAnnotationView
                annotationView?.frame.size.height = 40
                annotationView?.frame.size.width = 40
                annotationView?.configure(appConfig: appConfig, clusterLocations: cluster.memberAnnotations as! [LocationDetail])
                return annotationView
            } else if let location = annotation as? LocationDetail {
                let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: LocationAnnotationView.identifier, for: annotation) as? LocationAnnotationView
                annotationView?.frame.size.height = 40
                annotationView?.frame.size.width = 40
                let category = appConfig.category(from: location.categoryId)
                annotationView?.configure(with: location, and: category, appConfig: appConfig)
                return annotationView
            }
            
            return nil
        }

Update UIView

    func updateUIView(_ mapView: MKMapView, context: Context) {
        let coordinator = context.coordinator
        mapView.mapType = mapType
        mapView.userTrackingMode = trackingMode
        handleLocationChange(mapView, coordinator: coordinator)
    }
    
    func handleLocationChange(_ mapView: MKMapView, coordinator: Coordinator) {
        if coordinator.lastLocations.count != locations.count {
            mapView.removeAnnotations(mapView.annotations)
            mapView.addAnnotations(locations)
            mapView.setRegion(getRegion(for: locations), animated: true)
            
            coordinator.lastLocations = locations
            DispatchQueue.main.async {
                trackingMode = .none
            }
        }
    }

2

Answers


  1. Chosen as BEST ANSWER

    A few issues needed to be resolved. Firstly, I subclassed MKMapView on my Coordinator, I don't think this is truly needed as I have access to the mapView instance in updateUIView.

    I needed to update my makeUIView to properly update based on the updated state. Previously, I was mishandling my @State and both updating @State from the makeUIView and from the UIViewRepresentable struct. The general rule here is that any value that is update from UIViewRepresentable, aka a State value is published from SwiftUI, it should update the value on the Coordinator. Any value that the Coordinator updates, should be published to the State variable in the UIViewRepresentable struct. That will in turn, cause the updateUIView(..) function to be called, updating the Coordinator's property value with the newly updated State value.

        func updateUIView(_ mapView: MKMapView, context: Context) {
            mapView.mapType = mapType
            mapView.userTrackingMode = trackingMode
            mapView.removeAnnotations(mapView.annotations)
            mapView.addAnnotations(locations)
        }
    
    

    To solve the MKAnnotation clustering issue I checked my code for the clusteringIdentifier and I had a clusteringIdentifier on my LocationDetail as well as set on LocationAnnotationView and on my ClusterLocationAnnotationView so I removed that value and am only using it when the viewForAnnotation(:) is called.

    func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
                let appConfig = parent.appConfig
                
                if let cluster = annotation as? MKClusterAnnotation {
                    let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: ClusterLocationAnnotationView.identifier, for: cluster) as? ClusterLocationAnnotationView
                    annotationView?.frame.size.height = 40
                    annotationView?.frame.size.width = 40
                    annotationView?.configure(appConfig: appConfig, clusterLocations: cluster.memberAnnotations as! [LocationDetail])
                    annotationView?.clusteringIdentifier = "clustering-location-detail-identifier"
                    return annotationView
                } else if let location = annotation as? LocationDetail {
                    let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: LocationAnnotationView.identifier, for: annotation) as? LocationAnnotationView
                    annotationView?.frame.size.height = 40
                    annotationView?.frame.size.width = 40
                    let category = appConfig.category(from: location.categoryId)
                    annotationView?.configure(with: location, and: category, appConfig: appConfig)
                    annotationView?.clusteringIdentifier = "clustering-location-detail-identifier"
                    return annotationView
                }
                
                return nil
            }
    

  2. handleLocationChange needs to be moved into the coordinator so it can be called in the map region changed event.

    Also perhaps remove the location count check, it looks error prone.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search