user3386180
user3386180

Reputation:

Auto-Resizing Custom Views with Multi-Line UILabels

I’d like to create some reusable custom UI components to be used in my such as the following UIView containing two labels. The labels may, depending on the content, be multi-line, and I provided some constraints for upper and lower margins. The views are added to my layout mainly inside UIStackViews using Interface Builder or programmatically. Trouble here is, that the height of the views is not calculated correctly in runtime, cutting off a portion on the bottom of each view, particularly when there are multiple lines.

There is obviously some conceptual issue that I have not been able to figure out yet, and probably getting this two-label example right would help me getting a better understanding.

I commented out the overall height constraint, which I thought was necessary, but with that uncommented I see only the top line of the second label.

import UIKit

@IBDesignable class TwoLabelView: UIView {

    var topMargin: CGFloat = 11.0
    var verticalSpacing: CGFloat = 3.0
    var bottomMargin: CGFloat = 8.0

    @IBInspectable var firstLabelText: String = "" { didSet { updateView() } }
    @IBInspectable var secondLabelText: String = "" { didSet { updateView() } }

    var viewHeight: CGFloat = 0.0

    var firstLabel: UILabel!
    var secondLabel: UILabel!

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

    required public init?(coder: NSCoder) {
        super.init(coder:coder)
        setUpView()
    }

    func setUpView() {

        firstLabel = UILabel()
        firstLabel.font = UIFont.systemFont(ofSize: 18.0, weight: UIFontWeightBold)
        firstLabel.numberOfLines = 3
        firstLabel.lineBreakMode = NSLineBreakMode.byWordWrapping

        secondLabel = UILabel()
        secondLabel.font = UIFont.systemFont(ofSize: 13.0, weight: UIFontWeightRegular)
        secondLabel.numberOfLines = 20
        secondLabel.lineBreakMode = NSLineBreakMode.byWordWrapping

        addSubview(firstLabel)
        addSubview(secondLabel)

        updateView()
    }

    func updateView() {

        firstLabel.text = firstLabelText
        secondLabel.text = secondLabelText

        firstLabel.sizeToFit()
        secondLabel.sizeToFit()
        viewHeight = getHeight()

        setNeedsUpdateConstraints()
    }

    override func updateConstraints() {

        translatesAutoresizingMaskIntoConstraints = false
        firstLabel .translatesAutoresizingMaskIntoConstraints = false
        secondLabel.translatesAutoresizingMaskIntoConstraints = false
        removeConstraints(constraints)

        if self.isHidden == false {
            self.addConstraint(NSLayoutConstraint(item: firstLabel, attribute: .top, relatedBy: .equal, toItem: self, attribute: .top, multiplier: 1, constant: topMargin))
            self.addConstraint(NSLayoutConstraint(item: firstLabel, attribute: .left, relatedBy: .equal, toItem: self, attribute: .left, multiplier: 1, constant: 0.0))
            self.addConstraint(NSLayoutConstraint(item: firstLabel, attribute: .right, relatedBy: .equal, toItem: self, attribute: .right, multiplier: 1, constant: 0.0))
            self.addConstraint(NSLayoutConstraint(item: secondLabel, attribute: .top, relatedBy: .equal, toItem: firstLabel, attribute: .bottom, multiplier: 1, constant: verticalSpacing))
            self.addConstraint(NSLayoutConstraint(item: secondLabel, attribute: .left, relatedBy: .equal, toItem: self, attribute: .left, multiplier: 1, constant: 0.0))
            self.addConstraint(NSLayoutConstraint(item: secondLabel, attribute: .right, relatedBy: .equal, toItem: self, attribute: .right, multiplier: 1, constant: 0.0))
            self.addConstraint(NSLayoutConstraint(item: secondLabel, attribute: .bottom, relatedBy: .equal, toItem: self, attribute: .bottom, multiplier: 1, constant: bottomMargin))
            //self.addConstraint(NSLayoutConstraint(item: self, attribute: .height, relatedBy: .equal, toItem: nil, attribute: .notAnAttribute, multiplier: 1, constant: viewHeight))
        }

        super.updateConstraints()
    }

    func getHeight() -> CGFloat {

        return topMargin
            + firstLabel.frame.height
            + verticalSpacing
            + secondLabel.frame.height
            + bottomMargin
    }

    override open var intrinsicContentSize : CGSize {

        return CGSize(width: UIViewNoIntrinsicMetric, height: getHeight())
    }
}

Upvotes: 0

Views: 1232

Answers (1)

DonMag
DonMag

Reputation: 77432

Couple things...

