根据 titleLabel 的长度调整 UIButton 的大小

Sizing UIButton depending on length of titleLabel

所以我有一个 UIButton,我将其中的标题设置为长度动态的字符串。我希望 titleLabel 的宽度是屏幕宽度的一半。我试过使用 .sizeToFit() 但这会导致按钮在约束应用于 titleLabel 之前使用 CGSize。我尝试使用 .sizeThatFits(button.titleLabel?.intrinsicContentSize) 但这也没有用。我认为下面的重要函数是 init() 和 presentCallout(),但我展示整个 class 只是为了更完整地理解。我正在玩的 class 看起来像:

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()
    }
}

这是短标题和长标题的样子。当标题太长时,我希望文本换行并且气泡高度更高。正如您在下面的图像集中看到的,第一个 'Short Name' 作为地图注释气泡效果很好。但是,当名称变得超长时,它只会将气泡加宽到超出屏幕的程度。

https://imgur.com/a/I5z0zUd

非常感谢任何有关如何修复的帮助。谢谢!

UIButton class 拥有 titleLabel 并且将在该标签本身上定位和设置约束。您很有可能必须创建 UIButton 的子 class 并覆盖其“updateConstraints”方法以将 titleLabel 定位到您想要的位置。

您的代码可能不应该将按钮的大小基于屏幕的大小。它可能会设置层次结构中恰好是屏幕大小的其他视图的大小,但在设置视图大小的过程中抓住屏幕边界是不寻常的。

要在 UIButton 中启用多行 word-wrapping,您需要创建自己的按钮子类。

例如:

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
    }
}

该按钮会将标题换行到多行,与 auto-layout / 约束合作。

我没有任何使用 MapBox 的项目,但这里有一个使用您 CustomCalloutView 的修改版本的示例。我注释掉了任何 MapBox 特定代码。您 可能 能够 un-comment 这些行并使用此 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()
    }
}

这是一个示例视图控制器,显示具有各种长度标题的“标注视图”,限制为视图宽度的 70%:

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)
    }
}

看起来像这样: