Help us understand the problem. What is going on with this article?

UITextViewの任意の文字列にリンクを埋め込む

More than 3 years have passed since last update.

この記事はVASILY DEVELOPERS BLOGにも同じ内容で投稿しています。よろしければ他の記事も御覧ください。

最近のiQONのアップデートで、コーデのタグ表示のUIを変更しました。
この変更では、ユーザーがテキストで無制限に埋め込んだタグを選択できるようになりました。
例えば「#スニーカー」をタップすると、「スニーカー」タグが付いたコーデが表示されます。

iqon_tag.gif

他のアプリでも見かけるUIなので、簡単にUITextViewで実装できるかと思ってたのですが…
思いの外ハマったので、今回の実装を共有します。
UITextViewのサブクラスを作成して、上記の動きを実現します。

実装

1. UIGestureRecognizerを継承したクラスを作る

iQONのコーデのタグのような使い方だと、リンク文字列が多いためタッチイベントを奪ってしまい、スクロールに失敗することが多くなります。

normal_text_view

NSAttributedStringNSLinkAttributeNameの機能では上記の問題が発生するため、独自のジェスチャー TouchUpDownGestureRecognizer を実装しました。
このGestureRecognizerを次に説明するUITextViewのサブクラスに適用します。

このジェスチャーがUITextViewに対するタッチイベントの、開始、終了、移動、キャンセルの状態を決めます。

1-1. 親Viewのジェスチャーとの衝突を回避する

スクロールを阻害しないようにするには UIGestureRecognizerSubclass の下記のメソッドをオーバーライドします。

// TouchUpDownGestureRecognizer.swift

override func canPreventGestureRecognizer(preventedGestureRecognizer: UIGestureRecognizer) -> Bool {
    if let _ = preventedGestureRecognizer.view as? UIScrollView {
        // UIScrollViewのスクロールイベントを優先させる
        return false
    }
    return true
}

2. UITextViewを継承したクラスを作る

自作したジェスチャを受け取るUITextViewのサブクラスのLinkTextViewを作ります。

2-1. ジェスチャーの設定

LinkTextViewのイニシャライザでジェスチャーを設定します。

// LinkTextView.swift

required init?(coder aDecoder: NSCoder) {
    super.init(coder: aDecoder)

    ...

    let recognizer = TouchUpDownGestureRecognizer(target: self, action: "handleTouchUpDownGesture:")
    addGestureRecognizer(recognizer)
}

func handleTouchUpDownGesture(recognizer: TouchUpDownGestureRecognizer) {
    switch recognizer.state {
    case .Began:
        // テキストをハイライトさせる
        touchDownWithRecognizer(recognizer)
    case .Changed:
        break
    case .Ended:
        // ハイライトしたテキストを戻し、テキストをクロージャに渡す
        touchUpWithRecognizer(recognizer)
    default:
        // ハイライトしたテキストを戻す
        touchCancelWithRecognizer(recognizer)
    }
}

2-2. タップした文字列を判別する

下記のメソッドでタッチした位置のリンク文字列の位置と長さをNSRangeで受け取ります。

// LinkTextView.swift

/**
 選択したリンク文字列のrangeを返す

 - parameter recognizer: ジェスチャーレコグナイザ

 - returns: タップしたリンク文字列のNSRange。リンク出ない場合はnil
 */
private func selectedLinkRangeWithRecognizer(recognizer: TouchUpDownGestureRecognizer) -> NSRange? {
    var location = recognizer.locationInView(self)
    location.y -= textContainerInset.top    // タッチ位置がtextContainerInsetの分だけズレるので調整
    location.x -= textContainerInset.left

    let characterIndex = layoutManager.characterIndexForPoint(  // タッチした位置の文字列の位置
        location,
        inTextContainer: textContainer,
        fractionOfDistanceBetweenInsertionPoints: nil)

    // 指定した位置と属性に該当するリンク文字列のNSRangeをrangeに格納する
    var range = NSMakeRange(0, 0)
    let object = attributedText.attribute(
        LinkTextView.LinkKey,
        atIndex: characterIndex,
        effectiveRange: &range)

    return (object == nil) ? nil : range
}

ハマったこと

3D Touch 対応端末で動かなかった

iPhone 6s を購入して動作を検証すると、リンクが動きませんでした。
Xcode7のiPhoen 6sシミュレータでは再現しないため、かなり焦りましたが、原因は3D Touchの仕組みによるものでした。
UIGestureRecogniezrを継承したクラスでは、タッチの強さ (touch.force) の変化でも touchesMoved:withEvent: が呼ばれてしまうようです。

// TouchUpDownGestureRecognizer.swift

// 修正前
override func touchesMoved(touches: Set<UITouch>, withEvent event: UIEvent) {
    super.touchesMoved(touches, withEvent: event)

    state = .Cancelled
}

// 修正後
override func touchesMoved(touches: Set<UITouch>, withEvent event: UIEvent) {
    super.touchesMoved(touches, withEvent: event)

    if let touch = touches.first {
        let beforePoint = touch.previousLocationInView(view)
        let afterPoint = touch.locationInView(view)
        if !CGPointEqualToPoint(beforePoint, afterPoint) {
            // タッチした指が動いたらキャンセル
            // 3D Touch 対応端末では touch.force の変化でも touchesMoved:withEvent: が呼ばれてしまうので、厳密にtouchの位置の変化を監視する
            state = .Cancelled
        }
    }
}

できあがったもの

こんな感じで動きます。

link_text_view

使い方

LinkTextViewのattributedStringにリンク文字列をNSAttributedStringで渡します。
この時、リンクを識別するために LinkTextView.LinkKey という属性を含めます。
タップ時の処理はクロージャプロパティに設定し、リンク文字列をタップした時のみ、このクロージャが呼び出されます。

let mutableAttributedString = NSMutableAttributedString(string: "hello, world!")
let attributes = [
    NSForegroundColorAttributeName: UIColor.blueColor(),  // リンク文字列の色
    LinkTextView.LinkKey: "linked",                       // リンクと認識するためのカスタムキー
]
mutableAttributedString.addAttributes(attributes, range: NSRange(0, 5))  // helloをリンクにする

let textView = LinkTextView()
textView.attributedText = mutableAttributedString
textView.linkClickedBlock = { (string: String) in
    // リンクがタップされた時の処理
}

まとめ

文章の中の任意の文字列をリンクにする方法を紹介しました。
今回の方法を使えば、UIScrollViewにaddSubviewしてもスクロールイベントを妨害せずにリンクを作る事ができます。

3D Touch端末の問題をクリアしていますが、逆に3D TouchのPeekを実装するためには、別の実装にするのが良いと思います。

細かいところはいくつか省きましたが、そのまま動くサンプルプロジェクトを用意したので、動かしながら確認してみてください。
https://github.com/WorldDownTown/LinkTextView

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away