Sam
Sam

Reputation: 2341

Mapbox: add annotation only if the annotation is visible on screen

I have the data for my annotations stored in a database on Firebase. I've discovered that I can download data for 10,000 annotations and add these annotations to my map without much of a lag so long as the annotations do not have a custom view.

For my app however, I'm going to need to use custom views, each annotation view is an image composed of multiple image pieces. If I use custom views(even if the custom view is just a single UIImage), the app freezes up and eventually I receive the error "Message from debugger: Terminated due to memory issue". My app has a minimum zoom level of 15 so users can mostly only see what is around them.

My goal is to download the annotation data for all the annotations within maybe 10 km of the user(I'll do this with geohashing although this is not the focus of this question). The map on the phone will only be able to view a span of land of about a km or so.

I then either only want to

 a) add annotations that are visible on the phone 

or

b) only load the views for the annotations that are visible.

I want the annotations to be visible as soon as they are within the boundaries of the screen though, so that if a user is scrolling through the map, they see these annotations immediately.


I have this delegate function within my view controller that determines the view of each annotation, when I comment it out there is a slight delay is adding the annotations, but not a whole lot.

func mapView(_ mapView: MGLMapView, viewFor annotation: MGLAnnotation) -> MGLAnnotationView? {
    if annotation is MGLUserLocation && mapView.userLocation != nil {
        let view = CurrentUserAnnoView(reuseIdentifier: currentUser.uid!)
        self.currentUserAnno = view
        return view
    }
    else if annotation is UserAnnotation{
        let anno = annotation as! UserAnnotation
        let auid = anno.reuseIdentifier //The anno uid
        if let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: auid) {
            return annotationView
        } else {
            let annotationView = UserAnnotationView(reuseIdentifier: auid, size: CGSize(width: 45, height: 45), annotation: annotation)
            annotationView.isUserInteractionEnabled = true
            anno.view = annotationView
            return annotationView
        }
    }
    return MGLAnnotationView(annotation: annotation, reuseIdentifier: "ShouldntBeAssigned")  //Should never happen
}

Example

If you look at this youtube video, you can see that the annotations aren't always visible, and they only become visible as you zoom or move over them.
https://youtu.be/JWUFD48Od4M


MapViewController

class MapViewController: UIViewController {

    @IBOutlet weak var newPostView: NewPostView!
    @IBOutlet var mapView: MGLMapView!
    var data: MapData?
    var currentUserAnno: CurrentUserAnnoView?
    var testCounter = 0

    let geoFire = GeoFire(firebaseRef: Database.database().reference().child("/users/core"))

    @IBAction func tap(_ sender: UITapGestureRecognizer) {
        self.view.endEditing(true)

    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        geoFire.setLocation(CLLocation(latitude: 37.7853889, longitude: -122.4056973), forKey: "7")
        self.startup()
    }

    func startup(){
        if CLLocationManager.isOff(){
            let popup = UIAlertController(title: "Location Services are Disabled", message: "Please enable location services in your 'Settings -> Privacy' if you want to use this app", preferredStyle: UIAlertController.Style.alert)
            popup.addAction(UIAlertAction(title: "OK", style: UIAlertAction.Style.default, handler: {(alert: UIAlertAction) in
                self.startup()
            }))
            popup.view.layoutIfNeeded()
            self.present(popup, animated: true, completion: nil)
        }else{
            self.mapView.userTrackingMode = .follow
            self.data = MapData(delegate: self)
        }
    }

    @IBAction func newHidea(_ sender: Any) {
        newPostView.isHidden = false
    }


}

extension MapViewController: MGLMapViewDelegate{

    func mapView(_ mapView: MGLMapView, viewFor annotation: MGLAnnotation) -> MGLAnnotationView? {
        print(testCounter)
        testCounter = testCounter + 1
        if annotation is MGLUserLocation && mapView.userLocation != nil {
            let view = CurrentUserAnnoView(reuseIdentifier: currentUser.uid!)
            self.currentUserAnno = view
            return view
        }
        else if annotation is UserAnnotation{
            let anno = annotation as! UserAnnotation
//            let auid = anno.reuseIdentifier //The anno uid
            if let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: "UserAnnotationView") {
                return annotationView
            } else {
                let annotationView = UserAnnotationView(reuseIdentifier: "UserAnnotationView", size: CGSize(width: 45, height: 45), annotation: annotation)
                annotationView.isUserInteractionEnabled = true
                //anno.view = annotationView
                return annotationView
            }
        }
        return MGLAnnotationView(annotation: annotation, reuseIdentifier: "ShouldntBeAssigned")  //Should never happen
    }


    func mapView(_ mapView: MGLMapView, calloutViewFor annotation: MGLAnnotation) -> MGLCalloutView? {
    /*The regular anno status box is replaced by one with buttons*/
        let annotationPoint = mapView.convert(annotation.coordinate, toPointTo: nil)
        let viewFrame = CGRect(origin: CGPoint(x: 0, y: -10), size: CGSize(width: 180, height: 400))
        var cView: AnnoCalloutView
        if (annotation as! UserAnnotation).status != nil{
            cView =  StatusCallout(representedObject: annotation, frame: viewFrame, annotationPoint: annotationPoint)
        }else{
            cView = ProfileCallout(representedObject: annotation, frame: viewFrame, annotationPoint: annotationPoint)
        }
        return cView
    }

    func mapView(_ mapView: MGLMapView, annotationCanShowCallout annotation: MGLAnnotation) -> Bool {
        if (annotation is UserAnnotation) {
            return true
        }else{
            return false
        }

    }

    func mapView(_ mapView: MGLMapView, tapOnCalloutFor annotation: MGLAnnotation) {
        mapView.deselectAnnotation(annotation, animated: true)  // Hide the callout.
    }



}

//TODO: Check if there's a better method than a delegate to do this, since it's Model -> Controller
extension MapViewController: MapDataDelegate{
    func addAnnotation(_ anno: UserAnnotation) {
        self.mapView?.addAnnotation(anno)
    }
}

UserAnnotation

class UserAnnotation: NSObject, MGLAnnotation {

    //////////Ignore these, required for MGLAnnotation//////
    var title: String?
    var subtitle: String?
    ////////////////////////////////////////////////////////

    var coordinate: CLLocationCoordinate2D
    var status: Status?{
        didSet{
            //TODO: update annotation
        }
    }
    var reuseIdentifier: String
    var avatar: Avatar
    var uid: String

    //MARK: You could assign these when the profile is viewed once, so if they view it again you have it saved.
    var uName: String?
    var bio: String?

    init(coordinate: CLLocationCoordinate2D, avatar: Avatar, reuseIdentifier: String?, uid: String) {
//    init(coordinate: CLLocationCoordinate2D, reuseIdentifier uid: String?) {
        self.coordinate = coordinate
        self.title = "None"
        self.subtitle = "None"
        self.reuseIdentifier = reuseIdentifier!
        self.uid = uid
        self.avatar = avatar
        super.init()
//        self.setAvatar(avatar: avatar)
    }

    init(coordinate: CLLocationCoordinate2D, title: String?, subtitle: String?){
        print("This shouldn't be printing")
        self.coordinate = coordinate
        self.uName = "ShouldntBeSet"
        self.title = "ShouldntBeSet"
        self.subtitle = "ShouldntBeSet"
        self.reuseIdentifier = "ShouldntBeAssigned"
        self.uid = "ShouldntBeAssigned"
        self.avatar = Avatar(withValues: [0])
    }
}

UserAnnotationView

class UserAnnotationView: MGLAnnotationView {

    var anno: UserAnnotation?
    var statusView: UITextView?
    var imageView: UIImageView?
    var avatarImage: UIImage{
        let ai = AvatarImage()
        ai.update(with: (anno?.avatar.values)!)
        return ai.image!
    }


    init(reuseIdentifier: String, size: CGSize, annotation: MGLAnnotation) {
        super.init(reuseIdentifier: reuseIdentifier)
        // Prevents view from changing size when view tilted
        scalesWithViewingDistance = false
        frame = CGRect(x: 0, y: 0, width: size.width, height: size.height)
        self.anno = annotation as? UserAnnotation
        self.setUpImageView(frame: frame, size: size, annotation: annotation)
        if anno?.status != nil{
            self.createStatus(status: (anno?.status?.status)!)
        }
    }

    func reuseWithDifferentAnno(annotation: UserAnnotation){
        self.anno = annotation
        self.imageView!.image = UIImage(named: "Will")
        //        let av = AvatarImage.newAvatar(values: (anno?.avatar.values)!)
//        self.imageView!.image = av.image
//        if anno?.status != nil{
//            self.createStatus(status: (anno?.status?.status)!)
//        }else{
//            if statusView != nil{
//                deleteStatus()
//            }
//        }
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }

    private func setUpImageView(frame: CGRect, size: CGSize, annotation: MGLAnnotation){
        self.imageView = UIImageView(frame: frame)
        self.imageView!.translatesAutoresizingMaskIntoConstraints = false
        if annotation is UserAnnotation {
//            let av = AvatarImage.newAvatar(values: (anno?.avatar.values)!)
//            self.imageView!.image = av.image
            self.imageView!.image = UIImage(named: "Will")

        }else{
            let image = UIImage()
            self.imageView!.image = image
        }
        addSubview(self.imageView!)
        imageViewConstraints(imageView: self.imageView!, size: size)
    }

    func setImage(to image: UIImage){
        self.imageView!.image = image
    }

