Изменение размера UIButton в зависимости от длины titleLabel

#swift #xcode #uibutton #uilabel #nslayoutconstraint

Вопрос:

Итак, у меня есть UIButton, и я устанавливаю в нем заголовок в строку, которая имеет динамическую длину. Я хочу, чтобы ширина titleLabel составляла половину ширины экрана. Я пытался использовать .sizeToFit(), но это приводит к тому, что кнопка использует CGSize до того, как ограничение было применено к titleLabel . Я пытался использовать .sizeThatFits(button.titleLabel?.intrinsicContentSize) но это также не сработало. Я думаю, что важными функциями ниже являются init() и presentCallout() , но я показываю весь класс только для более полного понимания. Класс, с которым я играю, выглядит так:

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

Вот как это выглядит для короткого заголовка и длинного заголовка. Когда заголовок становится слишком длинным, я хочу, чтобы текст был перенесен, а пузырь получил более высокую высоту. Как вы можете видеть на изображении, приведенном ниже, первое «короткое имя» отлично работает как пузырь аннотации карты. Однако, когда имя становится очень длинным, оно просто расширяет пузырь до такой степени, что он исчезает с экрана.

https://imgur.com/a/I5z0zUd

Любая помощь в исправлении приветствуется. Спасибо!

Комментарии:

1. Ваш вопрос довольно запутанный… Если заголовок вашей кнопки Tap Me , например, что вы подразумеваете под «Я хочу, чтобы ширина метки заголовка составляла половину ширины экрана» ? Можете ли вы добавить пару изображений, чтобы прояснить свою цель?

2. @DonMag добавил изображения и поместил весь класс в текст вопроса, чтобы он был более полным.

3. Ах, вам нужна «многострочная кнопка»… была бы полезная информация при первой публикации вашего вопроса…

Ответ №1:

UIButton Класс владеет titleLabel и собирается позиционировать и устанавливать ограничения для этой метки самостоятельно. Скорее всего, вам придется создать подкласс UIButton и переопределить его метод «updateConstraints», чтобы поместить titleLabel его туда, куда вы хотите.

Вероятно, ваш код не должен основывать размер кнопки на размере экрана. Он может устанавливать размер какого-либо другого представления в вашей иерархии, которое соответствует размеру экрана, но захват границ экрана в середине настройки размера представления является необычным.

Комментарии:

1. Привет, Скотт, честно говоря, «ширина половины экрана» была произвольной. Ширина представления может быть некоторым фиксированным значением, например, 400 или около того. С этой спецификацией есть ли более простое решение, чем подкласс UIButton?

2. Не совсем… потому что кнопка владеет titleLabel и отвечает за ее позиционирование. Если вы не переопределите фреймворк, он это сделает.

Ответ №2:

Чтобы включить перенос слов в несколько строк в a UIButton , вам необходимо создать свой собственный подкласс button .

Например:

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

Эта кнопка перенесет заголовок на несколько строк, взаимодействуя с автоматической компоновкой / ограничениями.

У меня нет никаких проектов с MapBox, но вот пример использования модифицированной версии вашего CustomCalloutView . Я закомментировал любой конкретный код MapBox. Возможно, вы сможете отменить комментирование этих строк и использовать это как есть:

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

Это выглядит так:

введите описание изображения здесь

введите описание изображения здесь

введите описание изображения здесь