תגי Span

אפשר לנסות את הדרך של כתיבת הודעה
‫Jetpack Compose היא ערכת הכלים המומלצת לבניית ממשק משתמש ב-Android. איך משתמשים בטקסט בכתיבה.

תגי Span הם אובייקטים רבי-עוצמה של סימון שאפשר להשתמש בהם כדי לעצב טקסט ברמת התו או הפסקה. על ידי צירוף של span לאובייקטים של טקסט, אפשר לשנות את הטקסט במגוון דרכים, כולל הוספת צבע, הפיכת הטקסט לטקסט שאפשר ללחוץ עליו, שינוי גודל הטקסט וציור הטקסט בצורה מותאמת אישית. אפשר גם להשתמש ב-Span כדי לשנות מאפיינים של TextPaint, לצייר על Canvas ולשנות את פריסת הטקסט.

ב-Android יש כמה סוגים של טווחים שמכסים מגוון דפוסים נפוצים של עיצוב טקסט. אפשר גם ליצור טווחים משלכם כדי להחיל סגנון מותאם אישית.

יצירה והחלה של טווח

כדי ליצור טווח, אפשר להשתמש באחת מהמחלקות שמפורטות בטבלה הבאה. ההבדלים בין המחלקות תלויים בשאלה אם הטקסט עצמו ניתן לשינוי, אם תגי העיצוב של הטקסט ניתנים לשינוי ובאיזה מבנה נתונים בסיסי מאוחסנים נתוני הטווח.

דרגה טקסט שניתן לשינוי תגי עיצוב שניתנים לשינוי מבנה הנתונים
SpannedString לא לא מערך לינארי
SpannableString לא כן מערך לינארי
SpannableStringBuilder כן כן עץ אינטרוולים

כל שלוש המחלקות מרחיבות את הממשק Spanned. SpannableString וגם SpannableStringBuilder מרחיבים את הממשק של Spannable.

כך מחליטים באיזה פורמט להשתמש:

  • אם לא משנים את הטקסט או את תגי העיצוב אחרי היצירה, משתמשים ב-SpannedString.
  • אם אתם צריכים לצרף מספר קטן של תגי span לאובייקט טקסט יחיד והטקסט עצמו הוא לקריאה בלבד, השתמשו בתג SpannableString.
  • אם צריך לשנות טקסט אחרי שיוצרים אותו ולצרף לו טווחים, משתמשים ב-SpannableStringBuilder.
  • אם אתם צריכים לצרף מספר גדול של טווחים לאובייקט טקסט, בלי קשר לשאלה אם הטקסט עצמו הוא לקריאה בלבד, השתמשו ב-SpannableStringBuilder.

כדי להחיל טווח, קוראים ל-setSpan(Object _what_, int _start_, int _end_, int _flags_) באובייקט Spannable. הפרמטר what מתייחס לטווח שאתם מחילים על הטקסט, והפרמטרים start ו-end מציינים את החלק בטקסט שעליו אתם מחילים את הטווח.

אם מוסיפים טקסט בתוך הגבולות של span, ה-span מתרחב באופן אוטומטי כדי לכלול את הטקסט שנוסף. כשמוסיפים טקסט בגבולות של טווח – כלומר, במדדי ההתחלה או הסוף – הפרמטר 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. התג span מתרחב וכולל טקסט נוסף כשמשתמשים ב-Spannable.SPAN_EXCLUSIVE_INCLUSIVE.

אפשר לצרף כמה טווחי טקסט לאותו טקסט. בדוגמה הבאה אפשר לראות איך יוצרים טקסט מודגש באדום:

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 יש יותר מ-20 סוגים של span בחבילה android.text.style. ‫Android מסווג טווחים בשתי דרכים עיקריות:

  • איך התג span משפיע על הטקסט: התג span יכול להשפיע על המראה של הטקסט או על מדדי הטקסט.
  • היקף התג: יש תגי span שאפשר להחיל על תווים בודדים, ויש תגי span שאפשר להחיל רק על פסקה שלמה.
תמונה שמציגה קטגוריות שונות של טווחים
איור 4. קטגוריות של טווחים ב-Android.

בקטעים הבאים מפורטות הקטגוריות האלה.

תגי span שמשפיעים על מראה הטקסט

