Spany

Wypróbuj sposób tworzenia wiadomości
Jetpack Compose to zalecany zestaw narzędzi UI na Androida. Dowiedz się, jak używać tekstu w sekcji Utwórz

Spany to zaawansowane obiekty znaczników, których możesz używać do określania stylu tekstu na poziomie znaków lub akapitu. Dołączając spany do obiektów tekstowych, możesz modyfikować tekst na różne sposoby, m.in. dodając kolory, umożliwiając klikanie tekstu, skalując jego rozmiar i rysując tekst w niestandardowy sposób. Spany mogą też zmieniać właściwości TextPaint, rysować w elementach Canvas i zmieniać układ tekstu.

Android oferuje kilka typów zakresów obejmujących różne typowe wzorce stylów tekstu. Możesz też tworzyć własne zakresy i stosować niestandardowe style.

Tworzenie i stosowanie spanu

Aby utworzyć span, możesz użyć jednej z zajęć wymienionych w poniższej tabeli. Klasy różnią się w zależności od tego, czy sam tekst jest zmienny, czy znaczniki tekstowe są zmienne i jaka bazowa struktura danych zawiera dane spanów.

Kategoria Tekst zmienny Zmienne znaczniki Struktura danych
SpannedString Nie Nie Tablica liniowa
SpannableString Nie Tak Tablica liniowa
SpannableStringBuilder Tak Tak Drzewo interwałów

Wszystkie 3 klasy poszerzają interfejs Spanned. SpannableString i SpannableStringBuilder rozszerzają też interfejs Spannable.

Oto jak zdecydować, którego z nich użyć:

  • Jeśli po utworzeniu nie modyfikujesz tekstu ani znaczników, użyj polecenia SpannedString.
  • Jeśli chcesz dołączyć niewielką liczbę rozpiętości do jednego obiektu tekstowego, a tekst jest tylko do odczytu, użyj SpannableString.
  • Jeśli musisz zmienić tekst po jego utworzeniu i chcesz dołączyć do niego spany, użyj polecenia SpannableStringBuilder.
  • Jeśli chcesz dołączyć do obiektu tekstowego dużą liczbę rozpiętości, niezależnie od tego, czy tekst jest tylko do odczytu, użyj SpannableStringBuilder.

Aby zastosować span, wywołaj setSpan(Object _what_, int _start_, int _end_, int _flags_) na obiekcie Spannable. Parametr what odnosi się do zakresu, który ma być stosowany do tekstu, a parametry start i end wskazują fragment tekstu, do którego ma on zastosowanie.

Jeśli wstawisz tekst wewnątrz granic spanu, span zostanie automatycznie rozwinięty, aby uwzględnić wstawiony tekst. Gdy wstawiasz tekst na granicach zakresu, czyli w indeksach start lub end, parametr flags określa, czy span ma zostać rozszerzony, by uwzględnić wstawiony tekst. Użyj flagi Spannable.SPAN_EXCLUSIVE_INCLUSIVE, aby uwzględnić wstawiony tekst, i Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, aby go wykluczyć.

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

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 ma styl ForegroundColorSpan.

Rozpiętość jest ustawiana za pomocą właściwości Spannable.SPAN_EXCLUSIVE_INCLUSIVE, dlatego jest ona poszerzona o wstawiony tekst znajdujący się przy granicach spanu, jak 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)");
Obraz pokazujący, że span obejmuje więcej tekstu, gdy używana jest funkcja SPAN_EXCLUSIVE_INCLUSIVE.
Rysunek 2. Gdy używasz właściwości Spannable.SPAN_EXCLUSIVE_INCLUSIVE, span rozwija się, by uwzględnić dodatkowy tekst.

Do tego samego tekstu możesz dołączyć wiele elementów „span”. Poniższy przykład pokazuje, jak utworzyć pogrubiony i czerwony tekst:

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 o wielu rozpiętościach: „ForegroundColorSpan(Color.RED)” i „StyleSpan(BOLD)”
Rysunek 3. Tekst o wielu rozpiętościach: ForegroundColorSpan(Color.RED) i StyleSpan(BOLD).

Typy spanów na Androidzie

Android zapewnia w pakiecie android.text.style ponad 20 typów spanów. Android kategoryzuje spany na 2 główne sposoby:

  • Wpływ spanu na tekst: może on wpływać na wygląd tekstu lub dane tekstu.
  • Zakres spanu: niektóre spany można stosować do pojedynczych znaków, a inne do całego akapitu.
Obraz przedstawiający różne kategorie spanów
Rysunek 4. Kategorie spanów na Androidzie.

W poniższych sekcjach opisano te kategorie bardziej szczegółowo.

Spaliny, które wpływają na wygląd tekstu

Niektóre rozpiętości stosowane na poziomie znaków wpływają na wygląd tekstu, np. zmiana koloru tekstu lub tła czy dodanie podkreśleń lub przekreśleń. Te spany rozszerzają klasę CharacterStyle.

