skip to Main Content

So I have a UIButton and I’m setting the title in it to a string that is dynamic in length. I want the width of the titleLabel to be half of the screen width. I’ve tried using .sizeToFit() but this causes the button to use the CGSize before the constraint was applied to the titleLabel. I tried using .sizeThatFits(button.titleLabel?.intrinsicContentSize) but this also didn’t work. I think the important functions below are the init() & presentCallout(), but I’m showing the entire class just for a more complete understanding. The class I’m playing with looks like:

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)
//        I thought this would work, but it doesn't.
//        mainBody.translatesAutoresizingMaskIntoConstraints = false
//        mainBody.topAnchor.constraint(equalTo: self.topAnchor).isActive = true
//        mainBody.leftAnchor.constraint(equalTo: self.rightAnchor).isActive = true
//        mainBody.rightAnchor.constraint(equalTo: self.rightAnchor).isActive = true
//        mainBody.bottomAnchor.constraint(equalTo: self.bottomAnchor).isActive = true
    }
    
    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.titleLabel?.lineBreakMode = .byWordWrapping
        mainBody.titleLabel?.numberOfLines = 0
        mainBody.sizeToFit()
        
        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()
    }
}

This is what it looks like for a short title and a long title. When the title gets too long, I want the text to wrap and the bubble to get a taller height. As you can see in the image set below, the first ‘Short Name’ works fine as a map annotation bubble. When the name gets super long though, it just widens the bubble to the point it goes off the screen.

https://imgur.com/a/I5z0zUd

Any help on how to fix is much appreciated. Thanks!

2

