Пролеты

Попробуйте способ «Композиции»
Jetpack Compose — рекомендуемый набор инструментов для разработки пользовательского интерфейса для Android. Узнайте, как использовать текст в Compose.

Разделы — это мощные объекты разметки, которые можно использовать для стилизации текста на уровне символов или абзацев. Прикрепляя разделы к текстовым объектам, можно изменять текст различными способами, включая добавление цвета, возможность клика по тексту, масштабирование размера текста и настраиваемое отображение текста. Разделы также позволяют изменять свойства TextPaint , рисовать на Canvas и изменять расположение текста.

Android предоставляет несколько типов элементов span, охватывающих различные распространённые шаблоны оформления текста. Вы также можете создавать собственные элементы span для применения пользовательского стиля.

Создать и применить диапазон

Для создания диапазона можно использовать один из классов, перечисленных в следующей таблице. Классы различаются в зависимости от того, является ли сам текст изменяемым, является ли изменяемой разметка текста и какая базовая структура данных содержит данные диапазона.

Сорт Изменяемый текст Изменяемая разметка Структура данных
SpannedString Нет Нет Линейный массив
SpannableString Нет Да Линейный массив
SpannableStringBuilder Да Да Интервальное дерево

Все три класса расширяют интерфейс Spanned . SpannableString и SpannableStringBuilder также расширяют интерфейс Spannable .

Вот как решить, какой из них использовать:

  • Если вы не изменяете текст или разметку после создания, используйте SpannedString .
  • Если вам нужно прикрепить небольшое количество интервалов к одному текстовому объекту, а сам текст доступен только для чтения, используйте SpannableString .
  • Если вам необходимо изменить текст после его создания и прикрепить к нему интервалы, используйте SpannableStringBuilder .
  • Если вам необходимо прикрепить большое количество интервалов к текстовому объекту, независимо от того, доступен ли сам текст только для чтения, используйте SpannableStringBuilder .

Чтобы применить интервал, вызовите метод setSpan(Object _what_, int _start_, int _end_, int _flags_) к объекту Spannable . Параметр what определяет интервал, применяемый к тексту, а параметры start и end указывают на часть текста, к которой применяется интервал.

Если текст вставляется внутрь области, она автоматически расширяется, включая вставленный текст. При вставке текста на границах области, то есть в начальной или конечной позиции, параметр flags определяет, расширяется ли область, включая вставленный текст. Используйте флаг Spannable.SPAN_EXCLUSIVE_INCLUSIVE , чтобы включить вставленный текст, и флаг Spannable.SPAN_EXCLUSIVE_EXCLUSIVE чтобы исключить вставленный текст.

В следующем примере показано, как прикрепить ForegroundColorSpan к строке:

Котлин

val spannable = SpannableStringBuilder("Text is spantastic!")
spannable.setSpan(
    ForegroundColorSpan(Color.RED),
    8, // start
    12, // end
    Spannable.SPAN_EXCLUSIVE_INCLUSIVE
)

Ява

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 , он расширяется, включая вставленный текст на границах диапазона, как показано в следующем примере:

Котлин

val spannable = SpannableStringBuilder("Text is spantastic!")
spannable.setSpan(
    ForegroundColorSpan(Color.RED),
    8, // start
    12, // end
    Spannable.SPAN_EXCLUSIVE_INCLUSIVE
)
spannable.insert(12, "(& fon)")

Ява

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 .

К одному тексту можно прикрепить несколько диапазонов. В следующем примере показано, как создать жирный текст красного цвета:

Котлин

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
)

Ява

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 span

В Android представлено более 20 типов элементов span в пакете android.text.style . Android классифицирует элементы span двумя основными способами:

  • Как диапазон влияет на текст: диапазон может влиять на внешний вид текста или его метрики.
  • Область охвата: некоторые охваты можно применять к отдельным символам, в то время как другие должны применяться ко всему абзацу.
Изображение, демонстрирующее различные категории диапазона
Рисунок 4. Категории Android-диапазонов.

В следующих разделах эти категории описаны более подробно.

Пробелы, влияющие на внешний вид текста

