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

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

iqon_tag.gif

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

実装 

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

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

normal_text_view

NSAttributedString の NSLinkAttributeName の機能では上記の問題が発生するため、独自のジェスチャー (TouchUpDownGestureRecognizer) を実装しました。 このGestureRecognizerを次に説明するUITextViewのサブクラスに適用します。 このジェスチャーがUITextViewに対するタッチイベントの、開始、終了、移動、キャンセルの状態を決めます。

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

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

// TouchUpDownGestureRecognizer.swift override
func canPreventGestureRecognizer(preventedGestureRecognizer: UIGestureRecognizer) -> Bool {
    if let _ = preventedGestureRecognizer.view as? UIScrollView { return false }  // UIScrollViewのスクロールイベントを優先させる
    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? {
        // タッチ位置がtextContainerInsetの分だけズレるので調整
        var location = recognizer.locationInView(self) location.y -= textContainerInset.top
        // タッチした位置の文字列の位置
        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)
            // タッチした指が動いたらキャンセル // 3D Touch 対応端末では touch.force の変化でも touchesMoved:withEvent: が呼ばれてしまうので、厳密にtouchの位置の変化を監視する
            if !CGPointEqualToPoint(beforePoint, afterPoint) {
                state = .Cancelled
            }
        }

できあがったもの

こんな感じで動きます。 link_text_view

使い方

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

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