Индекс символа в точке касания для UILabel

Для UILabel я хотел бы узнать, какой индекс символа в определенный момент получен из события касания. Я хотел бы решить эту проблему для iOS 7 с помощью Text Kit.

Поскольку UILabel не предоставляет доступ к своему NSLayoutManager, я создал свой собственный на основе конфигурации UILabel следующим образом:

- (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 на этот вопрос, и инженер Apple порекомендовал переопределить drawTextInRect: UILabel реализацией, которая использует мой собственный менеджер компоновки, подобный этому фрагменту кода:

- (void)drawTextInRect:(CGRect)rect 
{
    [yourLayoutManager drawGlyphsForGlyphRange:NSMakeRange(0, yourTextStorage.length) atPoint:CGPointMake(0, 0)];
}

Я думаю, что будет много работы, чтобы мой собственный менеджер компоновки синхронизировался с настройками этикетки, поэтому я, вероятно, выберу UITextView, несмотря на то, что предпочитаю UILabel.

Обновление 2: я все-таки решил использовать UITextView. Целью всего этого было обнаружение нажатий на встроенные в текст ссылки. Я пытался использовать NSLinkAttributeName, но эта настройка не запускала обратный вызов делегата при быстром нажатии на ссылку. Вместо этого вы должны нажимать на ссылку в течение определенного времени — очень раздражает. Поэтому я создал CCHLinkTextView, у которого нет этой проблемы.


person Claus    schedule 25.01.2014    source источник
comment
поздняя реакция; уловкой для меня, чтобы заставить это работать, была строка textContainer.lineFragmentPadding = 0;, которая отсутствует в вашем образце, но присутствует в ответах ниже @Alexey Ishkov и @Kai Burghardt. Мне не пришлось хакнуть containerSize с номером 100.   -  person koen    schedule 19.02.2017


Ответы (7)


Поигрался с решением Алексея Ишкова. Наконец-то я получил решение! Используйте этот фрагмент кода в селекторе 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];

Надеюсь, это поможет некоторым людям!

person Kai Burghardt    schedule 07.11.2014
comment
На самом деле вам нужно немного настроить testStorage по сравнению с исходным размером метки. Эмпирическим фактом является то, что для каждой дополнительной строки UILabel вам нужно добавить около 1pt к высоте. Поэтому textStorage следует устанавливать динамически в зависимости от количества строк. - person malex; 12.04.2015
comment
Можете ли вы объяснить, почему магическое число ...textLabel.frame.size.height+100? - person tiritea; 04.10.2016
comment
Убедитесь, что вы сначала вызываете sizeToFit для UILabel. - person user1055568; 20.11.2017
comment
Вы можете заменить textLabel.frame.size.height+100 на CGFloat.greatestFiniteMagnitude. Инициализатору NSTextContainer нужно ограничение (maxWidth и maxHeight), оно не обязательно должно быть точным, оно просто должно быть больше или равно фактическому кадру. - person marcelosalloum; 16.07.2019

Я получил ту же ошибку, что и вы, индекс увеличился слишком быстро, поэтому в конце он не был точным. Причиной этой проблемы было то, что self.attributedText не содержал полной информации о шрифте для всей строки.

Когда UILabel визуализирует, он использует шрифт, указанный в self.font, и применяет его ко всей атрибутивной строке. Это не тот случай, когда присваивается атрибут TextStorage textStorage. Поэтому вам нужно сделать это самостоятельно:

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

Свифт 4

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

Надеюсь это поможет :)

person warly    schedule 17.02.2015
comment
Я бы добавил, что выравнивание текста также необходимо: let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.alignment = .center attributeText.addAttributes([.paragraphStyle: paragraphStyle], range: NSMakeRange(0, attributeText.string.count)) - person Cyupa; 13.07.2018

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)
        }
    }
}
person Eli Burke    schedule 02.02.2018
comment
Я использовал этот код для получения индекса символов при нажатии на ярлык. Он очень хорошо работает на первой линии. Но он не возвращает правильный индекс во второй строке, возвращает только последний индекс символов. Есть ли способ решить эту проблему. Я проверил, что положение постукивания правильное. но в layoutManager возвращается ложный индекс. - person mkjwa; 03.03.2018
comment
некоторые, которые я нашел, связаны с режимом разрыва строки. В случае Truncate tail layoutManager рассматривается как одна строка. В случае переноса слов он хорошо работает в нескольких строках. Этот код очень полезен. Благодарю. - person mkjwa; 03.03.2018
comment
Это как раз то, что мне было нужно, я пробовал много решений, но все они не помогли, когда дело доходит до многострочной метки с переносом слов. Я застрял на этом некоторое время, пока не нашел ваш метод. большое спасибо - person Gulfam Khan; 04.04.2019

Вот моя реализация для той же проблемы. Мне нужно было отметить #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
person Alexey Ishkov    schedule 05.09.2014

Я использую это в контексте UIViewRepresentable в SwiftUI и пытаюсь добавить к нему ссылки. Ни один из кодов, которые я нашел в этих ответах, не был совершенно правильным (особенно для многострочных), и это настолько точно (и настолько чисто), насколько я мог его получить:

// 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)
}
person Soroush Khanlou    schedule 05.08.2020

Я реализовал то же самое на 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)
person Ankur    schedule 03.02.2017

Свифт 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)
   }

}

меня устраивает.

person Mykhailo Zabarin    schedule 09.04.2020
comment
Отличный код. Вы можете заменить NSRange на Range‹String.Index›, чтобы сделать его более быстрым. - person Hola Soy Edu Feliz Navidad; 26.03.2021