Некоторые диапазоны, применяемые на уровне символов, влияют на внешний вид текста, например, меняют цвет текста или фона, а также добавляют подчеркивания или зачёркивания. Эти диапазоны расширяют класс CharacterStyle .

В следующем примере кода показано, как применить UnderlineSpan для подчеркивания текста:

Котлин

val string = SpannableString("Text with underline span")
string.setSpan(UnderlineSpan(), 10, 19, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)

Ява

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 .

Промежутки, влияющие на текстовые метрики

Другие диапазоны, применяемые на уровне символов, влияют на метрики текста, такие как высота строки и размер текста. Эти диапазоны расширяют класс MetricAffectingSpan .

Следующий пример кода создает RelativeSizeSpan , который увеличивает размер текста на 50%:

Котлин

val string = SpannableString("Text with relative size span")
string.setSpan(RelativeSizeSpan(1.5f), 10, 24, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)

Ява

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 . Поскольку MetricAffectingSpan расширяет CharacterStyle , подклассы влияют на внешний вид текста на уровне символов.

Промежутки, которые влияют на абзацы

Элемент span также может влиять на текст на уровне абзаца, например, изменяя выравнивание или поля блока текста. Элементы span, влияющие на целые абзацы, реализуют ParagraphStyle . Чтобы использовать эти элементы span, их необходимо прикрепить ко всему абзацу, за исключением завершающего символа новой строки. Если вы попытаетесь применить элемент span к чему-либо, кроме целого абзаца, Android не применит его вообще.

На рисунке 8 показано, как Android разделяет абзацы в тексте.

Рисунок 7. В Android абзацы заканчиваются символом новой строки ( \n ).

В следующем примере кода стиль QuoteSpan применяется к абзацу. Обратите внимание: если вы прикрепите этот стиль к любой позиции, кроме начала или конца абзаца, Android вообще не применит этот стиль.

Котлин

spannable.setSpan(QuoteSpan(color), 8, text.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)

Ява

