スパン

Compose を試す
Jetpack Compose は Android で推奨される UI ツールキットです。Compose でテキストを使用する方法について学習します。

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

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

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

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

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

3 つのクラスはすべて Spanned インターフェースを拡張します。また、SpannableStringSpannableStringBuilder の場合は、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
);
グレーのテキストと一部赤のテキストの画像。
図 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 へのアクセス権を付与することで、スパンがテキスト測定に与える影響をサブクラスで定義できます。MetricAffectingSpanCharacterSpan を拡張するため、サブクラスは文字レベルでのテキストの外観に影響します。

段落に影響するスパン

スパンは、テキスト ブロックの配置やマージンの変更など、段落レベルのテキストにも影響します。段落全体に影響するスパンは、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 を拡張します。継承により、RelativeSizeSpanCharacterStyle を拡張し、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 テストを実装して、正しいスパンが正しい場所に追加されていることを確認します。テキスト スタイル設定サンプルアプリには、テキストに 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 オブジェクトを 1 回設定します。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 パッケージのドキュメントをご覧ください。