📝

UITextView のテキスト装飾・タップイベントの付け方

2021/06/02に公開

UITextView のテキストの装飾について

UITextView を使ったデザインとして、例えば 氏名1、氏名2、氏名3... という文字を TextView で表示する時に、氏名部分はリンク付きのテキスト(タップすると別画面に遷移するなど)にしたい場合などがあると思います。

そんなテキストの装飾は主に以下の手順で行います。

  • リンクを付与したいテキストの範囲(NSRange)を決定する
  • NSRange ごとに attribute を付与する
  • attribute を取り出して任意の処理を行う

それぞれについて軽く説明していきます。

リンクを付与したいテキストの範囲(NSRange)を決定する

今回リンクを付与する対象のテキストは以下のようなものだとします。

let names = (1...6).map { "name\($0)" }
let namesText = names.joined(separator: "、") // "name1、name2、name3、name4、name5、name6"

こちらの namesText の読点(、)以外にリンクを付与していく想定になります。

文字の範囲については NSRange で扱っておくと後ほど楽になるので、NSRange で範囲を決定します。

func getTextRanges(names: [String], separatorCount: Int) -> [(NSRange, String)] {
    var ranges: [(NSRange, String)] = []
    var baseIndex = 0

    for name in names {
	// 1. NSString として扱うことによって絵文字などが含まれている場合のバグを防ぐ
        let nsNameString = NSString(string: name)
        let range = NSRange(location: baseIndex, length: nsNameString.length)
	// 2. Range と Name のタプルを追加する
        ranges.append((range, name))
	// 3. 読点分ずらすようにする
        baseIndex += nsNameString.length + separatorCount
    }

    return ranges
}

上記の処理は氏名ごとにループを回しているだけですが、簡単にコメントをつけた部分のみ説明します。

  1. NSString として文字を扱っておくことにより、例えばテキストに絵文字などが含まれている場合のバグを防ぐことができます。絵文字が含まれている場合に .count などを利用して文字数をカウントすると、文字コードの扱いの関係で想定とは異なる文字長が返却されてしまいます。具体的な仕組みについては、こちらの神記事で勉強させていただきました
  2. 後ほど NSRange ごとにテキストに対して何らかの処理を当てていきたいので、とりあえず NSRange と String(氏名) のタプルを追加していきます
  3. NSRange は読点をスキップしたもので数えていきたいので、読点分 baseIndex をずらすようにして for 文を回していきます

NSRange ごとに attribute を付与する

先程の手順で NSRange を決定できたので、その NSRange に基づいて attribute (テキストの装飾)を付与する処理を作っていきます。

// 1. 独自の NSAttributedString.Key を作成しておく
private extension NSAttributedString.Key {
    static let name = NSAttributedString.Key(rawValue: "name")
}

func applyAttributeString(initialString: String, attributedRange: [(NSRange, String)]) {
    // 2. NSMutableAttributedString を作って addAttribute する用意をする
    let attributedString = NSMutableAttributedString(string: initialString)
    // 3. [(NSRange, String)] の配列を回していきながら処理を行う
    attributedRange.forEach { range, name in
        // 4. 文字の foregroundColor Key に UIColor.blue をセットする
        attributedString.addAttribute(.foregroundColor, value: UIColor.blue, range: range)
	// 5. 独自に作成した name Key に name(String) をセットする
        attributedString.addAttribute(.name, value: name, range: range)
    }
    // 6. textView に attributedText を適用する
    textView.attributedText = NSAttributedString(attributedString: attributedString)
}

こちらもそれぞれ説明していきます。

  1. すぐに説明しますが、独自の attribute (今回で言うとテキストに name(String) の情報をつけておきたい)を付与するために、name という Key を NSAttributedString.Key を拡張する形で定義しておきます
  2. 実際にテキストに attribute を付与していくために、NSMutableAttributedString(string:) を利用して、attribute を付与するためのものを定義しておきます。似たようなものとして NSAttributedString(string:) もありますが、こちらはイニシャライズ時にしか attribute を付与することができないため、今回は NSMutable の方を利用しています
  3. forEach では、一番最初の手順で作成しておいた [(NSRange, String)] の配列を回していきながら、テキストの範囲(NSRange)に該当する部分に attribute を付与していきます
  4. 最初の attribute を付与している部分です。attributedString に対して addAttribute(_ name: NSAttributedString.Key, value: Any, range: NSRange)を呼ぶことによって、attribute を付与することができます。ここではデフォルトで用意されている .foregroundColor という Key に対して UIColor.blue を該当の NSRange にセットしています
  5. 次に独自に作成しておいた name という Key を利用して、name(String) を addAttribute します
  6. 最後に作成した attirubtedText を textView の attributedText に設定します

ここまでの手順で textView に attributedText を付与するところまで終えることができました。
ここまで終われば既に TextView に表示されている文字の氏名部分は UIColor.blue になっていると思います。

attribute を取り出して任意の処理を行う

さて、ここまでで TextView に表示されているテキストに対して装飾は施すことができているのですが、テキストリンクっぽくなっているものをタップしても何も反応しないと思います。

もちろんそれはタップするイベントを追加していないからなのですが、どのようにタップイベントを追加していくかについて最後に見ていこうと思います。
今回は自分が普段使っている RxSwift による例を見ていきますが、TextView のタップイベントを取れるのであれば他の方法でも問題ないと思います。(RxSwift についての細かい説明は省きます)

textView.rx.tapGesture()
    .when(.recognized)
    .subscribe(onNext: { [weak self] sender in
        guard let self = self else { return }
	let layoutManager = self.textView.layoutManager
	// TextView 中の locaiton を割り出す
	let location = sender.location(in: self.textView)
	// location に対するタップした文字の index を特定する
	let characterIndex = layoutManager.characterIndex(for: location, in: self.textView.textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
	// 念のため想定外の characterIndex は弾いておくようにする
	if characterIndex >= self.textView.textStorage.length { return }

	// タップした文字に .name が埋め込まれていれば任意の処理を行う
	let attributedValue = self.textView.attributedText?.attribute(.name, at: characterIndex, effectiveRange: nil)
	guard let name = attributeValue as? String else { return }
	// delegate などを介して任意の処理を行う
	self.delegate?.didTap(name)
    }).disposed(by: disposeBag)

タップイベントについては上記に書いたような実装を行えば実現することができます。
基本的には NSAttributedString.Key として埋め込んでいたものを取り出して、それを利用した処理を行っていくという流れになるかなと思います。

参照

Discussion