spannable.setSpan(new QuoteSpan(color), 8, text.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
Изображение, показывающее пример QuoteSpan
Рисунок 8. QuoteSpan , примененный к абзацу.

Создание пользовательских диапазонов

Если вам требуется больше функциональности, чем предусмотрено в существующих элементах span в Android, вы можете реализовать собственный элемент span. При реализации собственного элемента span определите, влияет ли он на текст на уровне символов или на уровне абзацев, а также на расположение или внешний вид текста. Это поможет вам определить, какие базовые классы вы можете расширить и какие интерфейсы, возможно, потребуется реализовать. Используйте следующую таблицу для справки:

Сценарий Класс или интерфейс
Ваш диапазон влияет на текст на уровне символов. CharacterStyle
Ваш диапазон влияет на внешний вид текста. UpdateAppearance
Ваш диапазон влияет на метрики текста. UpdateLayout
Ваш диапазон влияет на текст на уровне абзаца. ParagraphStyle

Например, если вам нужно реализовать пользовательский диапазон, изменяющий размер и цвет текста, расширьте RelativeSizeSpan . Благодаря наследованию RelativeSizeSpan расширяет CharacterStyle и реализует два интерфейса Update . Поскольку этот класс уже предоставляет обратные вызовы для updateDrawState и updateMeasureState , вы можете переопределить эти обратные вызовы для реализации своего собственного поведения. Следующий код создаёт пользовательский диапазон, расширяющий RelativeSizeSpan и переопределяющий обратный вызов updateDrawState для установки цвета TextPaint :

Котлин

class RelativeSizeColorSpan(
    size: Float,
    @ColorInt private val color: Int
) : RelativeSizeSpan(size) {
    override fun updateDrawState(textPaint: TextPaint) {
        super.updateDrawState(textPaint)
        textPaint.color = color
    }
}

Ява

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 содержит интервал, который применяет разметку к маркерам, прикрепляя BulletPointSpan к тексту. В следующем примере кода показано, как проверить, отображаются ли маркеры так, как ожидается:

Котлин

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

Ява

@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.

Тестовые пользовательские диапазоны

При тестировании областей Span убедитесь, что TextPaint содержит ожидаемые изменения и что на Canvas отображаются правильные элементы. Например, рассмотрим реализацию Span, которая добавляет маркер к тексту. Маркер имеет заданный размер и цвет, а между левым полем области рисования и маркером есть зазор.

Вы можете протестировать поведение этого класса, реализовав тест AndroidJUnit, проверив следующее:

  • Если вы правильно применили диапазон, на холсте появится маркер указанного размера и цвета, а между левым полем и маркером будет необходимое пространство.
  • Если не применять диапазон, никакое пользовательское поведение не отобразится.

Реализацию этих тестов можно увидеть в примере TextStyling на GitHub.

Вы можете протестировать взаимодействие с холстом, имитируя холст, передавая имитированный объект методу drawLeadingMargin() и проверяя, вызываются ли правильные методы с правильными параметрами.

Дополнительные примеры тестов диапазона можно найти в BulletPointSpanTest .

Лучшие практики использования интервалов

Существует несколько эффективных с точки зрения памяти способов установки текста в TextView , в зависимости от ваших потребностей.

Присоединить или отсоединить промежуток без изменения основного текста

TextView.setText() содержит несколько перегрузок, которые по-разному обрабатывают элементы span. Например, вы можете установить текстовый объект Spannable с помощью следующего кода:

Котлин

textView.setText(spannableObject)

Ява

textView.setText(spannableObject);

При вызове этой перегрузки setText() TextView создаёт копию Spannable как SpannedString и сохраняет её в памяти как CharSequence . Это означает, что текст и интервалы неизменяемы, поэтому, когда вам нужно обновить текст или интервалы, создайте новый объект Spannable и снова вызовите setText() , что также запустит повторное измерение и перерисовку макета.

Чтобы указать, что интервалы должны быть изменяемыми, можно вместо этого использовать setText(CharSequence text, TextView.BufferType type) , как показано в следующем примере:

Котлин

textView.setText(spannable, BufferType.SPANNABLE)
val spannableText = textView.text as Spannable
spannableText.setSpan(
     ForegroundColorSpan(color),
     8, spannableText.length,
     SPAN_INCLUSIVE_INCLUSIVE
)

Ява

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 , а затем обновите диапазоны по мере необходимости.

При присоединении, отсоединении или изменении положения элементов span элемент TextView автоматически обновляется, отражая изменения текста. Если вы изменяете внутренний атрибут существующего элемента span, вызовите метод invalidate() для внесения изменений, связанных с внешним видом, или requestLayout() для внесения изменений, связанных с метриками.

Несколько раз установить текст в TextView

В некоторых случаях, например, при использовании RecyclerView.ViewHolder , может потребоваться повторно использовать TextView и задать текст несколько раз. По умолчанию, независимо от того, задан ли BufferType , TextView создаёт копию объекта CharSequence и сохраняет её в памяти. Это делает все обновления TextView намеренными — вы не можете обновить исходный объект CharSequence для обновления текста. Это означает, что каждый раз, когда вы задаёте новый текст, TextView создаёт новый объект.

Если вы хотите лучше контролировать этот процесс и избежать создания дополнительных объектов, вы можете реализовать собственную Spannable.Factory и переопределить newSpannable() . Вместо создания нового текстового объекта вы можете привести существующую CharSequence к типу Spannable и вернуть её, как показано в следующем примере:

Котлин

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 :

Котлин

textView.setSpannableFactory(spannableFactory)

Ява

textView.setSpannableFactory(spannableFactory);

Установите объект Spannable.Factory один раз ViewHolder сразу после получения ссылки на TextView . Если вы используете RecyclerView , установите объект Factory при первом добавлении представлений. Это позволит избежать создания дополнительных объектов при привязке нового элемента к RecyclerView .

Изменить внутренние атрибуты диапазона

Если вам нужно изменить только внутренний атрибут изменяемого диапазона, например, цвет маркера в пользовательском диапазоне, вы можете избежать накладных расходов, связанных с многократным вызовом setText() , сохранив ссылку на диапазон в том виде, в котором он был создан. Когда вам нужно изменить диапазон, вы можете изменить ссылку, а затем вызвать метод invalidate() или requestLayout() для TextView в зависимости от типа изменённого атрибута.

В следующем примере кода реализация пользовательского маркера имеет красный цвет по умолчанию, который меняется на серый при нажатии кнопки:

Котлин

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

Ява

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 .