TextViewの一部に装飾を付けたりタップできるようにする方法

何番煎じか分かりませんが、TextViewに表示するテキストの一部に下線や色を付けたり、リンクを追加したりする方法を紹介します。
以下で紹介する方法は、文字列リソースの定義時に使用できる`<annotation>`タグを用いると、`<annotation>`タグで囲った部分をKotlinのコードから参照できる仕組み[^1]に着目したものです。

## 準備
適当なパッケージを作成し、以下のような拡張関数を定義してください。

```kotlin:TextViewExtension.kt
package com.example.textviewdecorationsample

import android.text.Annotation
import android.text.Spannable
import android.text.SpannableString
import android.text.SpannedString
import android.text.method.LinkMovementMethod
import android.text.style.CharacterStyle
import android.text.style.ClickableSpan
import android.widget.TextView
import androidx.annotation.StringRes
import androidx.core.text.getSpans

fun TextView.decorate(
    @StringRes resId: Int,
    map: Map<String, CharacterStyle>,
) {
    val text = this.context.getText(resId) as SpannedString
    val spannableString = SpannableString(text)
    text.getSpans<Annotation>(0, text.length).forEach { annotation ->
        if (annotation.key == "decoration-tag") {
            map[annotation.value]?.let { characterStyle ->
                spannableString.setSpan(
                    characterStyle,
                    text.getSpanStart(annotation),
                    text.getSpanEnd(annotation),
                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
                )
                if (characterStyle is ClickableSpan) {
                    this.isClickable = true
                    this.movementMethod = LinkMovementMethod.getInstance()
                }
            }
        }
    }
    this.text = spannableString
}
```

## 使ってみる
Android Viewで作られた適当な画面から`TextView#decorate`を呼び出してみましょう。

```kotlin:MainActivity.kt
package com.example.textviewdecorationsample

import android.content.Intent
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.text.TextPaint
import android.text.style.ClickableSpan
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import com.example.textviewdecorationsample.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {
    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.privacyPolicyAndTermsOfService.decorate(
            R.string.privacy_policy_and_terms_of_service,
            mapOf(
                "privacy_policy" to object : ClickableSpan() {
                    override fun onClick(widget: View) {
                        val intent = Intent(
                            Intent.ACTION_VIEW,
                            Uri.parse("https://example.com/privacy_policy.html")
                        )
                        startActivity(intent)
                    }

                    override fun updateDrawState(ds: TextPaint) {
                        ds.color = Color.BLUE
                        ds.isUnderlineText = true
                    }
                },
                "terms_of_service" to object : ClickableSpan() {
                    override fun onClick(widget: View) {
                        val intent = Intent(
                            Intent.ACTION_VIEW,
                            Uri.parse("https://example.com/terms_of_service.html")
                        )
                        startActivity(intent)
                    }

                    override fun updateDrawState(ds: TextPaint) {
                        ds.color = Color.MAGENTA
                        ds.isUnderlineText = true
                    }
                }
            )
        )
    }
}
```

```xml:activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    android:orientation="vertical"
    tools:context=".MainActivity">

    <androidx.appcompat.widget.AppCompatTextView
        android:id="@+id/privacy_policy_and_terms_of_service"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="20sp"
        tools:text="@string/privacy_policy_and_terms_of_service" />

    <androidx.appcompat.widget.AppCompatButton
        android:id="@+id/member_registration"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        android:text="@string/member_registration"
        android:textSize="20sp" />

</LinearLayout>
```

```xml:string.xml
<resources>
    <string name="privacy_policy_and_terms_of_service"><annotation decoration-tag="privacy_policy">プライバシーポリシー</annotation>と<annotation decoration-tag="terms_of_service">利用規約</annotation>に同意して</string>
    <string name="member_registration">会員登録する</string>
</resources>
```

やりたいことができましたね！

![画面プレビュー1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/274026/25db7982-df89-f9b1-72f0-0344e2324484.gif)

## 解説
前節の使用例において、`string.xml`に以下のような文字列を定義していました。

```xml
<string name="privacy_policy_and_terms_of_service"><annotation decoration-tag="privacy_policy">プライバシーポリシー</annotation>と<annotation decoration-tag="terms_of_service">利用規約</annotation>に同意して</string>
```

`<annotation>`タグでは、開発者が任意の属性(key)と値(value)を設定することができます。ここではカスタム属性`decoration-tag`を設定し、「プライバシーポリシー」には値`privacy_policy`を、「利用規約」には値`terms_of_service`を設定しています。

こうしておくことで、Kotlinのコードからタグを付けた部分を参照することができるようになります。
さらに`SpannedString#getSpanStart`および`SpannedString#getSpanEnd`を使用すると、文字列全体の中での`<annotation>`を付けた部分の位置を取得することができます。
これを使って`SpannableString#setSpan`をしてあげると、`<annotation>`で囲った部分に対して独自の設定をすることができる、という仕組みです。

```kotlin
val text = this.context.getText(resId) as SpannedString
val annotations = text.getSpans<Annotation>(0, text.length)
for (annotation in annotations) {
    // `decoration-tag`が設定されているアノテーションを探す
    if (annotation.key == "decoration-tag") {
        // さらに`decoration-tag`に`privacy_policy`という値が設定されているものを探す
        if (annotation.value == "privacy_policy") {
            // テキスト全体の中でどの位置にあるのか計算する
            val start = text.getSpanStart(annotation)
            val end = text.getSpanEnd(annotation)
            // TODO: 位置がわかったのでそこに独自の設定を適用する
        }
    }
}
```

もちろん、`<annotation>`タグを使わず、装飾を付けたい部分を正規表現等で検索して、その位置を計算することでも同じことはできます。
でも例えば、`すもももももももものうち`の`桃`の部分にだけ`https://en.wikipedia.org/wiki/Peach`に対するリンクを張りたいというケースを考えるとどうでしょうか？

`<annotation>`タグを使うと、装飾を施したい箇所に簡単かつ確実にアクセスできますね。

## レシピ集
`TextView#decorate`の使用例をいくつか紹介します。

### 文字列の一部だけに背景色を設定する
`android.text.style.BackgroundColorSpan`を使用します。

![BackgroundColorSpan.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/274026/b7e52258-aad5-5050-6c35-7566bcafb8e5.png)

```kotlin
binding.privacyPolicyAndTermsOfService.decorate(
    R.string.privacy_policy_and_terms_of_service,
    mapOf(
        "terms_of_service" to BackgroundColorSpan(Color.RED)
    )
)
```

### 文字列の一部を太字にしたり斜体にしたりする
`android.text.style.StyleSpan`を使用します。
![StyleSpan.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/274026/1d58d0da-c2b6-32a7-815e-79d6294ef551.png)

```kotlin
binding.privacyPolicyAndTermsOfService.decorate(
    R.string.privacy_policy_and_terms_of_service,
    mapOf(
        "privacy_policy" to StyleSpan(Typeface.BOLD),
        "terms_of_service" to StyleSpan(Typeface.ITALIC),
    )
)
```

`ClickableSpan`と組み合わせたい場合は(Android9以降限定になってしまいますが)以下のようにすると良いでしょう。

```kotlin
binding.privacyPolicyAndTermsOfService.decorate(
    mapOf(
        "privacy_policy" to object : ClickableSpan() {
            override fun onClick(widget: View) {
                // TODO
            }

            override fun updateDrawState(ds: TextPaint) {
                ds.typeface = Typeface.create(Typeface.DEFAULT, 700, false)
            }
        },
        "terms_of_service" to object : ClickableSpan() {
            override fun onClick(widget: View) {
                // TODO
            }

            override fun updateDrawState(ds: TextPaint) {
                ds.typeface = Typeface.create(Typeface.DEFAULT, 400, true)
            }
        }
    )
)
```


### 文字列の一部だけ文字サイズを変更する
`android.text.style.AbsoluteSizeSpan`を使用します。
![AbsoluteSizeSpan.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/274026/e55732f1-1393-f295-ca38-a8445d62f6b5.png)

```kotlin
binding.privacyPolicyAndTermsOfService.decorate(
    R.string.privacy_policy_and_terms_of_service,
    mapOf(
        "privacy_policy" to AbsoluteSizeSpan(30, true),
        "terms_of_service" to AbsoluteSizeSpan(10, true),
    )
)
```

### 文字列の一部を画像に置き換える
`android.text.style.ImageSpan`を使用します。

![ImageSpan.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/274026/2815eb1d-dd26-a05d-ccad-d67d9ed4cb5b.png)

```kotlin
binding.privacyPolicyAndTermsOfService.decorate(
    R.string.privacy_policy_and_terms_of_service,
    mapOf(
        "terms_of_service" to ImageSpan(this, R.mipmap.ic_launcher)
    )
)
```

### 文字列の一部を上付きにしたり下付きにしたりする
`android.text.style.SuperscriptSpan`および`android.text.style.SubscriptSpan`を使用します。
![SubscripSpanSuperscriptSpan.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/274026/5796e02d-9e78-944d-4f1e-8cf73dc395cd.png)

(文字サイズやTextViewの高さによっては見切れてしまうことがあるので要調整です)

```kotlin
binding.privacyPolicyAndTermsOfService.decorate(
    R.string.privacy_policy_and_terms_of_service,
    mapOf(
        "privacy_policy" to SuperscriptSpan(),
        "terms_of_service" to SubscriptSpan(),
    )
)
```

### 文字列の一部にリンクを付けたい場合(細かい設定は不要な場合)
`android.text.style.URLSpan`を使用します。
![URLSpan.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/274026/048cb020-788b-175c-4f77-99b0edd2b000.png)

```kotlin
binding.privacyPolicyAndTermsOfService.decorate(
    R.string.privacy_policy_and_terms_of_service,
    mapOf(
        "privacy_policy" to URLSpan("https://example.com/privacy_policy.html"),
        "terms_of_service" to URLSpan("https://example.com/terms_of_service"),
    )
)
```

### その他
他にも打ち消し線を引いたり、下線を引いたり、ブラーをかけたりすることができます。
`android.text.style.CharacterStyle`のサブクラスであれば何でも使えるので、詳しくはドキュメント[^2]を見てみてください。さらにもう少し込み入ったことをしたい場合は、`CharacterStyle`のサブクラスを自前で実装するとよいでしょう。


## 先行研究一覧
- https://qiita.com/sudo5in5k/items/c0479968a25bf3bd78df
- https://qiita.com/suzukihr/items/19a2ec4b9a163b151164
- https://qiita.com/amay077/items/7e5f33f28ac672b75b05
- https://qiita.com/tomoya-hiraiwa/items/fd75876194399848903c
- https://y-anz-m.blogspot.com/2011/08/androidspannable.html
- https://reon777.com/2021/03/17/android-text-hyperlink/
- https://qiita.com/orimomo/items/c6cd3fe71f299ef94323
- https://androidprogram.hatenadiary.org/entry/20100529/1275086958
- https://qiita.com/noranuk0/items/f4d09930517a7d209ce3
- https://qiita.com/wasabeef_jp/items/044bfa827be2f8bcba1f

[^1]: https://developer.android.com/guide/topics/resources/string-resource#StylingWithAnnotations
[^2]: https://developer.android.com/reference/android/text/style/CharacterStyle