How can I accurately detect if a link is clicked inside UILabels in Swift 4?
Asked Answered
M

6

18

I have managed to style the links without problem using NSMutableAttributedString but I am unable to accurately detect which character has been clicked. I have tried all the solutions in this question (that I could convert to Swift 4 code) but with no luck.

The following code works but fails to accurately detect which character has been clicked and gets the wrong location of the link:

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)
    print(indexOfCharacter)
    return NSLocationInRange(indexOfCharacter, targetRange)
}
Mcgraw answered 24/5, 2018 at 9:8 Comment(0)
M
9

I managed to solve this by using a UITextView instead of a UILabel. I originally, didn't want to use a UITextView because I need the element to behave like a UILabel and a UITextView can cause issues with scrolling and it's intended use, is to be editable text. The following class I wrote makes a UITextView behave like a UILabel but with fully accurate click detection and no scrolling issues:

import UIKit

class ClickableLabelTextView: UITextView {
    var delegate: DelegateForClickEvent?
    var ranges:[(start: Int, end: Int)] = []
    var page: String = ""
    var paragraph: Int?
    var clickedLink: (() -> Void)?
    var pressedTime: Int?
    var startTime: TimeInterval?

    override func awakeFromNib() {
        super.awakeFromNib()
        self.textContainerInset = UIEdgeInsets.zero
        self.textContainer.lineFragmentPadding = 0
        self.delaysContentTouches = true
        self.isEditable = false
        self.isUserInteractionEnabled = true
        self.isSelectable = false
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        startTime = Date().timeIntervalSinceReferenceDate
    }

    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        if let clickedLink = clickedLink {
            if let startTime = startTime {
                self.startTime = nil
                if (Date().timeIntervalSinceReferenceDate - startTime <= 0.2) {
                    clickedLink()
                }
            }
        }
    }

    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        var location = point
        location.x -= self.textContainerInset.left
        location.y -= self.textContainerInset.top
        if location.x > 0 && location.y > 0 {
            let index = self.layoutManager.characterIndex(for: location, in: self.textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
            var count = 0
            for range in ranges {
                if index >= range.start && index < range.end {
                    clickedLink = {
                        self.delegate?.clickedLink(page: self.page, paragraph: self.paragraph, linkNo: count)
                    }
                    return self
                }
                count += 1
            }
        }
        clickedLink = nil
        return nil
    }
}

The function hitTest get's called multiple times but that never causes a problem, as clickedLink() will only ever get called once per click. I tried disabling isUserInteractionEnabled for different views but didn't that didn't help and was unnecessary.

To use the class, simply add it to your UITextView. If you're using autoLayout in the Xcode editor, then disable Scrolling Enabled for the UITextView in the editor to avoid layout warnings.

In the Swift file that contains the code to go with your xib file (in my case a class for a UITableViewCell, you need to set the following variables for your clickable textView:

  • ranges - the start and end index of every clickable link with the UITextView
  • page - a String to identify the page or view that contains the the UITextView
  • paragraph - If you have multiple clickable UITextView, assign each one with an number
  • delegate - to delegate the click events to where ever you are able to process them.

You then need to create a protocol for your delegate:

protocol DelegateName {
    func clickedLink(page: String, paragraph: Int?, linkNo: Int?)
}

The variables passed into clickedLink give you all the information you need to know which link has been clicked.

Mcgraw answered 31/5, 2018 at 13:59 Comment(0)
P
15

If you don't mind rewriting you code, you should use UITextView instead of UILabel.

You can easily detect the link by setting UITextView's dataDetectorTypesand implement the delegate function to retrieve your clicked urls.

func textView(_ textView: UITextView, shouldInteractWith URL: URL, 
    in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool

Example

As it's hard to maintain and/or update two posts, see:
https://mcmap.net/q/99746/-create-tap-able-quot-links-quot-in-the-nsattributedstring-of-a-uilabel

See also official docs: https://developer.apple.com/documentation/uikit/uitextviewdelegate/1649337-textview

Paginal answered 27/5, 2018 at 7:17 Comment(3)
I was considering using a UITextView instead of a UILabel but a colleague advised against it because it could cause issues, as I am placing the labels inside a cell of a UITableView inside a UIViewController.Mcgraw
Your team might have reasons that I may not know then. Although I think this is the easiest way to achieve the result.Paginal
It's just me that's making the app, however, I've only started learning IOS development less than 1 month ago. When I am back at work, I will have a go with UITextView and find out what problems it causes, if any, unless of course I am able to fix it with UILabel.Mcgraw
M
9

I managed to solve this by using a UITextView instead of a UILabel. I originally, didn't want to use a UITextView because I need the element to behave like a UILabel and a UITextView can cause issues with scrolling and it's intended use, is to be editable text. The following class I wrote makes a UITextView behave like a UILabel but with fully accurate click detection and no scrolling issues:

import UIKit

class ClickableLabelTextView: UITextView {
    var delegate: DelegateForClickEvent?
    var ranges:[(start: Int, end: Int)] = []
    var page: String = ""
    var paragraph: Int?
    var clickedLink: (() -> Void)?
    var pressedTime: Int?
    var startTime: TimeInterval?

    override func awakeFromNib() {
        super.awakeFromNib()
        self.textContainerInset = UIEdgeInsets.zero
        self.textContainer.lineFragmentPadding = 0
        self.delaysContentTouches = true
        self.isEditable = false
        self.isUserInteractionEnabled = true
        self.isSelectable = false
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        startTime = Date().timeIntervalSinceReferenceDate
    }

    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        if let clickedLink = clickedLink {
            if let startTime = startTime {
                self.startTime = nil
                if (Date().timeIntervalSinceReferenceDate - startTime <= 0.2) {
                    clickedLink()
                }
            }
        }
    }

    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        var location = point
        location.x -= self.textContainerInset.left
        location.y -= self.textContainerInset.top
        if location.x > 0 && location.y > 0 {
            let index = self.layoutManager.characterIndex(for: location, in: self.textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
            var count = 0
            for range in ranges {
                if index >= range.start && index < range.end {
                    clickedLink = {
                        self.delegate?.clickedLink(page: self.page, paragraph: self.paragraph, linkNo: count)
                    }
                    return self
                }
                count += 1
            }
        }
        clickedLink = nil
        return nil
    }
}

The function hitTest get's called multiple times but that never causes a problem, as clickedLink() will only ever get called once per click. I tried disabling isUserInteractionEnabled for different views but didn't that didn't help and was unnecessary.

To use the class, simply add it to your UITextView. If you're using autoLayout in the Xcode editor, then disable Scrolling Enabled for the UITextView in the editor to avoid layout warnings.

In the Swift file that contains the code to go with your xib file (in my case a class for a UITableViewCell, you need to set the following variables for your clickable textView:

  • ranges - the start and end index of every clickable link with the UITextView
  • page - a String to identify the page or view that contains the the UITextView
  • paragraph - If you have multiple clickable UITextView, assign each one with an number
  • delegate - to delegate the click events to where ever you are able to process them.

You then need to create a protocol for your delegate:

protocol DelegateName {
    func clickedLink(page: String, paragraph: Int?, linkNo: Int?)
}

The variables passed into clickedLink give you all the information you need to know which link has been clicked.

Mcgraw answered 31/5, 2018 at 13:59 Comment(0)
V
5

I wanted to avoid posting an answer since it's more a comment on Dan Bray's own answer (can't comment due to lack of rep). However, I still think it's worth sharing.


I made some small (what I think are) improvements to Dan Bray's answer for convenience:

  • I found it a bit awkward to setup the textView with the ranges and stuff so I replaced that part with a textLink dict which stores the link strings and their respective targets. The implementing viewController only needs to set this to initialize the textView.
  • I added the underline style to the links (keeping the font etc. from interface builder). Feel free to add your own styles here (like blue font color etc.).
  • I reworked the callback's signature to make it more easy to be processed.
  • Note that I also had to rename the delegate to linkDelegate since UITextViews do have a delegate already.

The TextView:

import UIKit

class LinkTextView: UITextView {
  private var callback: (() -> Void)?
  private var pressedTime: Int?
  private var startTime: TimeInterval?
  private var initialized = false
  var linkDelegate: LinkTextViewDelegate?
  var textLinks: [String : String] = Dictionary() {
    didSet {
        initialized = false
        styleTextLinks()
    }
  }

  override func awakeFromNib() {
    super.awakeFromNib()
    self.textContainerInset = UIEdgeInsets.zero
    self.textContainer.lineFragmentPadding = 0
    self.delaysContentTouches = true
    self.isEditable = false
    self.isUserInteractionEnabled = true
    self.isSelectable = false
    styleTextLinks()
  }

  private func styleTextLinks() {
    guard !initialized && !textLinks.isEmpty else {
        return
    }
    initialized = true

    let alignmentStyle = NSMutableParagraphStyle()
    alignmentStyle.alignment = self.textAlignment        

    let input = self.text ?? ""
    let attributes: [NSAttributedStringKey : Any] = [
        NSAttributedStringKey.foregroundColor : self.textColor!,
        NSAttributedStringKey.font : self.font!,
        .paragraphStyle : alignmentStyle
    ]
    let attributedString = NSMutableAttributedString(string: input, attributes: attributes)

    for textLink in textLinks {
        let range = (input as NSString).range(of: textLink.0)
        if range.lowerBound != NSNotFound {
            attributedString.addAttribute(.underlineStyle, value: NSUnderlineStyle.styleSingle.rawValue, range: range)
        }
    }

    attributedText = attributedString
  }

  override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    startTime = Date().timeIntervalSinceReferenceDate
  }

  override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
    if let callback = callback {
        if let startTime = startTime {
            self.startTime = nil
            if (Date().timeIntervalSinceReferenceDate - startTime <= 0.2) {
                callback()
            }
        }
    }
  }

  override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
    var location = point
    location.x -= self.textContainerInset.left
    location.y -= self.textContainerInset.top
    if location.x > 0 && location.y > 0 {
        let index = self.layoutManager.characterIndex(for: location, in: self.textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
        for textLink in textLinks {
            let range = ((text ?? "") as NSString).range(of: textLink.0)
            if NSLocationInRange(index, range) {
                callback = {
                    self.linkDelegate?.didTap(text: textLink.0, withLink: textLink.1, inTextView: self)
                }
                return self
            }
        }
    }
    callback = nil
    return nil
  }
}

The delegate:

import Foundation

protocol LinkTextViewDelegate {
  func didTap(text: String, withLink link: String, inTextView textView: LinkTextView)
}

The implementing viewController:

override func viewDidLoad() {
  super.viewDidLoad()
  myLinkTextView.linkDelegate = self
  myLinkTextView.textLinks = [
    "click here" : "https://wwww.google.com",
    "or here" : "#myOwnAppHook"
  ]
}

And last but not least a big thank you to Dan Bray, who's solution this is after all!

Valence answered 15/6, 2018 at 10:19 Comment(2)
Using a Dictionary is a slight improvement, as it avoids the risk of multiple instances of the same range being created. As for styling with NSMutableAttributedString, I already do that but I chose to keep the code out of the class and use it just for detecting the links because not every link will necessarily be styled the same way.Mcgraw
Great solution. Just a word of warning, if the UITextView isn't contained within a UIView, then you risk triggering the delegate callback whenever you click somewhere on the enclosing superview at a location wherever the x and y are greater than the origin of the text view.Chiromancy
E
3

You can use MLLabel library. MLLabel is a subclass of UIlabel. The library has a class MLLinkLabel that is subclass of MLLabel. That means you can use it in place of UIlabel (even in interface builder just drag a UILabel and change it's class to MLLinkLabel)

MLLinkLabel can do the trick for you and it is very easy. Here is an example:

    label.didClickLinkBlock = {(link, linkText, label) -> Void in

        //Here you can check the type of the link and do whatever you want.
        switch link!.linkType {
        case .email:
            break
        case .none:
             break
        case .URL:
             break
        case .phoneNumber:
             break
        case .userHandle:
             break
        case .hashtag:
             break
        case .other:
             break
        }

    }

you can check the library in GitHub https://github.com/molon/MLLabel

Here is a screenshot from one of my apps that I used MLLabel in it.

enter image description here

Exteroceptor answered 29/5, 2018 at 8:3 Comment(0)
C
1

If you need a subclass of Label, solution may be something like one prepared in a playground (of cause some points should be optimized because this is just a draft):

//: A UIKit based Playground for presenting user interface

import UIKit
import PlaygroundSupport

extension String {
    // MARK: - String+RangeDetection

    func rangesOfPattern(patternString: String) -> [Range<Index>] {
        var ranges : [Range<Index>] = []

        let patternCharactersCount = patternString.count
        let strCharactersCount = self.count
        if  strCharactersCount >= patternCharactersCount {

            for i in 0...(strCharactersCount - patternCharactersCount) {
                let from:Index = self.index(self.startIndex, offsetBy:i)
                if let to:Index = self.index(from, offsetBy:patternCharactersCount, limitedBy: self.endIndex) {

                    if patternString == self[from..<to] {
                        ranges.append(from..<to)
                    }
                }
            }
        }

        return ranges
    }

    func nsRange(from range: Range<String.Index>) -> NSRange? {
        let utf16view = self.utf16
        if let from = range.lowerBound.samePosition(in: utf16view),
            let to = range.upperBound.samePosition(in: utf16view) {
            return NSMakeRange(utf16view.distance(from: utf16view.startIndex, to: from),
                               utf16view.distance(from: from, to: to))
        }
        return nil
    }

    func range(from nsRange: NSRange) -> Range<String.Index>? {
        guard
            let from16 = utf16.index(utf16.startIndex, offsetBy: nsRange.location, limitedBy: utf16.endIndex),
            let to16 = utf16.index(from16, offsetBy: nsRange.length, limitedBy: utf16.endIndex),
            let from = String.Index(from16, within: self),
            let to = String.Index(to16, within: self)
            else { return nil }
        return from ..< to
    }
}

final class TappableLabel: UILabel {

    private struct Const {
        static let DetectableAttributeName = "DetectableAttributeName"
    }

    var detectableText: String?
    var displayableContentText: String?

    var mainTextAttributes:[NSAttributedStringKey : AnyObject] = [:]
    var tappableTextAttributes:[NSAttributedStringKey : AnyObject] = [:]

    var didDetectTapOnText:((_:String, NSRange) -> ())?

    private var tapGesture:UITapGestureRecognizer?

    // MARK: - Public

    func performPreparation() {
        DispatchQueue.main.async {
            self.prepareDetection()
        }
    }

    // MARK: - Private

    private func prepareDetection() {

        guard let searchableString = self.displayableContentText else { return }
        let attributtedString = NSMutableAttributedString(string: searchableString, attributes: mainTextAttributes)

        if let detectionText = detectableText {

            var attributesForDetection:[NSAttributedStringKey : AnyObject] = [
                NSAttributedStringKey(rawValue: Const.DetectableAttributeName) : "UserAction" as AnyObject
            ]
            tappableTextAttributes.forEach {
                attributesForDetection.updateValue($1, forKey: $0)
            }

            for (_ ,range) in searchableString.rangesOfPattern(patternString: detectionText).enumerated() {
                let tappableRange = searchableString.nsRange(from: range)
                attributtedString.addAttributes(attributesForDetection, range: tappableRange!)
            }

            if self.tapGesture == nil {
                setupTouch()
            }
        }

        text = nil
        attributedText = attributtedString
    }

    private func setupTouch() {
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(TappableLabel.detectTouch(_:)))
        addGestureRecognizer(tapGesture)
        self.tapGesture = tapGesture
    }

    @objc private func detectTouch(_ gesture: UITapGestureRecognizer) {
        guard let attributedText = attributedText, gesture.state == .ended else {
            return
        }

        let textContainer = NSTextContainer(size: bounds.size)
        textContainer.lineFragmentPadding = 0.0
        textContainer.lineBreakMode = lineBreakMode
        textContainer.maximumNumberOfLines = numberOfLines

        let layoutManager = NSLayoutManager()
        layoutManager.addTextContainer(textContainer)

        let textStorage = NSTextStorage(attributedString: attributedText)
        textStorage.addAttribute(NSAttributedStringKey.font, value: font, range: NSMakeRange(0, attributedText.length))
        textStorage.addLayoutManager(layoutManager)

        let locationOfTouchInLabel = gesture.location(in: gesture.view)

        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)

        let characterIndex = layoutManager.characterIndex(for: locationOfTouchInTextContainer, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)

        if characterIndex < textStorage.length {
            let tapRange = NSRange(location: characterIndex, length: 1)
            let substring = (self.attributedText?.string as? NSString)?.substring(with: tapRange)

            let attributeName = Const.DetectableAttributeName
            let attributeValue = self.attributedText?.attribute(NSAttributedStringKey(rawValue: attributeName), at: characterIndex, effectiveRange: nil) as? String
            if let _ = attributeValue,
                let substring = substring {
                DispatchQueue.main.async {
                    self.didDetectTapOnText?(substring, tapRange)
                }
            }
        }

    }
}


class MyViewController : UIViewController {
    override func loadView() {
        let view = UIView()
        view.backgroundColor = .white

        let label = TappableLabel()
        label.frame = CGRect(x: 150, y: 200, width: 200, height: 20)
        label.displayableContentText = "Hello World! stackoverflow"
        label.textColor = .black
        label.isUserInteractionEnabled = true

        label.detectableText = "World!"
        label.didDetectTapOnText = { (value1, value2) in
            print("\(value1) - \(value2)\n")
        }
        label.performPreparation()

        view.addSubview(label)
        self.view = view
    }
}
// Present the view controller in the Live View window
PlaygroundPage.current.liveView = MyViewController()

demo:

enter image description here

Canaday answered 2/6, 2018 at 12:41 Comment(0)
H
0

For Dan Bray's solution above to work for me I had to call super.hitTest(point, with:event) instead of returning nil. Otherwise touchesBegan and touchesEnded were not invoked. I use the textViews inside a UIScrollView.

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
    var location = point
    location.x -= self.textContainerInset.left
    location.y -= self.textContainerInset.top
    if location.x > 0 && location.y > 0 {


    let index = self.layoutManager.characterIndex(for: location, in: self.textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
        for textLink in textLinks {
            let range = ((text ?? "") as NSString).range(of: textLink.0)
            if NSLocationInRange(index, range) {
                callback = {
                    self.linkDelegate?.didTap(text: textLink.0, withLink: textLink.1, inTextView: self)
                }
                return self
            }
        }
    }
    callback = nil
    return super.hitTest(point, with:event)
}
Havelock answered 27/10, 2020 at 21:1 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.