【iOS】UITextViewに貼り付けた画像をタップでプレビューする

UITextViewにはAttributedTextが使えるため、画像やEmojiを含んだウェブページ的な表現が出来ます。
「これで画像タップ時にプレビューとかもできたらな…」と思ったことはないですか？
僕はありました。

というわけで、以下のようにタップされた画像を開く方法を紹介します。
デフォルトではそのまんまの機能はなかったので、それを実現するコードと使い方の説明になります。
![imageview.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272677/32a1319c-7f38-0c97-e6db-bd6acbb43834.gif)
※ タップ判定が概ね正しいことを示すため、TextViewをSelectableにしています

# コードと使い方
このコードは、大半を以下の質問を参考にさせていただきました。
https://stackoverflow.com/questions/48498366/detect-tap-on-images-attached-in-nsattributedstring-while-uitextview-editing-is

```swift
import UIKit

class MemoViewController: UIViewController {
    @IBOutlet weak var textArea: UITextView!

    override func viewDidLoad() {
        super.viewDidLoad()
        // [1] UITextViewにタップ判定を追加
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(textAreaTapped(sender:)))
        textArea.addGestureRecognizer(tapGesture)
    }

    @objc func textAreaTapped(sender: UITapGestureRecognizer) {
        guard case let senderView = sender.view, (senderView is UITextView) else { return }

        // [2] UITextView、LayoutManager、Locationを取得
        let textView = senderView as! UITextView
        let layoutManager = textView.layoutManager
        var location = sender.location(in: textView)
        // [3] LocationがInset分ずれるようなので訂正
        location.x -= textView.textContainerInset.left
        location.y -= textView.textContainerInset.top

        // [4] LayoutManagerを使ってタップした場所にあるAttributedTextのIndexを取得
        let textContainer = textView.textContainer
        let characterIndex = layoutManager.characterIndex(for: location, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
        let textStorage = textView.textStorage
        guard characterIndex < textStorage.length else { return }

        // [5] タップ位置が対象AttributedText表示領域範囲内か検証。端にある場合など、判定領域が表示領域より大きいことがあるため
        let range = NSMakeRange(characterIndex, 1)
        let attributeBounds = layoutManager.boundingRect(forGlyphRange: range, in: textContainer)
        if attributeBounds.minX < location.x,
           attributeBounds.maxX > location.x,
           attributeBounds.minY < location.y,
           attributeBounds.maxY > location.y,
        // [6] 対象のAttributedTextのパーツを取り出し、画像ならプレビュー用ViewControllerに渡す
           let image = textView.getPartsOfRange(range).first as? UIImage {
            let viewController = ImagePreviewController(image: image) // ※プレビュー用VCは別途用意して下さい
            present(viewController, animated: true, completion: nil)
        }
    }
}

extension UITextView {
    func getPartsOfRange(_ range: NSRange) -> [AnyObject] {
        guard self.attributedText != nil else { return [] }
        var parts = [AnyObject]()

        let attributedString = self.attributedText
        attributedString?.enumerateAttributes(in: range, options: NSAttributedString.EnumerationOptions(rawValue: 0)) { (object, range, stop) in
            if object.keys.contains(NSAttributedString.Key.attachment) {
                if let attachment = object[NSAttributedString.Key.attachment] as? NSTextAttachment {
                    if let image = attachment.image {
                        parts.append(image)
                    } else if let image = attachment.image(forBounds: attachment.bounds, textContainer: nil, characterIndex: range.location) {
                        parts.append(image)
                    }
                }
            } else {
                let stringValue : String = attributedString!.attributedSubstring(from: range).string
                if (!stringValue.trimmingCharacters(in: .whitespaces).isEmpty) {
                    parts.append(stringValue as AnyObject)
                }
            }
        }
        return parts
    }
}
```

基本の使い方は以下の通りです。
1. タッププレビューを付けたいUITextViewに対して`UITapGestureRecognizer`を追加し、`textAreaTapped`を呼ぶようにする
2. UITextViewにextension等で`getPartsOfRange`を追加し、Rangeを指定してパーツを吐けるようにする
3. imageがとれたら、ViewControllerに渡してプレビュー。

※ このコードには画像閲覧用ViewControllerは付属しません

この例ではプレビューする画像はタップした1枚だけにしましたが、少し書き直して全ての画像を取得すればめくれるプレビューの実装も簡単と思います。応用してみてください。