Poniższy przykładowy kod pokazuje, jak zastosować element 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ślać tekst za pomocą funkcji „UnderlineSpan”
Rysunek 5. Tekst został podkreślony za pomocą funkcji UnderlineSpan.

Rozpiętości, które mają wpływ tylko na wygląd tekstu, powodują ponowne przerysowanie tekstu bez konieczności ponownego obliczania układu. Te spany implementują UpdateAppearance i rozszerzają zakres CharacterStyle. Podklasy CharacterStyle określają, jak rysować tekst, zapewniając dostęp do aktualizowania klasy TextPaint.

Spany, które wpływają na dane dotyczące tekstu

Inne rozpiętości stosowane na poziomie znaków wpływają na dane tekstowe, takie jak wysokość wiersza i rozmiar tekstu. Te spany rozszerzają klasę MetricAffectingSpan.

Ten przykładowy kod 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 obiektu RelativeSizeSpan
Rysunek 6. Tekst został powiększony za pomocą właściwości RelativeSizeSpan.

Zastosowanie spanu, który ma wpływ na dane tekstowe, powoduje, że obserwowany obiekt ponownie mierzy tekst, aby zapewnić jego prawidłowy układ i renderowanie. Na przykład zmiana rozmiaru tekstu może spowodować, że słowa pojawią się w innych wierszach. Zastosowanie poprzedniego rozpiętości wymaga ponownego pomiaru oraz obliczeń układu tekstu i przerysowania tekstu.

Spany, które wpływają na dane tekstowe, poszerzają klasę MetricAffectingSpan – abstrakcyjną klasę, która pozwala podklasom określać sposób, w jaki rozpiętość wpływa na pomiar tekstu, zapewniając dostęp do klasy TextPaint. MetricAffectingSpan rozszerza CharacterSpan, więc na wygląd tekstu na poziomie znaków wpływają podklasy.

Rozpiętości mające wpływ na akapity

Rozpiętość może też wpływać na tekst na poziomie akapitu, na przykład przez zmianę wyrównania lub marginesu bloku tekstu. Spany, które mają wpływ na całe akapity, stosują ParagraphStyle. Aby użyć tych spanów, musisz je dołączyć do całego akapitu z wyłączeniem nowego wiersza na końcu. Gdy próbujesz zastosować rozpiętość akapitu do czegoś innego niż cały akapit, Android w ogóle jej nie zastosuje.

Rysunek 8 pokazuje, w jaki sposób Android dzieli akapity w tekście.

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

W tym przykładzie kodu do akapitu zastosowano tag QuoteSpan. Pamiętaj, że jeśli dołączysz span do dowolnej innej pozycji niż początek lub koniec akapitu, Android w ogóle nie zastosuje tego 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 pokazujący przykładowy obiekt quotSpan
Rysunek 8. Do akapitu zastosowano QuoteSpan.

Utwórz niestandardowe spany

Jeśli potrzebujesz większej funkcjonalności niż ta dostępna w istniejących spanach na Androida, możesz wdrożyć span niestandardowy. Gdy implementujesz własny span, zastanów się, czy ma on wpływ na tekst na poziomie znaku czy akapitu, i czy ma wpływ na układ lub wygląd tekstu. Pomaga to określić, które klasy podstawowe możesz rozszerzyć i które interfejsy należy zaimplementować. Skorzystaj z tej tabeli:

Scenariusz Klasa lub interfejs
Rozpiętość wpływa na tekst na poziomie znaków. CharacterStyle
span wpływa na wygląd tekstu. UpdateAppearance
Rozpiętość wpływa na dane tekstowe. UpdateLayout
Rozpiętość wpływa na tekst na poziomie akapitu. ParagraphStyle

Jeśli na przykład musisz wdrożyć niestandardowe rozpiętość, która zmienia rozmiar i kolor tekstu, rozszerz element RelativeSizeSpan. Dzięki dziedziczeniu RelativeSizeSpan rozszerza zakres CharacterStyle i implementuje 2 interfejsy Update. Ponieważ ta klasa udostępnia już wywołania zwrotne dla updateDrawState i updateMeasureState, możesz je zastąpić, aby wdrożyć swoje działanie niestandardowe. Ten kod tworzy niestandardowy zakres, który rozszerza zakres RelativeSizeSpan i zastępuje wywołanie zwrotne updateDrawState, aby ustawić kolor pola 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ć niestandardowe spany. Możesz uzyskać ten sam efekt, dodając do tekstu RelativeSizeSpan i ForegroundColorSpan.

Testuj wykorzystanie spanów

Interfejs Spanned umożliwia zarówno ustawianie spanów, jak i ich pobieranie z tekstu. Podczas testowania zaimplementuj test JUnit (Android), aby sprawdzić, czy prawidłowe spany są dodane w prawidłowych lokalizacjach. Przykładowa aplikacja do stosowania stylów tekstu zawiera rozpiętość, która pozwala zastosować znaczniki do punktorów, dołączając do tekstu BulletPointSpan. Z tego przykładowego kodu dowiesz się, jak sprawdzić, czy listy punktowane wyświetlają się 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 na stronie MarkdownBuilderTest w GitHubie.

