Edit
See my answer for a full working solution:
I managed to solve this myself by using a UITextView
instead of a UILabel
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.
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 dataDetectorTypes
and implement the delegate function to retrieve your clicked urls.
func textView(_ textView: UITextView, shouldInteractWith URL: URL,
in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool
https://developer.apple.com/documentation/uikit/uitextviewdelegate/1649337-textview
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:
textLink
dict which stores the
link strings and their respective targets. The implementing viewController only needs to set this to initialize the textView.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!
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 numberdelegate
- 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.
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)
}
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: