Tap on a part of text of UILabel
Asked Answered
I

9

38

I have a problem that boundingRectForGlyphRange always returns CGRect.zero "0.0, 0.0, 0.0, 0.0".

For example, I am coding for touching on a part of text of UILabel feature. My text has first part is any text and second one is READ MORE.

I want the tap recognizer only work when I touch READ MORE. If I touch on any point on UILabel, CGRectContainsPoint always return true, then the action called.

Here my code:

override func viewDidLoad() {
        super.viewDidLoad()
        
        // The full string
        
        let firstPart:NSMutableAttributedString = NSMutableAttributedString(string: "Lorem ipsum dolor set amit ", attributes: [NSFontAttributeName: UIFont.systemFontOfSize(13)])
        firstPart.addAttribute(NSForegroundColorAttributeName, value: UIColor.blackColor(),
            range: NSRange(location: 0, length: firstPart.length))
        info.appendAttributedString(firstPart)
        
        // The "Read More" string that should be touchable
        let secondPart:NSMutableAttributedString = NSMutableAttributedString(string: "READ MORE", attributes: [NSFontAttributeName: UIFont.systemFontOfSize(14)])
        secondPart.addAttribute(NSForegroundColorAttributeName, value: UIColor.blackColor(),
            range: NSRange(location: 0, length: secondPart.length))
        info.appendAttributedString(secondPart)
        
        lblTest.attributedText = info
        
        // Store range of chars we want to detect touches for
        moreStringRange = NSMakeRange(firstPart.length, secondPart.length)
        print("moreStringRange\(moreStringRange)")
        
        tapRec.addTarget(self, action: "didTap:")
        lblTest.addGestureRecognizer(tapRec)
        
    }


    func didTap(sender:AnyObject) {
        // Storage class stores the string, obviously
        let textStorage:NSTextStorage = NSTextStorage(attributedString: info)
        // The storage class owns a layout manager
        let layoutManager:NSLayoutManager = NSLayoutManager()
        textStorage.addLayoutManager(layoutManager)
        
        // Layout manager owns a container which basically
        // defines the bounds the text should be contained in
        let textContainer:NSTextContainer = NSTextContainer(size: lblTest.frame.size)
        textContainer.lineFragmentPadding = 0
        textContainer.lineBreakMode = lblTest.lineBreakMode
        
        // Begin computation of actual frame
        // Glyph is the final display representation
        var glyphRange = NSRange()
        // Extract the glyph range
        layoutManager.characterRangeForGlyphRange(moreStringRange!, actualGlyphRange: &glyphRange)
        
        // Compute the rect of glyph in the text container
        print("glyphRange\(glyphRange)")
        print("textContainer\(textContainer)")
        let glyphRect:CGRect = layoutManager.boundingRectForGlyphRange(glyphRange, inTextContainer: textContainer)
        
        // Final rect relative to the textLabel.
        print("\(glyphRect)")
        
        // Now figure out if the touch point is inside our rect
        let touchPoint:CGPoint = tapRec.locationOfTouch(0, inView: lblTest)
        
        if CGRectContainsPoint(glyphRect, touchPoint) {
            print("User tapped on Read More. So show something more")
        }
    }
}
Illaffected answered 16/3, 2016 at 17:38 Comment(7)
Hope this will help you read moreSubotica
How about using two labels?Sipple
@Koen: It is just a demo. My text has many parts with attachments. I can't use many label.Illaffected
@Md.MuzahidulIslam: As I said, it is just a demo, "read more" (may be something else) may not in the end of string.Illaffected
Then check one #32175644Subotica
@Md.MuzahidulIslam. Please check the image. image to understand.Illaffected
Another possible way to do this: #20542176Sipple
O
100

#swift 4.2 Please find the solution here for getting specific text action of Label.

enter image description here

  1. Label declaration

    @IBOutlet weak var lblTerms: UILabel!
    
  2. Set attributed text to the label

    let text = "Please agree for Terms & Conditions."
    lblTerms.text = text
    self.lblTerms.textColor =  UIColor.white
    let underlineAttriString = NSMutableAttributedString(string: text)
    let range1 = (text as NSString).range(of: "Terms & Conditions.")
         underlineAttriString.addAttribute(NSAttributedString.Key.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: range1)
         underlineAttriString.addAttribute(NSAttributedString.Key.font, value: UIFont.init(name: Theme.Font.Regular, size: Theme.Font.size.lblSize)!, range: range1)
         underlineAttriString.addAttribute(NSAttributedString.Key.foregroundColor, value: Theme.color.primaryGreen, range: range1)
    lblTerms.attributedText = underlineAttriString
    lblTerms.isUserInteractionEnabled = true
    lblTerms.addGestureRecognizer(UITapGestureRecognizer(target:self, action: #selector(tapLabel(gesture:))))
    

It looks like the above image.

  1. Add the tapLabel action method to the controller

    @IBAction func tapLabel(gesture: UITapGestureRecognizer) {
    let termsRange = (text as NSString).range(of: "Terms & Conditions")
    // comment for now
    //let privacyRange = (text as NSString).range(of: "Privacy Policy")
    
    if gesture.didTapAttributedTextInLabel(label: lblTerms, inRange: termsRange) {
        print("Tapped terms")
    } else if gesture.didTapAttributedTextInLabel(label: lblTerms, inRange: privacyRange) {
        print("Tapped privacy") 
    } else {                
        print("Tapped none")
    }
    }
    
  2. Add UITapGestureRecognizer extension

    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 = CGPointMake((labelSize.width - textBoundingBox.size.width) * 0.5 - textBoundingBox.origin.x,
            //(labelSize.height - textBoundingBox.size.height) * 0.5 - textBoundingBox.origin.y);
            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 = CGPointMake(locationOfTouchInLabel.x - textContainerOffset.x,
            // locationOfTouchInLabel.y - textContainerOffset.y);
            let locationOfTouchInTextContainer = CGPoint(x: locationOfTouchInLabel.x - textContainerOffset.x, y: locationOfTouchInLabel.y - textContainerOffset.y)
            let indexOfCharacter = layoutManager.characterIndex(for: locationOfTouchInTextContainer, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
            return NSLocationInRange(indexOfCharacter, targetRange)
        }
    }
    

Make sure to do:

lblTerms.isUserInteractionEnabled = true
Ong answered 19/3, 2019 at 11:54 Comment(10)
@llesh do you have any idea about multiple subtexts? For example, I have the text like this: "Hello world! hello". Is there any way to detect tap on the 2nd word "hello"?Linoel
should also add alignment (label.textAlignment) to attributed string for proper working (i.e. centered text) like in https://mcmap.net/q/188423/-attributed-text-center-alignmentBerkeleian
For some reason for me, the tapping works but it's off by a considerable amount of pixels (like about 10 or 20 in my case). Any ideas why this might be?Decreasing
I have multiline text with special unicode charactersDecreasing
here is the data for my gesuture variables, LabelSize= (315.0, 43.0) LocationOfTouchInLabel= (235.0, 25.5) TextBoundingBox= (64.8134765625, 0.0, 185.373046875, 13.8) LocationOfTouchInTextContainer (235.0, 10.9) IndexOfCharacter= 32 TargetRange= {28, 5}Decreasing
@JosephAstrahan I'm facing similar issue in multi-language. Have you found any solution for this?Bandage
@chintan Shah Unfortunately I have not... please share if you find a solution though. I found some work arounds... but I'm not sure they are worth sharing.Decreasing
@JosephAstrahan, I am facing this issue. Did you able to find out some workaround?Carolanncarole
Greats Work & thanks you . Easy Developer Step by stepDevora
working solutionJospeh
P
38

After having several issues with this kind of stuff, using a lot of different librairies, etc... I found an interesting solution: http://samwize.com/2016/03/04/how-to-create-multiple-tappable-links-in-a-uilabel/

It's about to extend UITapGestureRegonizer and detect if the tap is in the range of the string when triggered.

Here is the updated Swift 4 version of this extension:

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: locationOfTouchInLabel.y - textContainerOffset.y)
        let indexOfCharacter = layoutManager.characterIndex(for: locationOfTouchInTextContainer, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
        return NSLocationInRange(indexOfCharacter, targetRange)
    }

}

To simplify range conversion, you also need this Range extension

extension Range where Bound == String.Index {
    var nsRange:NSRange {
        return NSRange(location: self.lowerBound.encodedOffset,
                   length: self.upperBound.encodedOffset -
                    self.lowerBound.encodedOffset)
    }
}

Once you have this extension, you can add a tap gesture to your label:

let tap = UITapGestureRecognizer(target: self, action: #selector(tapLabel(tap:)))
self.yourLabel.addGestureRecognizer(tap)
self.yourLabel.isUserInteractionEnabled = true

Here is the function to handle the tap:

@objc func tapLabel(tap: UITapGestureRecognizer) {
    guard let range = self.yourLabel.text?.range(of: "Substring to detect")?.nsRange else {
        return
    }
    if tap.didTapAttributedTextInLabel(label: self.yourLabel, inRange: range) {
        // Substring tapped
    }
}
Parenteral answered 25/10, 2017 at 19:12 Comment(5)
same not working for me also. I think that answer for only one line label not for the multiline label.Laszlo
For Multi-line text, set your label line break mode to "Truncate tail".Jannet
Not getting touch on UITableViewCell custom label textTicket
try this self.yourLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tapLabel(tap:)))) it will work.Interne
This works like a charm! Thank you ☑️🙌 Also a more updated implementation of nsRange computed property can be found here and works perfectly as this example below. for range in ranges { nsRanges.append(range.nsRange(in: subString)) } Find it here: #43233570Breban
T
30

To enable multiline tappable & don't want to subclass the UILabel then:

  • Write Extension function for UITapGestureRecognizer
extension UITapGestureRecognizer {
   
   func didTapAttributedTextInLabel(label: UILabel, inRange targetRange: NSRange) -> Bool {
       guard let attributedText = label.attributedText else { return false }

       let mutableStr = NSMutableAttributedString.init(attributedString: attributedText)
       mutableStr.addAttributes([NSAttributedString.Key.font : label.font!], range: NSRange.init(location: 0, length: attributedText.length))
       
       // If the label have text alignment. Delete this code if label have a default (left) aligment. Possible to add the attribute in previous adding.
       let paragraphStyle = NSMutableParagraphStyle()
       paragraphStyle.alignment = label.textAlignment
       mutableStr.addAttributes([NSAttributedString.Key.paragraphStyle : paragraphStyle], range: NSRange(location: 0, length: attributedText.length))

       // Create instances of NSLayoutManager, NSTextContainer and NSTextStorage
       let layoutManager = NSLayoutManager()
       let textContainer = NSTextContainer(size: CGSize.zero)
       let textStorage = NSTextStorage(attributedString: mutableStr)
       
       // 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: locationOfTouchInLabel.y - textContainerOffset.y);
       let indexOfCharacter = layoutManager.characterIndex(for: locationOfTouchInTextContainer, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
       return NSLocationInRange(indexOfCharacter, targetRange)
   }
   
}
  • Configure your UILable
label.text = "For any type of query please call us on +9186XXX-XXXXX or mail us at [email protected]"
label.isUserInteractionEnabled = true
label.lineBreakMode = .byWordWrapping
let tapGesture = UITapGestureRecognizer.init(target: self, action: #selector(tappedOnLabel(_:)))
tapGesture.numberOfTouchesRequired = 1
label.addGestureRecognizer(tapGesture)
  • Add the gesture recogniser selector function:
@objc func tappedOnLabel(_ gesture: UITapGestureRecognizer) {
    guard let text = label.text else { return }
    let numberRange = (text as NSString).range(of: "+9186XXX-XXXXX")
    let emailRange = (text as NSString).range(of: "[email protected]")    
    if gesture.didTapAttributedTextInLabel(label: self.label, inRange: numberRange) {
        print("number tapped")
    } else if gesture.didTapAttributedTextInLabel(label: self.label, inRange: emailRange) {
        print("Email tapped")
    }
}
Tungusic answered 17/5, 2020 at 13:39 Comment(2)
It worked, Thanks!!! saved a lot of hustle and time :) +1Proserpina
Finally found the one that is working, thank you :)Ciao
A
28

This is a real easy alternative for anyone who is willing to use a textView. I realize this question is about a UILabel but if you read the comments on some of the answers they don't work for some people and some of them are very code heavy which isn't very good for beginners. You can do this in 11 simple steps if you're willing to swap out a UILabel for a UITextView.

You can use NSMutableAttributedString and a UITextView. The UITextView has a delegate method: func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange) -> Bool { ... }. Once you set the part of the string that you want to make tappable the delegate method will activate it.

The 11 steps are listed below in the comments above each piece of code.

// 1st **BE SURE TO INCLUDE** UITextViewDelegate to the view controller's class
class VewController: UIViewController, UITextViewDelegate {

    // 2nd use a programmatic textView or use the textView from your storyboard
    lazy var yourTextView: UITextView = {
        let textView = UITextView()
        textView.textAlignment = .center
        textView.isEditable = false
        textView.showsVerticalScrollIndicator = false

        // *** If your text is only 1 line or if your textView's text will never change then uncomment these out ***
        /*
        textView.isScrollEnabled = false
        textView.sizeToFit()
        */

        return textView
    }()

   override func viewDidLoad() {
        super.viewDidLoad()

        // 3rd in viewDidLoad set the textView's delegate
        yourTextView.delegate = self

        // 4th create the first piece of the string you don't want to be tappable
        let regularText = NSMutableAttributedString(string: "any text ", attributes: [NSAttributedStringKey.font: UIFont.systemFont(ofSize: 17), NSAttributedStringKey.foregroundColor: UIColor.black])

        // 5th create the second part of the string that you do want to be tappable. I used a blue color just so it can stand out.
        let tappableText = NSMutableAttributedString(string: "READ MORE")
        tappableText.addAttribute(NSAttributedString.Key.font, value: UIFont.systemFont(ofSize: 17), range: NSMakeRange(0, tappableText.length))

        // 6th this ISN'T NECESSARY but this is how you add an underline to the tappable part. I also used a orange color so it can match the tappableText and set the value to 1 for the line height. The length of the underline is based on the tappableText's length using NSMakeRange(0, tappableText.length)
        tappableText.addAttribute(NSAttributedString.Key.underlineStyle, value: 1, range: NSMakeRange(0, tappableText.length))
        tappableText.addAttribute(NSAttributedString.Key.underlineColor, value: UIColor.orange, range: NSMakeRange(0, tappableText.length))

        // 7th this is the important part that connects the tappable link to the delegate method in step 11
        // use NSAttributedString.Key.link and the value "makeMeTappable" to link the NSAttributedString.Key.link to the method. FYI "makeMeTappable" is a name I choose for clarity, you can use anything like "anythingYouCanThinkOf"
        tappableText.addAttribute(NSAttributedString.Key.link, value: "makeMeTappable", range: NSMakeRange(0, tappableText.length))

        yourTextView.linkTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.orange] // change the color of READ MORE

        // 8th *** important append the tappableText to the regularText ***
        regularText.append(tappableText)

        // 9th set the regularText to the textView's attributedText property
        yourTextView.attributedText = regularText

        // *** If your text is only 1 line and you are using a PROGRAMMATIC textView you will need to set the height like so (or whichever method you use). If the textView is in storyboard then set the height there ***
        /*
        let height = yourTextView.intrinsicContentSize.height
        yourTextView.heightAnchor.constraint(equalToConstant: height).isActive = true
        */
   }

   // 10th add the textView's delegate method that activates urls. Make sure to return false for the tappable part
   func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange) -> Bool {
    
        // 11th use the value from the 7th step to trigger the url inside this method
        if URL.absoluteString == "makeMeTappable" {

            // in this situation I'm using the tappableText to present a view controller but it can be used for whatever you're trying to do
            let someVC = SomeController()
            let navVC = UINavigationController(rootViewController: someVC)
            present(navVC, animated: true, completion: nil)

            return false // *** IMPORTANT return false for this to actually work ***
        }

        return true
    }
}

