UILabel中触摸点的字符索引

26

对于一个 UILabel,我想找出在触摸事件接收到的特定点处的字符索引。我想使用 Text Kit 解决 iOS 7 中的这个问题。

由于 UILabel 没有提供访问其 NSLayoutManager 的方法,因此我根据 UILabel 的配置创建了自己的 NSLayoutManager,如下所示:

- (void)textTapped:(UITapGestureRecognizer *)recognizer
{
    if (recognizer.state == UIGestureRecognizerStateEnded) {
        CGPoint location = [recognizer locationInView:self];

        NSTextStorage *textStorage = [[NSTextStorage alloc] initWithAttributedString:self.attributedText];
        NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init];
        [textStorage addLayoutManager:layoutManager];
        NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:self.bounds.size];
        [layoutManager addTextContainer:textContainer];

        textContainer.maximumNumberOfLines = self.numberOfLines;
        textContainer.lineBreakMode = self.lineBreakMode;


        NSUInteger characterIndex = [layoutManager characterIndexForPoint:location
                                                          inTextContainer:textContainer
                                 fractionOfDistanceBetweenInsertionPoints:NULL];

        if (characterIndex < textStorage.length) {
            NSRange range = NSMakeRange(characterIndex, 1);
            NSString *value = [self.text substringWithRange:range];
            NSLog(@"%@, %zd, %zd", value, range.location, range.length);
        }
    }
}

上述代码在一个UILabel子类中,配置了UITapGestureRecognizer来调用textTapped: (Gist)。
得到的字符索引是有意义的(从左到右点击时增加),但不正确(最后一个字符在标签宽度的大约一半处到达)。看起来可能字体大小或文本容器大小没有正确配置,但找不到问题所在。
我真的很想保持我的类是UILabel的子类,而不是使用UITextView。有人解决了这个UILabel的问题吗?
更新:我在这个问题上花了一个DTS票据,苹果工程师建议覆盖UILabel的drawTextInRect:方法,使用自己的布局管理器实现,类似于这段代码片段:
- (void)drawTextInRect:(CGRect)rect 
{
    [yourLayoutManager drawGlyphsForGlyphRange:NSMakeRange(0, yourTextStorage.length) atPoint:CGPointMake(0, 0)];
}

我认为保持自己的布局管理器与标签设置同步需要大量工作,因此尽管我更喜欢UILabel,但我可能会选择UITextView。
更新2:最终我决定使用UITextView。所有这些的目的是检测嵌入文本中的链接的点击。我尝试使用NSLinkAttributeName,但这种设置在快速点击链接时不会触发委托回调。相反,您必须按链接一段时间 - 非常烦人。所以我创建了CCHLinkTextView来解决这个问题。

1
迟钝的反应;对我来说让这个工作的诀窍是这一行代码 textContainer.lineFragmentPadding = 0;,在你的示例中缺失了,但在 @Alexey Ishkov 和 @Kai Burghardt 的答案中存在。我不必使用数字100来破解containerSize。 - koen
9个回答

44

我尝试了Alexey Ishkov的解决方案。最终我得到了一个解决方案!在你的UITapGestureRecognizer选择器中使用这段代码片段:

UILabel *textLabel = (UILabel *)recognizer.view;
CGPoint tapLocation = [recognizer locationInView:textLabel];

// init text storage
NSTextStorage *textStorage = [[NSTextStorage alloc] initWithAttributedString:textLabel.attributedText];
NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init];
[textStorage addLayoutManager:layoutManager];

// init text container
NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:CGSizeMake(textLabel.frame.size.width, textLabel.frame.size.height+100) ];
textContainer.lineFragmentPadding  = 0;
textContainer.maximumNumberOfLines = textLabel.numberOfLines;
textContainer.lineBreakMode        = textLabel.lineBreakMode;

[layoutManager addTextContainer:textContainer];

NSUInteger characterIndex = [layoutManager characterIndexForPoint:tapLocation
                                inTextContainer:textContainer
                                fractionOfDistanceBetweenInsertionPoints:NULL];

希望这能对一些人有所帮助!


