スパン

Compose の方法を試す
Jetpack Compose は、Android で推奨される UI ツールキットです。Compose でテキストを使用する方法を学びます。

スパンは強力なマークアップ オブジェクトで、文字単位や段落単位でテキストのスタイルを設定できます。スパンをテキスト オブジェクトにアタッチすると、色の追加、テキストのクリック可能化、文字サイズの拡大縮小、テキストのカスタム描画など、さまざまな方法でテキストを変更できます。また、スパンを使用すると、TextPaint プロパティの変更、Canvas への描画、テキスト レイアウトの変更も行えます。

Android には、さまざまなタイプのスパンが用意されており、一般的なテキスト スタイル パターンを幅広くカバーします。また、独自のスパンを作成して、カスタム スタイルを適用することもできます。

スパンを作成して適用する

スパンを作成するには、次の表にリスト表示されているいずれかのクラスを使用します。クラスは、テキスト自体が変更可能かどうか、テキスト マークアップが変更可能かどうか、スパンデータを格納する基盤データ構造がどのような構造か、という点において異なります。

クラス テキストは変更可能か? マークアップは変更可能か? どのようなデータ構造か?
SpannedString × × リニア配列
SpannableString × リニア配列
SpannableStringBuilder 区間ツリー

3 つのクラスはすべて、Spanned インターフェースを拡張します。また、SpannableStringSpannableStringBuilder の場合は、Spannable インターフェースも拡張します。

どちらを使用するかを決定する方法は次のとおりです。

  • テキストやマークアップを作成後に変更しない場合は、SpannedString を使用します。
  • 単一のテキスト オブジェクトに少数のスパンをアタッチし、テキスト自体は読み取り専用にする場合は、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
);
一部が赤色のグレーのテキストが表示されている画像。
図 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)");
SPAN_EXCLUSIVE_INCLUSIVE を使用すると、スパンに追加のテキストが含まれる仕組みを示す画像。
図 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 = 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
);
複数のスパン(ForegroundColorSpan(Color.RED) と StyleSpan(BOLD))を使用したテキストを示す画像
図 3. 複数のスパン(ForegroundColorSpan(Color.RED)StyleSpan(BOLD))を使用したテキスト。

Android のスパンタイプ

Android は、android.text.style パッケージ内に 20 種以上のスパンタイプを用意しています。Android では、次の 2 つの基準に基づいて、スパンを大まかに分類しています。

  • スパンがテキストに及ぼす影響: スパンは、テキストの外観やテキストのサイズに影響を及ぼすことができます。
  • スパンの対象範囲: 個々の文字単位で適用できるスパンと、段落単位の適用が必要となるスパンがあります。
さまざまなスパンのカテゴリを示す画像
図 4. Android スパンのカテゴリ。

以降のセクションでは、各カテゴリについて詳しく説明します。

テキストの外観に影響するスパン

文字単位で適用される一部のスパンは、テキスト色や背景色の変更、下線や取り消し線の追加など、テキストの外観に影響します。これらのスパンは 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);
UnderlineSpan を使用してテキストに下線を引く方法を示す画像
図 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);
RelativeSizeSpan の使用方法を示す画像
図 6. RelativeSizeSpan を使用してテキストを大きくしました。

テキストのサイズに影響するスパンを適用すると、正しいレイアウトとレンダリングを実現するために、モニタリング オブジェクトがテキストを再測定します。そのため、たとえば、文字サイズを変更すると、異なる行に単語が表示される場合があります。上記のスパンを適用すると、再測定、テキスト レイアウトの再計算、テキストの再描画がトリガーされます。

テキストのサイズに影響するスパンは、MetricAffectingSpan クラスを拡張します。この抽象クラスは、TextPaint へのアクセスを提供することにより、スパンがテキスト測定に及ぼす影響をサブクラスで定義できるようにします。MetricAffectingSpanCharacterStyle を拡張するため、サブクラスは文字単位でテキストの外観に影響します。

段落に影響するスパン

スパンは、テキスト ブロックの配置や余白の変更など、段落単位でテキストに影響を及ぼすこともできます。段落全体に影響するスパンは、ParagraphStyle を実装します。このタイプのスパンを使用するには、末尾の改行文字を除く段落全体にアタッチします。段落スパンを段落全体以外に適用しようとしても、そのスパンは適用されません。

Android がテキスト内で段落を分離する方法を図 8 に示します。

