Rob
Rob

Reputation: 16002

Multiline UILabel: Adding custom underlines to specific parts of the label using boundingRect()

I want to be able to add custom underlines to some parts/words only of a multiline label.

I tried using NSAttributedString.Key.underlineStyle with a NSMutableAttributedString, but I don't have access to important parameters such as the spacing between the text and the underline, or the underline bar height.

So I was thinking of adding a sublayer to the UILabel below the expected text. Here is what I have done so far:

extension UILabel {

    func underline(word: String, withColor color: UIColor) {
        guard let text = self.text else {
            return
        }
        if let range = text.range(of: word) {
            let start = text.distance(from: text.startIndex, to: range.lowerBound)
            let end = text.distance(from: text.startIndex, to: range.upperBound)
            if let rect = self.boundingRectFor(characterRange: start..<end) {
                let c = CALayer()
                c.frame = CGRect(origin: CGPoint(x: rect.origin.x, y: rect.size.height + (self.font.pointSize / 8)), size: CGSize(width: rect.size.width, height: (self.font.pointSize / 4)))
                c.backgroundColor = color.cgColor
                self.layer.addSublayer(c)
            }
        }
    }

    private func boundingRectFor(characterRange: Range<Int>) -> CGRect? {
        guard let attributedText = attributedText else { return nil }
        let textStorage = NSTextStorage(attributedString: attributedText)
        let layoutManager = NSLayoutManager()
        textStorage.addLayoutManager(layoutManager)
        let textContainer = NSTextContainer(size: intrinsicContentSize)
        textContainer.lineFragmentPadding = 0.0
        layoutManager.addTextContainer(textContainer)
        var glyphRange = NSRange()
        layoutManager.characterRange(forGlyphRange: NSRange(location: characterRange.startIndex, length: characterRange.endIndex - characterRange.startIndex), actualGlyphRange: &glyphRange)
        return layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
    }

}

boundingRectFor(characterRange) is a method I have found that is expected to return the rect containing the substring from the range passed as the parameter.

underline(word: String, withColor color: UIColor) will look for the first occurrence of the given word in the label text and underline it.

Simple use in a view controller:

class ViewController: UIViewController {

    @IBOutlet var labels: [UILabel]!

    var texts: [String] = ["Lorem ipsum dolor sit amet.", 
                           "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", 
                           "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."]

    var textToUnderline = ["ipsum", "amet", "incididunt"]

    var underliningColors: [UIColor] = [.red, .blue, .purple]

    @IBOutlet weak var firstLabel: UILabel!

    override func viewDidLoad() {
        super.viewDidLoad()
        self.labels.enumerated().forEach { index, label in
            label.text = texts[index]
            label.underline(word: textToUnderline[index], withColor: underliningColors[index])
        }
    }

}

And here is a screenshot of the result:

enter image description here

Although this works fine when the first line is concerned, I doesn't work anymore when trying to underline another line.

To have a better understanding, I tried to print some values below if let rect (in the UILabel extension):

// will print the label frame and the rect to underline
print("frame: \(self.frame) and rect: \(rect)") 

And here is what I get:

frame: (24.0, 68.0, 366.0, 0.0) and rect: (34.0576171875, 0.0, 30.3369140625, 11.93359375)

frame: (24.0, 100.0, 366.0, 0.0) and rect: (176.35009765625, 0.0, 40.375, 20.287109375)

frame: (24.0, 124.0, 366.0, 0.0) and rect: (572.20458984375, 0.0, 71.14013671875, 17.900390625)

I realized that the origin.x of the last label rect (572.2...) exceeds its width (366), but I am struggling to understand why I am getting this result and how I can improve my function.

Thank you for your help.

Upvotes: 0

Views: 499

Answers (1)

matt
matt

Reputation: 535944

Your entire approach is wrong. Use a text view, not a label, so that you have access to the full text kit stack (NSLayoutManager, NSTextStorage, NSTextContainer). Construct the stack using a custom layout manager subclass and override drawBackground(forGlyphRange).

Upvotes: 3

Related Questions