Spany

Wypróbuj Compose
Jetpack Compose to zalecany zestaw narzędzi interfejsu na Androida. Dowiedz się, jak używać tekstu w funkcji Utwórz.

Spany to zaawansowane obiekty znaczników, których możesz używać do stylizowania tekstu na poziomie znaku lub akapitu. Dołączając zakresy do obiektów tekstowych, możesz zmieniać tekst na różne sposoby, np. dodawać kolor, sprawiać, że tekst będzie klikalny, skalować rozmiar tekstu i rysować tekst w niestandardowy sposób. Zakresy mogą też zmieniać właściwości TextPaint, rysować na Canvas i zmieniać układ tekstu.

Android udostępnia kilka typów zakresów, które obejmują różne typowe wzorce stylizacji tekstu. Możesz też tworzyć własne zakresy, aby stosować niestandardowe style.

Tworzenie i stosowanie zakresu

Aby utworzyć zakres, możesz użyć jednej z klas wymienionych w tabeli poniżej. Klasy różnią się w zależności od tego, czy sam tekst jest modyfikowalny, czy modyfikowalny jest znacznik tekstu oraz jaka struktura danych zawiera dane zakresu.

Kategoria Tekst z możliwością zmiany Znaczniki modyfikowalne Struktura danych
SpannedString Nie Nie Tablica liniowa
SpannableString Nie Tak Tablica liniowa
SpannableStringBuilder Tak Tak Drzewo przedziałowe

Wszystkie 3 klasy rozszerzają interfejs Spanned. SpannableStringSpannableStringBuilder rozszerzają też interfejs Spannable.

Aby zdecydować, który z nich wybrać:

  • Jeśli po utworzeniu nie modyfikujesz tekstu ani znaczników, użyj SpannedString.
  • Jeśli musisz dołączyć niewielką liczbę zakresów do jednego obiektu tekstowego, a sam tekst jest tylko do odczytu, użyj SpannableString.
  • Jeśli po utworzeniu tekstu musisz go zmodyfikować i dołączyć do niego zakresy, użyj znaku SpannableStringBuilder.
  • Jeśli musisz dołączyć do obiektu tekstowego dużą liczbę zakresów, niezależnie od tego, czy sam tekst jest tylko do odczytu, użyj SpannableStringBuilder.

Aby zastosować zakres, wywołaj setSpan(Object _what_, int _start_, int _end_, int _flags_) na obiekcie Spannable. Parametr what odnosi się do zakresu, który stosujesz do tekstu, a parametry startend wskazują część tekstu, do której stosujesz zakres.

Jeśli wstawisz tekst w zakresie obejmującym fragment, zakres automatycznie się rozszerzy, aby uwzględnić wstawiony tekst. Podczas wstawiania tekstu na granicach zakresu, czyli na indeksach początkowym lub końcowym, parametr flags określa, czy zakres ma się rozszerzyć, aby uwzględnić wstawiony tekst. Użyj flagi Spannable.SPAN_EXCLUSIVE_INCLUSIVE, aby uwzględnić wstawiony tekst, a flagi Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, aby go wykluczyć.

Poniższy przykład pokazuje, jak dołączyć ForegroundColorSpan do ciągu:

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
);
Obraz przedstawiający szary tekst, częściowo czerwony.
Rysunek 1. Tekst sformatowany za pomocą elementu ForegroundColorSpan.

Ponieważ zakres jest ustawiony za pomocą Spannable.SPAN_EXCLUSIVE_INCLUSIVE, rozszerza się on o wstawiony tekst na jego granicach, jak pokazano w tym przykładzie:

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)");
Ilustracja pokazująca, jak zakres obejmuje więcej tekstu, gdy używany jest typ SPAN_EXCLUSIVE_INCLUSIVE.
Rysunek 2. Zakres rozszerza się, aby uwzględnić dodatkowy tekst, gdy używasz Spannable.SPAN_EXCLUSIVE_INCLUSIVE.

Do tego samego tekstu możesz dołączyć wiele zakresów. Przykład poniżej pokazuje, jak utworzyć tekst pogrubiony i czerwony:

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
);
Obraz przedstawiający tekst z wieloma zakresami: `ForegroundColorSpan(Color.RED)` i `StyleSpan(BOLD)`
Rysunek 3. Tekst z wieloma zakresami:ForegroundColorSpan(Color.RED)StyleSpan(BOLD).