1
实际上,与原始标签大小相比,您需要稍微调整testStorage。经验事实是,对于每个额外的UILabel行,您需要将高度增加约1pt。因此,textStorage应根据行数动态设置。 - malex
10
你能解释一下为什么要使用...textLabel.frame.size.height+100 这个神奇数字吗? - tiritea
确保您首先在UILabel上调用sizeToFit。 - user1055568
1
你可以用 CGFloat.greatestFiniteMagnitude 替换 textLabel.frame.size.height+100NSTextContainer 的初始化器需要一个限制(maxWidth 和 maxHeight),它不需要精确,只需要等于或大于实际框架即可。 - marcelosalloum

23

我遇到了和你一样的错误,索引增加得太快,因此最后结果不准确。这个问题的原因是self.attributedText没有为整个字符串提供完整的字体信息。

当UILabel渲染时,它使用在self.font中指定的字体,并将其应用于整个attributedString。但是,在将attributedText分配给textStorage时,情况并非如此。因此,您需要自己完成这个步骤:

NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithAttributedString:self.attributedText];
[attributedText addAttributes:@{NSFontAttributeName: self.font} range:NSMakeRange(0, self.attributedText.string.length];

Swift 4

let attributedText = NSMutableAttributedString(attributedString: self.attributedText!)
attributedText.addAttributes([.font: self.font], range: NSMakeRange(0, attributedText.string.count))

希望这可以帮到您 :)


2
我想补充一下,文本对齐也是必要的:let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.alignment = .center attributedText.addAttributes([.paragraphStyle: paragraphStyle], range: NSMakeRange(0, attributedText.string.count)) - Cyupa

18

Swift 4是从许多来源综合而来的,包括这里的好答案。我的贡献是正确处理插入、对齐和多行标签。(大多数实现将在尾部空格上的轻点视为在行中最后一个字符上的轻点)

class TappableLabel: UILabel {

    var onCharacterTapped: ((_ label: UILabel, _ characterIndex: Int) -> Void)?

