如何准确检测是否在Swift 4中的UILabels内部点击了链接?
编辑
请参阅我的答案以获得完整的解决方案:
我设法通过使用UITextView
而不是UILabel
来解决这个问题。 我写了一个类,使UITextView
行为像UILabel
但具有完全准确的链接检测。
我已经设法使用NSMutableAttributedString
设置链接没有问题的样式,但我无法准确检测已单击的字符。 我已经尝试了这个问题中的所有解决方案(我可以转换为Swift 4代码),但没有运气。
以下代码有效但无法准确检测单击的字符并获取链接的错误位置:
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) }
如果您不介意重写代码,则应使用UITextView
而不是UILabel
。
您可以通过设置UITextView
的dataDetectorTypes
轻松检测链接,并实现委托function以检索您单击的URL。
func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool
https://developer.apple.com/documentation/uikit/uitextviewdelegate/1649337-textview
我设法通过使用UITextView
而不是UILabel
来解决这个问题。 我最初,不想使用UITextView
因为我需要该元素的行为像UILabel
和UITextView
可能会导致滚动问题,它的目的是使用,是可编辑的文本。 我编写的以下类使UITextView
行为类似于UILabel
但具有完全准确的点击检测并且没有滚动问题:
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, with event: UIEvent?) { startTime = Date().timeIntervalSinceReferenceDate } override func touchesEnded(_ touches: Set , 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 } }
函数hitTest
get被多次调用,但这不会导致问题,因为clickedLink()
每次点击只会被调用一次。 我尝试禁用isUserInteractionEnabled
用于不同的视图,但这没有帮助,并没有必要。
要使用该类,只需将其添加到UITextView
。 如果您在Xcode编辑器中使用autoLayout
,则在编辑器中禁用UITextView
Scrolling Enabled
以避免布局警告。
在包含与xib
文件一起使用的代码的Swift
文件中(在我的例子中是UITableViewCell
的类,您需要为可单击的xib
设置以下变量:
-
ranges
- 使用UITextView
的每个可单击链接的开始和结束索引 -
page
- 用于标识包含UITextView
的页面或视图的String
-
paragraph
- 如果您有多个可点击的UITextView
,请为每个人分配一个数字 -
delegate
- 将点击事件委派给您能够处理它们的地方。
然后,您需要为您的delegate
创建协议:
protocol DelegateName { func clickedLink(page: String, paragraph: Int?, linkNo: Int?) }
传递给clickedLink
的变量为您提供了解单击哪个链接所需的所有信息。
您可以使用MLLabel库。 MLLabel是UIlabel的子类。 该库有一个类MLLinkLabel,它是MLLabel的子类。 这意味着您可以使用它代替UIlabel(即使在界面构建器中,只需拖动UILabel并将其类更改为MLLinkLabel)
MLLinkLabel可以为您提供帮助,非常简单。 这是一个例子:
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 } }
你可以在GitHub上查看这个库https://github.com/molon/MLLabel
这是我使用其中一个MLLabel的应用程序的屏幕截图。
我想避免发布答案,因为它更多是对Dan Bray自己答案的评论(由于缺乏代表而无法发表评论)。 但是,我仍然认为值得分享。
为方便起见,我为Dan Bray的答案做了一些小改进(我认为是这样的改进):
- 我发现使用范围和东西设置
textLink
有点尴尬所以我用textLink
dict替换了那部分,它存储了链接字符串及其各自的目标。 实现viewController只需要设置它来初始化textView。 - 我在链接中添加了下划线样式(保持字体等来自界面构建器)。 您可以在此处添加自己的样式(如蓝色字体颜色等)。
- 我重新设计了回调的签名,使其更容易处理。
- 请注意,我还必须将
delegate
重命名为linkDelegate
因为UITextViews已经有委托。
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, with event: UIEvent?) { startTime = Date().timeIntervalSinceReferenceDate } override func touchesEnded(_ touches: Set , 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 } }
代表:
import Foundation protocol LinkTextViewDelegate { func didTap(text: String, withLink link: String, inTextView textView: LinkTextView) }
实现viewController:
override func viewDidLoad() { super.viewDidLoad() myLinkTextView.linkDelegate = self myLinkTextView.textLinks = [ "click here" : "https://wwww.google.com", "or here" : "#myOwnAppHook" ] }
最后但并非最不重要的是非常感谢Dan Bray,毕竟这是解决方案!
如果你需要Label
的子类,解决方案可能就像在操场上准备的那样(因为这只是一个草案,因此应该对某些点进行优化):
//: A UIKit based Playground for presenting user interface import UIKit import PlaygroundSupport extension String { // MARK: - String+RangeDetection func rangesOfPattern(patternString: String) -> [Range] { var ranges : [Range ] = [] 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..) -> 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? { 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()
演示: