Как я могу точно определить, щелкнули ли ссылку внутри UILabels в Swift 4?
редактировать
См. Мой ответ для полного рабочего решения:
Мне удалось решить это самостоятельно, используя UITextView
вместо UILabel
. Я написал класс, который заставляет UITextView
вести себя как UILabel
но с полностью точным обнаружением ссылок.
Мне удалось NSMutableAttributedString
ссылки без проблем с помощью 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)
}
Ответы
Ответ 1
Мне удалось решить эту проблему, используя 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<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
}
}
Функция hitTest
несколько раз, но это никогда не вызывает проблемы, так как clickedLink()
будет вызываться только один раз за клик. Я попытался отключить isUserInteractionEnabled
для разных просмотров, но не помог и не нужен.
Чтобы использовать класс, просто добавьте его в свой UITextView
. Если вы используете autoLayout
в редакторе Xcode, отключите Scrolling Enabled
для UITextView
в редакторе, чтобы избежать предупреждений макета.
В файле Swift
который содержит код для вашего xib
файла (в моем случае это класс для UITableViewCell
, вам нужно установить следующие переменные для вашего clickable textView:
-
ranges
- индекс начала и конца каждой UITextView
ссылки с UITextView
-
page
- String
чтобы идентифицировать страницу или представление, содержащее UITextView
-
paragraph
Если у вас есть несколько UITextView
, назначьте каждому номер с номером -
delegate
- делегировать события щелчка туда, где вы можете их обработать.
Затем вам необходимо создать протокол для вашего delegate
:
protocol DelegateName {
func clickedLink(page: String, paragraph: Int?, linkNo: Int?)
}
Переменные, переданные в clickedLink
дают вам всю информацию, необходимую вам, чтобы узнать, какая ссылка была нажата.
Ответ 2
Если вы не возражаете переписывать код, вы должны использовать UITextView
вместо UILabel
.
Вы можете легко обнаружить ссылку, установив UITextView
dataDetectorTypes
и реализовать функцию делегата для извлечения ваших URL-адресов.
func textView(_ textView: UITextView, shouldInteractWith URL: URL,
in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool
https://developer.apple.com/documentation/uikit/uitextviewdelegate/1649337-textview
Ответ 3
Вы можете использовать библиотеку 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.
Ответ 4
Я хотел избежать публикации ответа, поскольку он больше комментирует собственный ответ Дэн Брей (не могу комментировать из-за отсутствия репутации). Тем не менее, я все еще считаю, что это стоит того.
Я сделал некоторые небольшие (что я думаю) улучшения для Дэна Брея для удобства:
- Мне было немного неудобно настраивать textView с диапазонами и т.д., Поэтому я заменил эту часть
textLink
который хранит строки ссылок и их соответствующие цели. Для реализации 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<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
}
}
Делегат:
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"
]
}
И последнее, но не в последнюю очередь большое спасибо Дэну Брэю, который решил это в конце концов!
Ответ 5
Если вам нужен подкласс Label
, решение может быть чем-то вроде подготовленного на игровой площадке (из-за чего некоторые точки должны быть оптимизированы, потому что это всего лишь черновик):
//: 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()
демо: