スパンは、文字レベルまたは段落レベルでテキストのスタイルを設定するために使用できる強力なマークアップ オブジェクトです。テキスト オブジェクトにスパンをアタッチすると、色の追加、テキストのクリック対応、テキストサイズの調整、テキストの描画など、さまざまな方法でテキストを変更できます。また、TextPaint
プロパティの変更、Canvas
への描画、テキスト レイアウトの変更も可能です。
Android には、さまざまなタイプのスパンが用意されており、一般的なテキスト スタイル パターンを幅広くカバーします。また、独自のスパンを作成して、カスタム スタイルを適用することもできます。
スパンを作成して適用する
スパンを作成するには、次の表に示すクラスのいずれかを使用します。クラスは、テキスト自体が可変かどうか、テキスト マークアップが可変かどうか、スパンデータを格納する基になるデータ構造によって異なります。
クラス | テキストは変更可能か? | マークアップは変更可能か? | どのようなデータ構造か? |
---|---|---|---|
SpannedString |
× | × | リニア配列 |
SpannableString |
× | ○ | リニア配列 |
SpannableStringBuilder |
○ | ○ | 区間ツリー |
3 つのクラスはすべて、Spanned
インターフェースを拡張します。また、SpannableString
と SpannableStringBuilder
の場合は、Spannable
インターフェースも拡張します。
どちらを使用するかを決める方法は次のとおりです。
- テキストやマークアップを作成後に変更しない場合は、
SpannedString
を使用します。 - 1 つのテキスト オブジェクトに少数のスパンを接続する必要があり、テキスト自体が読み取り専用の場合は、
SpannableString
を使用します。 - 作成後にテキストを変更する必要があり、テキストにスパンをアタッチする必要がある場合は、
SpannableStringBuilder
を使用します。 - テキスト自体が読み取り専用かどうかにかかわらず、多数のスパンをテキスト オブジェクトにアタッチする必要がある場合は、
SpannableStringBuilder
を使用します。
スパンを適用するには、Spannable
オブジェクトに対して setSpan(Object _what_, int _start_, int _end_, int
_flags_)
を呼び出します。what パラメータはテキストに適用するスパンを参照し、start および end パラメータはスパンを適用するテキストの部分を示します。
スパンの境界内にテキストを挿入すると、スパンは自動的に拡張され、挿入されたテキストが含まれます。スパン境界(start インデックスまたは end インデックス)にテキストを挿入する場合、flags パラメータによって、スパンが挿入テキストを含むように拡張されるかどうかが決まります。挿入テキストを含めるには Spannable.SPAN_EXCLUSIVE_INCLUSIVE
フラグを使用し、挿入テキストを除外するには Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
を使用します。
次の例は、ForegroundColorSpan
を文字列にアタッチする方法を示しています。
Kotlin
val spannable = SpannableStringBuilder("Text is spantastic!") spannable.setSpan( ForegroundColorSpan(Color.RED), 8, // start 12, // end Spannable.SPAN_EXCLUSIVE_INCLUSIVE )
Java
SpannableStringBuilder spannable = new SpannableStringBuilder("Text is spantastic!"); spannable.setSpan( new ForegroundColorSpan(Color.RED), 8, // start 12, // end Spannable.SPAN_EXCLUSIVE_INCLUSIVE );
スパンは Spannable.SPAN_EXCLUSIVE_INCLUSIVE
を使用して設定されるため、次の例に示すように、スパンは拡張され、スパン境界に挿入されたテキストが含まれます。
Kotlin
val spannable = SpannableStringBuilder("Text is spantastic!") spannable.setSpan( ForegroundColorSpan(Color.RED), 8, // start 12, // end Spannable.SPAN_EXCLUSIVE_INCLUSIVE ) spannable.insert(12, "(& fon)")
Java
SpannableStringBuilder spannable = new SpannableStringBuilder("Text is spantastic!"); spannable.setSpan( new ForegroundColorSpan(Color.RED), 8, // start 12, // end Spannable.SPAN_EXCLUSIVE_INCLUSIVE ); spannable.insert(12, "(& fon)");
1 つのテキストに複数のスパンをアタッチできます。次の例は、太字で赤色のテキストを作成する方法を示しています。
Kotlin
val spannable = SpannableString("Text is spantastic!") spannable.setSpan(ForegroundColorSpan(Color.RED), 8, 12, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) spannable.setSpan( StyleSpan(Typeface.BOLD), 8, spannable.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE )
Java
SpannableString spannable = new SpannableString("Text is spantastic!"); spannable.setSpan( new ForegroundColorSpan(Color.RED), 8, 12, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE ); spannable.setSpan( new StyleSpan(Typeface.BOLD), 8, spannable.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE );
Android のスパンタイプ
Android は、android.text.style パッケージ内に 20 種以上のスパンタイプを用意しています。Android では、次の 2 つの基準に基づいて、スパンを大まかに分類しています。
- スパンがテキストに与える影響: スパンは、テキストの外観やテキストの指標に影響する可能性があります。
- スパンスコープ: 個々の文字に適用できるスパンもあれば、段落全体に適用する必要があるスパンもあります。
以降のセクションでは、これらのカテゴリについて詳しく説明します。
テキストの外観に影響するスパン
文字レベルで適用される一部のスパンは、テキストや背景色の変更、下線や取り消し線の追加など、テキストの外観に影響します。これらのスパンは CharacterStyle
クラスを拡張します。
次のコード例は、UnderlineSpan
を適用してテキストに下線を引く方法を示しています。
Kotlin
val string = SpannableString("Text with underline span") string.setSpan(UnderlineSpan(), 10, 19, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
Java
SpannableString string = new SpannableString("Text with underline span"); string.setSpan(new UnderlineSpan(), 10, 19, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
テキストの外観だけに影響するスパンは、レイアウトの再計算をトリガーせずに、テキストの再描画をトリガーします。これらのスパンは UpdateAppearance
を実装し、CharacterStyle
を拡張します。CharacterStyle
サブクラスは、TextPaint
を更新するためのアクセス権を付与することでテキストの描画方法を定義します。
テキストのサイズに影響するスパン
文字レベルで適用されるその他のスパンは、行の高さやテキストサイズなどのテキスト指標に影響します。これらのスパンは MetricAffectingSpan
クラスを拡張します。
次のコード例は、テキストサイズを 50% 拡大する RelativeSizeSpan
を作成します。
Kotlin
val string = SpannableString("Text with relative size span") string.setSpan(RelativeSizeSpan(1.5f), 10, 24, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
Java
SpannableString string = new SpannableString("Text with relative size span"); string.setSpan(new RelativeSizeSpan(1.5f), 10, 24, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
テキスト指標に影響するスパンを適用すると、正しいレイアウトとレンダリングのためにモニタリング オブジェクトでテキストが再測定されます。たとえば、テキストサイズを変更すると、別の行に表示される単語が表示される可能性があります。前のスパンを適用すると、再測定、テキスト レイアウトの再計算、テキストの再描画がトリガーされます。
テキスト指標に影響するスパンは、MetricAffectingSpan
クラスを拡張します。これは抽象クラスで、TextPaint
へのアクセスを提供することで、スパンがテキスト測定に与える影響をサブクラスで定義できます。MetricAffectingSpan
は CharacterSpan
を拡張するため、サブクラスは文字レベルでテキストの外観に影響を与えます。
段落に影響するスパン
スパンは、テキスト ブロックの配置やマージンの変更など、段落レベルのテキストにも影響します。段落全体に影響するスパンは、ParagraphStyle
を実装します。これらのスパンを使用するには、末尾の改行文字を除く段落全体に接続します。段落全体以外に段落スパンを適用しようとしても、そのスパンは適用されません。
Android がテキスト内で段落を分離する方法を図 8 に示します。
次のコード例は、QuoteSpan
を段落に適用します。スパンを段落の先頭または末尾以外の位置にアタッチした場合、スタイルはまったく適用されません。
Kotlin
spannable.setSpan(QuoteSpan(color), 8, text.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
Java
spannable.setSpan(new QuoteSpan(color), 8, text.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
カスタムスパンを作成する
Android の既存のスパンで実現できる機能よりも高度な機能が必要な場合は、カスタムスパンを実装します。独自のスパンを実装する場合は、そのスパンが文字レベルでテキストに影響するか、段落レベルでテキストに影響するかを決めます。また、テキストのレイアウトや外観に影響するかも決定します。これにより、拡張できる基本クラスと、実装する必要があるインターフェースを特定できます。次の表を参考にしてください。
シナリオ | クラスまたはインターフェース |
---|---|
文字単位でテキストに影響するスパンの場合。 | CharacterStyle |
テキストの外観に影響するスパンの場合。 | UpdateAppearance |
テキストのサイズに影響するスパンの場合。 | UpdateLayout |
段落単位でテキストに影響するスパンの場合。 | ParagraphStyle |
たとえば、テキストのサイズと色を変更するカスタムスパンを実装する必要がある場合は、RelativeSizeSpan
を拡張します。継承により、RelativeSizeSpan
は CharacterStyle
を拡張し、2 つの Update
インターフェースを実装します。このクラスには updateDrawState
と updateMeasureState
のコールバックがすでに用意されているため、これらのコールバックをオーバーライドしてカスタム動作を実装できます。次のコードは、RelativeSizeSpan
を拡張し、updateDrawState
コールバックをオーバーライドして TextPaint
の色を設定するカスタムスパンを作成します。
Kotlin
class RelativeSizeColorSpan( size: Float, @ColorInt private val color: Int ) : RelativeSizeSpan(size) { override fun updateDrawState(textPaint: TextPaint) { super.updateDrawState(textPaint) textPaint.color = color } }
Java
public class RelativeSizeColorSpan extends RelativeSizeSpan { private int color; public RelativeSizeColorSpan(float spanSize, int spanColor) { super(spanSize); color = spanColor; } @Override public void updateDrawState(TextPaint textPaint) { super.updateDrawState(textPaint); textPaint.setColor(color); } }
この例では、カスタムスパンを作成する方法を示します。RelativeSizeSpan
と ForegroundColorSpan
をテキストに適用しても、同じ効果を実現できます。
スパンの使用方法をテストする
Spanned
インターフェースを使用すると、スパンの設定と、テキストからのスパンの取得の両方を行うことができます。テストの際には、Android JUnit テストを実装して、正しい位置に正しいスパンが追加されていることを確認します。テキストのスタイル設定のサンプルアプリには、テキストに BulletPointSpan
をアタッチして箇条書きにマークアップを適用するスパンが含まれています。次のコード例は、箇条書きが想定どおりに表示されるかどうかをテストする方法を示しています。
Kotlin
@Test fun textWithBulletPoints() { val result = builder.markdownToSpans("Points\n* one\n+ two") // Check whether the markup tags are removed. assertEquals("Points\none\ntwo", result.toString()) // Get all the spans attached to the SpannedString. val spans = result.getSpans<Any>(0, result.length, Any::class.java) // Check whether the correct number of spans are created. assertEquals(2, spans.size.toLong()) // Check whether the spans are instances of BulletPointSpan. val bulletSpan1 = spans[0] as BulletPointSpan val bulletSpan2 = spans[1] as BulletPointSpan // Check whether the start and end indices are the expected ones. assertEquals(7, result.getSpanStart(bulletSpan1).toLong()) assertEquals(11, result.getSpanEnd(bulletSpan1).toLong()) assertEquals(11, result.getSpanStart(bulletSpan2).toLong()) assertEquals(14, result.getSpanEnd(bulletSpan2).toLong()) }
Java
@Test public void textWithBulletPoints() { SpannedString result = builder.markdownToSpans("Points\n* one\n+ two"); // Check whether the markup tags are removed. assertEquals("Points\none\ntwo", result.toString()); // Get all the spans attached to the SpannedString. Object[] spans = result.getSpans(0, result.length(), Object.class); // Check whether the correct number of spans are created. assertEquals(2, spans.length); // Check whether the spans are instances of BulletPointSpan. BulletPointSpan bulletSpan1 = (BulletPointSpan) spans[0]; BulletPointSpan bulletSpan2 = (BulletPointSpan) spans[1]; // Check whether the start and end indices are the expected ones. assertEquals(7, result.getSpanStart(bulletSpan1)); assertEquals(11, result.getSpanEnd(bulletSpan1)); assertEquals(11, result.getSpanStart(bulletSpan2)); assertEquals(14, result.getSpanEnd(bulletSpan2)); }
他のテスト例については、GitHub の MarkdownBuilderTest をご覧ください。
カスタムスパンをテストする
スパンをテストするときは、想定される変更が TextPaint
に含まれていることと、正しい要素が Canvas
に表示されることを確認します。たとえば、一部のテキストの先頭に箇条書きを付加するカスタムスパン実装があるとします。箇条書きにはサイズと色が指定され、ドローアブル領域の左余白と箇条書きの間にはギャップがあります。
このクラスの動作をテストするには、AndroidJUnit テストを実装して、以下の点をチェックします。
- スパンを正しく適用すると、指定したサイズと色の箇条書きがキャンバスに表示され、左余白と箇条書きの間に適切なスペースが確保されます。
- スパンを適用しない場合、カスタム動作は表示されません。
これらのテストの実装は、GitHub の TextStyling サンプルで確認できます。
キャンバスの操作をテストするには、キャンバスをモックし、モックされたオブジェクトを drawLeadingMargin()
メソッドに渡して、正しいメソッドが正しいパラメータで呼び出されることを確認します。
その他のスパンテストのサンプルについては、BulletPointSpanTest をご覧ください。
スパンの使用方法に関するベスト プラクティス
必要に応じて、メモリ効率の高い方法で TextView
にテキストを設定できます。
基盤テキストを変更せずにスパンのアタッチやデタッチを行う
TextView.setText()
には、スパンを異なる方法で処理する複数のオーバーロードが含まれています。たとえば、次のコードを使用して Spannable
テキスト オブジェクトを設定できます。
Kotlin
textView.setText(spannableObject)
Java
textView.setText(spannableObject);
この setText()
のオーバーロードを呼び出すと、TextView
は、Spannable
のコピーを SpannedString
として作成し、メモリ内に CharSequence
として保持します。つまり、テキストとスパンは不変であるため、テキストやスパンを更新する必要がある場合は、新しい Spannable
オブジェクトを作成して、再度 setText()
を呼び出します。これにより、レイアウトの再測定と再描画もトリガーされます。
スパンが可変であることを指定するには、代わりに setText(CharSequence text, TextView.BufferType
type)
を使用します。次の例をご覧ください。
Kotlin
textView.setText(spannable, BufferType.SPANNABLE) val spannableText = textView.text as Spannable spannableText.setSpan( ForegroundColorSpan(color), 8, spannableText.length, SPAN_INCLUSIVE_INCLUSIVE )
Java
textView.setText(spannable, BufferType.SPANNABLE); Spannable spannableText = (Spannable) textView.getText(); spannableText.setSpan( new ForegroundColorSpan(color), 8, spannableText.getLength(), SPAN_INCLUSIVE_INCLUSIVE);
この例では、BufferType.SPANNABLE
パラメータによって TextView
によって SpannableString
が作成され、TextView
によって保持される CharSequence
オブジェクトに可変マークアップと不変テキストが含まれるようになりました。スパンを更新するには、テキストを Spannable
として取得し、必要に応じてスパンを更新します。
スパンのアタッチやデタッチ、再配置を行うと、TextView
が自動的に更新され、テキストへの変更が反映されます。既存のスパンの内部属性を変更する場合は、invalidate()
を呼び出して外観関連の変更を行うか、requestLayout()
を呼び出して指標関連の変更を行います。
TextView 内でテキストを複数回設定する
RecyclerView.ViewHolder
を使用する場合など、TextView
を再利用してテキストを複数回設定したいことがあります。デフォルトでは、BufferType
を設定したかどうかに関係なく、TextView
は CharSequence
オブジェクトのコピーを作成し、メモリに保持します。これにより、すべての TextView
の更新が意図的なものになります。元の CharSequence
オブジェクトを更新してテキストを更新することはできません。つまり、新しいテキストを設定するたびに、TextView
によって新しいオブジェクトが作成されます。
このプロセスをより細かく制御し、余分なオブジェクトの作成を回避するには、独自の Spannable.Factory
を実装して、newSpannable()
をオーバーライドします。新しいテキスト オブジェクトを作成する代わりに、既存の CharSequence
をキャストして Spannable
として返すことができます。次の例をご覧ください。
Kotlin
val spannableFactory = object : Spannable.Factory() { override fun newSpannable(source: CharSequence?): Spannable { return source as Spannable } }
Java
Spannable.Factory spannableFactory = new Spannable.Factory(){ @Override public Spannable newSpannable(CharSequence source) { return (Spannable) source; } };
テキストを設定するときは、textView.setText(spannableObject, BufferType.SPANNABLE)
を使用する必要があります。それ以外の場合、ソース CharSequence
は Spanned
インスタンスとして作成され、Spannable
にキャストできないため、newSpannable()
が ClassCastException
をスローします。
newSpannable()
をオーバーライドした後、新しい Factory
を使用するように TextView
に指示します。
Kotlin
textView.setSpannableFactory(spannableFactory)
Java
textView.setSpannableFactory(spannableFactory);
TextView
への参照を取得した直後に、Spannable.Factory
オブジェクトを 1 回設定します。RecyclerView
を使用している場合は、最初にビューをインフレートするときに Factory
オブジェクトを設定します。これにより、RecyclerView
が新しいアイテムを ViewHolder
にバインドするときに、余分なオブジェクトが作成されなくなります。
内部スパン属性を変更する
変更可能なスパンの内部属性(カスタムの箇条書きスパンの箇条書きの色など)のみを変更する必要がある場合は、作成時にスパンへの参照を保持することで、setText()
を複数回呼び出すことによるオーバーヘッドを回避できます。スパンを変更する必要がある場合は、参照を変更してから、変更した属性のタイプに応じて TextView
で invalidate()
または requestLayout()
を呼び出します。
次のコード例では、カスタムの箇条書きの実装のデフォルト色は赤で、ボタンがタップされるとグレーに変わります。
Kotlin
class MainActivity : AppCompatActivity() { // Keeping the span as a field. val bulletSpan = BulletPointSpan(color = Color.RED) override fun onCreate(savedInstanceState: Bundle?) { ... val spannable = SpannableString("Text is spantastic") // Setting the span to the bulletSpan field. spannable.setSpan( bulletSpan, 0, 4, Spanned.SPAN_INCLUSIVE_INCLUSIVE ) styledText.setText(spannable) button.setOnClickListener { // Change the color of the mutable span. bulletSpan.color = Color.GRAY // Color doesn't change until invalidate is called. styledText.invalidate() } } }
Java
public class MainActivity extends AppCompatActivity { private BulletPointSpan bulletSpan = new BulletPointSpan(Color.RED); @Override protected void onCreate(Bundle savedInstanceState) { ... SpannableString spannable = new SpannableString("Text is spantastic"); // Setting the span to the bulletSpan field. spannable.setSpan(bulletSpan, 0, 4, Spanned.SPAN_INCLUSIVE_INCLUSIVE); styledText.setText(spannable); button.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { // Change the color of the mutable span. bulletSpan.setColor(Color.GRAY); // Color doesn't change until invalidate is called. styledText.invalidate(); } }); } }
Android KTX 拡張関数を使用する
Android KTX には、スパンの処理を容易にする拡張関数も含まれています。詳細については、androidx.core.text パッケージのドキュメントをご覧ください。