81

For a given NSRange, I'd like to find a CGRect in a UILabel that corresponds to the glyphs of that NSRange. For example, I'd like to find the CGRect that contains the word "dog" in the sentence "The quick brown fox jumps over the lazy dog."

Visual description of problem

The trick is, the UILabel has multiple lines, and the text is really attributedText, so it's a bit tough to find the exact position of the string.

The method that I'd like to write on my UILabel subclass would look something like this:

 - (CGRect)rectForSubstringWithRange:(NSRange)range;

Details, for those who are interested:

My goal with this is to be able to create a new UILabel with the exact appearance and position of the UILabel, that I can then animate. I've got the rest figured out, but it's this step in particular that's holding me back at the moment.

What I've done to try and solve the issue so far:

  • I'd hoped that with iOS 7, there'd be a bit of Text Kit that would solve this problem, but most every example I've seen with Text Kit focuses on UITextView and UITextField, rather than UILabel.
  • I've seen another question on Stack Overflow here that promises to solve the problem, but the accepted answer is over two years old, and the code doesn't perform well with attributed text.

I'd bet that the right answer to this involves one of the following:

  • Using a standard Text Kit method to solve this problem in a single line of code. I'd bet it would involve NSLayoutManager and textContainerForGlyphAtIndex:effectiveRange
  • Writing a complex method that breaks the UILabel into lines, and finds the rect of a glyph within a line, likely using Core Text methods. My current best bet is to take apart @mattt's excellent TTTAttributedLabel, which has a method that finds a glyph at a point - if I invert that, and find the point for a glyph, that might work.

Update: Here's a github gist with the three things I've tried so far to solve this issue: https://gist.github.com/bryanjclark/7036101

Community
  • 1
  • 1
bryanjclark
  • 6,247
  • 2
  • 35
  • 68
  • Dammit. Why isn't nslayoutmanager exposed on uilabel. Trying to solve the same thing. Did you come up with a solution? – Bob Spryn Oct 28 '13 at 23:39
  • Haven't gotten back to this issue yet, but after about a half-dozen different attempts at this point, my best hope is to implement something similar to Joshua's recommendation below. I'll share what I figure out when I do! – bryanjclark Oct 29 '13 at 06:28
  • 1
    Maybe not 100% what you need, but check out http://github.com/SebastienThiebaud/STTweetLabel. He leverages a textview for storage and calculation. Should be fairly simple to add what you need with that as a reference. – Bob Spryn Oct 29 '13 at 06:34
  • Thanks, Bob! I'd considered using STTweetLabel at one point, but wound up creating my own custom variation on TTTAttributedLabel instead - I'll look into STTweetLabel and see how they've solved the issue. Sounds like that's on the right track. – bryanjclark Oct 29 '13 at 20:38
  • Sure thing. He completely rewrote it for iOS7 only using that textview storage technique. Pretty crafty. Also begs the question again why they didn't expose these things on UILabel, when it's surely built on them. – Bob Spryn Oct 30 '13 at 05:52
  • Similar problem these days .. https://stackoverflow.com/questions/56935898 – Fattie Jul 12 '19 at 10:43
  • 1
    Luke's code works in most all the time. But sometimes the returned rect is zero. After long time for solving the wrong result, I found it's better to use UITextView, use UITextView's textContainer and layoutManager, the result is more precious. I think maybe there is some difference between UILabel's internal textContainer and the one generated by Luke's code. – Dan Lee Mar 12 '20 at 11:36

9 Answers9

96

Following Joshua's answer in code, I came up with the following which seems to work well:

- (CGRect)boundingRectForCharacterRange:(NSRange)range
{
    NSTextStorage *textStorage = [[NSTextStorage alloc] initWithAttributedString:[self attributedText]];
    NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init];
    [textStorage addLayoutManager:layoutManager];
    NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:[self bounds].size];
    textContainer.lineFragmentPadding = 0;
    [layoutManager addTextContainer:textContainer];

    NSRange glyphRange;

    // Convert the range for glyphs.
    [layoutManager characterRangeForGlyphRange:range actualGlyphRange:&glyphRange];

    return [layoutManager boundingRectForGlyphRange:glyphRange inTextContainer:textContainer];
}
Community
  • 1
  • 1
Luke Rogers
  • 2,369
  • 21
  • 28
  • 9
    Very cool! However, people should be aware that this does not work correctly if the font has been automatically shrunk to fit the current view size. You need to adjust your font size accordingly. – arsenius Jan 26 '14 at 16:18
  • This code is working fine on iOS7. Did anyone tried it on iOS6, in my code it's getting crash on iOS6 with log [__NSCFType _isDefaultFace]: unrecognized selector sent to instance. Someone has any idea about this ? – Richa Apr 16 '14 at 10:57
  • 3
    Great answer. Since UILabels do not have left margins, make sure you add textContainer.lineFragmentPadding = 0; – colinbrash May 22 '14 at 15:01
  • I'm using this and it works great, but doesn't work for every range I pass. [See my SO post here describing in more detail.](http://stackoverflow.com/questions/24970427/layoutmanager-boundingrectforglyphrangeintextcontainer-does-not-work-for-all-s) – Stephen Jul 27 '14 at 18:15
  • @Richa TextKit was first introduced in iOS7. – Shmidt Sep 14 '14 at 17:53
  • 2
    Awesome, however the solution is not taking in consideration the alignment for the attributedText. – Gabriel.Massana Jan 08 '15 at 10:24
  • @Gabriel.Massana i am running into the same problem, where i am dealing with center aligned text..did you ever find a solution for this? thanks! – trdavidson Nov 04 '15 at 23:08
  • @arsenius do you know how to make it work if the label has automatic shrink? Thanks! – Sonia Casas Jun 09 '16 at 14:56
  • @SoniaCasas Sorry I don't have a definite answer for you. I recall that I never had much luck even determining what font size a label is currently shrunk to. I suspect you might have to shrink the font to fit yourself, as in [this post](http://stackoverflow.com/a/4433612/467209), and then use the method mentioned by LukeRogers above. – arsenius Jun 10 '16 at 00:04
  • @arsenius I made it using [this method](http://stackoverflow.com/a/32567240/3311279) to get the label actual font size and then I "reassigned" to the label's attributed text. Maybe is not the best solution but it works – Sonia Casas Jun 10 '16 at 08:02
  • I think you should use like this... NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:CGSizeMake(self.bounds.size.width, CGFLOAT_MAX)]; The TextContainer's height should be CGFLOAT_MAX. Otherwise, the boundingRectForGlyphRange:inTextContainer: return value is always CGRectZero. – strawnut Jun 22 '16 at 05:11
  • @RodrigoRuiz I'm using this for multiple lines and it works. – Bonan Nov 15 '16 at 23:32
24

Building off of Luke Rogers's answer but written in swift:

Swift 2

extension UILabel {
    func boundingRectForCharacterRange(_ range: NSRange) -> CGRect? {

        guard let attributedText = attributedText else { return nil }

        let textStorage = NSTextStorage(attributedString: attributedText)
        let layoutManager = NSLayoutManager()

        textStorage.addLayoutManager(layoutManager)

        let textContainer = NSTextContainer(size: bounds.size)
        textContainer.lineFragmentPadding = 0.0

        layoutManager.addTextContainer(textContainer)

        var glyphRange = NSRange()

        // Convert the range for glyphs.
        layoutManager.characterRangeForGlyphRange(range, actualGlyphRange: &glyphRange)

        return layoutManager.boundingRectForGlyphRange(glyphRange, inTextContainer: textContainer)        
    }
}

Example Usage (Swift 2)

let label = UILabel()
let text = "aa bb cc"
label.attributedText = NSAttributedString(string: text)
let sublayer = CALayer()
sublayer.borderWidth = 1
sublayer.frame = label.boundingRectForCharacterRange(NSRange(text.range(of: "bb")!, in: text))
label.layer.addSublayer(sublayer)

Swift 3/4

extension UILabel {
    func boundingRect(forCharacterRange range: NSRange) -> CGRect? {

        guard let attributedText = attributedText else { return nil }

        let textStorage = NSTextStorage(attributedString: attributedText)
        let layoutManager = NSLayoutManager()

        textStorage.addLayoutManager(layoutManager)

        let textContainer = NSTextContainer(size: bounds.size)
        textContainer.lineFragmentPadding = 0.0

        layoutManager.addTextContainer(textContainer)

        var glyphRange = NSRange()

        // Convert the range for glyphs.
        layoutManager.characterRange(forGlyphRange: range, actualGlyphRange: &glyphRange)

        return layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)       
    }
}