    func makeTappable() {
        let tapGesture = UITapGestureRecognizer()
        tapGesture.addTarget(self, action: #selector(labelTapped))
        tapGesture.isEnabled = true
        self.addGestureRecognizer(tapGesture)
        self.isUserInteractionEnabled = true
    }

    @objc func labelTapped(gesture: UITapGestureRecognizer) {

        // only detect taps in attributed text
        guard let attributedText = attributedText, gesture.state == .ended else {
            return
        }

        // Configure NSTextContainer
        let textContainer = NSTextContainer(size: bounds.size)
        textContainer.lineFragmentPadding = 0.0
        textContainer.lineBreakMode = lineBreakMode
        textContainer.maximumNumberOfLines = numberOfLines

        // Configure NSLayoutManager and add the text container
        let layoutManager = NSLayoutManager()
        layoutManager.addTextContainer(textContainer)

        // Configure NSTextStorage and apply the layout manager
        let textStorage = NSTextStorage(attributedString: attributedText)
        textStorage.addAttribute(NSAttributedStringKey.font, value: font, range: NSMakeRange(0, attributedText.length))
        textStorage.addLayoutManager(layoutManager)

        // get the tapped character location
        let locationOfTouchInLabel = gesture.location(in: gesture.view)

        // account for text alignment and insets
        let textBoundingBox = layoutManager.usedRect(for: textContainer)
        var alignmentOffset: CGFloat!
        switch textAlignment {
        case .left, .natural, .justified:
            alignmentOffset = 0.0
        case .center:
            alignmentOffset = 0.5
        case .right:
            alignmentOffset = 1.0
        }
        let xOffset = ((bounds.size.width - textBoundingBox.size.width) * alignmentOffset) - textBoundingBox.origin.x
        let yOffset = ((bounds.size.height - textBoundingBox.size.height) * alignmentOffset) - textBoundingBox.origin.y
        let locationOfTouchInTextContainer = CGPoint(x: locationOfTouchInLabel.x - xOffset, y: locationOfTouchInLabel.y - yOffset)

        // figure out which character was tapped
        let characterTapped = layoutManager.characterIndex(for: locationOfTouchInTextContainer, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)

        // figure out how many characters are in the string up to and including the line tapped
        let lineTapped = Int(ceil(locationOfTouchInLabel.y / font.lineHeight)) - 1
        let rightMostPointInLineTapped = CGPoint(x: bounds.size.width, y: font.lineHeight * CGFloat(lineTapped))
        let charsInLineTapped = layoutManager.characterIndex(for: rightMostPointInLineTapped, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)

        // ignore taps past the end of the current line
        if characterTapped < charsInLineTapped {
            onCharacterTapped?(self, characterTapped)
        }
    }
}

3
我使用这段代码在点击标签时获取字符索引。它在第一行上很好地工作。但是在第二行中,它没有返回正确的索引,只返回最后一个字符的索引。是否有解决这个问题的方法?我已经检查了点击位置是正确的。但是在layoutManager中返回了错误的索引。 - mkjwa
3
我发现一些问题是由于换行模式造成的。在截断尾部的情况下,layoutManager 被视为单行。在自动换行时,它能够在多行中正常工作。这段代码非常有用。谢谢。 - mkjwa
这正是我所需要的,我尝试了许多解决方案,但当涉及到带有换行和自动换行的多行标签时,它们都失败了。我陷入了困境,感到沮丧,直到我找到了你的方法。非常感谢! - Gulfam Khan

7
这是我对同一问题的实现。 我需要用反应标记#hashtags和@usernames。
我没有覆盖drawTextInRect:(CGRect)rect,因为默认方法很完美。
此外,我还发现了以下不错的实现https://github.com/Krelborn/KILabel。 我也从这个示例中借鉴了一些想法。
@protocol EmbeddedLabelDelegate <NSObject>
- (void)embeddedLabelDidGetTap:(EmbeddedLabel *)embeddedLabel;
- (void)embeddedLabel:(EmbeddedLabel *)embeddedLabel didGetTapOnHashText:(NSString *)hashStr;
- (void)embeddedLabel:(EmbeddedLabel *)embeddedLabel didGetTapOnUserText:(NSString *)userNameStr;
@end

@interface EmbeddedLabel : UILabel
@property (nonatomic, weak) id<EmbeddedLabelDelegate> delegate;
- (void)setText:(NSString *)text;
@end


#define kEmbeddedLabelHashtagStyle      @"hashtagStyle"
#define kEmbeddedLabelUsernameStyle     @"usernameStyle"

typedef enum {
    kEmbeddedLabelStateNormal = 0,
    kEmbeddedLabelStateHashtag,
    kEmbeddedLabelStateUsename
} EmbeddedLabelState;


@interface EmbeddedLabel ()

@property (nonatomic, strong) NSLayoutManager *layoutManager;
@property (nonatomic, strong) NSTextStorage   *textStorage;
@property (nonatomic, weak)   NSTextContainer *textContainer;

@end


@implementation EmbeddedLabel

- (void)dealloc
{
    _delegate = nil;
}

- (id)initWithFrame:(CGRect)frame
{
    self = [super initWithFrame:frame];

    if (self)
    {
        [self setupTextSystem];
    }
    return self;
}

- (void)awakeFromNib
{
    [super awakeFromNib];
    [self setupTextSystem];
}

- (void)setupTextSystem
{
    self.userInteractionEnabled = YES;
    self.numberOfLines = 0;
    self.lineBreakMode = NSLineBreakByWordWrapping;

    self.layoutManager = [NSLayoutManager new];

    NSTextContainer *textContainer     = [[NSTextContainer alloc] initWithSize:self.bounds.size];
    textContainer.lineFragmentPadding  = 0;
    textContainer.maximumNumberOfLines = self.numberOfLines;
    textContainer.lineBreakMode        = self.lineBreakMode;
    textContainer.layoutManager        = self.layoutManager;

    [self.layoutManager addTextContainer:textContainer];

    self.textStorage = [NSTextStorage new];
    [self.textStorage addLayoutManager:self.layoutManager];
}

- (void)setFrame:(CGRect)frame
{
    [super setFrame:frame];
    self.textContainer.size = self.bounds.size;
}

- (void)setBounds:(CGRect)bounds
{
    [super setBounds:bounds];
    self.textContainer.size = self.bounds.size;
}

- (void)layoutSubviews
{
    [super layoutSubviews];
    self.textContainer.size = self.bounds.size;
}

- (void)setText:(NSString *)text
{
    [super setText:nil];

    self.attributedText = [self attributedTextWithText:text];
    self.textStorage.attributedString = self.attributedText;

    [self.gestureRecognizers enumerateObjectsUsingBlock:^(UIGestureRecognizer *recognizer, NSUInteger idx, BOOL *stop) {
        if ([recognizer isKindOfClass:[UITapGestureRecognizer class]]) [self removeGestureRecognizer:recognizer];
    }];
    [self addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(embeddedTextClicked:)]];
}

