Character index at touch point for UILabel

自闭症网瘾萝莉.ら 提交于 2019-11-27 12:44:22
Kai Burghardt

I played around with the solution of Alexey Ishkov. Finally i got a solution! Use this code snippet in your UITapGestureRecognizer selector:

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

Hope this will help some people out there!

warly

i got the same error as you, the index increased way to fast so it wasn't accurate at the end. The cause of this issue was that self.attributedTextdid not contain full font information for the whole string.

When UILabel renders it uses the font specified in self.font and applies it to the whole attributedString. This is not the case when assigning the attributedText to the textStorage. Therefore you need to do this yourself:

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

Hope this helps :)

Swift 4, synthesized from many sources including good answers here. My contribution is correct handling of inset, alignment, and multi-line labels. (most implementations treat a tap on trailing whitespace as a tap on the final character in the line)

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

Here you are my implementation for the same problem. I have needed to mark #hashtags and @usernames with reaction on the taps.

I do not override drawTextInRect:(CGRect)rect because default method works perfect.

Also I have found the following nice implementation https://github.com/Krelborn/KILabel. I used some ideas from this sample too.

@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

I have implemented the same on swift 3. Below is the complete code to find Character index at touch point for UILabel, it can help others who are working on swift and looking for the solution :

    //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)
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!