如何在UILabel中定位文本子串的CGRect?

82
对于给定的NSRange,我想在UILabel中找到与该NSRange的字形相对应的CGRect。例如,我想找到包含句子“The quick brown fox jumps over the lazy dog.”中单词“dog”的CGRect。

Visual description of problem

“诀窍是,UILabel有多行文本,而且文本实际上使用的是 attributedText,所以要找到字符串的确切位置有点棘手。”
“我想在我的UILabel子类中编写的方法应该像这样:”
 - (CGRect)rectForSubstringWithRange:(NSRange)range;

对于那些感兴趣的人,以下是详细信息:

我的目标是能够创建一个新的UILabel,其外观和位置与原始的UILabel完全相同,然后我可以对其进行动画处理。我已经想出了其他方面的解决方法,但就是这一步让我卡住了。

我已经尝试解决这个问题的方法:

  • 我曾希望在iOS 7中会有一些Text Kit来解决这个问题,但我看到的大多数Text Kit示例都集中在UITextViewUITextField上,而不是UILabel
  • 我在Stack Overflow上看到了另一个问题,承诺解决这个问题,但被接受的答案已经超过两年了,而且该代码在使用属性文本时表现不佳。

我敢打赌,解决这个问题的正确答案包括以下之一:

  • 使用标准的Text Kit方法,在一行代码中解决这个问题。我敢打赌它会涉及到NSLayoutManagertextContainerForGlyphAtIndex:effectiveRange
  • 编写一个复杂的方法来将UILabel分成多行,并找到一行内字形的矩形,可能需要使用Core Text方法。我目前最好的选择是拆开@mattt的优秀TTTAttributedLabel,它有一个在指定点找到字形的方法——如果我反转这个过程,找到字形的点,那么可能会起作用。

更新:这里是一个 GitHub Gist,其中包含我尝试解决此问题的三个方法:https://gist.github.com/bryanjclark/7036101


该死,为什么uilabel没有公开nslayoutmanager。我也在尝试解决相同的问题。你找到解决方案了吗? - Bob Spryn
1
也许不是你所需的100%,但请查看http://github.com/SebastienThiebaud/STTweetLabel。他利用文本视图进行存储和计算。有了这个参考,应该很容易添加你需要的内容。 - Bob Spryn
谢谢,Bob!我曾经考虑过使用STTweetLabel,但最终还是创建了自己的TTTAttributedLabel的自定义变体 - 我会研究一下STTweetLabel,看看他们是如何解决这个问题的。听起来他们走在了正确的轨道上。 - bryanjclark
当然。他完全重写了它,仅针对iOS7使用了那个textview存储技术。相当巧妙。这也再次引出了一个问题,为什么他们没有在UILabel上公开这些东西,当它肯定是建立在它们上面的。 - Bob Spryn
1
Luke的代码大多数时间都能正常工作。但有时返回的矩形是零。在长时间解决错误结果后,我发现使用UITextView更好,使用UITextView的textContainer和layoutManager,结果更精确。我认为可能是UILabel内部textContainer与Luke代码生成的textContainer之间存在一些差异。 - Dan Lee
显示剩余2条评论
9个回答

97

参照Joshua的回答写的代码,我得到了下面的结果,看起来效果很好:

- (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];
}

9
非常棒!然而,人们应该注意,如果字体已经自动缩小以适应当前的视图大小,则此方法可能无法正常工作。您需要相应地调整字体大小。 - arsenius
3
好的回答。由于UILabel没有左边距,所以请确保添加textContainer.lineFragmentPadding = 0;。 - colinbrash
@Richa TextKit最初是在iOS7中引入的。 - Shmidt
2
很棒,但是这个解决方案没有考虑attributedText的对齐方式。 - Gabriel.Massana
@Gabriel.Massana 我也遇到了同样的问题,我正在处理居中对齐的文本...你找到解决方案了吗?谢谢! - trdavidson
显示剩余7条评论

24

参考 Luke Rogers的答案,但是这里使用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)        
    }
}

使用示例(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)       
    }
}

示例用法(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)

1
我用以下代码解决了崩溃问题:`var glyphRange = NSRange() layoutManager.characterRangeForGlyphRange(range, actualGlyphRange: &glyphRange)` - Sonia Casas
1
@Nemanja 你怎么打破行之间的范围?我希望它像这样http://imgur.com/a/P9RzR,但当文本接近结尾时它不起作用。 - demiculus
5
需要注意的是,您的UILabel的属性文本必须设置NSAttributedStringKey.paragraphStyle和NSAttributedStringKey.font键的属性,以便此函数可以考虑文本对齐和字体。 - Zigglzworth
3
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
1
如果你发现居中文本无法正常工作,请不要使用标签的对齐方式,而是将属性字符串居中。 - User
显示剩余12条评论

14

我的建议是使用Text Kit。不幸的是,我们无法访问UILabel使用的布局管理器,但可能可以创建一个副本并使用它来获取范围的矩形。

