skip to Main Content

In my app, I fetch some HTML strings from the WordPress REST API of a website. I use the following extension to convert the HTML to an NSAttributedString that I can display in my UILabel:

extension NSAttributedString {
    
    convenience init(htmlString html: String, font: UIFont? = nil, useDocumentFontSize: Bool = false) throws {
        let options: [NSAttributedString.DocumentReadingOptionKey : Any] = [
            .documentType: NSAttributedString.DocumentType.html,
            .characterEncoding: String.Encoding.utf8.rawValue
        ]

        let data = html.data(using: .utf8, allowLossyConversion: true)
        guard (data != nil), let fontFamily = font?.familyName, let attr = try? NSMutableAttributedString(data: data!, options: options, documentAttributes: nil) else {
            try self.init(data: data ?? Data(html.utf8), options: options, documentAttributes: nil)
            return
        }

        let fontSize: CGFloat? = useDocumentFontSize ? nil : font!.pointSize
        let range = NSRange(location: 0, length: attr.length)
        attr.enumerateAttribute(.font, in: range, options: .longestEffectiveRangeNotRequired) { attrib, range, _ in
            if let htmlFont = attrib as? UIFont {
                let traits = htmlFont.fontDescriptor.symbolicTraits
                var descrip = htmlFont.fontDescriptor.withFamily(fontFamily)

                if (traits.rawValue & UIFontDescriptor.SymbolicTraits.traitBold.rawValue) != 0 {
                    descrip = descrip.withSymbolicTraits(.traitBold)!
                }

                if (traits.rawValue & UIFontDescriptor.SymbolicTraits.traitItalic.rawValue) != 0 {
                    descrip = descrip.withSymbolicTraits(.traitItalic)!
                }

                attr.addAttribute(.font, value: UIFont(descriptor: descrip, size: fontSize ?? htmlFont.pointSize), range: range)
                attr.addAttribute(NSAttributedString.Key.foregroundColor, value: UIColor.label, range: range)
            }
        }

        self.init(attributedString: attr)
    }

}

The UILabel is defined as follows:

let myLabel: UILabel = {
        let label = UILabel()
        label.translatesAutoresizingMaskIntoConstraints = false
        label.font = UIFont.systemFont(ofSize: 15, weight: .regular)
        return label
}()

I use the following code to display the NSAttributedString in the UILabel:

let contentAttributed = try? NSAttributedString(htmlString: html, font: UIFont.systemFont(ofSize: 15, weight: .regular))
myLabel.attributedText = contentAttributed
myLabel.numberOfLines = 3

With this, everything works as expected, as shown in two examples below:

Example 1 (everything ok)

Example 2 (everything ok)

I would really like the UILabels to be completely filled and with ellipsis at the end (truncated lines). If I add myLabel.lineBreakMode = .byTruncatingTail into my code, there are weird issues, as shown in the same examples below. The weird thing is that some of the UILabels still work absolutely fine (example 2), while others show the weird issues (example 1).

Example 1 (issues)

Example 2 (still everything ok)

If anyone knows where in this rather complicated issue I should start digging, any help would be greatly appreciated.

Edit: Some examples of HTML strings that produce the issue:

n<p>Liebe Damen &amp; Herren</p>nnnn<p>Erneut trifft es uns und wir müssen die Sammlung vom Samstag <strong>bis auf Weiteres verschieben!</strong>
n<p>nnLiebe Kollegen</p>nnnn<p>Am 28. August führen wir (endlich) wieder die Papiersammlung in der Gemeinde durch. Dabei sind wir auf möglichst viele Helfer:innen angewiesen.</p>
n<p>​Liebe Kollegen</p>nnnn<p>Nur ungerne stören wir eure Sommerferien mit einer unerfreulichen Nachricht: <strong>Das Katre 2021 vom 4. und 5. September wurde leider abgesagt!</strong></p>

The following HTML strings do not produce the issue:

n<p>Kürzlich hat der Bundesrat über die in Etappen stattfindende Lockerung der Massnahmen zum Schutz vor dem Coronavirus informiert. Leider haben die ersten Lockerungen des Bundes noch keinen Einfluss auf uns.</p>
<p>Liebe Eltern und Kollegen</p>n<p>Unser Jahresprogramm für 2019 ist nun fertig und online. Ihr findet es unter Medien -&gt; Downloads oder direkt hier:</p>n<p><a href="https: //somelink.com">Jahresprogramm_2019</a></p>

The App is run on the iPhone 13 Pro Simulator in portrait orientation. The UILabel is part of a custom UITableViewCell. The UITableView is aligned to the safe area on the left and right (constant 0). The UILabel is aligned to the contentView.leadingAnchor and contentView.trailingAnchor with a constant of 30 and -30, respectively.

2

