LoginSignup
28
27

More than 5 years have passed since last update.

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

Posted at

この記事は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

28
27
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
28
27