web-dev-qa-db-ja.com

Swift 4のUILabels内でリンクがクリックされたかどうかを正確に検出するにはどうすればよいですか?

編集

完全に機能するソリューションについては、私の回答を参照してください。

UITextViewの代わりにUILabelを使用して、これを自分で解決することができました。 UITextViewUILabelのように動作させるが、完全に正確なリンク検出を行うクラスを作成しました。


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)
}
13
Dan Bray

UITextViewの代わりにUILabelを使用してこれを解決することができました。私はもともと、要素をUITextViewのように動作させる必要があり、UILabelはスクロールで問題を引き起こす可能性があるため、UITextViewを使用したくありませんでした。編集可能なテキストになります。私が書いた次のクラスは、UITextViewUILabelのように動作させますが、完全に正確なクリック検出を備え、スクロールの問題はありません。

_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 getは複数回呼び出されますが、clickedLink()はクリックごとに1回しか呼び出されないため、問題が発生することはありません。さまざまなビューでisUserInteractionEnabledを無効にしようとしましたが、それは役に立たず、不要でした。

クラスを使用するには、クラスをUITextViewに追加するだけです。 XcodeエディターでautoLayoutを使用している場合は、レイアウトの警告を回避するために、エディターのUITextViewに対して_Scrolling Enabled_を無効にします。

Swiftファイルに対応するコードを含むxibファイル(私の場合はUITableViewCellのクラス)で、クリック可能なtextViewに次の変数を設定する必要があります。 :

  • ranges-UITextViewを持つすべてのクリック可能なリンクの開始インデックスと終了インデックス
  • page-Stringを含むページまたはビューを識別するためのUITextView
  • paragraph-クリック可能なUITextViewが複数ある場合は、それぞれに番号を割り当てます
  • delegate-クリックイベントを処理できる場所に委任します。

次に、delegateのプロトコルを作成する必要があります。

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

clickedLinkに渡される変数は、どのリンクがクリックされたかを知るために必要なすべての情報を提供します。

5
Dan Bray

コードを書き直してもかまわない場合は、UITextViewの代わりにUILabelを使用する必要があります。

UITextViewdataDetectorTypesを設定し、クリックしたURLを取得するためのデリゲート関数を実装することで、リンクを簡単に検出できます。

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

https://developer.Apple.com/documentation/uikit/uitextviewdelegate/1649337-textview

10
user9749232

MLLabelライブラリを使用できます。 MLLabelはUIlabelのサブクラスです。ライブラリには、MLLabelのサブクラスであるクラスMLLinkLabelがあります。つまり、UIlabelの代わりに使用できます(Interface Builderでも、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を使用したアプリのスクリーンショットです。

enter image description here

3
Developer84ios

ダンブレイ自身の回答に対するコメントであるため、回答の投稿は避けたかった(担当者が不足しているためコメントできない)。しかし、それでも共有する価値があると思います。


便宜上、Dan Brayの回答にいくつかの小さな(私が思うに)改善を加えました。

  • TextViewを範囲などで設定するのは少し厄介だと思ったので、その部分を、リンク文字列とそれぞれのターゲットを格納するtextLinkdictに置き換えました。実装するviewControllerは、textViewを初期化するためにこれを設定するだけで済みます。
  • リンクに下線スタイルを追加しました(フォントなどをInterface Builderから除外します)。ここに独自のスタイル(青いフォントの色など)を自由に追加してください。
  • コールバックの署名を作り直して、処理しやすくしました。
  • UITextViewsにはすでにデリゲートがあるため、delegateの名前をlinkDelegateに変更する必要があることに注意してください。

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"
  ]
}

そして最後になりましたが、これが結局のところ解決策であるダン・ブレイに大いに感謝します!

3
Pvt. Joker

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()

デモ:

enter image description here

0
gbk