我的建议是创建一个包含与标签中完全相同属性文本的NSTextStorage对象。然后创建一个NSLayoutManager,并将其添加到文本存储对象中。最后创建一个大小与标签相同的NSTextContainer,并将其添加到布局管理器中。

现在,文本存储具有与标签相同的文本,而文本容器的大小与标签相同,因此我们应该能够使用boundingRectForGlyphRange:inTextContainer:向我们创建的布局管理器请求范围的矩形。确保您首先使用布局管理器对象上的glyphRangeForCharacterRange:actualCharacterRange:将您的字符范围转换为字形范围。

如果一切顺利,那么应该能够给出指定标签内范围的边界CGRect

我没有测试过这个方法,但这是我的方法,通过模仿UILabel本身的工作方式应该有很好的成功机会。


这听起来像是一个不错的选择 - 我会尝试一下并稍后回报!如果成功了,我会在GitHub上提供代码。谢谢! - bryanjclark
@bryanjclark 你在这方面有什么好运气吗? - Joshua
我确实做到了这一点,它能够运行,但并不完美,我有点偏移,所以无法正确检测被触碰的单词,只有在触摸侧面的那个单词时才能正常工作... - Andres

5

Swift 4 解决方案,即使对于多行字符串也适用,边界被替换为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)
}
}

不幸的是,这根本不起作用:/ 它只提供intrinsicContentSize(“整体排版大小”,而不是实际字形)。您可以在此处查看结果:https://dev59.com/JrTna4cB1Zd3GeqPC_dh - Fattie

2
正确的答案是实际上你无法这样做。这里大部分的解决方案都试图以不同的精度重新创建 UILabel 的内部行为。恰巧在最近的 iOS 版本中,UILabel 使用 TextKit/CoreText 框架呈现文本。在早期版本中,它实际上在幕后使用 WebKit。谁知道它将来会使用什么。即使在现在,使用 TextKit 复制也存在明显的问题(不谈未来的解决方案)。我们并不完全知道(或不能保证)文本布局和渲染实际上是如何执行的,即使用了哪些参数-只需查看所有提到的边缘情况。有些解决方案可能会“偶然”适用于您测试的某些简单情况-即使在当前 iOS 版本中也不能保证任何内容。文本渲染很难,更不用说 RTL 支持、动态类型、表情符号等。
如果您需要一个合适的解决方案,请不要使用 UILabel。它是一个简单的组件,其 TextKit 内部未在 API 中公开,这清楚地表明 Apple 不希望开发人员构建依赖于此实现细节的解决方案。
或者,您可以使用 UITextView。它方便地公开了其 TextKit 内部,并且非常可配置,因此您可以实现几乎所有 UILabel 擅长的功能。
您还可以直接使用 TextKit(甚至更低级别的 CoreText)。如果您实际上需要查找文本位置,那么很可能很快就需要这些框架中提供的其他强大工具。它们起初很难使用,但我觉得大部分复杂性来自于学习文本布局和渲染的工作原理-这是一项非常相关和有用的技能。一旦熟悉了 Apple 的出色文本框架,您将感到可以用文本做更多事情。

1

你能否基于UITextView创建你的类?如果可以,可以查看UiTextInput协议方法。特别是几何和命中测试方法。


不幸的是,我认为这在这里并不是务实的选择。我的UILabel是TTTAttributedLabel的自定义重写,如果可能的话,我宁愿避免重写大量的代码来创建这个方法。谢谢你! - bryanjclark
@bryanjclark,你最终找到解决方案了吗? - Jenel Ejercito Myers

0

我不喜欢将范围作为参数输入,因为我很懒。我宁愿让方法为我找到范围。考虑到这一点,我已经修改了上面NoodleOfDeath's answer的答案,以接受一个String作为参数。

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)
    }
}

0
对于任何正在寻找一个“纯文本”扩展的人来说!
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)
    }
}

附言:更新了Noodle of Death答案


.attributedText与.text兼容:您将获得相同的CGRect。 - Cœur
不幸的是,这根本不起作用。您可以在此处查看输出:https://dev59.com/JrTna4cB1Zd3GeqPC_dh - Fattie

-1

如果您启用了自动字体大小调整功能,另一种方法是这样的:

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

2
"CalculationHelper" 是什么? - Carmelo Gallo
那只是我写的一个类,用于计算字符串的矩形。 - freshking
2
是的,我知道它是一个类 :) 你能分享一下吗?否则你的代码是不完整的 ;) - Carmelo Gallo
2
我不是指boundingRectWithSize:,而是如果您可以分享CalculationHelper类的话。无论如何,我已经自己解决了 ;) - Carmelo Gallo
@freshking,boundingRectWithSize 的链接现在应该是https://developer.apple.com/documentation/foundation/nsstring/1524729-boundingrectwithsize。我已经直接将其包含在你的答案中,以避免代码不完整。 - Cœur

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接