iosswiftautolayoutuilabelintrinsic-content-size

How to make a custom view's frame match its instrinsic content size


Custom Label that Resizes According to its Contents

I'm making a label from scratch. (My end purpose is to make a vertical text label for Mongolian script, but for now I am just making a normal horizontal text label as practice.)

As long as there are no length and width constraints, I want my custom label's frame to resize according to its intrinsic content size.

It Works in IB

In IB everything seems to be working fine when I test a UILabel, my custom label (a UIView subclass), and a button.

I add top and leading constrains to each of these views but don't set any height or width constraints.

enter image description here

If I resize them on the storyboard (but still without adding any more constraints)...

enter image description here

And then choose Update Frames for All Views in View Controller, the normal label and my custom label both resize properly to their intrinsic content sizes.

enter image description here

It Doesn't Work on a Running App

When I change the label text at runtime, though, my custom label's frame is not resizing. (I temporarily added a dark blue border to the text layer to help differentiate it here from the custom label's frame, which is the light blue background color.)

enter image description here

Clicking "Change text" gives

enter image description here

As you can see, the text layer frame changed but the custom view's frame didn't.

Code

This is my custom label class:

import UIKit
@IBDesignable
class UILabelFromScratch: UIView {

    private let textLayer = CATextLayer()

    @IBInspectable var text: String = "" {
        didSet {
            updateTextLayerFrame()
        }
    }

    @IBInspectable var fontSize: CGFloat = 17 {
        didSet {
            updateTextLayerFrame()
        }
    }

    // MARK: - Initialization

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

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

    func setup() {

        // Text layer
        textLayer.borderColor = UIColor.blueColor().CGColor
        textLayer.borderWidth = 1
        textLayer.contentsScale = UIScreen.mainScreen().scale
        layer.addSublayer(textLayer)
    }

    // MARK: Other methods

    override func intrinsicContentSize() -> CGSize {
        return textLayer.frame.size
    }

    func updateTextLayerFrame() {

        let myAttribute = [ NSFontAttributeName: UIFont.systemFontOfSize(fontSize) ]
        let attrString = NSMutableAttributedString(string: self.text, attributes: myAttribute )
        let size = dimensionsForAttributedString(attrString)

        textLayer.frame = CGRect(x: self.layer.bounds.origin.x, y: self.layer.bounds.origin.y, width: size.width, height: size.height)
        textLayer.string = attrString

    }

    func dimensionsForAttributedString(attrString: NSAttributedString) -> CGSize {

        var ascent: CGFloat = 0
        var descent: CGFloat = 0
        var width: CGFloat = 0
        let line: CTLineRef = CTLineCreateWithAttributedString(attrString)
        width = CGFloat(CTLineGetTypographicBounds(line, &ascent, &descent, nil))

        // make width an even integer for better graphics rendering
        width = ceil(width)
        if Int(width)%2 == 1 {
            width += 1.0
        }

        return CGSize(width: width, height: ceil(ascent+descent))
    }
}

And here is the view controller class:

import UIKit
class ViewController: UIViewController {

    @IBOutlet weak var normalLabel: UILabel!
    @IBOutlet weak var labelFromScratch: UILabelFromScratch!

    @IBAction func changeTextButtonTapped(sender: UIButton) {

        normalLabel.text = "Hello"
        labelFromScratch.text = "Hello"
    }
}

Question

What is it that UILabel does that I am missing in my custom label? I overrode intrinsicContentSize:

override func intrinsicContentSize() -> CGSize {
    return textLayer.frame.size
}

What else do I need to do?


Solution

  • I was missing a single line of code:

    invalidateIntrinsicContentSize()
    

    I added it after updating the text layer frame.

    func updateTextLayerFrame() {
    
        // ...
    
        textLayer.frame = CGRect(x: self.layer.bounds.origin.x, y: self.layer.bounds.origin.y, width: size.width, height: size.height)
        invalidateIntrinsicContentSize()
    
        // ...
    }
    

    The documentation says

    Call this when something changes in your custom view that invalidates its intrinsic content size. This allows the constraint-based layout system to take the new intrinsic content size into account in its next layout pass.

    Where I found this solution: