iosswiftuilabel

Wrong intrinsicContentSize for custom UILabel with paddings


This is my AttributedLabel class:

import UIKit

class AttributedLabel: UILabel {
    var cornerRadius: CGFloat = 3 {
        didSet {
            layer.cornerRadius = cornerRadius
        }
    }

    var borderColor: UIColor = .clear {
        didSet {
            layer.borderColor = borderColor.cgColor
            layer.borderWidth = 1
        }
    }

    var masksToBounds: Bool = false {
        didSet {
            layer.masksToBounds = masksToBounds
        }
    }

    var borderWidth: CGFloat = 1 {
        didSet {
            layer.borderWidth = borderWidth
        }
    }

    private var strikeThrough = false
    private var paddingTop: CGFloat = 0
    private var paddingBottom: CGFloat = 0
    private var paddingLeft: CGFloat = 0
    private var paddingRight: CGFloat = 0
    override var text: String? {
        didSet {
            if strikeThrough {
                attributedText = NSAttributedString(string: text ?? "", strikeThrough: true)
            }
        }
    }

    // MARK: - Initialization

    convenience init(
        paddingTop: CGFloat = 0,
        paddingBottom: CGFloat = 0,
        paddingLeft: CGFloat = 0,
        paddingRight: CGFloat = 0,
        strikeThrough: Bool = false
    ) {
        self.init()
        self.paddingTop = paddingTop
        self.paddingBottom = paddingBottom
        self.paddingLeft = paddingLeft
        self.paddingRight = paddingRight
        self.strikeThrough = strikeThrough
        backgroundColor = .yellow
    }

    // MARK: - Overriden

    override func drawText(in rect: CGRect) {
        let insets = UIEdgeInsets(top: paddingTop, left: paddingLeft, bottom: paddingBottom, right: paddingRight)
        super.drawText(in: rect.inset(by: insets))
    }

    override var intrinsicContentSize: CGSize {
        var contentSize = super.intrinsicContentSize
        contentSize.height += paddingTop
        contentSize.height += paddingBottom
        contentSize.width += paddingLeft
        contentSize.width += paddingRight
        return contentSize
    }
}

And this is the result:

enter image description here

First label is truncated. Why? It is defined like this:

private let titleLabel: UILabel = {
    let label = AttributedLabel(paddingLeft: 20, paddingRight: 20)
    label.textColor = .mineShaft
    label.font = UIFont.poppinsMedium.withSize(18)
    label.textAlignment = .left
    label.numberOfLines = 0
    return label
}()

and has text assigned:

titleLabel.text = "Czy jesteś za wdrożeniem aplikacji PLZ?"

I don't know if it matters, but titleLabel is added into UIStackView:

contentStackView.addArrangedSubview(titleLabel)

After changes:

enter image description here


Solution

  • With your code:

    override var intrinsicContentSize: CGSize {
        var contentSize = super.intrinsicContentSize
        // etc...
        return contentSize
    }
    

    The text will not be wrapped, because the label has no intrinsic width. So, if you use this whole paragraph as .text contentSize may end up being something like (1500, 20)

    You want to use an internal CGSize property, calculate the height of the text in layoutSubviews(), and then call invalidateIntrinsicContentSize().

    Here's a quick example:


    Helper extension:

    extension NSAttributedString {
        func boundingRect(for size: CGSize) -> CGRect {
            let options: NSStringDrawingOptions = [.usesLineFragmentOrigin, .usesFontLeading]
            return boundingRect(with: size, options: options, context: nil)
        }
    }
    

    Your class, with a few modifications:

    class AttributedLabel: UILabel {
        var cornerRadius: CGFloat = 3 {
            didSet {
                layer.cornerRadius = cornerRadius
            }
        }
        
        var borderColor: UIColor = .clear {
            didSet {
                layer.borderColor = borderColor.cgColor
                layer.borderWidth = 1
            }
        }
        
        var masksToBounds: Bool = false {
            didSet {
                layer.masksToBounds = masksToBounds
            }
        }
        
        var borderWidth: CGFloat = 1 {
            didSet {
                layer.borderWidth = borderWidth
            }
        }
    
        // will be set in layoutSubviews()
        private var mySize: CGSize = .zero
    
        private var strikeThrough = false
        private var paddingTop: CGFloat = 0
        private var paddingBottom: CGFloat = 0
        private var paddingLeft: CGFloat = 0
        private var paddingRight: CGFloat = 0
        override var text: String? {
            didSet {
                let attributedString = NSAttributedString(string: text ?? "", attributes: [
                    .font: self.font ?? UIFont.systemFont(ofSize: 18, weight: .regular)
                ])
                self.attributedText = attributedString
                setNeedsLayout()
            }
        }
        
        // MARK: - Initialization
        
        convenience init(
            paddingTop: CGFloat = 0,
            paddingBottom: CGFloat = 0,
            paddingLeft: CGFloat = 0,
            paddingRight: CGFloat = 0,
            strikeThrough: Bool = false
        ) {
            self.init()
            self.paddingTop = paddingTop
            self.paddingBottom = paddingBottom
            self.paddingLeft = paddingLeft
            self.paddingRight = paddingRight
            self.strikeThrough = strikeThrough
            backgroundColor = .yellow
        }
        
        // MARK: - Overriden
        
        override func drawText(in rect: CGRect) {
            let insets = UIEdgeInsets(top: paddingTop, left: paddingLeft, bottom: paddingBottom, right: paddingRight)
            super.drawText(in: rect.inset(by: insets))
        }
        
        override var intrinsicContentSize: CGSize {
            // super.intrinsicContentSize will NOT wrap the text
            //var contentSize = super.intrinsicContentSize
            var contentSize = mySize
            contentSize.height += paddingTop
            contentSize.height += paddingBottom
            contentSize.width += paddingLeft
            contentSize.width += paddingRight
            return contentSize
        }
    
        override func layoutSubviews() {
            super.layoutSubviews()
            
            // this will be called more than once...
            //  during development, log the calls to make sure we're not in an infinite loop
            print(#function)
            
            let attributedString = self.attributedText ?? NSAttributedString(string: "")
            mySize = attributedString.boundingRect(for: .init(width: bounds.width - (paddingLeft + paddingRight), height: .greatestFiniteMagnitude)).size
            invalidateIntrinsicContentSize()
        }
    }
    

    Example view controller:

    class LabelTestVC: UIViewController {
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            let st = UIStackView()
            st.axis = .vertical
            st.spacing = 12
            
            st.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(st)
    
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                st.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
                st.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
                st.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
            ])
            
            let samples: [String] = [
                "Czy jesteś za wdrożeniem aplikacji PLZ?",
                "single line",
                "This will be long enough that we're sure it will need to wrap.",
                "This example has\nembedded\nnewLine characters\nso we get 4 lines.",
            ]
            
            for str in samples {
                let label = AttributedLabel(paddingLeft: 20, paddingRight: 20)
                label.textColor = .red // .mineShaft
                label.font = .systemFont(ofSize: 18.0, weight: .regular) //UIFont.poppinsMedium.withSize(18)
                label.textAlignment = .left
                label.numberOfLines = 0
                
                label.text = str
                st.addArrangedSubview(label)
                
                // so we can see the framing
                label.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
            }
            
            // so we can see the framing
            st.backgroundColor = .systemBlue
            
        }
        
    }
    

    Result (iPhone 15 Pro):

    enter image description here

    and device rotated:

    enter image description here


    Edit

    There are several problems with this approach... we could find a way to manage it by "double-calculating" bounding boxes, but we hit some additional issues when dynamically changing the label's text.

    A more reliable approach can be found here: Adding space/padding to a UILabel

    Here is a modified version that should fit your needs...


    class PaddingLabel: UILabel {
    
        public var cornerRadius: CGFloat = 3 {
            didSet {
                layer.cornerRadius = cornerRadius
            }
        }
        
        public var borderColor: UIColor = .clear {
            didSet {
                layer.borderColor = borderColor.cgColor
                layer.borderWidth = 1
            }
        }
        
        public var masksToBounds: Bool = false {
            didSet {
                layer.masksToBounds = masksToBounds
            }
        }
        
        public var borderWidth: CGFloat = 1 {
            didSet {
                layer.borderWidth = borderWidth
            }
        }
    
        private let strikeThroughAttribute: [NSAttributedString.Key : Any] = [
            .strikethroughStyle: NSUnderlineStyle.single.rawValue,
            .strikethroughColor: UIColor.red,
        ]
    
        private var strikeThrough = false
        private var paddingTop: CGFloat = 0
        private var paddingBottom: CGFloat = 0
        private var paddingLeft: CGFloat = 0
        private var paddingRight: CGFloat = 0
        override var text: String? {
            didSet {
                var attribs: [NSAttributedString.Key : Any] = [
                    .font: self.font ?? UIFont.systemFont(ofSize: 18, weight: .regular)
                ]
                if strikeThrough {
                    attribs.merge(strikeThroughAttribute) { (a, _) in a }
                }
                let attributedString = NSAttributedString(string: text ?? "", attributes: attribs)
                self.attributedText = attributedString
            }
        }
    
        convenience init(
            paddingTop: CGFloat = 0,
            paddingBottom: CGFloat = 0,
            paddingLeft: CGFloat = 0,
            paddingRight: CGFloat = 0,
            strikeThrough: Bool = false
        ) {
            self.init()
            self.paddingTop = paddingTop
            self.paddingBottom = paddingBottom
            self.paddingLeft = paddingLeft
            self.paddingRight = paddingRight
            self.strikeThrough = strikeThrough
            backgroundColor = .yellow
        }
    
        override func drawText(in rect: CGRect) {
            let insets = UIEdgeInsets(top: paddingTop, left: paddingLeft, bottom: paddingBottom, right: paddingRight)
            super.drawText(in: rect.inset(by: insets))
        }
        
        override var intrinsicContentSize: CGSize {
            let size = super.intrinsicContentSize
            return CGSize(width: size.width + paddingLeft + paddingRight,
                          height: size.height + paddingTop + paddingBottom)
        }
        
        override var bounds: CGRect {
            didSet {
                // ensures this works within stack views if multi-line
                preferredMaxLayoutWidth = bounds.width - (paddingLeft + paddingRight)
            }
        }
    }
    

    And, a new test controller with both a non-width-contrained label and several labels in a stack view. Tapping anywhere will dynamically update the text in the labels:

    class LabelTestVC: UIViewController {
        
        var singleTexts: [String] = [
            "Zakończony",
            "This is a longer line of text.",
            "Four",
            "The last non-stackView text."
        ]
        var samples: [String] = [
            "Tap anywhere to update the label texts dynamically.",
            "single line",
            "Czy jesteś za wdrożeniem aplikacji PLZ?",
            "This will be long enough that we're sure it will need to wrap.",
            "This example has\nembedded\nnewLine characters\nso we get 4 lines.",
        ]
    
        override func viewDidLoad() {
            super.viewDidLoad()
            
            let testLabel = PaddingLabel(paddingLeft: 20, paddingRight: 20)
    
            let st = UIStackView()
            st.axis = .vertical
            st.spacing = 12
            
            // if the label has no constraint controlling its width,
            //  it cannot have .numberOfLines = 0
            testLabel.numberOfLines = 1
            testLabel.font = .systemFont(ofSize: 18.0, weight: .regular) //UIFont.poppinsMedium.withSize(18)
            testLabel.textAlignment = .left
            
            testLabel.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(testLabel)
            
            st.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(st)
            
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                
                testLabel.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
                testLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
                testLabel.trailingAnchor.constraint(lessThanOrEqualTo: g.trailingAnchor, constant: -20.0),
                
                st.topAnchor.constraint(equalTo: testLabel.bottomAnchor, constant: 20.0),
                st.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
                st.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
                
            ])
            
            testLabel.text = singleTexts[0]
            testLabel.backgroundColor = UIColor(red: 0.5, green: 1.0, blue: 0.75, alpha: 1.0)
            
            for str in samples {
                let label = PaddingLabel(paddingLeft: 20, paddingRight: 20)
                label.textColor = .red // .mineShaft
                label.font = .systemFont(ofSize: 18.0, weight: .regular) //UIFont.poppinsMedium.withSize(18)
                label.textAlignment = .left
                label.numberOfLines = 0
                
                label.text = str
                st.addArrangedSubview(label)
                
                // so we can see the framing
                label.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
            }
            
            // so we can see the framing
            st.backgroundColor = .systemBlue
            
        }
        
        override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
            if let v = view.subviews.first as? UILabel {
                let s = singleTexts.removeFirst()
                singleTexts.append(s)
                v.text = singleTexts[0]
            }
            if let sv = view.subviews.last as? UIStackView {
                let s = samples.removeFirst()
                samples.append(s)
                for (v, str) in zip(sv.arrangedSubviews, samples) {
                    if let v = v as? UILabel {
                        v.text = str
                    }
                }
            }
        }
        
    }