    func createStatus(status: String){
        if (status == self.statusView?.text) && (self.subviews.contains(self.statusView!)){
            return
        }else if self.statusView != nil && self.subviews.contains(self.statusView!){
            deleteStatus()
        }
        self.statusView = UITextView()
        self.statusView!.text = status
        self.statusView!.isHidden = false
        self.adjustUITextViewHeight()
        self.statusView!.translatesAutoresizingMaskIntoConstraints = false
        self.statusView!.layer.cornerRadius = 5
        self.statusView!.textAlignment = .center
        addSubview(self.statusView!)
        textViewConstraints(textView: self.statusView!, isAbove: self.imageView!)
    }

    func deleteStatus(){
        self.statusView?.removeFromSuperview()
        self.statusView = nil
    }

    private func adjustUITextViewHeight(){

        self.statusView!.translatesAutoresizingMaskIntoConstraints = true
        self.statusView!.sizeToFit()
        self.statusView!.isScrollEnabled = false
    }

    private func imageViewConstraints(imageView: UIImageView, size: CGSize){
        let widCon = NSLayoutConstraint(item: imageView, attribute: .width, relatedBy: .equal, toItem: nil, attribute: .width, multiplier: 1, constant: size.width)
        let heightCon = NSLayoutConstraint(item: imageView, attribute: .height, relatedBy: .equal, toItem: nil, attribute: .height, multiplier: 1, constant: size.height)
        let cenCon = NSLayoutConstraint(item: imageView, attribute: .centerX, relatedBy: .equal, toItem: self, attribute: .centerX, multiplier: 1, constant: 0)
        NSLayoutConstraint.activate([cenCon, widCon, heightCon])
    }

    private func textViewConstraints(textView status: UITextView, isAbove imageView: UIImageView){
        let cenCon = NSLayoutConstraint(item: status, attribute: .centerX, relatedBy: .equal, toItem: imageView, attribute: .centerX, multiplier: 1, constant: 0)
        let botCon = NSLayoutConstraint(item: status, attribute: .bottom, relatedBy: .equal, toItem: imageView, attribute: .top, multiplier: 1, constant: -10)
        let widCon = NSLayoutConstraint(item: status, attribute: .width, relatedBy: .lessThanOrEqual, toItem: nil, attribute: .width, multiplier: 1, constant: 200)
        NSLayoutConstraint.activate([cenCon, botCon, widCon])
    }

}

MapData

class MapData {
    var annotations = [String:UserAnnotation]()
    var updateTimer: Timer?
    var delegate: MapDataDelegate

    init(delegate: MapDataDelegate){
        self.delegate = delegate
        self.startTimer()
    }

    @objc func getUsers(){
        FBCore.getAllUsers(completion:{(users) in
            for child in users {
                let value = child.value as! NSDictionary
                self.getDataFor(user: value, whoseUidIs: child.key)
            }
        })
    }

    func getDataFor(user: NSDictionary, whoseUidIs annoid: String){
        if annoid != currentUser.uid! && (currentUser.blockedBy?[annoid] ?? false) != true && (currentUser.blocks?[annoid] ?? false) != true{
            guard let (coord, status, avatar) = FBCoreUser.get(forQryVal: user)
                else {return}
            if let anno = self.annotations[annoid]{
                anno.coordinate = coord
                if status != nil{// && anno.view!.isSelected == false {
                    if ((status?.isExpired)!){
                        anno.status = nil
                    }else{
                        anno.status = status
                    }
                }
                if avatar.values != anno.avatar.values{
                    anno.avatar = avatar
                }
            }else{
                let anno = UserAnnotation(coordinate: coord, avatar: avatar, reuseIdentifier: "UserAnnotation", uid: annoid)
                if status != nil{
                    if ((status?.isExpired)!){
                        anno.status = nil
                    }else{
                        anno.status = status
                    }
                }
                self.annotations[annoid] = anno
                //print(anno.reuseIdentifier)
                delegate.addAnnotation(anno)
            }
        }
    }

    func startTimer(){
        // Scheduling timer to Call the function "updateCounting" with the interval of 5 seconds
        if updateTimer != nil{
            updateTimer!.invalidate()
        }
        updateTimer = Timer.scheduledTimer(timeInterval: 5, target: self, selector: #selector(getUsers), userInfo: nil, repeats: true)
    }
}

Upvotes: 3

Views: 2597

Answers (4)

Alex
Alex

Reputation: 1581

The problem you describe is a common issue when dealing with a large number of point, and i'm afraid that your approach is not going to help you. This is especially true if/when a user makes a heavy use of your map, zooming from min to max zooms. All points will be downloaded, and you'll fall into the same problem. Note: If you choose to remove annotations as the user zooms out, that's called clustering, which is what the solutions below provide, out of the box (a.k.a don't reinvent the wheel)

