Android リアルタイム入力でハッシュタグ形式に文字装飾するTIPS

# 本稿の目的
`#`から始まるハッシュタグの部分の色が変わり、タップするとハッシュタグの内容に応じたフィードの検索などを行う機能は最近ではあたりまえのUXになっています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125724/8e1385cf-052a-7e93-ede3-8c520df845a8.png)

Androidでの文字装飾は`UnderlineSpan`や、`ForegroundColorSpan`を組み合わせれば簡単に実装できますが、文字装飾した部分をクリックできるようにしたり、TwitterやFacebookのようにリアルタイムで入力した内容に応じて文字装飾を行うためにはどのように実装するべきかを説明します。

[iOSについても記述しています。よろしければどうぞ。](https://qiita.com/nosaka/items/000271616c072285674c)

# ラベルにハッシュタグの形式に相当する部分を文字装飾する＆クリックできるようにする

## ハッシュタグの形式に相当する部分を正規表現をして抽出する
文字装飾するには`Spannable`を実装した`CharSequence`を`TextView`に設定します。

任意の文字列、もしくはすでに`TextView`に設定されている文字列からハッシュタグの形式に相当する部分を正規表現をして抽出し、文字装飾を行います。

```kotlin
val spannableStringBuilder = SpannableStringBuilder(charSequence)

val matcher = "(?:^|\\s)(#([^\\s]+))[^\\s]?".toRegex().toPattern().matcher(charSequence)

while (matcher.find()) {
    if (matcher.groupCount() != 2) continue
    // index[1]...ハッシュタグの`#`を含む文字列
    // index[2]...ハッシュタグ内のコンテンツの文字列
    val content = charSequence.subSequence(matcher.start(2), matcher.end(2))
    val st = matcher.start(1)
    val ed = matcher.end(1)
    Log.d("HASH_TAG", "detected hash tag. start:=$st, end:=$ed, tag:=$content")

    spannableStringBuilder.setSpan(UnderlineSpan(), st, ed, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
    ForegroundColorSpan(Color.BLUE).run {
        spannableStringBuilder.setSpan(this, st, ed, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
    }

}
```
上記例では`CharSequence`を実装したインスタンス`charSequence`をもとに`SpannableStringBuilder`を生成し、「空白、または行頭で`#`からはじまる1文字以上の空白までの最短一致の文字列」をハッシュタグとして検出、下線と文字色を青に装飾しています。

なお、Twitterなどでは`#`以降は記号や数字を許容していませんが、上記の例では空白以外のすべてを対象としています。
Twitterのように記号と数字を除き、「ひらがな、カナ、英字、漢字」のみを対象とする例だと正規表現は`(?:^|\\s)(#([ぁ-んァ-ンーa-zA-Z一-龠\\-\\r]+))[^\\s]?`とおきかえてください。


### 大事な注意点について
#### `Spannable.setSpan`は重複して設定される

前述のコード例で`CharSequence`を実装したインスタンス`charSequence`が`Spannable`も実装しており、すでに何らかの文字装飾が行われている場合はその文字装飾も引き継がれます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125724/7661a112-b863-505e-d055-43da894fb172.png)

このため、前述のコード例でのcharSequenceが`TextView`から取得したもので、すでに設定されている文字装飾などを除去したい場合は、`SpannableStringBuilder`のコンストラクタ引数は`CharSequence.toString`を指定します。

```
val spannableStringBuilder = SpannableStringBuilder(charSequence.toString())
```

さらには、前述のコード例で設定している`Spannable.setSpan`も同じ処理を複数回呼び出せば、**見た目上は同じでも都度同じSpan増えていきます**。この点の回避策などは後述します。

#### `(?:^|\\s)(#([ぁ-んァ-ンーa-zA-Z一-龠\\-\\r]+))[^\\s]?`では世界を制せない
「記号と数字はハッシュタグの対象にしたくない」ということで、`(?:^|\\s)(#([ぁ-んァ-ンーa-zA-Z一-龠\\-\\r]+))[^\\s]?`の正規表現への置き換え例をあげましたが、この正規表現だと、アラビア文字（العالم لي）などはハッシュタグとして認識されなくなります。これでは世界を制せませんね。

```kotlin
val spannableStringBuilder = SpannableStringBuilder(charSequence)

val matcher = "(?:^|\\s)(#([^\\s]+))[^\\s]?".toRegex().toPattern().matcher(charSequence)

while (matcher.find()) {
    if (matcher.groupCount() != 2) continue
    // index[1]...ハッシュタグの`#`を含む文字列
    // index[2]...ハッシュタグ内のコンテンツの文字列
    val content = charSequence.subSequence(matcher.start(2), matcher.end(2))
    val st = matcher.start(1)
    val ed = matcher.end(1)
    Log.d("HASH_TAG", "detected hash tag. start:=$st, end:=$ed, tag:=$content")
    // サロゲートペアが含まれる場合は対象外とする
    if (content.length != content.toString().codePointCount(0, content.length)) continue
    spannableStringBuilder.setSpan(UnderlineSpan(), st, ed, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
    ForegroundColorSpan(Color.BLUE).run {
        spannableStringBuilder.setSpan(this, st, ed, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
    }

}
```

ただ、😡👨‍👩‍👧‍👧といった文字はハッシュタグにしたくないよ、という場合は、前述のコード例でサロゲートペアは除外する処理を追加してください。
補足すると、😡👨‍👩‍👧‍👧らのEmojiはサロゲートペアで表現されており、こちらでサロゲートペアが含まれている場合は除去する仕組みを取っています。
こちらは [絵文字を支える技術の紹介](https://qiita.com/nonanona/items/b148c212ba7c24942e93) の記事を大変参考にさせていただきました。

## ハッシュタグの形式に相当する部分をクリックできるようにする
前述の正規表現で抽出した範囲に対して`ClickableSpan`を設定し、抽象メソッドを実装してください。

```kotlin
spannableStringBuilder.setSpan(object : ClickableSpan(){
    override fun onClick(widget: View) {
        Log.d("HASH_TAG", "hash tag clicked. tag:=$content")
        // 処理を記述する
    }

}, st, ed, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
```

# EditTextで入力中にハッシュタグ形式に変換する
`EditText.addTextChangedListener`で入力の監視を行い、`TextWatcher.afterTextChanged`で前述の正規表現を使ってハッシュタグに相当する文字装飾します。

ただし、このときに次のような点に注意して実装する必要があります。

1. `TextView.setText`をするとバックスペースなどが効かないため、直接操作する
2. 文字列編集中の場合、とくに日本語変換中などはこの処理を行わないように制御する
3. 文字編集のたびに同じ範囲、同じ文字装飾が行われることを防止する


これらの注意点を実装した`TextWatcher.afterTextChanged`の例が以下です。

```kotlin
override fun afterTextChanged(charSequence: Editable?) {
    if (charSequence == null || charSequence.isEmpty()) return // 未入力状態の場合は処理しない
    // (1) `TextView.setText`をするとバックスペースなどが効かないため、Editableを取得する
    val spannable: Spannable = textView.editableText ?: SpannableString(textView.text)

    // (2) 文字列編集中の場合、とくに日本語変換中などはこの処理を行わないように制御する
    if (spannable.isEditing()) return
    // (3) 文字編集のたびに同じ範囲、同じ文字装飾が行われることを防止する
    spannable.removeHashTagSpans()

    val matcher = PATTERN_HASH_TAG.matcher(charSequence)
    while (matcher.find()) {
        if (matcher.groupCount() != 2) continue
        // index[1]...ハッシュタグの`#`を含む文字列
        // index[2]...ハッシュタグ内のコンテンツの文字列
        val content = charSequence.subSequence(matcher.start(2), matcher.end(2))
        val st = matcher.start(1)
        val ed = matcher.end(1)

        if (content.isContainsEmoji()) {
            // サロゲート文字は許可しない
            continue
        }

        spannable.setSpan(
            HashTagUnderlineSpan(),
            st,
            ed,
            Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
        )
        callback?.let { callback ->
            spannable.setSpan(
                HashTagClickableSpan(callback, content),
                st,
                ed,
                Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
            )
        }
        HashTagForegroundColorSpan(Color.BLUE).run {
            spannable.setSpan(this, st, ed, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
        }
        if (spannable !is Editable) {
            textView.removeTextChangedListener(this)
            textView.setTextKeepState(spannable)
            textView.addTextChangedListener(this)
        }
    }
}
```


## `TextView.setText`をするとバックスペースなどが効かないため、Editableを取得する
`TextWatcher.afterTextChanged`内で`TextView.setText`を行う場合、文字装飾の編集をする前後で`TextWatcher`の除去と再設定を行なえば正常に動作するかと思われますが、この場合、バックスペースが正常に動作しない(長押し削除ができず、1文字づつの削除となってしまう)不具合に遭遇します。
このため、[TextView.getEditableText](https://developer.android.com/reference/android/widget/TextView.html#getEditableText())で[Editable](https://developer.android.com/reference/android/text/Editable.html)を取得する必要があります。

> This is the interface for text whose content and markup can be changed (as opposed to immutable text like Strings). If you make a DynamicLayout of an Editable, the layout will be reflowed as the text is changed.

[Editable](https://developer.android.com/reference/android/text/Editable.html)のドキュメントに記載の上記を参照するとわかる通り、コンテンツとマークアップが取得可能な場合に提供されるインターフェースであり、対象となる`TextView`が編集可能でない場合は[TextView.getEditableText](https://developer.android.com/reference/android/widget/TextView.html#getEditableText())で`null`が返却されます。


```kotlin
// (1) `TextView.setText`をするとバックスペースなどが効かないため、Editableを取得する
val spannable: Spannable = textView.editableText ?: SpannableString(textView.text)
```

なお、`TextView.getText`でも`Spannable`なインターフェースを取得できますが、次のように「やるとおこ😡だよ」と言っているのでやめておいた方が無難でしょう。[参考](https://ja.wikipedia.org/wiki/%E6%BF%80%E3%81%8A%E3%81%93%E3%81%B7%E3%82%93%E3%81%B7%E3%82%93%E4%B8%B8)

> The content of the return value should not be modified. If you want a modifiable one, you should make your own copy first.

さらに、前述の例では[Editable](https://developer.android.com/reference/android/text/Editable.html)が取得できないことを考慮して、この場合は文字装飾の編集をする前後で`TextWatcher`の除去と再設定を行なっています。

```kotlin
if (spannable !is Editable) {
    textView.removeTextChangedListener(this)
    textView.setTextKeepState(spannable)
    textView.addTextChangedListener(this)
}
```

## 文字列編集中の場合、とくに日本語変換中などはこの処理を行わないように制御する
前述の例では、次のように編集中かどうかを`Spannable.isEditing`というメソッドで検査していますが、こちらは拡張関数として定義した関数です。

```kotlin
// (1) 文字列編集中の場合、とくに日本語変換中などはこの処理を行わないように制御する
if (spannable.isEditing()) return
```

`EditText`で日本語入力で変換中であるかを検査せずに文字装飾を行うと、変換前で文字入力が確定してしまいます。
たとえば、ローマ字入力などで「か」という文字を入力しようとする場合、`k`、`a`のキーを入力する必要がありますが、`k`の段階で入力が確定してしまい、「kあ」と入力のまま変換ができなくなります。
これを防止するために現在の`TextView`から取得した`Spannable`から[Spannable.SPAN_COMPOSING](https://developer.android.com/reference/android/text/Spanned.html#SPAN_COMPOSING)のフラグがあるSpanが含まれているかを検査し、入力中かどうかを判定する必要があります。

```kotlin
/**
 * 編集中判定処理
 *
 * @return true...編集中、false...非編集中
 */
private fun Spannable.isEditing(): Boolean {
    val spans = this.getSpans<Any>()
    return spans.any { this.getSpanFlags(it) and Spannable.SPAN_COMPOSING == Spannable.SPAN_COMPOSING }
}
```

## 文字編集のたびに同じ範囲、同じ文字装飾が行われることを防止する
[`Spannable.setSpan`は重複して設定される](#spannablesetspanは重複して設定される)での注意点で説明したとおり、同じ`Spannable`のインスタンスに対して`Spannable.setSpan`を複数回呼び出すと、**見た目上は同じでも都度同じSpan増えていきます**。

```kotlin
val spannableString = SpannableString("ABCDEFGHIJK")

spannableString.setSpan(UnderlineSpan(), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
println(spannableString.getSpans<UnderlineSpan>().size) // 1
spannableString.setSpan(UnderlineSpan(), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
println(spannableString.getSpans<UnderlineSpan>().size) // 2
```

これを回避するために、ハッシュタグで使用する文字装飾は次のようなinterfaceとその実装を用意し、識別できるようにします。

```kotlin
/**
 * HashTagの文字装飾を示すInterface
 * 本クラス内で文字装飾されたものを識別するために使用する
 */
private interface HashTagSpan

/**
 * [HashTagSpan]実装[ForegroundColorSpan]
 */
private class HashTagForegroundColorSpan(@ColorInt color: Int) : ForegroundColorSpan(color), HashTagSpan

/**
 * [HashTagSpan]実装[UnderlineSpan]
 */
private class HashTagUnderlineSpan : UnderlineSpan(), HashTagSpan

/**
 * [HashTagSpan]実装[ClickableSpan]
 */
private class HashTagClickableSpan(
    private val callback: (content: CharSequence) -> Unit,
    private val content: CharSequence) : ClickableSpan(), HashTagSpan {
    override fun onClick(widget: View) {
        callback(content)
    }
}
```

こうすることで、実際に今回の入力された内容に応じたハッシュタグ形式の文字装飾を行う前に、次のメソッドで`HashTagSpan`の実装のみを現在の`Spannable`から除去することができます。
この前処理はとくに、他の処理でハイライトやアイコン画像表示などの文字装飾を行なっている場合に、それハッシュタグ形式の文字装飾以外を除去せずに、かつ、不必要に同じSpanを増殖させない効果があります。

```kotlin
// (3) 文字編集のたびに同じ範囲、同じ文字装飾が行われることを防止する
spannableStringBuilder.removeHashTagSpans()
```

```kotlin
/**
 * [HashTagSpan] 除去処理
 * [TextView.setText]
 */
private fun Spannable.removeHashTagSpans() {
    val hashTagSpans = this.getSpans<HashTagSpan>()
    hashTagSpans.forEach {
        this.removeSpan(it)
    }
}
```

## それ以外のTips
`EditText`入力中に`TextWatcher.onTextChanged`内で`TextView.setText`を行うと、カーソルの位置が先頭に戻ってしまい、入力時におかしな挙動を示します。
具体的には「あい」と入力したはずが「いあ」のように表示されます。
このため、`TextView.setTextKeepState`で`TextView`の内容を設定してあげましょう