Answers


  1. It looks like the strings that work only have one paragraph of text, whereas the ones that do not have multiple. Apple’s documentation implies that lineBreakMode applies to the label’s whole attributedText, as long as it’s set after the attributedText is assigned a value. That doesn’t explain this behavior though, and the fact that parts of the string are being repeated makes me think this is a bug on how multi-paragraph NSAttributedStrings interact with a label’s lineBreakMode.

    NSMutableParagraphStyle has a property called lineBreakMode, which will allow you to set the line break mode for each paragraph individually. That might give you a few different options for workarounds.

    Alternatively – setting the numberOfLines to 0 also fixes the problem for me, though it’s not ideal since it might require you to set a max height for the label, which could worsen the user experience with dynamic font sizes.

    Viel Glück!

    Login or Signup to reply.
  2. I’m posting this answer, not because it is a solution but because I think it is worth discussion…


    Edit

    Personally, I would say Attributed Text and .lineBreakMode = .byTruncatingTail is rather buggy.

    This does not appear to be related to Attributed Text, but rather to the presence of a newline character.

    For example, if the label is set to .numberOfLines = 3 and we use this text:

    myLabel.text = "Let's test this:nPlenty of text to wrap onto more than three lines. Note this is plain text, not attributed text. It may or may not exhibit the same problems."
    

    we will see the same corrupted text wrapping occur.

    Also note: this does NOT happen on iOS prior to 15.4!

    /Edit


    In all the following examples, the same attributed string is used for pairs of rows, but only the cyan labels have myLabel.lineBreakMode = .byTruncatingTail.


    First note:

    If we set myLabel.numberOfLines = 0, we get this output:

    enter image description here

    Which doesn’t make much sense… Apparently, a newline character gets factored in at the end of the attributed string — but even so, we get almost, but not enough space for TWO additional lines (I’m guessing it’s a newline and a paragraph vertical space?):

    enter image description here


    Second note:

    If we DO NOT have enough text to exceed the bounds of the label (again, we’re setting myLabel.numberOfLines = 3 here), we get another oddity. This is when I rotate the device:

    enter image description here

    Even though the text is NOT truncated, we get the ... — because UIKit is trying to add 1.5 additional blank lines at the end of the string.


    Third note:

    As I mentioned in a comment, I know we came across this once before here on SO — unfortunately, I can’t remember if we resolved it or (probably) not.


    To reproduce with Plain Text

    class WrapBugVC: UIViewController {
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            let stackView = UIStackView()
            stackView.axis = .vertical
            stackView.spacing = 4
            stackView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(stackView)
            
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                
                stackView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
                stackView.widthAnchor.constraint(equalToConstant: 315.0),
                stackView.centerXAnchor.constraint(equalTo: g.centerXAnchor),
                
            ])
    
            var vInfo: UILabel
            
            let testStr1: String = "Let's test this (no newline character): Plenty of text to wrap onto more than three lines. Note this is plain text, not attributed text."
            let testStr2: String = "Let's test this:nPlenty of text to wrap onto more than three lines. Note this is plain text, not attributed text."
    
            vInfo = UILabel()
            vInfo.font = .italicSystemFont(ofSize: 15.0)
            vInfo.text = "NO newline, .byWordWrapping"
            stackView.addArrangedSubview(vInfo)
            
            let v1 = UILabel()
            v1.numberOfLines = 3
            v1.text = testStr1
            v1.backgroundColor = .yellow
            v1.lineBreakMode = .byWordWrapping
            stackView.addArrangedSubview(v1)
            
            vInfo = UILabel()
            vInfo.font = .italicSystemFont(ofSize: 15.0)
            vInfo.text = "NO newline, .byTruncatingTail"
            stackView.addArrangedSubview(vInfo)
            
            let v2 = UILabel()
            v2.numberOfLines = 3
            v2.text = testStr1
            v2.backgroundColor = .cyan
            v2.lineBreakMode = .byTruncatingTail
            stackView.addArrangedSubview(v2)
            
            vInfo = UILabel()
            vInfo.font = .italicSystemFont(ofSize: 15.0)
            vInfo.text = "WITH newline, .byWordWrapping"
            stackView.addArrangedSubview(vInfo)
            
            let v3 = UILabel()
            v3.numberOfLines = 3
            v3.text = testStr2
            v3.backgroundColor = .yellow
            v3.lineBreakMode = .byWordWrapping
            stackView.addArrangedSubview(v3)
    
            vInfo = UILabel()
            vInfo.font = .italicSystemFont(ofSize: 15.0)
            vInfo.text = "WITH newline, .byTruncatingTail"
            stackView.addArrangedSubview(vInfo)
            
            let v4 = UILabel()
            v4.numberOfLines = 3
            v4.text = testStr2
            v4.backgroundColor = .cyan
            v4.lineBreakMode = .byTruncatingTail
            stackView.addArrangedSubview(v4)
    
            [v1, v2, v3, v4].forEach { v in
                stackView.setCustomSpacing(16.0, after: v)
            }
            
        }
        
    }
    

    Result:

    enter image description here


    Workaround

    You may want to try this as a workaround…

    class TextViewLabel: UITextView {
        
        public var numberOfLines: Int = 0 {
            didSet {
                textContainer.maximumNumberOfLines = numberOfLines
            }
        }
        public var lineBreakMode: NSLineBreakMode = .byTruncatingTail {
            didSet {
                textContainer.lineBreakMode = lineBreakMode
            }
        }
        
        override init(frame: CGRect, textContainer: NSTextContainer?) {
            super.init(frame: frame, textContainer: textContainer)
            commonInit()
        }
        
        required init?(coder aDecoder: NSCoder) {
            super.init(coder: aDecoder)
            commonInit()
        }
        
        private func commonInit() -> Void {
            isScrollEnabled = false
            isEditable = false
            isSelectable = false
            textContainerInset = UIEdgeInsets.zero
            textContainer.lineFragmentPadding = 0
        }
        
    }
    

    Using TextViewLabel in place of UILabel will avoid the bug:

    enter image description here


    Edit 2

    As of iOS 16.2 there are still inconsistencies with word-wrapping and tail-truncation between UILabel and UITextView.

    Here is some code to see them side-by-side:

    // a replacement for UILabel
    class TextViewLabel: UITextView {
        
        public var numberOfLines: Int = 0 {
            didSet {
                textContainer.maximumNumberOfLines = numberOfLines
            }
        }
        public var lineBreakMode: NSLineBreakMode = .byTruncatingTail {
            didSet {
                textContainer.lineBreakMode = lineBreakMode
            }
        }
        
        override init(frame: CGRect, textContainer: NSTextContainer?) {
            super.init(frame: frame, textContainer: textContainer)
            commonInit()
        }
        
        required init?(coder aDecoder: NSCoder) {
            super.init(coder: aDecoder)
            commonInit()
        }
        
        private func commonInit() -> Void {
            isScrollEnabled = false
            isEditable = false
            isSelectable = false
            textContainerInset = UIEdgeInsets.zero
            textContainer.lineFragmentPadding = 0
        }
        
    }
    
    class ViewController: UIViewController {
        
        let labelStackView = UIStackView()
        let textViewStackView = UIStackView()
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            let scrollView = UIScrollView()
            
            let hStack = UIStackView()
            hStack.axis = .horizontal
            hStack.spacing = 8
            hStack.distribution = .fillEqually
            hStack.alignment = .top
            
            [labelStackView, textViewStackView].forEach { v in
                v.axis = .vertical
                v.spacing = 4
                hStack.addArrangedSubview(v)
            }
            
            hStack.translatesAutoresizingMaskIntoConstraints = false
            scrollView.addSubview(hStack)
    
            scrollView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(scrollView)
            
            let g = view.safeAreaLayoutGuide
            let cg = scrollView.contentLayoutGuide
            let fg = scrollView.frameLayoutGuide
    
            NSLayoutConstraint.activate([
                
                scrollView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
                scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
                scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
                scrollView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -20.0),
    
                hStack.topAnchor.constraint(equalTo: cg.topAnchor, constant: 8.0),
                hStack.leadingAnchor.constraint(equalTo: cg.leadingAnchor, constant: 8.0),
                hStack.trailingAnchor.constraint(equalTo: cg.trailingAnchor, constant: -8.0),
                hStack.bottomAnchor.constraint(equalTo: cg.bottomAnchor, constant: -8.0),
    
                hStack.widthAnchor.constraint(equalTo: fg.widthAnchor, constant: -16.0),
                
            ])
            
            let infoFont: UIFont = .italicSystemFont(ofSize: 10.0)
            let testFont: UIFont = .systemFont(ofSize: 12.0)
            
            var testStr: String = "This is some example text to test truncation consistency. At the end of this line, is a pair of newline chars:nnA HEADLINEnnThe headline was also followed by a pair of newline chars."
    
            for i in 3...10 {
                
                let infoLabel = UILabel()
                infoLabel.font = infoFont
                infoLabel.text = "UILabel - numLines: (i)"
                labelStackView.addArrangedSubview(infoLabel)
                
                let v = UILabel()
                v.font = testFont
                v.numberOfLines = i
                v.text = testStr
                v.backgroundColor = .yellow
                v.lineBreakMode = .byTruncatingTail
                labelStackView.addArrangedSubview(v)
                
                labelStackView.setCustomSpacing(16.0, after: v)
                
            }
            
            for i in 3...10 {
                
                let infoLabel = UILabel()
                infoLabel.font = infoFont
                infoLabel.text = "TextViewLabel - numLines: (i)"
                textViewStackView.addArrangedSubview(infoLabel)
                
                let v = TextViewLabel()
                v.font = testFont
                v.numberOfLines = i
                v.text = testStr
                v.backgroundColor = .cyan
                v.lineBreakMode = .byTruncatingTail
                textViewStackView.addArrangedSubview(v)
                
                textViewStackView.setCustomSpacing(16.0, after: v)
                
            }
            
            scrollView.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
            
        }
        
    }
    

    Differences also exist from one iOS version to another – for example:

    enter image description here

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search