UILabelで任意の文字列タップを検出する

# はじめに

特定の文字列に触れたことを検知したい時や色・フォントを変更したい時のメモです。今回はUILabelのカスタムクラスを作成する感じで実装しました。

# TL;DR

```PartiallyLinkLabel.swift
protocol PartiallyLinkLabelProtocol: class {
    func partiallyLinkLabelLinkTextDidTap()
}

class PartiallyLinkLabel: UILabel {
    private var partTextRang: NSRange?
    private weak var viewProtocol: PartiallyLinkLabelProtocol?

    @objc private func textDidTap(_ gesture: UITapGestureRecognizer) {
        if attributedTextDidTap(gesture) {
            viewProtocol?.partiallyLinkLabelLinkTextDidTap()
        }
    }

    func set(_ viewProtocol: PartiallyLinkLabelProtocol, defaultColor: UIColor, partColor: UIColor, text: String, partText: String) {
        self.isUserInteractionEnabled = true
        self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.textDidTap(_:))))
        self.viewProtocol = viewProtocol
        let attrText = NSMutableAttributedString()
        let partRang = NSString(string: text).range(of: partText)
        let aboveText = text.prefix(partRang.location)
        let belowText = text.suffix(text.length - (partRang.location + partRang.length))
        let aboveAttributeString = NSAttributedString(string: String(aboveText), attributes: [.foregroundColor: defaultColor])
        let belowAttributeString = NSAttributedString(string: String(belowText), attributes: [.foregroundColor: defaultColor])
        let partAttributeString = NSAttributedString(string: partText, attributes: [.foregroundColor: partColor])
        // defaultの.foregroundColorは.darkGrayにしたいのでそれぞれ分割して追加
        attrText.append(aboveAttributeString)
        attrText.append(partAttributeString)
        attrText.append(belowAttributeString)
        self.partTextRang = partRang
        self.attributedText = attrText
    }

    private func attributedTextDidTap(_ gesture: UITapGestureRecognizer) -> Bool {
        guard let partRang = partTextRang, let attrText = self.attributedText else {
            return false
        }
        let layoutManager = NSLayoutManager()
        let textContainer = NSTextContainer(size: CGSize.zero)
        let textStorage = NSTextStorage(attributedString: attrText)
        let labelSize = self.bounds.size

        layoutManager.addTextContainer(textContainer)
        textStorage.addLayoutManager(layoutManager)

        textContainer.lineFragmentPadding = 0.0
        textContainer.lineBreakMode = self.lineBreakMode
        textContainer.maximumNumberOfLines = self.numberOfLines
        textContainer.size = labelSize

        let locationOfTouchInLabel = gesture.location(in: self)
        let textBoundingBox = layoutManager.usedRect(for: textContainer)
        let textContainerOffset = CGPoint(x: (labelSize.width - textBoundingBox.size.width) * 0.5, y: (labelSize.height - textBoundingBox.size.height) * 0.5)
        // NSLayoutManagerからcharacterIndexを取得する際にはtextが表示されているboundsが使用されるため、offset分マイナスして調整する
        let locationOfTouchInTextContainer = CGPoint(x: locationOfTouchInLabel.x - textContainerOffset.x, y: locationOfTouchInLabel.y - textContainerOffset.y)
        let indexOfCharacter = layoutManager.characterIndex(for: locationOfTouchInTextContainer, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)

        return NSLocationInRange(indexOfCharacter, partRang)
    }
}
```

## Usage

```ViewController.swift
class ViewController: UIViewController {

    @IBOutlet private weak var termsOfServiceLabel: PartiallyLinkLabel!

    override func viewDidLoad() {
        super.viewDidLoad()
        termsOfServiceLabel.set(self, defaultColor: .darkGray, partColor: .red, text: "決済をしたと同時に利用規約に同意したとみなします", partJaText: "利用規約")
    }
}

extension ViewController: PartiallyLinkLabelProtocol {
    func partiallyLinkLabelLinkTextDidTap() {
        // TODO: `利用規約` tapped event.
    }
}
```

## 結果

上手く表示されました。`利用規約`をタップすることでProtocolMethodが呼び出され任意の処理を実行することができます。
<img width="355" alt="スクリーンショット 2020-02-18 22.28.55.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/292456/ad4db699-f025-d00a-9a6c-e60a23847205.png">

# 注意点

なぜわざわざTouchを検出した`CGPoint`からtextContainerのOffSet分値を小さくすると上手くいくのか疑問だったが、コメントに残した通り`NSLayoutManager`からcharacterIndexを取得する際にはtextが表示されているbounds(余白を除く幅)が使用されるため、offset分マイナスして調整することでタップ位置が正しく検出することができる感じだった。

```PartiallyLinkLabel.swift
   let locationOfTouchInLabel = gesture.location(in: self)
   let textBoundingBox = layoutManager.usedRect(for: textContainer)
   let textContainerOffset = CGPoint(x: (labelSize.width -    textBoundingBox.size.width) * 0.5, y: (labelSize.height -    textBoundingBox.size.height) * 0.5)
   // NSLayoutManagerからcharacterIndexを取得する際にはtextが表示されてい   るboundsが使用されるため、offset分マイナスして調整する
   let locationOfTouchInTextContainer = CGPoint(x:    locationOfTouchInLabel.x - textContainerOffset.x, y:    locationOfTouchInLabel.y - textContainerOffset.y)
   let indexOfCharacter = layoutManager.characterIndex(for:    locationOfTouchInTextContainer, in: textContainer,    fractionOfDistanceBetweenInsertionPoints: nil)
```