Typy zakresów Androida

Android udostępnia ponad 20 typów zakresów w pakiecie android.text.style. Android dzieli zakresy na 2 główne sposoby:

  • Jak zakres wpływa na tekst: zakres może wpływać na wygląd tekstu lub jego dane.
  • Zakres: niektóre zakresy można stosować do poszczególnych znaków, a inne muszą być stosowane do całego akapitu.
Obraz przedstawiający różne kategorie zakresów
Rysunek 4. Kategorie zakresów Androida.

Więcej informacji o tych kategoriach znajdziesz w kolejnych sekcjach.

Zakresy wpływające na wygląd tekstu

Niektóre zakresy, które są stosowane na poziomie znaku, wpływają na wygląd tekstu, np. zmieniają kolor tekstu lub tła oraz dodają podkreślenia lub przekreślenia. Te zakresy rozszerzają klasę CharacterStyle.

Poniższy przykład kodu pokazuje, jak zastosować znak UnderlineSpan, aby podkreślić tekst:

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);
Obraz pokazujący, jak podkreślić tekst za pomocą elementu `UnderlineSpan`
Rysunek 5. Tekst podkreślony za pomocą elementu UnderlineSpan.

Zakresy, które wpływają tylko na wygląd tekstu, powodują ponowne narysowanie tekstu bez ponownego obliczania układu. Te zakresy implementują interfejs UpdateAppearance i rozszerzają interfejs CharacterStyle. CharacterStyle podklasy określają sposób rysowania tekstu, zapewniając dostęp do aktualizacji TextPaint.

Zakresy, które wpływają na dane tekstowe

Inne zakresy, które mają zastosowanie na poziomie znaku, wpływają na dane tekstowe, takie jak wysokość wiersza i rozmiar tekstu. Te zakresy rozszerzają klasę MetricAffectingSpan.

Ten przykład kodu tworzy element RelativeSizeSpan, który zwiększa rozmiar tekstu o 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);
Obraz pokazujący użycie RelativeSizeSpan
Rysunek 6. Tekst powiększony za pomocą funkcjiRelativeSizeSpan.

Zastosowanie zakresu, który wpływa na dane tekstowe, powoduje ponowne zmierzenie tekstu przez obiekt obserwujący w celu prawidłowego układu i renderowania. Na przykład zmiana rozmiaru tekstu może spowodować, że słowa pojawią się w innych wierszach. Zastosowanie powyższego zakresu powoduje ponowne pomiary, ponowne obliczenie układu tekstu i ponowne narysowanie tekstu.

Zakresy, które wpływają na dane tekstowe, rozszerzają klasę MetricAffectingSpan, czyli klasę abstrakcyjną, która umożliwia podklasom określanie, jak zakres wpływa na pomiar tekstu, poprzez zapewnienie dostępu do TextPaint. Ponieważ klasa MetricAffectingSpan rozszerza klasę CharacterStyle, podklasy wpływają na wygląd tekstu na poziomie znaku.

Zakresy wpływające na akapity

Zakres może też wpływać na tekst na poziomie akapitu, np. zmieniać wyrównanie lub margines bloku tekstu. Zakresy, które wpływają na całe akapity, implementują interfejs ParagraphStyle. Aby użyć tych zakresów, dołącz je do całego akapitu, z wyjątkiem znaku nowego wiersza na końcu. Jeśli spróbujesz zastosować zakres akapitu do czegoś innego niż cały akapit, Android w ogóle nie zastosuje zakresu.

Ilustracja 8 pokazuje, jak Android oddziela akapity w tekście.

Rysunek 7. W Androidzie akapity kończą się znakiem nowego wiersza (\n).

Poniższy przykład kodu stosuje element QuoteSpan do akapitu. Pamiętaj, że jeśli dołączysz zakres do pozycji innej niż początek lub koniec akapitu, Android w ogóle nie zastosuje stylu.

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);
Obraz przedstawiający przykład elementu QuoteSpan
Rysunek 8. Wartość QuoteSpan zastosowana do akapitu.

Tworzenie niestandardowych zakresów