Example Usage (Swift 3/4)

let label = UILabel()
let text = "aa bb cc"
label.attributedText = NSAttributedString(string: text)
let sublayer = CALayer()
sublayer.borderWidth = 1
sublayer.frame = label.boundingRect(forCharacterRange: NSRange(text.range(of: "bb")!, in: text))
label.layer.addSublayer(sublayer)
rolling_codes
  • 15,174
  • 22
  • 76
  • 112
  • on line `let textStorage = NSTextStorage(attributedString: attributedText)` you mean `let textStorage = NSTextStorage(attributedString: attributedText!)` ? because I Have an error on that line when i paste it – Sonia Casas Jun 09 '16 at 09:48
  • Also I got a crash of type EXEC_BAD_ACCESS on `return layoutManager.boundingRectForGlyphRange(glyphRange.memory, inTextContainer: textContainer)`. Range is not nil, attributed text is not nil and bounds are correctly set. Thanks for the help! – Sonia Casas Jun 09 '16 at 10:06
  • 1
    I Solved the crash with this: `var glyphRange = NSRange() layoutManager.characterRangeForGlyphRange(range, actualGlyphRange: &glyphRange)` – Sonia Casas Jun 09 '16 at 14:42
  • @SoniaCasas yes my fault. Objective-C and Swift methods inout parameters are a headache. I'll add the edit – rolling_codes Jun 09 '16 at 14:43
  • For me the y value of the rect is always zero no matter which line the character is on – Doug Amos Jun 09 '16 at 16:48
  • @DougAmos Strange. Can you show me the code you used to display your `UILabel`? The `y` coordinate of the `CGRect` for which the substring will occur is directly related to the dimensions of the `UILabel` at runtime – rolling_codes Jun 09 '16 at 16:51
  • @DougAmos You have not specified the size of the label, nor the `numberOfLines` property. I am guessing `item.title!` contains a newline character or it is wrapped? Can you specify/print the `bounds` of `cell.titleLabel` – rolling_codes Jun 09 '16 at 16:56
  • @NoodleOfDeath The following repo shows an incorrect y value in a simple example: https://github.com/dougamos/label-rect-example – Doug Amos Jun 10 '16 at 10:35
  • How to use your code? It returns `CGRect` , right? i don't see any rounded effect here – TomSawyer Jul 07 '16 at 11:59
  • It works great. Just, if you have multiple lines, make sure you break the ranges between lines. I split words by spaces, for example – Nemanja Feb 10 '17 at 11:17
  • 1
    @Nemanja how do you break the ranges between lines? I want it to be like this http://imgur.com/a/P9RzR but when the text is near the end it doesn't work. – demiculus Jun 14 '17 at 09:27
  • I just experienced an issue where the rect returned was CGRect.zero. It was caused by lineSpacing. Apparently the bounds of the UILabel doesn’t include lineSpacing in the last line. When calculating the rect of a range in the last line, the calculated rect will be out of bounds of the UILabel, and CGRect.zero is returned. By adding lineSpacing to the height of bounds.size when creating `NSTextContainer` fixed it for me. – buron Nov 24 '17 at 13:01
  • 5
    It's important to note that your UILabel's attributed text must have attributes for keys NSAttributedStringKey.paragraphStyle and NSAttributedStringKey.font set so that this function can account for that text alignment and font – Zigglzworth Jan 09 '18 at 15:16
  • 3
    @Hemang example usage: `let t = "aa bb cc"; label.attributedText = NSAttributedString(string: t); let l = CALayer(); l.borderWidth = 1; l.frame = label.boundingRectForCharacterRange(NSRange(t.range(of: "bb")!, in: t)); label.layer.addSublayer(l);` – Cœur Mar 26 '18 at 10:56
  • For the Swift 3 usage working off of @Cœur: `let t = "aa bb cc"; label.attributedText = NSAttributedString(string: t); let l = CALayer(); l.borderWidth = 1; l.frame = label.boundingRect(forCharacterRange: NSRange(t.range(of: "bb")!, in: t)); label.layer.addSublayer(l);` – rolling_codes May 29 '18 at 16:13
  • 1
    In case you see it not working for centered text, don't use label's alignment, center the attributed string... – User Aug 11 '18 at 22:27
