スパンは強力なマークアップ オブジェクトで、文字単位や段落単位でテキストのスタイルを設定できます。スパンをテキスト オブジェクトにアタッチすると、色の追加、テキストのクリック可能化、文字サイズの拡大縮小、テキストのカスタム描画など、さまざまな方法でテキストを変更できます。また、スパンを使用すると、TextPaint
プロパティの変更、Canvas
への描画、テキスト レイアウトの変更も行えます。
Android には、さまざまなタイプのスパンが用意されており、一般的なテキスト スタイル パターンを幅広くカバーします。また、独自のスパンを作成して、カスタム スタイルを適用することもできます。
スパンを作成して適用する
スパンを作成するには、下記の表にリスト表示されているいずれかのクラスを使用します。各クラスは、テキスト自体が変更可能かどうか、テキスト マークアップが変更可能かどうか、スパンデータを格納する基盤データ構造がどのような構造か、という点において異なります。
クラス | テキストは変更可能か? | マークアップは変更可能か? | どのようなデータ構造か? |
---|---|---|---|
SpannedString |
× | × | リニア配列 |
SpannableString |
× | ○ | リニア配列 |
SpannableStringBuilder |
○ | ○ | 区間ツリー |
どのクラスを使用するかを決定する方法は、次のとおりです。
- テキストやマークアップを作成後に変更しない場合は、
SpannedString
を使用します。 - 単一のテキスト オブジェクトに少数のスパンをアタッチし、テキスト自体は読み取り専用にする場合は、
SpannableString
を使用します。 - 作成後にテキストを変更する必要があり、テキストにスパンをアタッチする必要がある場合は、
SpannableStringBuilder
を使用します。 - テキスト オブジェクトに多数のスパンをアタッチする必要がある場合は、テキスト自体を読み取り専用にするかどうかにかかわらず、
SpannableStringBuilder
を使用します。
上記のクラスはすべて、Spanned
インターフェースを拡張します。また、SpannableString
と SpannableStringBuilder
の場合は、Spannable
インターフェースも拡張します。
スパンを適用するには、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 );
図 1. ForegroundColorSpan
を使用してスタイルを設定したテキスト
このスパンは 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)");
図 2.Spannable.SPAN_EXCLUSIVE_INCLUSIVE
を使用しているため、追加テキストを含むように拡張するスパン
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 = 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 );
図 3. 複数のスパン(ForegroundColorSpan(Color.RED)
と StyleSpan(BOLD)
)を使用したテキスト
Android のスパンタイプ
Android は、android.text.style パッケージ内に 20 種以上のスパンタイプを用意しています。Android では、次の 2 つの基準に基づいて、スパンを大まかに分類しています。
- スパンがテキストに及ぼす影響: スパンは、テキストの外観やテキストのサイズに影響を及ぼすことができます。
- スパンの対象範囲: 個々の文字単位で適用できるスパンと、段落単位の適用が必要となるスパンがあります。
図 4. スパンのカテゴリ: 文字か段落か、外観かサイズか
以下のセクションでは、各カテゴリについて詳しく説明します。
テキストの外観に影響するスパン
スパンは、テキスト色や背景色の変更、下線や取り消し線の追加など、テキストの外観に影響を及ぼすことができます。このタイプのスパンはすべて、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);
図 5. UnderlineSpan
を使用してテキストに下線を引く
テキストの外観だけに影響するスパンは、レイアウトの再計算をトリガーせずに、テキストの再描画をトリガーします。このタイプのスパンは、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);
図 6. RelativeSizeSpan
を使用して文字サイズを設定する
テキストのサイズに影響するスパンを適用すると、正しいレイアウトとレンダリングを実現するために、モニタリング オブジェクトがテキストを再測定します。そのため、たとえば、文字サイズを変更すると、異なる行に単語が表示される場合があります。上記のスパンを適用すると、再測定、テキスト レイアウトの再計算、およびテキストの再描画がトリガーされます。このタイプのスパンは通常、MetricAffectingSpan
クラスを拡張します。この抽象クラスは、TextPaint
へのアクセスを提供することにより、スパンがテキスト測定に及ぼす影響をサブクラスで定義できるようにします。MetricAffectingSpan
は CharacterSpan
を拡張するため、サブクラスは文字単位でテキストの外観に影響します。
個々の文字に影響するスパン
スパンは、文字単位でテキストに影響を及ぼすことができます。たとえば、背景色やスタイル、サイズなどの文字要素を更新できます。個々の文字に影響するスパンは、CharacterStyle
クラスを拡張します。
テキスト内の文字のサブセットに BackgroundColorSpan
をアタッチするサンプルコードを以下に示します。
Kotlin
val string = SpannableString("Text with a background color span") string.setSpan(BackgroundColorSpan(color), 12, 28, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
Java
SpannableString string = new SpannableString("Text with a background color span"); string.setSpan(new BackgroundColorSpan(color), 12, 28, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
図 7. テキストに BackgroundColorSpan
を適用する
段落に影響するスパン
スパンは、テキスト ブロック全体の配置や余白の変更など、段落単位でテキストに影響を及ぼすこともできます。段落全体に影響するスパンは、ParagraphStyle
を実装します。このタイプのスパンを使用する場合は、末尾の改行文字を除く段落全体にアタッチする必要があります。段落スパンを段落全体以外に適用しようとしても、そのスパンは適用されません。
Android がテキスト内で段落を分離する方法を図 8 に示します。
図 8. Android では、段落は改行文字('\n'
)で終了する
段落全体に 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);
図 9. 段落に QuoteSpan を適用する
カスタムスパンを作成する
Android の既存のスパンで実現できる機能よりも高度な機能が必要な場合は、カスタムスパンを実装します。独自のスパンを実装する場合は、影響を受けるテキストが文字単位なのか段落単位なのか、さらに、影響を受けるのがテキストのレイアウトなのか外観なのか、を決定する必要があります。これにより、どの基本クラスを拡張し、どのインターフェースを実装する必要があるのかを判断できます。次の表を参考にしてください。
シナリオ | クラスまたはインターフェース |
---|---|
文字単位でテキストに影響するスパンの場合。 | CharacterStyle |
段落単位でテキストに影響するスパンの場合。 | ParagraphStyle |
テキストの外観に影響するスパンの場合。 | UpdateAppearance |
テキストのサイズに影響するスパンの場合。 | UpdateLayout |
たとえば、テキストのサイズと色を変更可能なカスタムスパンを実装する必要がある場合は、RelativeSizeSpan
を拡張します。このクラスはすでに 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 テストを実装して、正しい位置に正しいスパンが追加されているか検証する必要があります。Text Styling サンプルには、テキストに BulletPointSpan
をアタッチすることにより、箇条書きのマークアップを適用するスパンが含まれています。箇条書きが想定どおりに表示されるかテストするサンプルコードを以下に示します。
Kotlin
@Test fun textWithBulletPoints() { val result = builder.markdownToSpans(“Points\n* one\n+ two”) // check that the markup tags were 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 that the correct number of spans were created assertEquals(2, spans.size.toLong()) // check that the spans are instances of BulletPointSpan val bulletSpan1 = spans[0] as BulletPointSpan val bulletSpan2 = spans[1] as BulletPointSpan // check that 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 that the markup tags were 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 that the correct number of spans were created assertEquals(2, spans.length); // check that the spans are instances of BulletPointSpan BulletPointSpan bulletSpan1 = (BulletPointSpan) spans[0]; BulletPointSpan bulletSpan2 = (BulletPointSpan) spans[1]; // check that 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)); }
他のテスト例については、MarkdownBuilderTest をご覧ください。
カスタムスパン実装をテストする
スパンをテストする際は、想定どおりの変更内容が TextPaint
に組み込まれているか、Canvas
上に正しい要素が表示されるか検証する必要があります。たとえば、一部のテキストの先頭に箇条書きを付加するカスタムスパン実装があるとします。箇条書きはサイズと色が指定されており、ドローアブル領域の左マージンと箇条書きの間には隙間が存在します。
このクラスの動作をテストするには、AndroidJUnit テストを実装して、以下の点をチェックします。
- スパンを正しく適用した場合、指定したサイズと色の箇条書きがキャンバス上に表示され、左マージンと箇条書きの間に適切なスペースが存在します。
- スパンを適用しなかった場合、カスタム動作は表示されません。
このテストの実装については、TextStylingKotlin サンプルをご覧ください。
キャンバスのインタラクションをテストする場合は、キャンバスをモックして、モックしたオブジェクトを drawLeadingMargin()
メソッドに渡し、正しいメソッドが正しいパラメータで呼び出されるか検証します。以下の例をご覧ください。
Kotlin
val GAP_WIDTH = 5 val canvas = mock(Canvas::class.java) val paint = mock(Paint::class.java) val text = SpannableString("text") @Test fun drawLeadingMargin() { val x = 10 val dir = 15 val top = 5 val bottom = 7 val color = Color.RED // Given a span that is set on a text val span = BulletPointSpan(GAP_WIDTH, color) text.setSpan(span, 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) // When the leading margin is drawn span.drawLeadingMargin(canvas, paint, x, dir, top, 0, bottom, text, 0, 0, true, mock(Layout::class.java)) // Check that the correct canvas and paint methods are called, // in the correct order val inOrder = inOrder(canvas, paint) // bullet point paint color is the one we set inOrder.verify(paint).color = color inOrder.verify(paint).style = eq<Paint.Style>(Paint.Style.FILL) // a circle with the correct size is drawn // at the correct location val xCoordinate = GAP_WIDTH.toFloat() + x.toFloat() + dir * BulletPointSpan.DEFAULT_BULLET_RADIUS val yCoordinate = (top + bottom) / 2f inOrder.verify(canvas) .drawCircle( eq(xCoordinate), eq(yCoordinate), eq(BulletPointSpan.DEFAULT_BULLET_RADIUS), eq(paint) ) verify(canvas, never()).save() verify(canvas, never()).translate( eq(xCoordinate), eq(yCoordinate) ) }
Java
private int GAP_WIDTH = 5; private Canvas canvas = mock(Canvas.class); private Paint paint = mock(Paint.class); private SpannableString text = new SpannableString("text"); @Test public void drawLeadingMargin() { int x = 10; int dir = 15; int top = 5; int bottom = 7; int color = Color.RED; // Given a span that is set on a text BulletPointSpan span = new BulletPointSpan(GAP_WIDTH, color); text.setSpan(span, 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); // When the leading margin is drawn span.drawLeadingMargin(canvas, paint, x, dir, top, 0, bottom, text, 0, 0, true, mock (Layout.class)); // Check that the correct canvas and paint methods are called, in the correct order InOrder inOrder = inOrder(canvas, paint); inOrder.verify(paint).setColor(color); inOrder.verify(paint).setStyle(eq(Paint.Style.FILL)); // a circle with the correct size is drawn // at the correct location int xCoordinate = (float)GAP_WIDTH + (float)x + dir * BulletPointSpan.BULLET_RADIUS; int yCoordinate = (top + bottom) / 2f; inOrder.verify(canvas) .drawCircle( eq(xCoordinate), eq(yCoordinate), eq(BulletPointSpan.BULLET_RADIUS), eq(paint)); verify(canvas, never()).save(); verify(canvas, never()).translate( eq(xCoordinate), eq(yCoordinate); }
他のスパンテストについては、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
オブジェクトを一度設定するようにしてください。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 our mutable span bulletSpan.color = Color.GRAY // color won’t be changed 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 our mutable span bulletSpan.setColor(Color.GRAY); // color won’t be changed until invalidate is called styledText.invalidate(); } }); } }
Android KTX 拡張関数を使用する
Android KTX には、スパンの処理をさらに簡単にする拡張関数が含まれています。詳細については、androidx.core.text パッケージのドキュメントをご覧ください。