Help us understand the problem. What is going on with this article?

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

本稿の目的

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

image.png

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

iOSについても記述しています。よろしければどうぞ。

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

ハッシュタグの形式に相当する部分を正規表現をして抽出する

文字装飾するにはSpannableを実装したCharSequenceTextViewに設定します。

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

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を実装したインスタンスcharSequenceSpannableも実装しており、すでに何らかの文字装飾が行われている場合はその文字装飾も引き継がれます。
image.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]?の正規表現への置き換え例をあげましたが、この正規表現だと、アラビア文字(العالم لي)などはハッシュタグとして認識されなくなります。これでは世界を制せませんね。

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はサロゲートペアで表現されており、こちらでサロゲートペアが含まれている場合は除去する仕組みを取っています。
こちらは 絵文字を支える技術の紹介 の記事を大変参考にさせていただきました。

ハッシュタグの形式に相当する部分をクリックできるようにする

前述の正規表現で抽出した範囲に対してClickableSpanを設定し、抽象メソッドを実装してください。

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の例が以下です。

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.getEditableTextEditableを取得する必要があります。

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のドキュメントに記載の上記を参照するとわかる通り、コンテンツとマークアップが取得可能な場合に提供されるインターフェースであり、対象となるTextViewが編集可能でない場合はTextView.getEditableTextnullが返却されます。

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

なお、TextView.getTextでもSpannableなインターフェースを取得できますが、次のように「やるとおこ😡だよ」と言っているのでやめておいた方が無難でしょう。参考

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

さらに、前述の例ではEditableが取得できないことを考慮して、この場合は文字装飾の編集をする前後でTextWatcherの除去と再設定を行なっています。

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

文字列編集中の場合、とくに日本語変換中などはこの処理を行わないように制御する

前述の例では、次のように編集中かどうかをSpannable.isEditingというメソッドで検査していますが、こちらは拡張関数として定義した関数です。

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

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

/**
 * 編集中判定処理
 *
 * @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は重複して設定されるでの注意点で説明したとおり、同じSpannableのインスタンスに対してSpannable.setSpanを複数回呼び出すと、見た目上は同じでも都度同じSpan増えていきます

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とその実装を用意し、識別できるようにします。

/**
 * 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を増殖させない効果があります。

// (3) 文字編集のたびに同じ範囲、同じ文字装飾が行われることを防止する
spannableStringBuilder.removeHashTagSpans()
/**
 * [HashTagSpan] 除去処理
 * [TextView.setText]
 */
private fun Spannable.removeHashTagSpans() {
    val hashTagSpans = this.getSpans<HashTagSpan>()
    hashTagSpans.forEach {
        this.removeSpan(it)
    }
}

それ以外のTips

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

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away