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


> _この記事は[VASILY DEVELOPERS BLOG](http://tech.vasily.jp/ios_linktextview/)にも同じ内容で投稿しています。よろしければ他の記事も御覧ください。_

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

![iqon_tag.gif](https://media.giphy.com/media/xTiTni0yPXEC0QqJ5C/giphy.gif)

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

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

![normal_text_view](https://media.giphy.com/media/xTiTny9mpsG7fMYriU/giphy.gif)


`NSAttributedString`の`NSLinkAttributeName`の機能では上記の問題が発生するため、独自のジェスチャー [TouchUpDownGestureRecognizer](https://github.com/WorldDownTown/LinkTextView/blob/master/LinkTextView/TouchUpDownGestureRecognizer.swift) を実装しました。
このGestureRecognizerを次に説明する`UITextView`のサブクラスに適用します。

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

### 1-1. 親Viewのジェスチャーとの衝突を回避する
スクロールを阻害しないようにするには `UIGestureRecognizerSubclass` の下記のメソッドをオーバーライドします。

```swift
// 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のイニシャライザでジェスチャーを設定します。

```swift
// 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で受け取ります。

```swift
// 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:` が呼ばれてしまうようです。

```swift
// 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](https://media.giphy.com/media/3o85xxdmTz3hbmkRkQ/giphy.gif)

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

```swift
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