- (NSMutableAttributedString *)attributedTextWithText:(NSString *)text
{
    NSMutableParagraphStyle *style = [NSMutableParagraphStyle new];
    style.alignment = self.textAlignment;
    style.lineBreakMode = self.lineBreakMode;

    NSDictionary *hashStyle   = @{ NSFontAttributeName : [UIFont boldSystemFontOfSize:[self.font pointSize]],
                                   NSForegroundColorAttributeName : (self.highlightedTextColor ?: (self.textColor ?: [UIColor darkTextColor])),
                                   NSParagraphStyleAttributeName : style,
                                   kEmbeddedLabelHashtagStyle : @(YES) };

    NSDictionary *nameStyle   = @{ NSFontAttributeName : [UIFont boldSystemFontOfSize:[self.font pointSize]],
                                   NSForegroundColorAttributeName : (self.highlightedTextColor ?: (self.textColor ?: [UIColor darkTextColor])),
                                   NSParagraphStyleAttributeName : style,
                                   kEmbeddedLabelUsernameStyle : @(YES)  };

    NSDictionary *normalStyle = @{ NSFontAttributeName : self.font,
                                   NSForegroundColorAttributeName : (self.textColor ?: [UIColor darkTextColor]),
                                   NSParagraphStyleAttributeName : style };

    NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:@"" attributes:normalStyle];
    NSCharacterSet *charSet = [NSCharacterSet characterSetWithCharactersInString:kWhiteSpaceCharacterSet];
    NSMutableString *token = [NSMutableString string];
    NSInteger length = text.length;
    EmbeddedLabelState state = kEmbeddedLabelStateNormal;

    for (NSInteger index = 0; index < length; index++)
    {
        unichar sign = [text characterAtIndex:index];

        if ([charSet characterIsMember:sign] && state)
        {
            [attributedText appendAttributedString:[[NSAttributedString alloc] initWithString:token attributes:state == kEmbeddedLabelStateHashtag ? hashStyle : nameStyle]];
            state = kEmbeddedLabelStateNormal;
            [token setString:[NSString stringWithCharacters:&sign length:1]];
        }
        else if (sign == '#' || sign == '@')
        {
            [attributedText appendAttributedString:[[NSAttributedString alloc] initWithString:token attributes:normalStyle]];
            state = sign == '#' ? kEmbeddedLabelStateHashtag : kEmbeddedLabelStateUsename;
            [token setString:[NSString stringWithCharacters:&sign length:1]];
        }
        else
        {
            [token appendString:[NSString stringWithCharacters:&sign length:1]];
        }
    }

    [attributedText appendAttributedString:[[NSAttributedString alloc] initWithString:token attributes:state ? (state == kEmbeddedLabelStateHashtag ? hashStyle : nameStyle) : normalStyle]];
    return attributedText;
}

- (void)embeddedTextClicked:(UIGestureRecognizer *)recognizer
{
    if (recognizer.state == UIGestureRecognizerStateEnded)
    {
        CGPoint location = [recognizer locationInView:self];

        NSUInteger characterIndex = [self.layoutManager characterIndexForPoint:location
                                                           inTextContainer:self.textContainer
                                  fractionOfDistanceBetweenInsertionPoints:NULL];

        if (characterIndex < self.textStorage.length)
        {
            NSRange range;
            NSDictionary *attributes = [self.textStorage attributesAtIndex:characterIndex effectiveRange:&range];

            if ([attributes objectForKey:kEmbeddedLabelHashtagStyle])
            {
                NSString *value = [self.attributedText.string substringWithRange:range];
                [self.delegate embeddedLabel:self didGetTapOnHashText:[value stringByReplacingOccurrencesOfString:@"#" withString:@""]];
            }
            else if ([attributes objectForKey:kEmbeddedLabelUsernameStyle])
            {
                NSString *value = [self.attributedText.string substringWithRange:range];
                [self.delegate embeddedLabel:self didGetTapOnUserText:[value stringByReplacingOccurrencesOfString:@"@" withString:@""]];
            }
            else
            {
                [self.delegate embeddedLabelDidGetTap:self];
            }
        }
        else
        {
            [self.delegate embeddedLabelDidGetTap:self];
        }
    }
}

