Строчные элементы

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

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

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

Как создать и применить диапазон

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

Класс Изменяемый текст Изменяемая разметка Структура данных
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 к строке:

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 диапазон расширяется, чтобы включить дополнительный текст.

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

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

Типы span-объектов в Android

В пакете android.text.style доступно более 20 типов диапазонов. В Android диапазоны делятся на две основные категории:

  • Как диапазон влияет на текст: диапазон может влиять на внешний вид текста или его метрики.
  • Область действия: некоторые диапазоны можно применять к отдельным символам, а другие – ко всему абзацу.
Изображение, на котором показаны разные категории интервалов
Рисунок 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.

В примере ниже создается объект 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.

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

Диапазоны, влияющие на метрики текста, расширяют класс MetricAffectingSpan – абстрактный класс, который позволяет подклассам определять, как диапазон влияет на измерение текста, предоставляя доступ к TextPaint. Поскольку MetricAffectingSpan расширяет CharacterStyle, подклассы влияют на внешний вид текста на уровне символов.

Диапазоны, влияющие на абзацы

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

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

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

В следующем примере кода к абзацу применяется QuoteSpan. Обратите внимание, что если вы прикрепите диапазон к любой позиции, кроме начала или конца абзаца, 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
Диапазон влияет на внешний вид текста. UpdateAppearance
Диапазон влияет на текстовые показатели. UpdateLayout
Ваш диапазон влияет на текст на уровне абзаца. ParagraphStyle

Например, если вам нужно реализовать собственный диапазон, который изменяет размер и цвет текста, расширьте RelativeSizeSpan. Благодаря наследованию RelativeSizeSpan расширяет CharacterStyle и реализует два интерфейса Update. Поскольку этот класс уже предоставляет обратные вызовы для updateDrawState и updateMeasureState, вы можете переопределить их, чтобы реализовать собственное поведение. В следующем коде создается пользовательский диапазон, который расширяет 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);
    }
}

В этом примере показано, как создать собственный интервал. Того же эффекта можно добиться, применив к тексту RelativeSizeSpan и ForegroundColorSpan.

Проверить использование span

Интерфейс Spanned позволяет как задавать диапазоны, так и извлекать их из текста. При тестировании используйте тест Android JUnit, чтобы убедиться, что нужные диапазоны добавлены в правильные места. В примере приложения Text Styling содержится диапазон, который применяет разметку к пунктам списка, добавляя к тексту 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.

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

Дополнительные примеры тестов span можно найти в 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 заставляет TextView создать SpannableString, а объект CharSequence, который хранится в TextView, теперь имеет изменяемую разметку и неизменяемый текст. Чтобы обновить диапазон, получите текст как Spannable, а затем обновите диапазоны.

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

Задайте текст в TextView несколько раз.

В некоторых случаях, например при использовании RecyclerView.ViewHolder, может потребоваться повторно использовать TextView и задать текст несколько раз. По умолчанию, независимо от того, задали ли вы BufferType, TextView создает копию объекта CharSequence и хранит ее в памяти. Это позволяет избежать случайных изменений в 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, что приводит к возникновению ошибки ClassCastException в newSpannable().

После переопределения 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 также содержит функции расширения, которые упрощают работу с диапазонами. Дополнительную информацию можно найти в документации по пакету androidx.core.text.