34
votes

I have created a view controller that looks like this:

enter image description here

I want the two top buttons to always have 20 points between themselves and the left/right edges of the whole view. They should always have the same width too. I have created the constraints for all of this and it works exactly how I want it to. The problem is the vertical constraints. The buttons should always be 20 points beneath the top edge. They should have the same height. However, autolayout doesn't respect that the left label needs two lines to fit all its text, so the result looks like this:

enter image description here

I want it to look like in the first picture. I can't add constant height constraints to the buttons because when the app runs on iPad, only one line is needed and it would be wasteful to have extra space then.

In viewDidLoad I tried this:

- (void)viewDidLoad
{
    [super viewDidLoad];
    self.leftButton.titleLabel.preferredMaxLayoutWidth = (self.view.frame.size.width - 20.0 * 3) / 2.0;
    self.rightButton.titleLabel.preferredMaxLayoutWidth = (self.view.frame.size.width - 20.0 * 3) / 2.0;
}

But that did not change anyhting at all.

The question: How do I make autolayout respect that the left button needs two lines?

17
it might be because you have the "same height" constraint on the left button. That will make it the same height as the other buttons, changing the content size within the buttonuser2277872
I tried to remove the "same height" constraint but it didn't work.user3124010
Have you tried adding >= constraints to the labels to establish a minimum inset?jaggedcow
No, but I tried it now, but unfortunely it did not work either. Now on iPad, the buttons are "double high", which I don't want them to be.user3124010

17 Answers

43
votes

I had the same problem where I wanted my button to grow along with its title. I had to sublcass the UIButton and its intrinsicContentSize so that it returns the intrinsic size of the label.

- (CGSize)intrinsicContentSize
{
    return self.titleLabel.intrinsicContentSize;
}

Since the UILabel is multiline, its intrinsicContentSize is unknown and you have to set its preferredMaxLayoutWidth See objc.io article about that

- (void)layoutSubviews
{
    [super layoutSubviews];
    self.titleLabel.preferredMaxLayoutWidth = self.titleLabel.frame.size.width;
    [super layoutSubviews];
}

The rest of the layout should work. If you set your both button having equal heights, the other one will grow to. The complete button looks like this

@implementation TAButton

- (instancetype)initWithCoder:(NSCoder *)coder
{
    self = [super initWithCoder:coder];
    if (self) {
        self.titleLabel.numberOfLines = 0;
        self.titleLabel.lineBreakMode = NSLineBreakByWordWrapping;
    }
    return self;
}

- (CGSize)intrinsicContentSize
{
    return self.titleLabel.intrinsicContentSize;
}

- (void)layoutSubviews
{
    [super layoutSubviews];
    self.titleLabel.preferredMaxLayoutWidth = self.titleLabel.frame.size.width;
    [super layoutSubviews];
}

@end
24
votes

Swift 4.1.2 Version based on @Jan answer.

import UIKit

class MultiLineButton: UIButton {

    // MARK: - Init

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

        self.commonInit()
    }

    private func commonInit() {
        self.titleLabel?.numberOfLines = 0
        self.titleLabel?.lineBreakMode = .byWordWrapping
    }

    // MARK: - Overrides

    override var intrinsicContentSize: CGSize {
        get {
             return titleLabel?.intrinsicContentSize ?? CGSize.zero
        }
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        titleLabel?.preferredMaxLayoutWidth = titleLabel?.frame.size.width ?? 0
        super.layoutSubviews()
    }

}
8
votes

This respects content edge insets and worked for me:

class MultilineButton: UIButton {

    func setup() {
        self.titleLabel?.numberOfLines = 0
        self.setContentHuggingPriority(UILayoutPriorityDefaultLow + 1, for: .vertical)
        self.setContentHuggingPriority(UILayoutPriorityDefaultLow + 1, for: .horizontal)
    }

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

    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
    }

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

A simple solution working for me: make the multiline button to respect its title height in Swift 4.2 by adding a constraint for the button's height based on its title label's height:

let height = NSLayoutConstraint(item: multilineButton,
                                attribute: .height,
                                relatedBy: .equal,
                                toItem: multilineButton.titleLabel,
                                attribute: .height,
                                multiplier: 1,
                                constant: 0)