14

My suggestion would be to make use of Text Kit. Unfortunately we don't have access to the layout manager that a UILabel uses however it might be possible to create a replica of it and use that to get the rect for a range.

My suggestion would be to create a NSTextStorage object containing the exact same attributed text as is in your label. Next create a NSLayoutManager and add that to the the text storage object. Finally create a NSTextContainer with the same size as the label and add that to the layout manager.

Now the text storage has the same text as the label and the text container is the same size as the label so we should be able to ask the layout manager we created for a rect for our range using boundingRectForGlyphRange:inTextContainer:. Make sure you convert your character range to a glyph range first using glyphRangeForCharacterRange:actualCharacterRange: on the layout manager object.

All going well that should give you a bounding CGRect of the range you specified within the label.

I haven't tested this but this would be my approach and by mimicking how the UILabel itself works should have a good chance of succeeding.

Joshua
  • 15,200
  • 21
  • 100
  • 172
  • This sounds like a good bet - I'll give it a shot and report back later! If it works out, I'll provide the code that wound up working for me on GitHub. Thanks! – bryanjclark Oct 28 '13 at 05:48
  • @bryanjclark Did you have any luck with this? – Joshua Oct 31 '13 at 18:07
  • I did exactly this, and it works, but not perfectly, I'm having a little offset so it fails to correctly detect the touched word, and it works when touching the one that is right on the side... – Andres Jun 03 '14 at 21:08
5

swift 4 solution, will work even for multiline strings, bounds were replaced with intrinsicContentSize

extension UILabel {
func boundingRectForCharacterRange(range: NSRange) -> 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: range, actualGlyphRange: &glyphRange)

    return layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
}
}
Dima
  • 51
  • 1
  • 2
  • unfortunately this simply does not work :/ it just gives the intrinsicContentSize (the "overall typographical size", not the actual glyph shape). You can see the outcome here: https://stackoverflow.com/questions/56935898 – Fattie Jul 12 '19 at 13:27
2

