UITextView: Find location of ellipsis in truncated text
Asked Answered
N

2

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 ...?

Nyasaland answered 13/1, 2017 at 5:54 Comment(0)
N
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..

Nyasaland answered 13/1, 2017 at 5:54 Comment(3)
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? – Trueman
@Trueman 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. – Nyasaland
@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. – Trueman
M
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
    }
}
Monohydroxy answered 8/9, 2020 at 15:35 Comment(2)
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 ? πŸ€” – Engraft
The original question was for a UITextView and you're doing something on a UILabel. – Malines

© 2022 - 2024 β€” McMap. All rights reserved.