multilineButton.addConstraint(height)
3
votes

Lot of answers here, but the simple one by @Yevheniia Zelenska worked fine for me. Simplified Swift 5 version:

@IBOutlet private weak var button: UIButton! {
    didSet {
        guard let titleHeightAnchor = button.titleLabel?.heightAnchor else { return }
        button.heightAnchor.constraint(equalTo: titleHeightAnchor).isActive = true
    }
}
2
votes

Complete class in Swift 3 - based on @Jan, @Quantaliinuxite and @matt bezark:

@IBDesignable
class MultiLineButton:UIButton {

    //MARK: -
    //MARK: Setup
    func setup () {
        self.titleLabel?.numberOfLines = 0

        //The next two lines are essential in making sure autolayout sizes us correctly
        self.setContentHuggingPriority(UILayoutPriorityDefaultLow+1, for: .vertical)
        self.setContentHuggingPriority(UILayoutPriorityDefaultLow+1, for: .horizontal)
    }

    //MARK:-
    //MARK: Method overrides
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setup()
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
    }

    override var intrinsicContentSize: CGSize {
        return self.titleLabel!.intrinsicContentSize
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        titleLabel?.preferredMaxLayoutWidth = self.titleLabel!.frame.size.width
    }
}
2
votes

add the missing constraints:

if let label = button.titleLabel {

    button.addConstraint(NSLayoutConstraint(item: label, attribute: .top, relatedBy: .equal, toItem: button, attribute: .top, multiplier: 1.0, constant: 0.0))
    button.addConstraint(NSLayoutConstraint(item: label, attribute: .bottom, relatedBy: .equal, toItem: button, attribute: .bottom, multiplier: 1.0, constant: 0.0))
}
1
votes

Have you tried using this:

self.leftButton.titleLabel.textAlignment = NSTextAlignmentCenter;
self.leftButton.titleLabel.lineBreakMode = NSLineBreakByWordWrapping | NSLineBreakByTruncatingTail;
self.leftButton.titleLabel.numberOfLines = 0;
1
votes

UPDATED Swift/Swift 2.0 version again based on @Jan's answer

@IBDesignable
class MultiLineButton:UIButton {

  //MARK: -
  //MARK: Setup
  func setup () {
    self.titleLabel?.numberOfLines = 0

    //The next two lines are essential in making sure autolayout sizes us correctly
    self.setContentHuggingPriority(UILayoutPriorityDefaultLow+1, forAxis: .Vertical) 
    self.setContentHuggingPriority(UILayoutPriorityDefaultLow+1, forAxis: .Horizontal)
  }

  //MARK:-
  //MARK: Method overrides
  required init?(coder aDecoder: NSCoder) {
    super.init(coder: aDecoder)
    setup()
  }

  override init(frame: CGRect) {
    super.init(frame: frame)
    setup()
  }

  override func intrinsicContentSize() -> CGSize {
    return self.titleLabel!.intrinsicContentSize()
  }

  override func layoutSubviews() {
    super.layoutSubviews()
    titleLabel?.preferredMaxLayoutWidth = self.titleLabel!.frame.size.width
  }
}
1
votes

tweaks for Swift 3.1

intrisicContentSize is a property instead of a function

override var intrinsicContentSize: CGSize {
    return self.titleLabel!.intrinsicContentSize
}
1
votes

There is a solution without subclassing on iOS11. Just need to set one additional constraint in code to match height of button and button.titleLabel.

ObjC:

// In init or overriden updateConstraints method
NSLayoutConstraint *constraint = [NSLayoutConstraint constraintWithItem:self.button
                                                              attribute:NSLayoutAttributeHeight
                                                              relatedBy:NSLayoutRelationEqual
                                                                 toItem:self.button.titleLabel
                                                              attribute:NSLayoutAttributeHeight
                                                             multiplier:1
                                                               constant:0];

[self addConstraint:constraint];

And in some cases (as said before):

- (void)layoutSubviews {
    [super layoutSubviews];

    self.button.titleLabel.preferredMaxLayoutWidth = CGRectGetWidth(self.button.titleLabel.frame);
}

