本稿の目的
#
から始まるハッシュタグの部分の色が変わり、タップするとハッシュタグの内容に応じたフィードの検索などを行う機能は最近ではあたりまえのUXになっています。
Androidでの文字装飾はUnderlineSpan
や、ForegroundColorSpan
を組み合わせれば簡単に実装できますが、文字装飾した部分をクリックできるようにしたり、TwitterやFacebookのようにリアルタイムで入力した内容に応じて文字装飾を行うためにはどのように実装するべきかを説明します。
ラベルにハッシュタグの形式に相当する部分を文字装飾する&クリックできるようにする
ハッシュタグの形式に相当する部分を正規表現をして抽出する
文字装飾するにはSpannable
を実装したCharSequence
をTextView
に設定します。
任意の文字列、もしくはすでに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
を実装したインスタンスcharSequence
がSpannable
も実装しており、すでに何らかの文字装飾が行われている場合はその文字装飾も引き継がれます。
このため、前述のコード例での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
で前述の正規表現を使ってハッシュタグに相当する文字装飾します。
ただし、このときに次のような点に注意して実装する必要があります。
-
TextView.setText
をするとバックスペースなどが効かないため、直接操作する - 文字列編集中の場合、とくに日本語変換中などはこの処理を行わないように制御する
- 文字編集のたびに同じ範囲、同じ文字装飾が行われることを防止する
これらの注意点を実装した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.getEditableTextでEditableを取得する必要があります。
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.getEditableTextでnull
が返却されます。
// (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
で日本語入力で変換中であるかを検査せずに文字装飾を行うと、変換前で文字入力が確定してしまいます。
たとえば、ローマ字入力などで「か」という文字を入力しようとする場合、k
、a
のキーを入力する必要がありますが、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.setTextKeepState
でTextView
の内容を設定してあげましょう