Пролеты

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

Промежутки — это мощные объекты разметки, которые можно использовать для стилизации текста на уровне символов или абзацев. Прикрепив интервалы к текстовым объектам, вы можете изменять текст различными способами, включая добавление цвета, создание кликабельности текста, масштабирование размера текста и рисование текста по индивидуальному заказу. Spans также может изменять свойства 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 к строке:

Котлин

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

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

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

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

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

Диапазон также может влиять на текст на уровне абзаца, например, на изменение выравнивания или полей блока текста. Промежутки, влияющие на целые абзацы, реализуют ParagraphStyle . Чтобы использовать эти интервалы, вы прикрепляете их ко всему абзацу, исключая конечный символ новой строки. Если вы попытаетесь применить интервал абзаца к чему-то другому, кроме целого абзаца, 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 , примененный к абзацу.

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

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

Сценарий Класс или интерфейс
Ваш диапазон влияет на текст на уровне символов. 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 , чтобы убедиться, что правильные диапазоны добавлены в правильных местах. Пример приложения «Стилизация текста» содержит диапазон, который применяет разметку к пунктам списка путем присоединения 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.

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

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

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

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

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

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

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

Рекомендации по использованию интервалов

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

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

TextView.setText() содержит несколько перегрузок, которые по-разному обрабатывают диапазоны. Например, вы можете установить текстовый объект 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 , а затем обновите диапазоны по мере необходимости.

Когда вы присоединяете, отсоединяете или перемещаете диапазоны, TextView автоматически обновляется, отражая изменения в тексте. Если вы измените внутренний атрибут существующего диапазона, вызовите 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 один раз, сразу после получения ссылки на TextView . Если вы используете RecyclerView , установите объект Factory при первом раздувании представлений. Это позволяет избежать создания дополнительных объектов, когда ваш RecyclerView привязывает новый элемент к вашему ViewHolder .

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

Если вам нужно изменить только внутренний атрибут изменяемого диапазона, например цвет маркера в пользовательском диапазоне маркеров, вы можете избежать накладных расходов при многократном вызове 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 .