Swift:

let constraint = NSLayoutConstraint(item: button,
                                    attribute: .height,
                                    relatedBy: .equal,
                                    toItem: button.titleLabel,
                                    attribute: .height,
                                    multiplier: 1,
                                    constant: 0)

self.addConstraint(constraint)

+

override func layoutSubviews() {
    super.layoutSubviews()

    button.titleLabel.preferredMaxLayoutWidth = button.titleLabel.frame.width
}
1
votes

None of the other answers had everything working for me. Here's my answer:

class MultilineButton: UIButton {
    func setup() {
        titleLabel?.textAlignment = .center
        titleLabel?.numberOfLines = 0
        titleLabel?.lineBreakMode = .byWordWrapping
    }

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

    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
    }

    override var intrinsicContentSize: CGSize {
        var titleContentSize = titleLabel?.intrinsicContentSize ?? CGSize.zero
        titleContentSize.height += contentEdgeInsets.top + contentEdgeInsets.bottom
        titleContentSize.width += contentEdgeInsets.left + contentEdgeInsets.right
        return titleContentSize
    }

    override func layoutSubviews() {
        titleLabel?.preferredMaxLayoutWidth = 300 // Or whatever your maximum is
        super.layoutSubviews()
    }
}

This won't cater for an image, however.

1
votes

Version, which also taking into account titleEdgeInsets and not overrides standard button behaviour unless titleLabel?.numberOfLines set to zero and button image set to nil.

open class Button: UIButton {

   override open var intrinsicContentSize: CGSize {
      if let titleLabel = titleLabel, titleLabel.numberOfLines == 0, image == nil {
         let size = titleLabel.intrinsicContentSize
         let result = CGSize(width: size.width + contentEdgeInsets.horizontal + titleEdgeInsets.horizontal,
                             height: size.height + contentEdgeInsets.vertical + titleEdgeInsets.vertical)
         return result
      } else {
         return super.intrinsicContentSize
      }
   }

   override open func layoutSubviews() {
      super.layoutSubviews()
      if let titleLabel = titleLabel, titleLabel.numberOfLines == 0, image == nil {
         let priority = UILayoutPriority.defaultLow + 1
         if titleLabel.horizontalContentHuggingPriority != priority {
            titleLabel.horizontalContentHuggingPriority = priority
         }
         if titleLabel.verticalContentHuggingPriority != priority {
            titleLabel.verticalContentHuggingPriority = priority
         }
         let rect = titleRect(forContentRect: contentRect(forBounds: bounds))
         titleLabel.preferredMaxLayoutWidth = rect.size.width
         super.layoutSubviews()
      }
   }
}
0
votes

@Jan's answer doesn't work for me in (at least) iOS 8.1, 9.0 with Xcode 9.1. The problem: titleLabel's -intrinsicContentSize returns very big width and small height as there is no width limit at all (titleLabel.frame on call has zero size that leads to measurements problem). Moreover, it doesn't take into account possible insets and/or image.

So, here is my implementation that should fix all the stuff (only one method is really necessary):

@implementation PRButton

- (CGSize)intrinsicContentSize
{
    CGRect titleFrameMax = UIEdgeInsetsInsetRect(UIEdgeInsetsInsetRect(UIEdgeInsetsInsetRect(
        self.bounds, self.alignmentRectInsets), self.contentEdgeInsets), self.titleEdgeInsets
    );
    CGSize titleSize = [self.titleLabel sizeThatFits:CGSizeMake(titleFrameMax.size.width, CGFLOAT_MAX)];

    CGSize superSize = [super intrinsicContentSize];
    return CGSizeMake(
        titleSize.width + (self.bounds.size.width - titleFrameMax.size.width),
        MAX(superSize.height, titleSize.height + (self.bounds.size.height - titleFrameMax.size.height))
    );
}