Update: To change the color of the tappable part, which using my example would be READ MORE, follow this answer

Alkali answered 10/9, 2018 at 16:59 Comment(4)
Overriding the shouldInteractWith is such a goddamn beautiful solution, thank you for this. Most every other solution involves determining the physical location of a tap on a piece of text.Garderobe
Good answer. Thanks @LanceSamariaPhira
@Garderobe is so right! Thank you for this great answer.Boneset
fyi, the value of makeMeTappable has to not contain any whitespace otherwise the app will crashInquest
A
20

For multi-line labels you have to set the textStorage font or the incorrect range will be returned

guard let attributedString = self.attributedText else { return }

let mutableAttribString = NSMutableAttributedString(attributedString: attributedString)
mutableAttribString.addAttributes([NSAttributedString.Key.font: myFont], range: NSRange(location: 0, length: attributedString.length))

let textStorage = NSTextStorage(attributedString: mutableAttribString)

There are a lot of answers to this question. However, there are many people complaining that the tap fails for multi-line labels and that is correct for most answers on this page. The incorrect range for the tap is returned because the textStorage doesn't have the correct font.

let textStorage = NSTextStorage(attributedString: label.attributedText!)

You can fix this quickly by adding the correct font to your textStorage instance:

guard let attributedString = self.attributedText else { return -1 }

let mutableAttribString = NSMutableAttributedString(attributedString: attributedString)
mutableAttribString.addAttributes([NSAttributedString.Key.font: myFont], range: NSRange(location: 0, length: attributedString.length))

let textStorage = NSTextStorage(attributedString: mutableAttribString)

Putting it all together you get something like this:

protocol AtMentionsLabelTapDelegate: class {
  func labelWasTappedForUsername(_ username: String)
}

class AtMentionsLabel: UILabel {
  private var tapGesture: UITapGestureRecognizer = UITapGestureRecognizer()
  weak var tapDelegate: AtMentionsLabelTapDelegate?

  var mentions: [String] = [] // usernames to style

  override init(frame: CGRect) {
    super.init(frame: frame)
    commonInit()
  }

  required init?(coder aDecoder: NSCoder) {
    super.init(coder: aDecoder)
    commonInit()
  }