חלק מהתגים שמוגדרים ברמת התו משפיעים על מראה הטקסט, למשל שינוי צבע הטקסט או הרקע, והוספה של קו תחתון או קו חוצה. הטווחים האלה מרחיבים את המחלקה 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.

תגי span שמשפיעים רק על המראה של הטקסט מפעילים ציור מחדש של הטקסט בלי להפעיל חישוב מחדש של הפריסה. הטווחים האלה מיישמים את UpdateAppearance ומרחיבים את CharacterStyle. מחלקות משנה של CharacterStyle מגדירות איך לצייר טקסט על ידי מתן גישה לעדכון TextPaint.

תגי span שמשפיעים על מדדי טקסט

טווחים אחרים שחלים ברמת התו משפיעים על מדדי טקסט, כמו גובה השורה וגודל הטקסט. הטווחים האלה מרחיבים את המחלקה MetricAffectingSpan.

בדוגמת הקוד הבאה נוצר RelativeSizeSpan שמגדיל את גודל הטקסט ב-50%:

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.

החלת span שמשפיע על מדדי הטקסט גורמת לאובייקט שמתבצעת בו מדידה למדוד מחדש את הטקסט כדי שהפריסה והעיבוד יהיו נכונים – לדוגמה, שינוי גודל הטקסט עשוי לגרום למילים להופיע בשורות שונות. החלת התג span שלמעלה מפעילה מדידה מחדש, חישוב מחדש של פריסת הטקסט וציור מחדש של הטקסט.

טווחים שמשפיעים על מדדי טקסט מרחיבים את המחלקה MetricAffectingSpan, מחלקה מופשטת שמאפשרת למחלקות משנה להגדיר איך הטווח משפיע על מדידת הטקסט על ידי מתן גישה אל TextPaint. ‫MetricAffectingSpan extends CharacterStyle, ולכן מחלקות משנה משפיעות על מראה הטקסט ברמת התו.

תגי span שמשפיעים על פסקאות

תג span יכול להשפיע גם על טקסט ברמת הפסקה, למשל לשנות את היישור או את השוליים של בלוק טקסט. תגי span שמשפיעים על פסקאות שלמות מטמיעים את ParagraphStyle. כדי להשתמש בטווחים האלה, צריך לצרף אותם לפסקה כולה, לא כולל תו השורה החדשה בסוף. אם מנסים להחיל טווח של פסקה על משהו שהוא לא פסקה שלמה, Android לא מחיל את הטווח בכלל.

באיור 8 מוצג אופן ההפרדה בין פסקאות בטקסט ב-Android.

איור 7. ב-Android, פסקאות מסתיימות בתו של שורה חדשה (\n).

בדוגמת הקוד הבאה מוחל QuoteSpan על פסקה. חשוב לזכור שאם מצמידים את ה-span למיקום כלשהו מלבד ההתחלה או הסוף של פסקה, מערכת Android לא מחילה את הסגנון בכלל.

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
התג span משפיע על מראה הטקסט. UpdateAppearance
הטווח משפיע על מדדי הטקסט. UpdateLayout
הטווח משפיע על הטקסט ברמת הפסקה. ParagraphStyle

לדוגמה, אם אתם צריכים להטמיע טווח מותאם אישית שמשנה את הגודל והצבע של הטקסט, צריך להרחיב את RelativeSizeSpan. באמצעות ירושה, RelativeSizeSpan extends CharacterStyle ומיישם את שני הממשקים Update. מכיוון שהמחלקות האלה כבר מספקות קריאות חוזרות ל-updateDrawState ול-updateMeasureState, אפשר לבטל את הקריאות החוזרות האלה כדי להטמיע התנהגות מותאמת אישית. הקוד הבא יוצר טווח מותאם אישית שמרחיב את RelativeSizeSpan ומבטל את הקריאה החוזרת (callback) של 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 כוללת תג span שמחיל תגי עיצוב על תבליטים על ידי צירוף 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));
}

דוגמאות נוספות לבדיקות זמינות ב-MarkdownBuilderTest ב-GitHub.

בדיקת טווחים בהתאמה אישית

