How to fixed vertical positioning glitch in Mapbox callout

Viewed 97

So I have a custom AnnotationView class (what the pin itself looks like) and a custom CalloutView class (what the bubble that appears above it looks like) For some reason, I get this odd behavior where if I press the annotation, it selects and appears properly (as seen in the first picture), but then if I deselect the annotation and reselect it (without moving the map at all), the callout reappears in the wrong positioning. The problem appears to be the rect.origin.y value, but I'm not sure--I tried hardcoding it but the problem still persists. In the video, I click my mouse down and it causes the annotation to move up. Any help much appreciated!

class AnnotationView: MGLAnnotationView {
    weak var delegate: MapAnnotationDelegate?
    var pointAnnotation: MGLPointAnnotation?
    
    required override init(reuseIdentifier: String?) {
        super.init(reuseIdentifier: reuseIdentifier)
        if reuseIdentifier == "default" {
            super.centerOffset = CGVector(dx: 0, dy: -17.5)
        }
        let rect = CGRect(x: 0, y: 0, width: 35, height: 35)
        let button = UIButton(frame: rect)
        let image = UIImage(named: reuseIdentifier ?? "default")?.resize(targetSize: CGSize(width: 35, height: 35))
        button.setImage(image, for: .normal)
        button.setImage(image, for: .selected)
        button.setImage(image, for: .highlighted)
        button.imageView?.contentMode = .scaleAspectFit
        button.addTarget(self, action: #selector(annotationAction), for: .touchUpInside)
        frame = rect
        addSubview(button)
        isEnabled = false
    }
    
    required init?(coder: NSCoder) {
        return nil
    }
    
    @objc private func annotationAction() {
        delegate?.tappedAnnotation(annotation: pointAnnotation!)
    }
}

class CustomCalloutView: UIView, MGLCalloutView {
    var representedObject: MGLAnnotation
    
    // Allow the callout to remain open during panning.
    let dismissesAutomatically: Bool = false
    let isAnchoredToAnnotation: Bool = true
    
    // https://github.com/mapbox/mapbox-gl-native/issues/9228
    override var center: CGPoint {
        set {
            var newCenter = newValue
            newCenter.y -= bounds.midY
            super.center = newCenter
        }
        get {
            return super.center
        }
    }
    
    lazy var leftAccessoryView = UIView() /* unused */
    lazy var rightAccessoryView = UIView() /* unused */
    
    weak var delegate: MGLCalloutViewDelegate?
    
    let tipHeight: CGFloat = 10.0
    let tipWidth: CGFloat = 20.0
    
    let mainBody: UIButton
    
    required init(representedObject: MGLAnnotation) {
        self.representedObject = representedObject
        self.mainBody = UIButton(type: .system)
        
        super.init(frame: .zero)
        
        backgroundColor = .clear
        
        mainBody.backgroundColor = .white
        mainBody.tintColor = .black
        mainBody.contentEdgeInsets = UIEdgeInsets(top: 10.0, left: 10.0, bottom: 10.0, right: 10.0)
        mainBody.layer.cornerRadius = 4.0
        
        addSubview(mainBody)
    }
    
