Character index at touch point for UILabel
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 storageNSTextStorage *textStorage = [[NSTextStorage alloc] initWithAttributedString:textLabel.attributedText];NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init];[textStorage addLayoutManager:layoutManager];// init text containerNSTextContainer *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!
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.attributedText
did 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) } }}