5

I have a UITextView with some attributed text, where the textContainer.maximumNumberOfLine is set (3 in this case).

I'd like to find the index of the ellipsis character within the character range of the attributed string.

E.g:

Original String:

"Lorem ipsum dolor sit amet, consectetur adipiscing elit"

String as displayed, after truncation:

Lorem ipsum dolor sit amet, consectetur...

How do I determine the index of the ...?

Tim Malseed
  • 6,003
  • 6
  • 48
  • 66

2 Answers2

10

Here's an extension function to NSAttributedString which does the job. Works for single & multiline text.

This took me all of about 8 hours to figure out, so I thought I'd post it as a Q&A.

(Swift 2.2)

/**
    Returns the index of the ellipsis, if this attributed string is truncated, or NSNotFound otherwise.
*/
func truncationIndex(maximumNumberOfLines: Int, width: CGFloat) -> Int {

    //Create a dummy text container, used for measuring & laying out the text..

    let textContainer = NSTextContainer(size: CGSize(width: width, height: CGFloat.max))
    textContainer.maximumNumberOfLines = maximumNumberOfLines
    textContainer.lineBreakMode = NSLineBreakMode.ByTruncatingTail

    let layoutManager = NSLayoutManager()
    layoutManager.addTextContainer(textContainer)

    let textStorage = NSTextStorage(attributedString: self)
    textStorage.addLayoutManager(layoutManager)

    //Determine the range of all Glpyhs within the string

    var glyphRange = NSRange()
    layoutManager.glyphRangeForCharacterRange(NSMakeRange(0, self.length), actualCharacterRange: &glyphRange)

    var truncationIndex = NSNotFound

    //Iterate over each 'line fragment' (each line as it's presented, according to your `textContainer.lineBreakMode`)
    var i = 0
    layoutManager.enumerateLineFragmentsForGlyphRange(glyphRange) { (rect, usedRect, textContainer, glyphRange, stop) in
        if (i == maximumNumberOfLines - 1) {

            //We're now looking at the last visible line (the one at which text will be truncated)

            let lineFragmentTruncatedGlyphIndex = glyphRange.location
            if lineFragmentTruncatedGlyphIndex != NSNotFound {
                truncationIndex = layoutManager.truncatedGlyphRangeInLineFragmentForGlyphAtIndex(lineFragmentTruncatedGlyphIndex).location
            }
            stop.memory = true
        }
        i += 1
    }

    return truncationIndex
}

Note that this has not been tested beyond some simple cases. There may be edge cases requiring some adjustments..

Tim Malseed
  • 6,003
  • 6
  • 48
  • 66
  • 1
    Looking at the documentation for NSLayoutManager, seems like it would be a better option to use `firstUnlaidCharacter()` or `firstUnlaidGlyph()` instead of enumerating the line fragments? – DrOverbuild Jul 08 '19 at 16:44
  • 1
    @DrOverbuild I'm really not sure, it's been a couple years since I worked on this. I don't remember whether that was possible at the time, whether I tried it and it didn't work, or if that's just an option I didn't consider. – Tim Malseed Jul 09 '19 at 00:10
  • 3
    @TimMalseed Ok so after working on this for half the day, I learned that `firstUnlaidCharacter()` and `firstUnlaidGlyph()` work just as well as iterating through each line fragment, which wasn't running anyway. Also I had to change the line break mode to `.byWordWrap`. But other than that this was really helpful. – DrOverbuild Jul 09 '19 at 05:17
0

I updated @Tim Malseed's solution with Swift 5

extension UILabel {

 var truncationIndex: Int? {
        guard let text = text else {
            return nil
        }
        let attributes: [NSAttributedString.Key: UIFont] = [.font: font]
        let attributedString = NSAttributedString(string: text, attributes: attributes)
        let textContainer = NSTextContainer(
            size: CGSize(width: frame.size.width,
                         height: CGFloat.greatestFiniteMagnitude)
        )
        textContainer.maximumNumberOfLines = numberOfLines
        textContainer.lineBreakMode = NSLineBreakMode.byTruncatingTail

        let layoutManager = NSLayoutManager()
        layoutManager.addTextContainer(textContainer)

        let textStorage = NSTextStorage(attributedString: attributedString)
        textStorage.addLayoutManager(layoutManager)

        //Determine the range of all Glpyhs within the string
        var glyphRange = NSRange()
        layoutManager.glyphRange(
            forCharacterRange: NSMakeRange(0, attributedString.length),
            actualCharacterRange: &glyphRange
        )

        var truncationIndex = NSNotFound
        //Iterate over each 'line fragment' (each line as it's presented, according to your `textContainer.lineBreakMode`)
        var i = 0
        layoutManager.enumerateLineFragments(
            forGlyphRange: glyphRange
        ) { rect, usedRect, textContainer, glyphRange, stop in
            if (i == self.numberOfLines - 1) {
                //We're now looking at the last visible line (the one at which text will be truncated)
                let lineFragmentTruncatedGlyphIndex = glyphRange.location
                if lineFragmentTruncatedGlyphIndex != NSNotFound {
                    truncationIndex = layoutManager.truncatedGlyphRange(inLineFragmentForGlyphAt: lineFragmentTruncatedGlyphIndex).location
                }
                stop.pointee = true
            }
            i += 1
        }
        return truncationIndex
    }
}
Shawn Baek
  • 1,928
  • 3
  • 20
  • 34
  • I think this doesn't work when you indicate '0' to the number of lines attributes in your UILabel as it's recommended to layout as many lines as possible in the label. Am I wrong ? – XLE_22 Jan 18 '21 at 16:48
  • The original question was for a UITextView and you're doing something on a UILabel. – Claus Jørgensen Sep 07 '21 at 07:52