כשבודקים טווחים, צריך לוודא שהתג TextPaint מכיל את השינויים הצפויים ושהרכיבים הנכונים מופיעים בתג Canvas. לדוגמה, נניח שאתם מטמיעים טווח מותאם אישית שמוסיף תבליט לפני טקסט מסוים. לנקודה יש גודל וצבע מוגדרים, ויש רווח בין השוליים הימניים של האזור שאפשר לצייר בו לבין הנקודה.

כדי לבדוק את ההתנהגות של המחלקה הזו, אפשר להטמיע בדיקת AndroidJUnit ולבדוק את הדברים הבאים:

  • אם תחילת הטווח מוגדרת בצורה נכונה, תופיע בלוח הציור נקודת תבליט בגודל ובצבע שצוינו, ויהיה רווח מתאים בין השוליים הימניים לבין נקודת התבליט.
  • אם לא תגדירו את הטווח, אף אחד מההתנהגויות המותאמות אישית לא יופיע.

אפשר לראות את ההטמעה של הבדיקות האלה בדוגמה TextStyling ב-GitHub.

כדי לבדוק את האינטראקציות עם Canvas, אפשר ליצור מוקאפ של Canvas, להעביר את אובייקט המוקאפ לשיטה drawLeadingMargin() ולוודא שהשיטות הנכונות נקראות עם הפרמטרים הנכונים.

דוגמאות נוספות לבדיקת טווח זמינות ב-BulletPointSpanTest.

שיטות מומלצות לשימוש בתגי span

יש כמה דרכים להגדיר טקסט ב-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, ולאובייקט CharSequence שמוחזק על ידי TextView יש עכשיו סימון שניתן לשינוי וטקסט שלא ניתן לשינוי. כדי לעדכן את הטווח, מאחזרים את הטקסט כ-Spannable ואז מעדכנים את הטווחים לפי הצורך.

כשמצרפים, מפרידים או משנים את המיקום של טווחים, התוכן של TextView מתעדכן באופן אוטומטי בהתאם לשינוי בטקסט. אם משנים מאפיין פנימי של span קיים, צריך להתקשר אל invalidate() כדי לבצע שינויים שקשורים למראה או אל requestLayout() כדי לבצע שינויים שקשורים למדדים.

הגדרת טקסט ב-TextView כמה פעמים

במקרים מסוימים, למשל כשמשתמשים ב-RecyclerView.ViewHolder, יכול להיות שתרצו להשתמש שוב ב-TextView ולהגדיר את הטקסט כמה פעמים. כברירת מחדל, בלי קשר להגדרה של BufferType, הפקודה TextView יוצרת עותק של האובייקט CharSequence ומחזיקה אותו בזיכרון. כך כל העדכונים של TextView נעשים בכוונה – אי אפשר לעדכן את האובייקט המקורי של 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(), אומרים ל-TextView להשתמש ב-Factory החדש:

Kotlin

textView.setSpannableFactory(spannableFactory)

Java

textView.setSpannableFactory(spannableFactory);

מגדירים את האובייקט Spannable.Factory פעם אחת, מיד אחרי שמקבלים הפניה אל TextView. אם אתם משתמשים ב-RecyclerView, צריך להגדיר את אובייקט Factory כשמנפחים את התצוגות בפעם הראשונה. כך נמנעת יצירה של אובייקטים מיותרים כש-RecyclerView מקשר פריט חדש ל-ViewHolder.

שינוי מאפיינים פנימיים של טווח

אם אתם צריכים לשנות רק מאפיין פנימי של טווח שניתן לשינוי, כמו צבע התבליט בטווח של תבליט בהתאמה אישית, אתם יכולים להימנע מהתקורה של קריאה ל-setText() כמה פעמים על ידי שמירת הפניה לטווח בזמן שהוא נוצר. אם צריך לשנות את טווח התאריכים, אפשר לשנות את ההפניה ואז לבצע קריאה אל invalidate() או אל requestLayout() ב-TextView, בהתאם לסוג המאפיין ששיניתם.

בדוגמה הבאה לקוד, הטמעה של תבליט בהתאמה אישית כוללת צבע ברירת מחדל אדום שמשתנה לאפור כשמקישים על לחצן:

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 כולל גם פונקציות הרחבה שמקלות על העבודה עם spans. מידע נוסף זמין בתיעוד של חבילת androidx.core.text.