Jeśli potrzebujesz więcej funkcji niż te, które są dostępne w przypadku istniejących zakresów Androida, możesz wdrożyć niestandardowy zakres. Podczas wdrażania własnego zakresu zdecyduj, czy ma on wpływać na tekst na poziomie znaku czy akapitu, a także czy ma wpływać na układ czy wygląd tekstu. Pomoże Ci to określić, które klasy bazowe możesz rozszerzyć i które interfejsy musisz zaimplementować. Skorzystaj z tej tabeli:

Scenariusz Klasa lub interfejs
Zakres wpływa na tekst na poziomie znaku. CharacterStyle
Zakres wpływa na wygląd tekstu. UpdateAppearance
Zakres ma wpływ na dane tekstowe. UpdateLayout
Zakres wpływa na tekst na poziomie akapitu. ParagraphStyle

Jeśli na przykład chcesz wdrożyć niestandardowy zakres, który modyfikuje rozmiar i kolor tekstu, rozszerz klasę RelativeSizeSpan. Dzięki dziedziczeniu klasa RelativeSizeSpan rozszerza CharacterStyle i implementuje 2 interfejsy Update. Ta klasa udostępnia już wywołania zwrotne dla updateDrawStateupdateMeasureState, więc możesz je zastąpić, aby wdrożyć niestandardowe działanie. Poniższy kod tworzy niestandardowy zakres, który rozszerza RelativeSizeSpan i zastępuje wywołanie zwrotne updateDrawState, aby ustawić kolor 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);
    }
}

Ten przykład pokazuje, jak utworzyć niestandardowy zakres. Ten sam efekt możesz uzyskać, stosując do tekstu RelativeSizeSpan i ForegroundColorSpan.

Wykorzystanie zakresu testu

Interfejs Spanned umożliwia ustawianie zakresów i pobieranie ich z tekstu. Podczas testowania zaimplementuj test Android JUnit, aby sprawdzić, czy prawidłowe zakresy są dodawane we właściwych miejscach. Przykładowa aplikacja do stylizowania tekstu zawiera zakres, który stosuje znaczniki do punktów, dołączając do tekstu element BulletPointSpan. Poniższy przykład kodu pokazuje, jak sprawdzić, czy punkty są wyświetlane zgodnie z oczekiwaniami:

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));
}

Więcej przykładów testów znajdziesz w MarkdownBuilderTest na GitHubie.

Testowanie niestandardowych zakresów

Podczas testowania zakresów sprawdź, czy element TextPaint zawiera oczekiwane modyfikacje i czy na stronie Canvas wyświetlają się prawidłowe elementy. Rozważmy na przykład niestandardową implementację zakresu, która dodaje punkt do tekstu. Punktor ma określony rozmiar i kolor, a między lewym marginesem obszaru rysowania a punktorem jest odstęp.

Działanie tej klasy możesz przetestować, implementując test AndroidJUnit, który sprawdzi:

  • Jeśli prawidłowo zastosujesz zakres, na obszarze roboczym pojawi się punkt o określonym rozmiarze i kolorze, a między lewym marginesem a punktem będzie odpowiednia spacja.
  • Jeśli nie zastosujesz zakresu, żadne niestandardowe zachowanie się nie pojawi.

Implementację tych testów możesz zobaczyć w przykładzie TextStyling na GitHubie.

Możesz przetestować interakcje z elementem Canvas, tworząc jego imitację, przekazując ją do metody drawLeadingMargin() i sprawdzając, czy wywoływane są odpowiednie metody z prawidłowymi parametrami.

Więcej przykładów testów zakresu znajdziesz w klasie BulletPointSpanTest.

Sprawdzone metody korzystania z zakresów

Istnieje kilka sposobów na ustawienie tekstu w TextView, które nie wymagają dużej ilości pamięci. Zależy to od Twoich potrzeb.

Dołączanie i odłączanie zakresu bez zmiany tekstu źródłowego

TextView.setText() zawiera wiele przeciążeń, które różnie obsługują zakresy. Możesz na przykład ustawić obiekt tekstowy Spannable za pomocą tego kodu:

Kotlin

textView.setText(spannableObject)

Java

textView.setText(spannableObject);