  func commonInit() {
    isUserInteractionEnabled = true

    lineBreakMode = .byWordWrapping
    tapGesture = UITapGestureRecognizer()
    tapGesture.addTarget(self, action: #selector(handleLabelTap(recognizer:)))
    tapGesture.numberOfTapsRequired = 1
    tapGesture.isEnabled = true
    addGestureRecognizer(tapGesture)
  }


  @objc func handleLabelTap(recognizer: UITapGestureRecognizer) {
    let tapLocation = recognizer.location(in: self)
    let tapIndex = indexOfAttributedTextCharacterAtPoint(point: tapLocation)

    for username in mentions {
      if let ranges = self.attributedText?.rangesOf(subString: username) {
        for range in ranges {
          if tapIndex > range.location && tapIndex < range.location + range.length {
            tapDelegate?.labelWasTappedForUsername(username)
            return
          }
        }
      }
    }
  }

  func indexOfAttributedTextCharacterAtPoint(point: CGPoint) -> Int {
    guard let attributedString = self.attributedText else { return -1 }

    let mutableAttribString = NSMutableAttributedString(attributedString: attributedString)
    // Add font so the correct range is returned for multi-line labels
    mutableAttribString.addAttributes([NSAttributedString.Key.font: font], range: NSRange(location: 0, length: attributedString.length))

    let textStorage = NSTextStorage(attributedString: mutableAttribString)

    let layoutManager = NSLayoutManager()
    textStorage.addLayoutManager(layoutManager)

    let textContainer = NSTextContainer(size: frame.size)
    textContainer.lineFragmentPadding = 0
    textContainer.maximumNumberOfLines = numberOfLines
    textContainer.lineBreakMode = lineBreakMode
    layoutManager.addTextContainer(textContainer)

    let index = layoutManager.characterIndex(for: point, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
    return index
  }
}

extension NSAttributedString {
  func rangesOf(subString: String) -> [NSRange] {
    var nsRanges: [NSRange] = []
    let ranges = string.ranges(of: subString, options: .caseInsensitive, locale: nil)

    for range in ranges {
      nsRanges.append(range.nsRange)
    }

    return nsRanges
  }
}

extension String {
  func ranges(of substring: String, options: CompareOptions = [], locale: Locale? = nil) -> [Range<Index>] {
    var ranges: [Range<Index>] = []
    while let range = self.range(of: substring, options: options, range: (ranges.last?.upperBound ?? self.startIndex) ..< self.endIndex, locale: locale) {
      ranges.append(range)
    }
    return ranges
  }
}
Arvind answered 4/2, 2019 at 13:45 Comment(5)
You save my day, dude! The only one correct answer with very important font addition. Thank)Rhynchocephalian
No idea why setting the label's font to the attributedString fixes this, but it does. Thanks a log @DoesData! Saved my dayFleischman
Yes.. really great!! I spend around 3 hours..finally this solution helped me a lot!!Damages
This should be the accepted answer. Thanks, I was stuck on this for days!Liaotung
Yeah, was wondering why "font" was never included in any of the other answers. Felt like the font information was pretty important.Luff
C
9

Swift 3. I've developed an extension:

 extension UILabel {
        ///Find the index of character (in the attributedText) at point
        func indexOfAttributedTextCharacterAtPoint(point: CGPoint) -> Int {
            assert(self.attributedText != nil, "This method is developed for attributed string")
            let textStorage = NSTextStorage(attributedString: self.attributedText!)
            let layoutManager = NSLayoutManager()
            textStorage.addLayoutManager(layoutManager)
            let textContainer = NSTextContainer(size: self.frame.size)
            textContainer.lineFragmentPadding = 0
            textContainer.maximumNumberOfLines = self.numberOfLines
            textContainer.lineBreakMode = self.lineBreakMode
            layoutManager.addTextContainer(textContainer)

            let index = layoutManager.characterIndex(for: point, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
            return index
        } 
    }

And now I can check if the tapped character is in range:

        let range = SOME_RANGE
        let tapLocation = gesture.location(in: MY_TEXT_LABEL)
        let index = textLbl.indexOfAttributedTextCharacterAtPoint(point: tapLocation)