    required init?(coder decoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    // MARK: - MGLCalloutView API
    func presentCallout(from rect: CGRect, in view: UIView, constrainedTo constrainedRect: CGRect, animated: Bool) {
        
        delegate?.calloutViewWillAppear?(self)
        
        view.addSubview(self)
        
        // Prepare title label.
        mainBody.setTitle(representedObject.title!, for: .normal)
        mainBody.sizeToFit()
        mainBody.backgroundColor = .red
        
        if isCalloutTappable() {
            // Handle taps and eventually try to send them to the delegate (usually the map view).
            mainBody.addTarget(self, action: #selector(CustomCalloutView.calloutTapped), for: .touchUpInside)
        } else {
            // Disable tapping and highlighting.
            mainBody.isUserInteractionEnabled = false
        }
        
        // Prepare our frame, adding extra space at the bottom for the tip.
        let frameWidth = mainBody.bounds.size.width
        let frameHeight = mainBody.bounds.size.height + tipHeight
        let frameOriginX = rect.origin.x + (rect.size.width/2.0) - (frameWidth/2.0)
        let frameOriginY = rect.origin.y - frameHeight
        frame = CGRect(x: frameOriginX, y: frameOriginY, width: frameWidth, height: frameHeight)
        
        if animated {
            alpha = 0
            
            UIView.animate(withDuration: 0.2) { \[weak self\] in
                guard let strongSelf = self else {
                    return
                }
                
                strongSelf.alpha = 1
                strongSelf.delegate?.calloutViewDidAppear?(strongSelf)
            }
        } else {
            delegate?.calloutViewDidAppear?(self)
        }
    }
    
    func dismissCallout(animated: Bool) {
        if (superview != nil) {
            if animated {
                UIView.animate(withDuration: 0.2, animations: { \[weak self\] in
                    self?.alpha = 0
                }, completion: { \[weak self\] _ in
                    self?.removeFromSuperview()
                })
            } else {
                removeFromSuperview()
            }
        }
    }
    
    // MARK: - Callout interaction handlers
    
    func isCalloutTappable() -> Bool {
        if let delegate = delegate {
            if delegate.responds(to: #selector(MGLCalloutViewDelegate.calloutViewShouldHighlight)) {
                return delegate.calloutViewShouldHighlight!(self)
            }
        }
        return false
    }
    
    @objc func calloutTapped() {
        if isCalloutTappable() && delegate!.responds(to: #selector(MGLCalloutViewDelegate.calloutViewTapped)) {
            delegate!.calloutViewTapped!(self)
        }
    }
    
    // MARK: - Custom view styling
    
    override func draw(_ rect: CGRect) {
        // Draw the pointed tip at the bottom.
        let fillColor: UIColor = .white
        
        let tipLeft = rect.origin.x + (rect.size.width / 2.0) - (tipWidth / 2.0)
        let tipBottom = CGPoint(x: rect.origin.x + (rect.size.width / 2.0), y: rect.origin.y + rect.size.height)
        let heightWithoutTip = rect.size.height - tipHeight - 1
        
        let currentContext = UIGraphicsGetCurrentContext()!
        
        let tipPath = CGMutablePath()
        tipPath.move(to: CGPoint(x: tipLeft, y: heightWithoutTip))
        tipPath.addLine(to: CGPoint(x: tipBottom.x, y: tipBottom.y))
        tipPath.addLine(to: CGPoint(x: tipLeft + tipWidth, y: heightWithoutTip))
        tipPath.closeSubpath()
        
        fillColor.setFill()
        currentContext.addPath(tipPath)
        currentContext.fillPath()
    }
}

For context, the relevant Mapbox map delegate functions are:

func tappedAnnotation(annotation: MGLPointAnnotation) {
    let selectedAnnotation = control.mapView.selectedAnnotations.first
    guard control.mapView.annotations != nil, selectedAnnotation !== annotation else { return }
    //control is the actual map being passed into this coordinator function
    for annotation in control.mapView.annotations! {
        if annotation.subtitle == "PrimaryAnno" {
            control.mapView.removeAnnotation(annotation)
        }
    }
    control.mapView.selectAnnotation(annotation, animated: false, completionHandler: nil)
}

func mapView(_ mapView: MGLMapView, viewFor annotation: MGLAnnotation) -> MGLAnnotationView? {
    if annotation is MGLPointAnnotation {

        var reuseid = ""
        //I use the subtitles to indicate which image I use for my annotations
        if annotation.subtitle! == "primaryAnno" {
            reuseid = "default"
        } else {
            reuseid = annotation.subtitle!
        }


        if let reusable = mapView.dequeueReusableAnnotationView(withIdentifier: reuseid) as? AnnotationView {
            reusable.pointAnnotation = annotation as? MGLPointAnnotation
            return reusable
        } else {
            let new = AnnotationView(reuseIdentifier: reuseid)
            new.delegate = self
            new.pointAnnotation = annotation as? MGLPointAnnotation
            return new
        }
    } else {
        return nil
    }
}

And this is a protocol I use to define the tapping on an annotation:

protocol MapAnnotationDelegate: AnyObject {
    func tappedAnnotation(annotation: MGLPointAnnotation)
}

UPDATE: I figured out that the 'correction' happens when I click on the map or change the region, so the issue is with the initial tap on annotation. I've tried setting a breakpoint in the tappedAnnotation function and it seems like once that function ends (it subsequently redirects to the annotationAction() function), the callout just renders in the wrong position.

1

2

https://youtu.be/4x5o9T6p8aw

0 Answers
Related