Podczas wywoływania tego przeciążenia funkcji setText() funkcja TextView tworzy kopię obiektu Spannable jako SpannedString i przechowuje ją w pamięci jako CharSequence. Oznacza to, że tekst i zakresy są niezmienne, więc gdy chcesz zaktualizować tekst lub zakresy, utwórz nowy obiekt Spannable i ponownie wywołaj setText(). Spowoduje to również ponowne pomiary i ponowne narysowanie układu.

Aby wskazać, że zakresy muszą być modyfikowalne, możesz zamiast tego użyć setText(CharSequence text, TextView.BufferType type), jak pokazano w tym przykładzie:

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);

W tym przykładzie parametr BufferType.SPANNABLE powoduje, że TextView tworzy SpannableString, a obiekt CharSequence przechowywany przez TextView ma teraz zmienne znaczniki i niezmienny tekst. Aby zaktualizować zakres, pobierz tekst jako Spannable, a następnie w razie potrzeby zaktualizuj zakresy.

Gdy dołączysz, odłączysz lub zmienisz położenie zakresów, TextView automatycznie zaktualizuje się, aby odzwierciedlić zmianę w tekście. Jeśli zmienisz atrybut wewnętrzny istniejącego zakresu, wywołaj metodę invalidate(), aby wprowadzić zmiany związane z wyglądem, lub metodę requestLayout(), aby wprowadzić zmiany związane z danymi.

Wielokrotne ustawianie tekstu w obiekcie TextView

W niektórych przypadkach, np. podczas korzystania z RecyclerView.ViewHolder, możesz chcieć ponownie użyć TextView i kilkakrotnie ustawić tekst. Domyślnie, niezależnie od tego, czy ustawisz BufferType, funkcja TextView tworzy kopię obiektu CharSequence i przechowuje ją w pamięci. Dzięki temu wszystkie aktualizacje są TextViewcelowe – nie możesz zaktualizować oryginalnego CharSequenceobiektu, aby zaktualizować tekst. Oznacza to, że za każdym razem, gdy ustawiasz nowy tekst, TextView tworzy nowy obiekt.

Jeśli chcesz mieć większą kontrolę nad tym procesem i uniknąć tworzenia dodatkowych obiektów, możesz zaimplementować własną funkcję Spannable.Factory i zastąpić funkcję newSpannable(). Zamiast tworzyć nowy obiekt tekstowy, możesz przekształcić i zwrócić istniejący obiekt CharSequence jako Spannable, jak pokazano w tym przykładzie:

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;
    }
};

Podczas ustawiania tekstu musisz używać znaku textView.setText(spannableObject, BufferType.SPANNABLE). W przeciwnym razie źródło CharSequence jest tworzone jako instancja Spanned i nie można jej przekształcić w Spannable, co powoduje, że newSpannable() zgłasza wyjątek ClassCastException.

Po zastąpieniu newSpannable() poinformuj TextView, aby używał nowego Factory:

Kotlin

textView.setSpannableFactory(spannableFactory)

Java

textView.setSpannableFactory(spannableFactory);

Ustaw obiekt Spannable.Factory tylko raz, zaraz po uzyskaniu odwołania do TextView. Jeśli używasz RecyclerView, ustaw obiekt Factory podczas pierwszego rozszerzania widoków. Pozwala to uniknąć tworzenia dodatkowych obiektów, gdy RecyclerView wiąże nowy produkt z ViewHolder.

Zmienianie atrybutów wewnętrznego zakresu

Jeśli musisz zmienić tylko atrybut wewnętrzny zakresu modyfikowalnego, np. kolor punktora w zakresie niestandardowego punktora, możesz uniknąć obciążenia związanego z wielokrotnym wywoływaniem funkcji setText(), zachowując odwołanie do zakresu podczas jego tworzenia. Gdy musisz zmodyfikować zakres, możesz zmodyfikować odwołanie, a następnie wywołać funkcję invalidate() lub requestLayout() na obiekcie TextView w zależności od typu zmienionego atrybutu.

W przykładzie kodu poniżej niestandardowy punkt wypunktowania ma domyślny kolor czerwony, który zmienia się na szary po kliknięciu przycisku:

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();
            }
        });
    }
}

Korzystanie z funkcji rozszerzających Androida KTX

Android KTX zawiera też funkcje rozszerzające, które ułatwiają pracę z zakresami. Więcej informacji znajdziesz w dokumentacji pakietu androidx.core.text.