Character index at touch point for UILabel
Asked Answered
S

9

26

For a UILabel, I'd like to find out which character index is at specific point received from a touch event. I'd like to solve this problem for iOS 7 using Text Kit.

Since UILabel doesn't provide access to its NSLayoutManager, I created my own based on UILabel's configuration like this:

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

The code above is in a UILabel subclass with a UITapGestureRecognizer configured to call textTapped: (Gist).

The resulting character index makes sense (increases when tapping from left to right), but is not correct (the last character is reached at roughly half the width of the label). It looks like maybe the font size or text container size is not configured properly, but can't find the problem.

I'd really like to keep my class a subclass of UILabel instead of using UITextView. Has anyone solved this problem for UILabel?

Update: I spent a DTS ticket on this question and the Apple engineer recommended to override UILabel's drawTextInRect: with an implementation that uses my own layout manager, similar to this code snippet:

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

I think it would be a lot of work to keep my own layout manager in sync with the label's settings, so I'll probably go with UITextView despite my preference for UILabel.

Update 2: I decided to use UITextView after all. The purpose of all this was to detect taps on links embedded in the text. I tried to use NSLinkAttributeName, but this setup didn't trigger the delegate callback when tapping a link quickly. Instead, you have to press the link for a certain amount of time – very annoying. So I created CCHLinkTextView that doesn't have this problem.

Snazzy answered 25/1, 2014 at 11:6 Comment(1)
Late reaction; the trick for me to get this to work was the line textContainer.lineFragmentPadding = 0;, which is absent in your sample, but is present in the answers below by @Alexey Ishkov and @Kai Burghardt. I did not have to hack the containerSize with the number 100.Sigrid
R
44

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!

Raccoon answered 7/11, 2014 at 17:44 Comment(4)
In fact you need to adjust testStorage slightly in compare to original label size. It is empiric fact that for every additional line of UILabel you need to add about 1pt to height. So textStorage should be set dynamically depending on number of lines.Ardyth
can you explain why the ...textLabel.frame.size.height+100 magic number?Pseudo
Make sure you call sizeToFit on the UILabel firstUnreeve
You can replace textLabel.frame.size.height+100 with CGFloat.greatestFiniteMagnitude. The NSTextContainer initializer needs a limitation (maxWidth and maxHeight), it doesn't need to be exact, it just needs to be equals to or greater than the actual frame.Anteversion
P
23

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

Possessive answered 17/2, 2015 at 9:59 Comment(1)
I would add that the text alignment is also needed: let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.alignment = .center attributedText.addAttributes([.paragraphStyle: paragraphStyle], range: NSMakeRange(0, attributedText.string.count))Priggery
A
19

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)
        }
    }
}
Airsick answered 2/2, 2018 at 18:14 Comment(3)
I did use this code for getting a character index when tapping on label. It works very well on firs line. But it doesn't return the correct index at the secon line, only returns the last index of characters. Is there a way to solve this problem. I checked tapped position is correct. but in layoutManager has returnning the false index.Disparate
some I found is due to linebreakmode. In case of Truncate tail, layoutManager is considered as single line. In case of Word Wrap, It works well in multi lines. This code is very helpful. thanks.Disparate
This is just what I needed, I tried many solutions but all of them failed when it comes to multiline label with word-wrap. I was stuck on this for a while frustrated until I found your method. thanks alotBrightness
U
7

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
Unearthly answered 5/9, 2014 at 1:35 Comment(0)
P
3

I'm using this in the context of a UIViewRepresentable in SwiftUI, and trying to add links to it. None of the code I found in these answers was quite right (especially for multi-line), and this is as precise (and as clean) as I could get it:

// 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)
}
Pournaras answered 5/8, 2020 at 20:40 Comment(0)
S
3

Wow, this was awful to debug. All the answered already provided were close, and can work, right up until you apply a custom font. Everything fell apart after i applied a custom font.

The lines that made it work for me was setting

layoutManager.usesFontLeading = false

and added extra height to the text container size

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

The full code is provided below. Yes this looks muchlike all the others, but here it is anyways.

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

In the process of Debugging this I created some useful items for displaying the bounding boxes of the view, and each of the characters. Those are provided below.


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
        )
    }
}
Siclari answered 16/12, 2022 at 20:32 Comment(1)
layoutManager.usesFontLeading = false was the point for me tooCalvities
E
1

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)
Elmer answered 3/2, 2017 at 10:46 Comment(0)
I
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)
   }

}

it works for me.

Idleman answered 9/4, 2020 at 10:38 Comment(1)
Great code. You could replace NSRange with Range<String.Index> for making it more Swifty.Sainfoin
A
0

In Swift 5, create a Class for interactive label, and assign it to any uiLabel that you want to make it clickable URLs. It will work on multiline, It will find if substring in label is URL and will make it clickable.

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

Usage: Create a UILabel in view controller and assign as LinkUILabel

  @IBOutlet weak var detailLbl: LinkUILabel!
  detailLbl.text = text
Abbess answered 16/6, 2023 at 6:37 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.