iosswiftuitextviewtextkitnslayoutmanager

iOS NSLayoutManager's usedRect gives wrong rect if the attributed string doesn't have its own font attribute


I am creating a subclass of UILabel in which I need to detect clicks in an NSAttributedString. For various reasons, I am not allowed to use UITextView. So, based on various other SO posts, I am using NSLayoutManager, NSTextContainer, and NSTextStorage to calculate the textBoundingBox.

All this works pretty well except my layoutManager.usedRect gives me the wrong textBoundingBox if my NSAttributedString doesn't have an explicit font attribute in it.

If I set a font attribute to it, then it gives me the correct textBoundingBox.

Code:

import UIKit

class ClickableLabel3: UILabel, NSLayoutManagerDelegate {
    
    private let layoutManager = NSLayoutManager()
    private let textContainer = NSTextContainer(size: .zero)
    private let textStorage = NSTextStorage()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setup()
    }
    
    private func setup() {
        
        numberOfLines = 0
        adjustsFontForContentSizeCategory = true
        isUserInteractionEnabled = true
        lineBreakMode = .byWordWrapping
        contentMode = .redraw
        clearsContextBeforeDrawing = true
        isMultipleTouchEnabled = false
        
        backgroundColor = .red.withAlphaComponent(0.1)
        
        textContainer.lineFragmentPadding = 0
        textContainer.maximumNumberOfLines = numberOfLines
        textContainer.lineBreakMode = lineBreakMode
        textContainer.layoutManager = layoutManager
        
        layoutManager.textStorage = textStorage
        layoutManager.addTextContainer(textContainer)
        layoutManager.delegate = self
        
        textStorage.addLayoutManager(layoutManager)
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        calculateRects()
    }
    
    private func calculateRects(){
                
        guard let attr = attributedText else {
            return
        }
        
        let labelSize = frame.size
        textContainer.size = labelSize
        textStorage.setAttributedString(attr)
        
        layoutManager.ensureLayout(for: textContainer)
        
        let textBoundingBox = layoutManager.usedRect(for: textContainer)
        
        print("labelSize: \(labelSize)")
        print("textBoundingBox: \(textBoundingBox)")
    
    }
}

Use the above as follows:

import SnapKit
import UIKit

let label = ClickableLabel3()
label.setContentHuggingPriority(.required, for: .vertical)
label.setContentHuggingPriority(.required, for: .horizontal)
view.addSubview(label)
label.snp.makeConstraints { make in
    make.horizontalEdges.equalTo(view.safeAreaLayoutGuide)
    make.verticalEdges.greaterThanOrEqualTo(view.safeAreaLayoutGuide).priority(.high)
    make.centerY.equalToSuperview()
}

let attributedString = NSMutableAttributedString(string: "Lorem Ipsum is simply dummy text of the printing and typesetting industry:\n\n") 
attributedString.append(NSAttributedString(string: "This is a link with long text... recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum", attributes: [.clickable: "https://news.ycombinator.com/", .foregroundColor: UIColor.link]))

label.attributedText = attributedString

Above prints incorrect textBoundingBox:

labelSize: (440.0, 122.0)
textBoundingBox: (0.0, 0.0, 428.208984375, 55.2)

Notice the height of the textBoundingBox is much smaller. I believe this is due to it defaulting to some much smaller font size.

However, if I add my own font attribute to the attributedString, then it returns the correct textBoundingBox:

let font = UIFont.systemFont(ofSize: 18, weight: .regular)
let attributedString = NSMutableAttributedString(string: "Lorem Ipsum is simply dummy text of the printing and typesetting industry:\n\n", attributes: [.font: font]) 
attributedString.append(NSAttributedString(string: "This is a link with long text... recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum", attributes: [.clickable: "https://news.ycombinator.com/", .foregroundColor: UIColor.link, .font: font]))

label.attributedText = attributedString

This prints correct textBoundingBox:

labelSize: (440.0, 129.0)
textBoundingBox: (0.0, 0.0, 433.6853386759758, 128.8828125)

Is there a way for me to handle the lack of font attribute in the attributed string without explicitly adding it?

I tried looking if NSLayoutManager or NSTextStorage or NSTextContainer had a font property I could set but can't find one. How can I override the default font?


Solution

  • I figured out the answer.

    Add below AFTER line textStorage.setAttributedString(attr):

    textStorage.enumerateAttribute(.font, in: NSRange(location: 0, length: textStorage.length), options: []) { value, subrange, pointeeStop in
        guard value == nil else { return }
        textStorage.addAttribute(.font, value: font as Any, range: subrange)
    }
    

    In this case, it enumerates the font attribute and if no font exists, then it applies the UILabel's current font.