図 7. 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);
QuoteSpan の例を示す画像
図 8. 段落に適用された QuoteSpan

カスタムスパンを作成する

Android の既存のスパンで実現できる機能よりも高度な機能が必要な場合は、カスタムスパンを実装します。独自のスパンを実装する場合は、影響を受けるテキストが文字単位なのか段落単位なのか、さらに、影響を受けるのがテキストのレイアウトなのか外観なのか、を決定する必要があります。これにより、どの基本クラスを拡張し、どのインターフェースを実装する必要があるのかを判断できます。次の表を参考にしてください。

シナリオ クラスまたはインターフェース
文字単位でテキストに影響するスパンの場合。 CharacterStyle
テキストの外観に影響するスパンの場合。 UpdateAppearance
テキストのサイズに影響するスパンの場合。 UpdateLayout
段落単位でテキストに影響するスパンの場合。 ParagraphStyle

たとえば、テキストのサイズと色を変更するカスタムスパンを実装する必要がある場合は、RelativeSizeSpan を拡張します。RelativeSizeSpan は継承によって CharacterStyle を拡張し、2 つの Update インターフェースを実装します。このクラスはすでに updateDrawStateupdateMeasureState 用のコールバックを備えているため、このコールバックをオーバーライドすることで、カスタム動作を実装できます。次のコードは、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);
    }
}

この例は、カスタムスパンの作成方法を示しています。RelativeSizeSpanForegroundColorSpan をテキストに適用した場合も、同じ効果を実現できます。

スパンの使用方法をテストする

Spanned インターフェースを使用すると、スパンを設定することも、テキストからスパンを取得することもできます。テストを行う場合は、Android JUnit テストを実装して、正しい位置に正しいスパンが追加されているか検証します。Text Styling サンプルアプリには、テキストに 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 パラメータにより TextViewSpannableString を作成し、TextView によって保持される CharSequence オブジェクトに可変マークアップと不変テキストが含まれるようになります。スパンを更新するには、テキストを Spannable として取得して、必要に応じてスパンを更新します。

スパンのアタッチやデタッチ、再配置を行うと、TextView が自動的に更新され、テキストへの変更が反映されます。既存のスパンの内部属性を変更する場合は、外観関連の変更を行うには invalidate() を、指標関連の変更を行うには requestLayout() を呼び出します。

TextView 内でテキストを複数回設定する

RecyclerView.ViewHolder を使用する場合など、TextView を再利用してテキストを複数回設定したいことがあります。デフォルトでは、BufferType を設定したかどうかにかかわらず、TextViewCharSequence オブジェクトのコピーを作成し、メモリ内に保持します。これにより、すべての TextView の更新が意図的に行われるようになります。元の CharSequence オブジェクトを更新しても、テキストが更新されることはありません。つまり、新しいテキストを設定するたびに、TextView は新しいオブジェクトを作成します。

このプロセスを詳細に制御し、余分なオブジェクトの作成を避けたい場合は、独自の Spannable.Factory を実装して、newSpannable() をオーバーライドします。次の例に示すように、新しいテキスト オブジェクトを作成する代わりに、既存の CharSequenceSpannable としてキャストして返すことができます。

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) を使用する必要があります。そうでない場合、ソース CharSequenceSpanned インスタンスとして作成され、Spannable にキャストできなくなります。そのため、newSpannable()ClassCastException をスローするようになります。

newSpannable() をオーバーライドした後、新しい Factory を使用するように TextView に指示します。

Kotlin

textView.setSpannableFactory(spannableFactory)

Java

textView.setSpannableFactory(spannableFactory);

TextView への参照を取得したらすぐに、Spannable.Factory オブジェクトを一度設定します。RecyclerView を使用している場合は、ビューを最初にインフレートしたときに Factory オブジェクトを設定します。これにより、RecyclerView が新しいアイテムを ViewHolder にバインドする際に、余分なオブジェクトが作成されるのを防ぐことができます。

内部スパン属性を変更する

カスタム箇条書きスパンの行頭記号の色など、可変スパンの内部属性だけを変更する必要がある場合は、スパンへの参照を作成時に保持することにより、setText() を複数回呼び出すことによるオーバーヘッドを避けることができます。スパンを変更する必要がある場合は、参照を変更してから、変更した属性のタイプに応じて TextViewinvalidate() または 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 パッケージのドキュメントをご覧ください。