See the post by Mapbox on that topic, it's for GL JS, but the same reasoning applies in your case. For iOS, mapbox has released a clustering api, I haven't tried it, but it seems to do the job. There's also an extensive code example you could get inspiration from. I won't reproduce it here, for obvious reasons, just a pic of the final result, so you can figure out if that's what you need.

clustering datapoints

There is also plenty of code on github to do that, see here

in the mapbox code

func mapView(_ mapView: MGLMapView, didFinishLoading style: MGLStyle) {
let url = URL(fileURLWithPath: Bundle.main.path(forResource: "ports", ofType: "geojson")!)
let source = MGLShapeSource(identifier: "clusteredPorts",
url: url,
options: [.clustered: true, .clusterRadius: icon.size.width])
style.addSource(source)
[...]
}

replace the lines:

let url = URL(fileURLWithPath: Bundle.main.path(forResource: "ports", ofType: "geojson")!)
let source = MGLShapeSource(identifier: "clusteredPorts",
url: url, options: [.clustered: true, .clusterRadius: icon.size.width])

by:

let source = MGLShapeSource(identifier: "my points",
shapes: shapes, options: [.clustered: true, .clusterRadius: icon.size.width])

where shapes is [MGLShape], created from your waypoints. MGLShape is derived from Annotations using MGLShape : NSObject <MGLAnnotation, NSSecureCoding>, see here. See also here for MGLShapeSource protoypes.

You will have to create a method, to instantiate those shapes, from your waypoints, or in short:

let source = MGLShapeSource(identifier: "my points",
shapes: self.createShapes(from:annotations), options: [.clustered: true, .clusterRadius: icon.size.width])

Upvotes: 0

Ron Srebro
Ron Srebro

Reputation: 6862

From what I can tell from your code it seems like you're not using reuseIdentifier properly.

The goal of the resueIdentifier and dequeuing views is to never have more views created that whats actually visible (or at least minimize it)

You use it to get views of the same type that you already created, but are not visible or needed anymore. So if your custom view has an UIImageView and a label and some layouts, you won't create it again, but reuse one that was already created.

Once you get a view back that is available you assign the properties that change from annotation to annotation without creating another view.

What this means is that it doesn't matter if you downloaded 10,000 or 100,000 annotations, the number of views created for the map will never be larger than the number of views visible on the screen.

Saying that, your code should look something like this:

func mapView(_ mapView: MGLMapView, viewFor annotation: MGLAnnotation) -> MGLAnnotationView? {
    if annotation is MGLUserLocation && mapView.userLocation != nil {
        let view = CurrentUserAnnoView(reuseIdentifier: "userLocation")
        self.currentUserAnno = view
        return view
    }
    else if annotation is UserAnnotation{
        let anno = annotation as! UserAnnotation
        let reuseIdentifier = "myCustomAnnotationView"
        if let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: reuseIdentifier) {
        // set view properties for current annotation before returing
            annotationView.image = anno.image
            annotationView.name = anno.name // etc.
            return annotationView
        } else {
            let annotationView = UserAnnotationView(reuseIdentifier: reuseIdentifier, size: CGSize(width: 45, height: 45), annotation: annotation)
            annotationView.isUserInteractionEnabled = true
//          anno.view = annotationView // this is strange, not sure if it makes sense
            annotationView.image = anno.image // set annotation view properties
            annotationView.name = anno.name // etc.
            return annotationView
        }
    }
    return MGLAnnotationView(annotation: annotation, reuseIdentifier: "ShouldntBeAssigned")  //Should never happen
}

Upvotes: 2

Najinsky
Najinsky

Reputation: 638

If I'm following this right,

We can ignore the dataset size issue because it will be reduced by only showing annotations for their immediate vicinity.

For those you will show, they work fine when they are just annotations, but when you give them a custom view they are slow to appear and disappear.

By looking at the video, this seems to simply be an animation issue. The view-annotations are being faded in and out. If you don't want that effect then turn off that animation.

I can't tell from the code snippet where that animation might be specified, but it should be easy to locate. For example, several of the MLG apis have an (animated: Boolean) property where you can specify false.

Upvotes: 0

Josh Homann
Josh Homann

Reputation: 16327

Mapbox has delegate methods for did change and will change region (take your pick).

func mapView(_ mapView: MGLMapView, regionDidChangeAnimated animated: Bool)

when the region changes you need to set the annotations on the map to the annotations inside that region. The easiest way to do it seems to be to convert the coordinates to the mapView's space and then check if they are in the mapView's bounds.



let newAnnotations = allAnnotations.filter { annotation in
  let point = mapView.convert(annotation.coordinate, toPointTo: mapView)
  return mapView.bounds.contains(point)
}
if let existingAnnotations = mapView.annotations {
  mapView.removeAnnotations(existingAnnotations)
}
mapView.addAnnotations(newAnnotations)

Upvotes: 0

Related Questions