The proper answer is you actually can't. Most of the solutions here trying to re-create UILabel internal behavior with varying precision. It just so happens that in recent iOS versions UILabel renders text using TextKit/CoreText frameworks. In earlier versions it actually used WebKit under the hood. Who knows what it will use in future. Reproducing with TextKit have obvious problems even right now (not talking about future-proof solutions). We don't quite know (or can't guarantee) how the text layout and rendering are actually performed, i.e. which parameters are used – just look at all the edge cases mentioned. Some solution may 'accidentally' work for you for some simple cases you tested – that doesn't guarantee anything even within current version of iOS. Text rendering is hard, don't get me started on RTL support, Dynamic Type, emojis etc.

If you need a proper solution just don't use UILabel. It is simple component and the fact that its TextKit internals aren't exposed in the API is a clear indication that Apple don't want developers to build solutions relying on this implementation detail.

Alternatively you can use UITextView. It conveniently exposes its TextKit internals and is very configurable so you can achieve almost everything UILabel is good for.

You may also just go lower level and use TextKit directly (or even lower with CoreText). Chances are, if you actually need to find text positions you may soon need other powerful tools available in these frameworks. They are rather hard to use at first but I feel like most of the complexity comes from actually learning how text layout and rendering works – and that's a very relevant and useful skill. Once you get familiar with Apple's excellent text frameworks you will feel empowered to do so much more with text.

shim
  • 9,289
  • 12
  • 69
  • 108
Max O
  • 997
  • 8
  • 13
1

Can you instead base your class on UITextView? If so, check out the UiTextInput protocol methods. See in particular the geometry and hit resting methods.

pickwick
  • 3,134
  • 22
  • 30
  • Unfortunately, I don't think that'd be the pragmatic choice here. My UILabel is a custom rewrite of TTTAttributedLabel, and I'd hate to have to rewrite a huge quantity of code to create this one method, if possible. Thank you, though! – bryanjclark Oct 17 '13 at 04:47
  • @bryanjclark did you ever figure out a solution? – Jenel Ejercito Myers Dec 16 '16 at 20:29
0

For anyone who's looking for a plain text extension!

extension UILabel {    
    func boundingRectForCharacterRange(range: NSRange) -> CGRect? {
        guard let text = text else { return nil }

        let textStorage = NSTextStorage.init(string: text)
        let layoutManager = NSLayoutManager()

        textStorage.addLayoutManager(layoutManager)

        let textContainer = NSTextContainer(size: bounds.size)
        textContainer.lineFragmentPadding = 0.0

        layoutManager.addTextContainer(textContainer)

        var glyphRange = NSRange()

        // Convert the range for glyphs.
        layoutManager.characterRange(forGlyphRange: range, actualGlyphRange: &glyphRange)

        return layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
    }
}

P.S. Updated answer of Noodle of Death`s answer.

Hemang
  • 26,840
  • 19
  • 119
  • 186
  • .attributedText is compatible with .text: you would get the same CGRect. – Cœur Mar 23 '18 at 10:55
  • unfortunately this simply doesn't work. you can see the output here: https://stackoverflow.com/questions/56935898 – Fattie Jul 12 '19 at 13:29
0

I'm not keen on feeding in ranges as a parameter because I'm lazy. I'd rather have the method find the range for me. With that in mind, I've adapted NoodleOfDeath's answer above to take a String as a parameter.

extension String {
    func nsRange(of substring: String) -> NSRange? {
        // Get the swift range
        guard let range = range(of: substring) else { return nil }

        // Get the distance to the start of the substring
        let start = distance(from: startIndex, to: range.lowerBound) as Int
        //Get the distance to the end of the substring
        let end = distance(from: startIndex, to: range.upperBound) as Int

        //length = endOfSubstring - startOfSubstring
        //start = startOfSubstring
        return NSMakeRange(start, end - start)
    }
}


extension UILabel {
    /// Finds the bounding rect of a substring in a `UILabel`
    /// - Parameter substring: The substring whose bounding box you'd like to determine
    /// - Returns: An optional `CGRect`
    func boundingRect(for substring: String) -> CGRect? {
        guard let attributedText = attributedText,
              let text = self.text,
              let range: NSRange = text.nsRange(of: substring) else {
            return nil
        }

        let textStorage = NSTextStorage(attributedString: attributedText)
        let layoutManager = NSLayoutManager()
        textStorage.addLayoutManager(layoutManager)
        let textContainer = NSTextContainer(size: bounds.size)
        textContainer.lineFragmentPadding = 0.0
        layoutManager.addTextContainer(textContainer)
        var glyphRange = NSRange()
        layoutManager.characterRange(forGlyphRange: range, actualGlyphRange: &glyphRange)

        return layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
    }
}
Adrian
  • 16,233
  • 18
  • 112
  • 180
-1

Another way of doing this, if you have automatic font size adjustment enabled, would be like this:

let stringLength: Int = countElements(self.attributedText!.string)
let substring = (self.attributedText!.string as NSString).substringWithRange(substringRange)

//First, confirm that the range is within the size of the attributed label
if (substringRange.location + substringRange.length  > stringLength)
{
    return CGRectZero
}

//Second, get the rect of the label as a whole.
let textRect: CGRect = self.textRectForBounds(self.bounds, limitedToNumberOfLines: self.numberOfLines)

let path: CGMutablePathRef = CGPathCreateMutable()
CGPathAddRect(path, nil, textRect)
let framesetter = CTFramesetterCreateWithAttributedString(self.attributedText)
let tempFrame: CTFrameRef = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, stringLength), path, nil)
if (CFArrayGetCount(CTFrameGetLines(tempFrame)) == 0)
{
    return CGRectZero
}

let lines: CFArrayRef = CTFrameGetLines(tempFrame)
let numberOfLines: Int = self.numberOfLines > 0 ? min(self.numberOfLines, CFArrayGetCount(lines)) :  CFArrayGetCount(lines)
if (numberOfLines == 0)
{
    return CGRectZero
}

var returnRect: CGRect = CGRectZero

let nsLinesArray: NSArray = CTFrameGetLines(tempFrame) // Use NSArray to bridge to Array
let ctLinesArray = nsLinesArray as Array
var lineOriginsArray = [CGPoint](count:ctLinesArray.count, repeatedValue: CGPointZero)

CTFrameGetLineOrigins(tempFrame, CFRangeMake(0, numberOfLines), &lineOriginsArray)

for (var lineIndex: CFIndex = 0; lineIndex < numberOfLines; lineIndex++)
{
    let lineOrigin: CGPoint = lineOriginsArray[lineIndex]
    let line: CTLineRef = unsafeBitCast(CFArrayGetValueAtIndex(lines, lineIndex), CTLineRef.self) //CFArrayGetValueAtIndex(lines, lineIndex) 
    let lineRange: CFRange = CTLineGetStringRange(line)

    if ((lineRange.location <= substringRange.location) && (lineRange.location + lineRange.length >= substringRange.location + substringRange.length))
    {
        var charIndex: CFIndex = substringRange.location - lineRange.location; // That's the relative location of the line
        var secondary: CGFloat = 0.0
        let xOffset: CGFloat = CTLineGetOffsetForStringIndex(line, charIndex, &secondary);

        // Get bounding information of line

        var ascent: CGFloat = 0.0
        var descent: CGFloat = 0.0
        var leading: CGFloat = 0.0

        let width: Double = CTLineGetTypographicBounds(line, &ascent, &descent, &leading)
        let yMin: CGFloat = floor(lineOrigin.y - descent);
        let yMax: CGFloat = ceil(lineOrigin.y + ascent);

        let yOffset: CGFloat = ((yMax - yMin) * CGFloat(lineIndex))

        returnRect = (substring as NSString).boundingRect(with: CGSize(width: Double.greatestFiniteMagnitude, height: Double.greatestFiniteMagnitude), options: .usesLineFragmentOrigin, attributes: [.font: self.font ?? UIFont.systemFont(ofSize: 1)], context: nil)
        returnRect.origin.x = xOffset + self.frame.origin.x
        returnRect.origin.y = yOffset + self.frame.origin.y + ((self.frame.size.height - textRect.size.height) / 2)

        break
    }
}

return returnRect
Cœur
  • 37,241
  • 25
  • 195
  • 267
freshking
  • 1,824
  • 18
  • 31