Answers


  1. The UIButton class owns the titleLabel and is going to position and set the constraints on that label itself. More likely than not you are going to have to create a subclass of UIButton and override its "updateConstraints" method to position the titleLabel where you want it to go.

    Your code should probably not be basing the size of the button off the size of the screen. It might set the size of off some other view in your hierarchy that happens to be the size of the screen but grabbing the screen bounds in the middle of setting a view’s size is unusual.

    Login or Signup to reply.
  2. To enable word-wrapping to multiple lines in a UIButton, you need to create your own button subclass.

    For example:

    class MultilineTitleButton: UIButton {
        
        required init?(coder aDecoder: NSCoder) {
            super.init(coder: aDecoder)
            commonInit()
        }
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        
        func commonInit() -> Void {
            self.titleLabel?.numberOfLines = 0
            self.titleLabel?.textAlignment = .center
            self.setContentHuggingPriority(UILayoutPriority.defaultLow + 1, for: .vertical)
            self.setContentHuggingPriority(UILayoutPriority.defaultLow + 1, for: .horizontal)
        }
        
        override var intrinsicContentSize: CGSize {
            let size = self.titleLabel!.intrinsicContentSize
            return CGSize(width: size.width + contentEdgeInsets.left + contentEdgeInsets.right, height: size.height + contentEdgeInsets.top + contentEdgeInsets.bottom)
        }
        
        override func layoutSubviews() {
            super.layoutSubviews()
            titleLabel?.preferredMaxLayoutWidth = self.titleLabel!.frame.size.width
        }
    }
    

    That button will wrap the title onto multiple lines, cooperating with auto-layout / constraints.

    I don’t have any projects with MapBox, but here is an example using a modified version of your CustomCalloutView. I commented out any MapBox specific code. You may be able to un-comment those lines and use this as-is:

    class CustomCalloutView: UIView { //}, MGLCalloutView {
        //var representedObject: MGLAnnotation
        var repTitle: String = ""
        
        // 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
        
        // NOTE: this causes a vertical shift when NOT using MapBox
    //  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
        var anchorView: UIView!
        
        override func willMove(toSuperview newSuperview: UIView?) {
            if newSuperview == nil {
                anchorView.removeFromSuperview()
            }
        }
        
        //required init(representedObject: MGLAnnotation) {
        required init(title: String) {
            self.repTitle = title
            self.mainBody = MultilineTitleButton()
            
            super.init(frame: .zero)
            
            backgroundColor = .clear
            
            mainBody.backgroundColor = .white
            mainBody.setTitleColor(.black, for: [])
            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)
            mainBody.translatesAutoresizingMaskIntoConstraints = false
            let padding: CGFloat = 8.0
            NSLayoutConstraint.activate([
                mainBody.topAnchor.constraint(equalTo: self.topAnchor, constant: padding),
                mainBody.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: padding),
                mainBody.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -padding),
                mainBody.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -padding),
            ])
        }
        
        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)
            
            // since we'll be using auto-layout for the mutli-line button
            //  we'll add an "anchor view" to the superview
            //  it will be removed when self is removed
            anchorView = UIView(frame: rect)
            anchorView.isUserInteractionEnabled = false
            anchorView.backgroundColor = .clear
    
            view.addSubview(anchorView)
            
            view.addSubview(self)
            
            // Prepare title label.
            //mainBody.setTitle(representedObject.title!, for: .normal)
            mainBody.setTitle(self.repTitle, for: .normal)
            
    //      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
    //      }
            
            self.translatesAutoresizingMaskIntoConstraints = false
            
            anchorView.autoresizingMask = [.flexibleTopMargin, .flexibleLeftMargin, .flexibleRightMargin, .flexibleBottomMargin]
    
            NSLayoutConstraint.activate([
                
                self.centerXAnchor.constraint(equalTo: anchorView.centerXAnchor),
                self.bottomAnchor.constraint(equalTo: anchorView.topAnchor),
                self.widthAnchor.constraint(lessThanOrEqualToConstant: constrainedRect.width),
            ])
    
            
            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) {
            print(#function)
            // Draw the pointed tip at the bottom.
            let fillColor: UIColor = .red
            
            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()
        }
    }
    

    Here is a sample view controller showing that "Callout View" with various length titles, restricted to 70% of the width of the view:

    class CalloutTestVC: UIViewController {
    
        let sampleTitles: [String] = [
            "Short Title",
            "Slightly Longer Title",
            "A ridiculously long title that will need to wrap!",
        ]
        var idx: Int = -1
        
        let tapView = UIView()
    
        var ccv: CustomCalloutView!
    
        override func viewDidLoad() {
            super.viewDidLoad()
            view.backgroundColor = UIColor(red: 0.8939146399, green: 0.8417750597, blue: 0.7458069921, alpha: 1)
            
            tapView.backgroundColor = .systemBlue
            tapView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(tapView)
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                tapView.centerYAnchor.constraint(equalTo: g.centerYAnchor),
                tapView.centerXAnchor.constraint(equalTo: g.centerXAnchor),
                tapView.widthAnchor.constraint(equalToConstant: 60),
                tapView.heightAnchor.constraint(equalTo: tapView.widthAnchor),
            ])
            
            // tap the Blue View to cycle through Sample Titles for the Callout View
            //  using the Blue view as the "anchor rect"
            let t = UITapGestureRecognizer(target: self, action: #selector(gotTap))
            tapView.addGestureRecognizer(t)
        }
    
        @objc func gotTap() -> Void {
            if ccv != nil {
                ccv.removeFromSuperview()
            }
    
            // increment sampleTitles array index
            //  to cycle through the strings
            idx += 1
            
            let validIdx = idx % sampleTitles.count
            
            let str = sampleTitles[validIdx]
            
            // create a new Callout view
            ccv = CustomCalloutView(title: str)
            
            // to restrict the "callout view" width to less-than 1/2 the screen width
            //      use view.width * 0.5 for the constrainedTo width
            // may look better restricting it to 70%
            ccv.presentCallout(from: tapView.frame, in: self.view, constrainedTo: CGRect(x: 0, y: 0, width: view.frame.size.width * 0.7, height: 100), animated: false)
        }
    }
    

    It looks like this:

    enter image description here

    enter image description here

    enter image description here

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