Przetestuj niestandardowe spany

Podczas testowania spanów sprawdź, czy TextPaint zawiera oczekiwane modyfikacje i czy Canvas zawiera właściwe elementy. Rozważmy np. implementację niestandardowego spanu, która będzie dołączać punktor przed tekstem. Punktor ma określony rozmiar i kolor, a między lewym marginesem obszaru możliwego do rysowania a punktorem znajduje się przerwa.

Możesz przetestować działanie tej klasy, implementując test AndroidJUnit i sprawdzając, czy:

  • Jeśli prawidłowo zastosujesz rozpiętość, na obszarze roboczym pojawi się punktor o podanym rozmiarze i kolorze, a między jego lewym marginesem a punktorem pojawi się odpowiednia przestrzeń.
  • Jeśli nie zastosujesz zakresu, nie pojawi się żadne niestandardowe zachowanie.

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

Interakcje Canvas możesz przetestować, podszywając się pod obiekt canvas, przesyłając imitację obiektu do metody drawLeadingMargin() i sprawdzając, czy właściwe metody są wywoływane z prawidłowymi parametrami.

Więcej próbek testowych spanów znajdziesz w bulletPointSpanTest.

Sprawdzone metody korzystania ze spanów

Istnieje kilka sposobów ustawiania tekstu w elemencie TextView, które oszczędzają pamięć, w zależności od potrzeb.

Dołącz lub odłącz rozpiętość bez zmiany bazowego tekstu

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

Kotlin

textView.setText(spannableObject)

Java

textView.setText(spannableObject);

Wywołując to przeciążenie związane z tabelą setText(), TextView tworzy kopię Spannable jako SpannedString i zachowuje ją w pamięci jako CharSequence. Oznacza to, że tekst i zakresy spanów są stałe, więc gdy musisz zaktualizować tekst lub spany, utwórz nowy obiekt Spannable i ponownie wywołaj setText(), co również uruchamia ponowne pomiary i rysowanie układu.

Aby wskazać, że spany muszą być zmienne, użyj parametru setText(CharSequence text, TextView.BufferType type), jak 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 sprawia, że TextView tworzy SpannableString, a obiekt CharSequence przechowywany przez TextView ma teraz zmienne znaczniki i stały tekst. Aby zaktualizować span, pobierz tekst jako Spannable, a następnie w razie potrzeby zaktualizuj spany.

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

Wielokrotne ustawienie tekstu w komponencie TextView

W niektórych przypadkach, np. gdy używasz elementu RecyclerView.ViewHolder, możesz użyć właściwości TextView i wpisać tekst kilka razy. Domyślnie, niezależnie od tego, czy ustawisz BufferType, TextView tworzy kopię obiektu CharSequence i przechowuje ją w pamięci. Dzięki temu wszystkie aktualizacje TextView są celowe – nie można zaktualizować oryginalnego obiektu CharSequence, 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 dodatkowego obiektu, możesz wdrożyć własny Spannable.Factory i zastąpić metodę newSpannable(). Zamiast tworzyć nowy obiekt tekstowy, możesz rzutować 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;
    }
};

Aby ustawić tekst, musisz użyć polecenia textView.setText(spannableObject, BufferType.SPANNABLE). W przeciwnym razie źródło CharSequence jest tworzone jako wystąpienie Spanned i nie można go rzutować na Spannable, przez co newSpannable() zgłasza ClassCastException.

Po zastąpieniu kolumny newSpannable() poproś użytkownika TextView o używanie nowego Factory:

Kotlin

textView.setSpannableFactory(spannableFactory)

Java

textView.setSpannableFactory(spannableFactory);

Ustaw obiekt Spannable.Factory raz, zaraz po otrzymaniu odwołania do TextView. Jeśli używasz właściwości RecyclerView, ustaw obiekt Factory podczas pierwszego powiększania widoków. Unikniesz w ten sposób dodatkowego tworzenia obiektu, gdy RecyclerView wiąże nowy element z ViewHolder.

Zmień atrybuty spanu wewnętrznego

Jeśli chcesz zmienić tylko wewnętrzny atrybut zmiennej spanu, np. kolor punktu w niestandardowym rozpiętości punktora, możesz uniknąć wielokrotnego wywoływania funkcji setText(), zachowując podczas jego tworzenia odwołanie do tego zakresu. Gdy musisz zmienić span, możesz zmodyfikować plik referencyjny, a potem wywołać metodę invalidate() lub requestLayout() w zależności od typu zmienionego atrybutu TextView.

W tym przykładowym kodzie niestandardowa implementacja punktora 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 rozszerzenia KTX na Androidzie

KTX na Androidzie zawiera też funkcje rozszerzeń, które ułatwiają pracę ze spanami. Więcej informacji znajdziesz w dokumentacji pakietu androidx.core.text.