@end
0
votes
//Swift 4 - Create Dynamic Button MultiLine Dynamic

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

         /// Add DemoButton 1
        let demoButton1 = buildButton("Demo 1")
        //demoButton1.addTarget(self, action: #selector(ViewController.onDemo1Tapped), for: .touchUpInside)
        view.addSubview(demoButton1)

        view.addConstraint(NSLayoutConstraint(item: demoButton1, attribute: .centerX, relatedBy: .equal, toItem: view, attribute: .centerX, multiplier: 1, constant: 0))
        view.addConstraint(NSLayoutConstraint(item: demoButton1, attribute: .centerY, relatedBy: .equal, toItem: view, attribute: .centerY, multiplier: 1, constant: -180))

    }

    func buildButton(_ title: String) -> UIButton {
        let button = UIButton(type: .system)
        button.backgroundColor = UIColor(red: 80/255, green: 70/255, blue: 66/255, alpha: 1.0)

        //--------------------------
        //to make the button multiline
        //button.titleLabel!.lineBreakMode = .byWordWrapping
        button.titleLabel?.textAlignment = .center
        button.titleLabel?.numberOfLines = 0
        //button.titleLabel?.adjustsFontSizeToFitWidth = true
        //button.sizeToFit()
        button.titleLabel?.preferredMaxLayoutWidth = self.view.bounds.width//200
        button.layer.borderWidth = 2
        let height = NSLayoutConstraint(item: button,
                                        attribute: .height,
                                        relatedBy: .equal,
                                        toItem: button.titleLabel,
                                        attribute: .height,
                                        multiplier: 1,
                                        constant: 0)
        button.addConstraint(height)
        //--------------------------

        button.setTitle(title, for: UIControlState())
        button.layer.cornerRadius = 4.0
        button.setTitleColor(UIColor(red: 233/255, green: 205/255, blue: 193/255, alpha: 1.0), for: UIControlState())
        button.translatesAutoresizingMaskIntoConstraints = false
        return button
    }
}
0
votes

Instead of calling layoutSubviews twice I'd calculate preferredMaxLayoutWidth manually

@objcMembers class MultilineButton: UIButton {

override var intrinsicContentSize: CGSize {
    // override to have the right height with autolayout
    get {
        var titleContentSize = titleLabel!.intrinsicContentSize
        titleContentSize.height += contentEdgeInsets.top + contentEdgeInsets.bottom
        return titleContentSize
    }
}

override func awakeFromNib() {
    super.awakeFromNib()
    titleLabel!.numberOfLines = 0
}

override func layoutSubviews() {
    let contentWidth = width - contentEdgeInsets.left - contentEdgeInsets.right
    let imageWidth = imageView?.width ?? 0 + imageEdgeInsets.left + imageEdgeInsets.right
    let titleMaxWidth = contentWidth - imageWidth - titleEdgeInsets.left - titleEdgeInsets.right

    titleLabel!.preferredMaxLayoutWidth = titleMaxWidth
    super.layoutSubviews()
}
}
0
votes

I could not find a proper answer that took all these into account:

  1. Use AutoLayout only (meaning no override of layoutSubviews)
  2. Respect the button's contentEdgeInsets
  3. Minimalist (no playing with buttons's intrinsicContentSize)

So here's my take on it, which respects all three points from above.

final class MultilineButton: UIButton {

    /// Buttons don't have built-in layout support for multiline labels. 
    /// This constraint is here to provide proper button's height given titleLabel's height and contentEdgeInset.
    private var heightCorrectionConstraint: NSLayoutConstraint?
           
    override var contentEdgeInsets: UIEdgeInsets {
        didSet {
            heightCorrectionConstraint?.constant = -(contentEdgeInsets.top + contentEdgeInsets.bottom)
        }
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupLayout()
    }
      
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setupLayout()
    }
    
    private func setupLayout() {  
        titleLabel?.numberOfLines = 0
      
        heightCorrectionConstraint = titleLabel?.heightAnchor.constraint(equalTo: heightAnchor, constant: 0)
        heightCorrectionConstraint?.priority = .defaultHigh
        heightCorrectionConstraint?.isActive = true
    }
}

Note

I did not modify the button's intrinsicContentSize, there is no need to play with it. When the label is 2+ lines, the button's natural intrinsicContentSize's height is smaller than the desired height. The constraint that I added (heightCorrectionConstraint) corrects that automatically. Just make sure that the button's contentHuggingPriority in the vertical axis is smaller than the heightCorrectionConstraint's priority (which is the default).