First, you don't need to keep re-creating the constraints. Create them once when you setup the labels.

Second, you want to use the constraints to let auto-layout control the sizing - that's what they're for.

Third, auto-sizing multiline-labels can be tricky. Well, a better word might be confounding! For auto-layout to render and size the text in the label, it has to start with a width. Unfortunately, the common scenario is having the label's width controlled by something else - its superview, a stack view, etc. BUT... you also want the width of the label to control or "push out the sides" of its superview.

So, you need to make sure the label has a preferredMaxLayoutWidth. Of course, you don't want to hard-code that - defeats the purpose of creating a flexible control.

The trick, from my experience anyway, is to force auto-layout to run a couple passes... and set the preferredMaxLayoutWidth sorta "in the middle" of the process.

Try this out, and see if you get what you're going for:

//
//  TwoLabelView.swift
//
//  Created by Don Mag on 8/2/17.
//

class FixAutoLabel: UILabel {

    override func layoutSubviews() {
        super.layoutSubviews()
        if(self.preferredMaxLayoutWidth != self.bounds.size.width) {
            self.preferredMaxLayoutWidth = self.bounds.size.width
        }
    }

}

@IBDesignable class TwoLabelView: UIView {

    var topMargin: CGFloat = 11.0
    var verticalSpacing: CGFloat = 3.0
    var bottomMargin: CGFloat = 8.0

    @IBInspectable var firstLabelText: String = "" { didSet { updateView() } }
    @IBInspectable var secondLabelText: String = "" { didSet { updateView() } }

    var firstLabel: FixAutoLabel!
    var secondLabel: FixAutoLabel!

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

    required public init?(coder: NSCoder) {
        super.init(coder:coder)
        setUpView()
    }

    override func prepareForInterfaceBuilder() {
        super.prepareForInterfaceBuilder()
        setUpView()
    }

    func setUpView() {

        firstLabel = FixAutoLabel()
        firstLabel.font = UIFont.systemFont(ofSize: 18.0, weight: UIFontWeightBold)
        firstLabel.numberOfLines = 3
        firstLabel.lineBreakMode = NSLineBreakMode.byTruncatingTail

        secondLabel = FixAutoLabel()
        secondLabel.font = UIFont.systemFont(ofSize: 13.0, weight: UIFontWeightRegular)
        secondLabel.numberOfLines = 20
        secondLabel.lineBreakMode = NSLineBreakMode.byTruncatingTail

        addSubview(firstLabel)
        addSubview(secondLabel)

        // we're going to set the constraints
        firstLabel .translatesAutoresizingMaskIntoConstraints = false
        secondLabel.translatesAutoresizingMaskIntoConstraints = false

        // pin both labels' left-edges to left-edge of self
        firstLabel.leftAnchor.constraint(equalTo: leftAnchor, constant: 0.0).isActive = true
        secondLabel.leftAnchor.constraint(equalTo: leftAnchor, constant: 0.0).isActive = true

        // pin both labels' right-edges to right-edge of self
        firstLabel.rightAnchor.constraint(equalTo: rightAnchor, constant: 0.0).isActive = true
        secondLabel.rightAnchor.constraint(equalTo: rightAnchor, constant: 0.0).isActive = true

        // pin firstLabel to the top of self + topMargin (padding)
        firstLabel.topAnchor.constraint(equalTo: topAnchor, constant: topMargin).isActive = true

        // pin top of secondLabel to bottom of firstLabel + verticalSpacing
        secondLabel.topAnchor.constraint(equalTo: firstLabel.bottomAnchor, constant: verticalSpacing).isActive = true

        // pin bottom of self to bottom of secondLabel + bottomMargin (padding)
        bottomAnchor.constraint(equalTo: secondLabel.bottomAnchor, constant: bottomMargin).isActive = true

        // colors are just for debugging so we can see the frames of the labels
        firstLabel.backgroundColor = .cyan
        secondLabel.backgroundColor = .green

        // call common "refresh" func
        updateView()
    }

    func updateView() {

        firstLabel.preferredMaxLayoutWidth = self.bounds.width
        secondLabel.preferredMaxLayoutWidth = self.bounds.width

        firstLabel.text = firstLabelText
        secondLabel.text = secondLabelText

        firstLabel.sizeToFit()
        secondLabel.sizeToFit()

        setNeedsUpdateConstraints()

    }

    override open var intrinsicContentSize : CGSize {
        // just has to have SOME intrinsic content size defined
        // this will be overridden by the constraints
        return CGSize(width: 1, height: 1)
    }
}

Upvotes: 1

Related Questions