@end

3

我将在SwiftUI的UIViewRepresentable上下文中使用它,并尝试添加链接。我找到的所有代码都不是很准确(特别是对于多行),这是我能得到的最精确(也是最干净)的代码:

// set up the text engine
let layoutManager = NSLayoutManager()
let textContainer = NSTextContainer(size: .zero)
let textStorage = NSTextStorage(attributedString: attrString)

// copy over properties from the label
// assuming left aligned text, might need further adjustments for other alignments
textContainer.lineFragmentPadding = 0
textContainer.lineBreakMode = label.lineBreakMode
textContainer.maximumNumberOfLines = label.numberOfLines
let labelSize = label.bounds.size
textContainer.size = labelSize

// hook up the text engine
layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager)

// adjust for the layout manager's geometry (not sure exactly how this works but it's required)
let locationOfTouchInLabel = tap.location(in: label)
let textBoundingBox = layoutManager.usedRect(for: textContainer)
let textContainerOffset = CGPoint(
    x: labelSize.width/2 - textBoundingBox.midX,
    y: labelSize.height/2 - textBoundingBox.midY
)
let locationOfTouchInTextContainer = CGPoint(
    x: locationOfTouchInLabel.x - textContainerOffset.x,
    y: locationOfTouchInLabel.y - textContainerOffset.y
)

// actually perform the check to get the index, accounting for multiple lines
let indexOfCharacter = layoutManager.characterIndex(for: locationOfTouchInTextContainer, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)

// get the attributes at the index
let attributes = attrString.attributes(at: indexOfCharacter, effectiveRange: nil)

// use `.attachment` instead of `.link` so you can bring your own styling
if let url = attributes[.attachment] as? URL {
     UIApplication.shared.open(url, options: [:], completionHandler: nil)
}

2

哇,这真是个难以调试的问题。所有已提供的答案都接近正确,而且可以工作,但只要应用自定义字体,一切就会崩溃。

对我有效的代码是设置:

layoutManager.usesFontLeading = false

并且增加了文本容器的高度

textContainer.size = CGSize(
    width: labelSize.width,
    height: labelSize.height + 10000
)

完整的代码如下所示。是的,这看起来与其他代码非常相似,但无论如何,这里还是提供了它。
// I'm inside a lambda here with weak self, so lets guard my required items.
guard let self, event.state == .ended, let text = self.attributedText else { return nil }
                
// Create instances of NSLayoutManager, NSTextContainer and NSTextStorage
let layoutManager = NSLayoutManager()
let textContainer = NSTextContainer(size: CGSize.zero)
let textStorage = NSTextStorage(attributedString: text)
                
// Configure layoutManager and textStorage
layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager)
                
// Configure textContainer
layoutManager.usesFontLeading = false
textContainer.lineFragmentPadding = 0.0
textContainer.lineBreakMode = self.lineBreakMode
textContainer.maximumNumberOfLines = self.numberOfLines
textContainer.size = CGSize(
    width: self.bounds.size.width,
    height: self.bounds.size.height + 10000
)
                
return layoutManager.characterIndex(for: event.location(in: self), in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)

在调试过程中,我创建了一些用于显示视图边界框及每个字符的有用项目。以下是这些项目。

public struct UILabelLayoutManagerInfo {
    let layoutManager: NSLayoutManager
    let textContainer: NSTextContainer
    let textStorage: NSTextStorage
}

public class DebugUILabel: UILabel {
    override public func draw(_ rect: CGRect) {
        super.draw(rect)
        if let ctx = UIGraphicsGetCurrentContext(), let info = makeLayoutManager() {
            ctx.setStrokeColor(UIColor.red.cgColor)
            ctx.setLineWidth(1)
            for i in 0..<attributedText!.length {
                ctx.addRect(info.layoutManager.boundingRect(forGlyphRange: NSRange(location: i, length: 1), in: info.textContainer))
                ctx.strokePath()
            }
            ctx.setStrokeColor(UIColor.blue.cgColor)
            ctx.setLineWidth(2)
            ctx.addRect(info.layoutManager.usedRect(for: info.textContainer))
            ctx.strokePath()
        }
    }
}


public extension UILabel {
    