        if index > range.location && index < range.location + range.length {
         //YES, THE TAPPED CHARACTER IS IN RANGE
        }
Cassel answered 24/2, 2017 at 14:34 Comment(3)
Thank you. This is really helpful. In multiline text, at the bottom line isn't tapped when we implement this extension. Besides, We try let textContainer = NSTextContainer(size: CGSize(width: (self.label?.frame.width)!, height: (self.label.frame.height)+100)) but no chance.Divert
Good approach. But it doesn't deal with non-left aligned text (ex. center or right). Looks like there is no way to pass alignment from UILabel to NSTextContainerDierdre
Increasing height of 5 pixel of UILabel is allowing me to detect text in last line.Reitman
R
3

Your text kit stack is faulty. You forgot to add the text container to the layout manager! Therefore there is no text to lay out, and the layout manager cannot report any glyph rect. Therefore that glyph rect is NSRectZero, which is why you can never report a tap within it.

Another problem is that you are calling characterRangeForGlyphRange when you should be calling glyphRangeForCharacterRange, and you don't seem to know how to use the result (in fact, you throw away the result).

Here is working code that shows just the part about using the text stack. I start with a string "Hello to you". I will show how to learn where the rect for "to" is:

let s = "Hello to you"
let ts = NSTextStorage(
    attributedString: NSAttributedString(string:s))
let lm = NSLayoutManager()
ts.addLayoutManager(lm)
let tc = NSTextContainer(size: CGSizeMake(4000,400))
lm.addTextContainer(tc) // ****
tc.lineFragmentPadding = 0
let toRange = (s as NSString).rangeOfString("to")
let gr = lm.glyphRangeForCharacterRange(
    toRange, actualCharacterRange: nil) // ****
let glyphRect = lm.boundingRectForGlyphRange(
    gr, inTextContainer: tc)

The result is {x 30.68 y 0 w 10.008 h 13.8}. Now we can proceed to test whether a tap is in that rect. Go Ye And Do Likewise.

Ribband answered 16/3, 2016 at 20:3 Comment(1)
where is the label?Laszlo
G
1

Agree with the top votest answer.But that answer is suitable for center textAlignment, when your UIlabel's textAlignment is not center, also your UILabel has edgeInset, you need modify some calculation code. My post answer consider the textAlignment and edgetInset.

func didTapAttributedTextInLabel(label: UILabel, inRange targetRange: NSRange, edgeInset: UIEdgeInsets? = nil) -> 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)
        let alignment = label.textAlignment ?? .center
        // Configure textContainer
        textContainer.lineFragmentPadding = 0.0
        textContainer.lineBreakMode = label.lineBreakMode
        textContainer.maximumNumberOfLines = label.numberOfLines
        let labelSize = CGSize(width: label.bounds.width - (edgeInset?.left ?? 0) - (edgeInset?.right ?? 0), height: label.bounds.height - (edgeInset?.top
                ?? 0) - (edgeInset?.bottom ?? 0))
        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)
        var xOffset: CGFloat = 0
        var yOffset: CGFloat = 0
        if alignment == .left {
            xOffset = (edgeInset?.left ?? 0) - textBoundingBox.origin.x
            yOffset = (labelSize.height - textBoundingBox.size.height) * 0.5 - textBoundingBox.origin.y + (edgeInset?.top ?? 0)
        } else if alignment == .right {
            xOffset = label.bounds.width - (edgeInset?.right ?? 0) - labelSize.width - textBoundingBox.origin.x
            yOffset = (labelSize.height - textBoundingBox.size.height) * 0.5 - textBoundingBox.origin.y + (edgeInset?.top ?? 0)
        } else {
            xOffset = (labelSize.width - textBoundingBox.size.width) * 0.5 - textBoundingBox.origin.x + (edgeInset?.left ?? 0)
            yOffset = (labelSize.height - textBoundingBox.size.height) * 0.5 - textBoundingBox.origin.y + (edgeInset?.top ?? 0)
        }
        let textContainerOffset = CGPoint(
            x: xOffset,
            y: yOffset
        )
        let locationOfTouchInTextContainer = CGPoint(
            x: locationOfTouchInLabel.x - textContainerOffset.x,
            y: locationOfTouchInLabel.y - textContainerOffset.y
        )
        let indexOfCharacter = layoutManager.characterIndex(for: locationOfTouchInTextContainer, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)

        return NSLocationInRange(indexOfCharacter, targetRange)
    }

And another situation is need to be consider.If your text is "magina:linkedword",the "magina" part is normal text,the "linkedword" is link.When you set text for UILabel, you need connect text and one space, otherwise when you tap the end of the label, the function 'didTapAttributedTextInLabel' will return true, but it actually beyond the text.

var attrString = NSMutableAttributedString(string: text + " ")//add one space to present 
Guerrilla answered 17/7, 2021 at 5:26 Comment(0)
T
0

I've developed this small iOS library SmartString which you could use to handle Taps on a specific Substring inside a UILabel.

Usage:

// Example 1
let smartString = "hello, " + "tap here".onTap { string in 
    print(string) // -> "tap here"
}
label.smartString = smartString

// Example 2 (style your string to make it looks nice)
let smartString = "hello" 
+ "tap here".font(.systemFont(ofSize: 22))
    .color(.blue)
    .underline()
    .onTap { string in 
        print(string) // -> "tap here"
}
label.smartString = smartString
Teryn answered 10/8, 2022 at 15:32 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.