    func makeLayoutManager() -> UILabelLayoutManagerInfo? {
        guard let text = self.attributedText else { return nil }
        
        // Create instances of NSLayoutManager, NSTextContainer and NSTextStorage
        let layoutManager = NSLayoutManager()
        let textContainer = NSTextContainer(size: CGSize.zero)
        let textStorage = NSTextStorage(attributedString: text)
        
        // Configure layoutManager and textStorage
        layoutManager.addTextContainer(textContainer)
        textStorage.addLayoutManager(layoutManager)
        
        // Configure textContainer
        layoutManager.usesFontLeading = false
        textContainer.lineFragmentPadding = 0.0
        textContainer.lineBreakMode = self.lineBreakMode
        textContainer.maximumNumberOfLines = self.numberOfLines
        textContainer.size = CGSize(
            width: self.bounds.size.width,
            height: self.bounds.size.height + 10000
        )
        
        return UILabelLayoutManagerInfo(
            layoutManager: layoutManager,
            textContainer: textContainer,
            textStorage: textStorage
        )
    }
}

1

Swift 5

 extension UITapGestureRecognizer {

 func didTapAttributedTextInLabel(label: UILabel, inRange targetRange: NSRange) -> Bool {
    // Create instances of NSLayoutManager, NSTextContainer and NSTextStorage
    let layoutManager = NSLayoutManager()
    let textContainer = NSTextContainer(size: CGSize.zero)
    let textStorage = NSTextStorage(attributedString: label.attributedText!)

    // Configure layoutManager and textStorage
    layoutManager.addTextContainer(textContainer)
    textStorage.addLayoutManager(layoutManager)

    // Configure textContainer
    textContainer.lineFragmentPadding = 0.0
    textContainer.lineBreakMode = label.lineBreakMode
    textContainer.maximumNumberOfLines = label.numberOfLines
    let labelSize = label.bounds.size
    textContainer.size = labelSize

    // Find the tapped character location and compare it to the specified range
    let locationOfTouchInLabel = self.location(in: label)
    let textBoundingBox = layoutManager.usedRect(for: textContainer)
    let textContainerOffset = CGPoint(x: (labelSize.width - textBoundingBox.size.width) * 0.5 - textBoundingBox.origin.x,
                                      y: (labelSize.height - textBoundingBox.size.height) * 0.5 - textBoundingBox.origin.y);
    let locationOfTouchInTextContainer = CGPoint(x: (locationOfTouchInLabel.x - textContainerOffset.x),
                                                 y: 0 );
    // Adjust for multiple lines of text
    let lineModifier = Int(ceil(locationOfTouchInLabel.y / label.font.lineHeight)) - 1
    let rightMostFirstLinePoint = CGPoint(x: labelSize.width, y: 0)
    let charsPerLine = layoutManager.characterIndex(for: rightMostFirstLinePoint, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)

    let indexOfCharacter = layoutManager.characterIndex(for: locationOfTouchInTextContainer, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
    let adjustedRange = indexOfCharacter + (lineModifier * charsPerLine)

    return NSLocationInRange(adjustedRange, targetRange)
   }

}

它对我有效。


优秀的代码。你可以用 Range<String.Index> 替换 NSRange,使其更符合 Swift 的风格。 - Hola Soy Edu Feliz Navidad

1

我已经在swift 3上实现了同样的功能。以下是完整的代码,用于查找UILabel上触摸点的字符索引,它可以帮助那些正在使用swift并寻找解决方案的人:

    //here myLabel is the object of UILabel
    //added this from @warly's answer
    //set font of attributedText
    let attributedText = NSMutableAttributedString(attributedString: myLabel!.attributedText!)
    attributedText.addAttributes([NSFontAttributeName: myLabel!.font], range: NSMakeRange(0, (myLabel!.attributedText?.string.characters.count)!))

    // Create instances of NSLayoutManager, NSTextContainer and NSTextStorage
    let layoutManager = NSLayoutManager()
    let textContainer = NSTextContainer(size: CGSize(width: (myLabel?.frame.width)!, height: (myLabel?.frame.height)!+100))
    let textStorage = NSTextStorage(attributedString: attributedText)

    // Configure layoutManager and textStorage
    layoutManager.addTextContainer(textContainer)
    textStorage.addLayoutManager(layoutManager)

    // Configure textContainer
    textContainer.lineFragmentPadding = 0.0
    textContainer.lineBreakMode = myLabel!.lineBreakMode
    textContainer.maximumNumberOfLines = myLabel!.numberOfLines
    let labelSize = myLabel!.bounds.size
    textContainer.size = labelSize

    // get the index of character where user tapped
    let indexOfCharacter = layoutManager.characterIndex(for: tapLocation, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)

0
在Swift 5中,创建一个用于交互式标签的类,并将其分配给您想要将其设置为可点击URL的任何uiLabel。它适用于多行文本,会查找标签中的子字符串是否为URL,并使其可点击。
import Foundation
import UIKit

@IBDesignable
class LinkUILabel: UILabel {
  
  required init(coder aDecoder: NSCoder) {
    super.init(coder: aDecoder)!
  }
  
  override init(frame: CGRect) {
    super.init(frame: frame)
  }
  
  override var text: String? {
    didSet {
      guard text != nil else { return }
      self.addAttributedString()
    }
  }
  
  // Find the URLs from a string with multiple urls and add attributes
  private func addAttributedString() {
    let labelStr = self.text ?? ""
    guard labelStr != "" else { return }
    let stringArray : [String] = labelStr.split(separator: " ").map { String($0) }
    let attributedString = NSMutableAttributedString(string: labelStr)
    
    for urlStr in stringArray where isValidUrl(urlStr: urlStr) {
      self.isUserInteractionEnabled = true
      self.isEnabled = true
      let startIndices = labelStr.indices(of: urlStr).map { $0.utf16Offset(in: labelStr) }
      for index in startIndices {
        attributedString.addAttribute(.link, value: urlStr, range: NSRange(location: index, length: urlStr.count))
      }
    }
    self.attributedText = attributedString
  }
  
  private func isValidUrl(urlStr: String) -> Bool {
    if let url = NSURL(string: urlStr) {
      return UIApplication.shared.canOpenURL(url as URL)
    }
    return false
  }

  // Triggered when the user lifts a finger.
  override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
    guard let touch = touches.first else { return }
    let location = touch.location(in: self)
    
    // Configure NSTextContainer
    let textContainer = NSTextContainer(size: bounds.size)
    textContainer.lineFragmentPadding = 0.0
    textContainer.lineBreakMode = lineBreakMode
    textContainer.maximumNumberOfLines = numberOfLines
    
    // Configure NSLayoutManager and add the text container
    let layoutManager = NSLayoutManager()
    layoutManager.addTextContainer(textContainer)
    
    guard let attributedText = attributedText else { return }
    
    // Configure NSTextStorage and apply the layout manager
    let textStorage = NSTextStorage(attributedString: attributedText)
    textStorage.addAttribute(NSAttributedString.Key.font, value: font!, range: NSMakeRange(0, attributedText.length))
    textStorage.addLayoutManager(layoutManager)
    
    // get the tapped character location
    let locationOfTouchInLabel = location
    
    // account for text alignment and insets
    let textBoundingBox = layoutManager.usedRect(for: textContainer)
    let alignmentOffset: CGFloat = aligmentOffset(for: self)
    
    let xOffset = ((bounds.size.width - textBoundingBox.size.width) * alignmentOffset) - textBoundingBox.origin.x
    let yOffset = ((bounds.size.height - textBoundingBox.size.height) * alignmentOffset) - textBoundingBox.origin.y
    let locationOfTouchInTextContainer = CGPoint(x: locationOfTouchInLabel.x - xOffset, y: locationOfTouchInLabel.y - yOffset)
    
    // work out which character was tapped
    let characterIndex = layoutManager.characterIndex(for: locationOfTouchInTextContainer, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
    
    let attributeValue = self.attributedText?.attribute(.link, at: characterIndex, effectiveRange: nil)
    if let value = attributeValue {
      if  let url = NSURL(string: value as! String) {
        UIApplication.shared.open(url as URL)
        return
      }
    }
  }
  
  private func aligmentOffset(for label: UILabel) -> CGFloat {
    switch label.textAlignment {
    case .left, .natural, .justified:
      return 0.0
    case .center:
      return 0.5
    case .right:
      return 1.0
    @unknown default:
      return 0.0
    }
  }
}

使用方法: 在视图控制器中创建一个UILabel,并将其分配为LinkUILabel。
  @IBOutlet weak var detailLbl: LinkUILabel!
  detailLbl.text = text

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