Стилизация текста с помощью Span
Перевод статьи @florina.muntenescu Spantastic text styling with Spans
Используйте span, чтобы стилизовать текст в Android! Вы сможете изменить цвет нескольких символов, сделать их кликабельными, изменить размер текста или даже нарисовать свои маркеры для списка с помощью span. Span-ы могут изменить свойства TextPaint
, могут рисовать на Canvas
, или даже изменить текстовый лайаут и изменить высоту строки текста. Span-ы это объекты разметки, которые могут быть прикреплены или откреплены от текста; они могут быть применены к целому параграфу или части текста.
Давайте рассмотрим, как использовать span, что предоставляют span-ы “из коробки”, как проще создать свой span и наконец как их тестировать:
Тестирование “кастомных” span-ов
Тестирование использования span-ов
Стилизация текста в Android
Android предлагает несколько способов стилизации текста:
- Простые стили (Single style) — когда стиль применяется ко всему тексту отображаемому TextView
- Мульти стили (Multi style) — когда несколько стилей могут быть применены к тексту, параграфу
Простой стиль (Single style) подразумевает стилизацию всего текста в TextView, используя XML атрибуты или стили и темы (которые применяются к TextView). Этот подход — простой, но не позволяет применить стилизацию к части текста. Например, при применении textStyle=”bold”
, текст целиком будет жирным; вы не можете сделать жирным какой-то символ в тексте.
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="32sp"
android:textStyle="bold"/>
Multi style подразумевает добавление нескольких стилей к одному и тому же тексту. Например, сделать одно слово со стилем italic а другое bold. Мульти стили могут быть реализованы через использование HTML тегов, span-ов или управлением отрисовки текста на холсте Canvas.
ForegroundColorSpan
, StyleSpan(ITALIC), ScaleXSpan(1.5f), StrikethroughSpan.HTML теги это простое решение для решения простых задач, таких как: сделать текст жирным, с наклоном, или даже вывести маркеры для списка. Чтобы стилизовать текст, включающий HTML теги, вызовите метод Html.fromHtml
. Под капотом - HTML форматирование конвертируется в span-ы. Пожалуйста обратите внимание, что класс Html
— не поддерживает все HTML теги и css стили, например чтобы изменить цвет маркеров списка.
val text = "My text <ul><li>bullet one</li><li>bullet two</li></ul>"
myTextView.text = Html.fromHtml(text)
Что касается рисования текста на canvas, то это следует использовать, если нужно сделать что-то, что не поддерживается платформой, например рисование текста по кривой.
Span-ы позволяют вам применить одновременно несколько стилей с большой степенью кастомизации. Например, вы можете определить параграфы текста с маркерами, применив BulletSpan
. Вы можете, например, изменить расстояние между маркером и текстом, также изменить цвет маркера. Начиная с Android P, вы можете даже установить радиус маркера списка. Вы также можете создать свою реализацию для span. Ознакомьтесь с секцией “Создание кастомных span-ов” ниже, чтобы узнать как это сделать.
val spannable = SpannableString("My text \nbullet one\nbullet two")spannable.setSpan(
BulletPointSpan(gapWidthPx, accentColor),
/* start index */ 9, /* end index */ 18,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)spannable.setSpan(
BulletPointSpan(gapWidthPx, accentColor),
/* start index */ 20, /* end index */ spannable.length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)myTextView.text = spannable
Вы можете комбинировать простые стили и мульти стили. Вы можете рассматривать стили, которые вы применяете к TextView как “базовые” стили. Стилизация текста через span-ы применяется “поверх” базового стиля и переписывает базовый стиль. Например, когда меняет цвет текста TextView через атрибут textColor=”@color.blue”
и применяем ForegroundColorSpan(Color.PINK)
для первых 4 символов текста, то первые 4 символа будут розовыми (потому что span переопределил основной стиль), а цвет остальных символов будет определятся цветом, заданным через атрибут.
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/blue"/>val spannable = SpannableString(“Text styling”)
spannable.setSpan(
ForegroundColorSpan(Color.PINK),
0, 4,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)myTextView.text = spannable

Применение Span-ов
При использовании span-ов, вы будете работать с одним из следующих классов: SpannedString
, SpannableString
or SpannableStringBuilder
. Разница между ними заключается в том, что текст или объекты разметки являются изменяемыми или неизменяемыми во внутренней структуре, которую они используют: SpannedString
и SpannableString
используют линейные массивы для хранения добавляемых span-ов, аSpannableStringBuilder
использует дерево отрезков.
Как решить какой класс нужно использовать:
- Если у вас есть неизменяемый текст, к которому вы хотите применить стили ? ->
SpannedString
- Если ваш стилизованный текст будет меняться ->
SpannableStringBuilder
- Установить небольшое кол-во span-ов (<~10)? ->
SpannableString
- Установить большое кол-во span-ов (>~10) ->
SpannableStringBuilder
Например, если вы работаете с текстом, который не изменяется, но к которому вы хотите прицепить span-ы, вы должны использовать SpannableString
.
╔════════════════════════╦══════════════╦════════════════╗
║ Class ║ Mutable Text ║ Mutable Markup ║
╠════════════════════════╬══════════════╬════════════════╣
║ SpannedString ║ no ║ no ║
║ SpannableString ║ no ║ yes ║
║ SpannableStringBuilder ║ yes ║ yes ║
╚════════════════════════╩══════════════╩════════════════╝
Все эти классы имплементятся от интерфейсаSpanned
, но классы которые имеют объекты разметки (SpannableString
и SpannableStringBuilder
) наследуются от Spannable
.
Spanned
-> неизменный текст и неизменная разметка
Spannable
(расширяет Spanned
)-> неизменный текст и изменяемая разметка
Примените span через вызов setSpan(Object what, int start, int end, int flags)
на Spannable
объекте. Здесь what
— это объект, который будет применен к тексту с позиции start и по позицию end текста. Флаг указывает, должен ли диапазон расширяться, чтобы включить текст, вставленный в их начальную или конечную точку, или нет. Независимо от того, какой флаг установлен, всякий раз, когда текст вставлен в положение больше начальной точки и меньше конечной точки, диапазон автоматически расширяется.
Например, настройка ForegroundColorSpan
может быть такой:
val spannable = SpannableStringBuilder(“Text is spantastic!”)spannable.setSpan(
ForegroundColorSpan(Color.RED),
8, 12,
Spannable.SPAN_EXCLUSIVE_INCLUSIVE)
Тк span был настроен используя флаг SPAN_EXCLUSIVE_INCLUSIVE
, то когда вставляется текст в конец span-а, span будет расширен, чтобы включить новый текст (те стиль будет применен к добавляемому тексту):
val spannable = SpannableStringBuilder(“Text is spantastic!”)spannable.setSpan(
ForegroundColorSpan(Color.RED),
/* start index */ 8, /* end index */ 12,
Spannable.SPAN_EXCLUSIVE_INCLUSIVE)spannable.insert(12, “(& fon)”)
Если span настроен с флагом Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
, то вставляемый в конец текст не будет изменен (те стиль не будет применен к добавляемому тексту).
Множество span-ов может быть составлено и присоединено к одному и тому же текстовому сегменту. Например, текст, выделенный как полужирным, так и красным, можно построить таким образом:
val spannable = SpannableString(“Text is spantastic!”)spannable.setSpan(
ForegroundColorSpan(Color.RED),
8, 12,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)spannable.setSpan(
StyleSpan(BOLD),
8, spannable.length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)

Framework spans
Android framework определяет несколько интерфейсов и абстрактных классов, которые проверяются во время измерения и во время рендеринга. У этих классов есть методы, которые позволяют span-у получить доступ к объектам как например TextPaint
или Canvas
.
Android framework предлагает более 20 классов и интерфейсов в пакете android.text.stylepackage для работы со span-ами, производных от главных интерфейсов и абстрактных классов. Мы сгруппировали span-ы на группы:
- Исходя из того, изменяет ли span только внешний вид, а также текстовую метрику / макет
- Исходя из того, влияют ли они на текст по символам или на уровне абзаца
Span-ы которые влияют на представление и метрику текста
Первая категория влияет на представление текста на уровне симовлов: цвет текста или фона, подчеркивание, зачеркивание, и т.д. — заставляет перерисовать текст без его перекомпоновки (пересчета размера view, которое отображает текст). Эти span-ы имплементят UpdateAppearance
и расширяют CharacterStyle
. CharacterStyle
субклассы определяют как рисовать текст посредством предоставления доступа к TextPaint
.
Span-ы влияющие на метрику — изменяют размеры лайаута и требуют повторного рендеринга компонентов.
Например, span который влияет на размер текста требует изменение размеров лайаута и перекомпоновки компонентов. Эти span-ы обычно расширяют MetricAffectingSpan
класс. Этот абстрактый класс позволяет субклассам определить как span влияет на измерение текста (это делает через предоставление доступа к TextPaint
). Поскольку MetricAffectingSpan
расширяет CharacterSpan
, подклассы влияют на внешний вид текст на уровне символов.
У вас может быть соблазн всегда пересоздавать CharSequence
с текстом и разметкой вызывая TextView.setText(CharSequence)
. Но это всегда требует повторного пересчета размеров лайаута и создания дополнительных объектов (что может сказать на отзывчивости ui). Чтобы избежать этого установите текст через вызов TextView.setText(Spannable, BufferType.SPANNABLE)
и затем, когда вам будет необходимо изменить span-ы, извлеките Spannable
объект из TextView через приведение TextView.getText()
к Spannable
. Мы заглянем что происходит под капотом TextView.setText
в следующей статье.
Для примера, рассмотрим Spannable
объект установленный и извлеченный следующим образом:
val spannableString = SpannableString(“Spantastic text”)// setting the text as a Spannable
textView.setText(spannableString, BufferType.SPANNABLE)// later getting the instance of the text object held
// by the TextView
// this can can be cast to Spannable only because we set it as a
// BufferType.SPANNABLE before
val spannableText = textView.text as Spannable
Теперь, когда мы определили spannableText
, мы можем не вызывать textView.setText
повторно, потому что мы можем изменять CharSequence
объект напрямую через объект spannableText.
Вот как мы можем установить изменить внешний вид, применив span:
Вариант 1: Изменение внешнего вида текста, без изменеия размеров TextView
spannableText.setSpan(
ForegroundColorSpan(colorAccent),
0, 4,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
В приведенном выше примере будет вызван TextView.onDraw
, но не TextView.onLayout
.
Вариант 2: Изменение размеров текста
spannableText.setSpan(
RelativeSizeSpan(2f),
0, 4,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
Тк RelativeSizeSpan
изменяет размер текста, то высота и ширина view может измениться , TextView
вызовет методы onMeasure
и onLayout
.
Character vs paragraph affecting spans
Span могут влиять на текст как на символьном уровне, изменяя например цвет фона, стиль, размер символов, так и на уровне параграфа, например изменяя выравнивание текста в параграфе целиком. В зависимости от того, как нужно стилизовать текст span-ы расширяют CharacterStyle
или имплиментят ParagraphStyle
. Span-ы которые расширяют ParagraphStyle
должны быть прикреплены от первого символа до последнего символа параграфа, иначе вы не стилизация текста не будет отображена. В Android параграфы определяются с новой строки с помощью символа перевода каретки (\n
) .
Например, CharacterStyle
span BackgroundColorSpan
может быть прикреплен к любому символу в тексте. Ниже мы прикрепили его начиная с 5-го до 8-го символа:
val spannable = SpannableString(“Text is\nspantastic”)spannable.setSpan(
BackgroundColorSpan(color),
5, 8,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
ParagraphStyle
span, QuoteSpan
, может быть прикреплен только к началу параграфа. Например, “Text is\nspantastic” включает символ новой строки на 8м символе текста, поэтому мы можем прикрепить QuoteSpan
только начиная с позиции 0 или 8, иначе текст не будет стилизован.
spannable.setSpan(
QuoteSpan(color),
8, text.length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
Создание кастомных span-ов
Когда вы реализовываете свой span, вы должны решить на каком уровне вы хотите стилизовать текст: на символьном уровне или на уровне параграфа, должны ли стилизация приводить к перекомпоновке view или только перерисовывать текст, без изменения размера view. Но прежде написания своего кода, проверьте существует ли требуемая функциональность в классах фреймворка.
TL;DR:
- Изменение стилизации текста на символьном уровне ->
CharacterStyle
- Изменение стилизации текста на уровне параграфа ->
ParagraphStyle
- Стилизация текста без изменения размера TextView ->
UpdateAppearance
- Стилизация текста c изменением размера TextView ->
UpdateLayout
Предположим, что нам нужно реализовать span, который увеличивает размер текста в соотвествии с пропорциями TextView так, как делает это RelativeSizeSpan
, и устанавливает цвет текста, как это делает ForegroundColorSpan
. Чтобы сделать это вы можете расширить RelativeSizeSpan
переопределив метод updateDrawState
в котором установить цвет текста используя объект TextPaint
.
class RelativeSizeColorSpan(
@ColorInt private val color: Int,
size: Float
) : RelativeSizeSpan(size) {override fun updateDrawState(textPaint: TextPaint?) {
super.updateDrawState(ds)
textPaint?.color = color
}
}
Замечание: точно такой же эффект может быть достигнут если применить оба существующих класса RelativeSizeSpan
и ForegroundColorSpan
к тексту.
Тестирование кастомных span-ов
Тестирование span-ов означает проверку того, что действительно были внесены ожидаемые изменения в TextPaint или что на Canvas были отрисованы элементы. Например, рассмотрим пользовательскую реализацию span-a, который добавляет точку (bullet point) абзаца с указанным размером и цветом, а также отступ слева от точки (bullet point). Вы можете посмотреть реализацию здесь android-text sample. Чтобы протестировать этот класс, реализуйте AndroidJUnit , чтобы проверить:
- Отрисовку круга определенного размера
- Ничего не рисуется, если span не прикреплен к тексту
- Установлен правильный отступ, ширина которого передается параметров конструктора
Ниже пример кода для тестирования.
val canvas = mock(Canvas::class.java)
val paint = mock(Paint::class.java)
val text = SpannableString("text")@Test fun drawLeadingMargin() {
val x = 10
val dir = 15
val top = 5
val bottom = 7
val color = Color.RED// Given a span that is set on a text
val span = BulletPointSpan(GAP_WIDTH, color)
text.setSpan(span, 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)// When the leading margin is drawn
span.drawLeadingMargin(canvas, paint, x, dir, top, 0, bottom,
text, 0, 0, true, mock(Layout::class.java))// Check that the correct canvas and paint methods are called,
//in the correct order
val inOrder = inOrder(canvas, paint)// bullet point paint color is the one we set
inOrder.verify(paint).color = color
inOrder.verify(paint).style = eq<Paint.Style>(Paint.Style.FILL)// a circle with the correct size is drawn
// at the correct location
val xCoordinate = GAP_WIDTH.toFloat() + x.toFloat()
+dir * BulletPointSpan.DEFAULT_BULLET_RADIUS
val yCoord = (top + bottom) / 2finOrder.verify(canvas)
.drawCircle(
eq(xCoordinate),
eq(yCoord),
eq(BulletPointSpan.DEFAULT_BULLET_RADIUS),
eq(paint))
verify(canvas, never()).save()
verify(canvas, never()).translate(
eq(xCoordinate),
eq(yCoordinate))
}
Проверьте полный код теста здесь — BulletPointSpanTest
.
Тестирование использования span-ов
Spanned
интерфейс позволяет настраивать и извлекать span-ы из текста. Проверьте что span-ы корректно добавлены в нужное место через реализацию Android JUnit теста. В android-text sample мы конвертируем теги разметки точки (bullet point) в маркеры (которые будут отрисованы в TextView). Это можно сделать прицепив BulletPointSpans
к тексту, в нужную позицию. Ниже код как это можно протестировать:
@Test fun textWithBulletPoints() {
val result = builder.markdownToSpans(“Points\n* one\n+ two”)// check that 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)assertEquals(2, spans.size.toLong())// check that the span is indeed a BulletPointSpan
val bulletSpan = spans[0] as BulletPointSpan// check that the start and end indexes are the expected ones
assertEquals(7, result.getSpanStart(bulletSpan).toLong())
assertEquals(11, result.getSpanEnd(bulletSpan).toLong())val bulletSpan2 = spans[1] as BulletPointSpan
assertEquals(11, result.getSpanStart(bulletSpan2).toLong())
assertEquals(14, result.getSpanEnd(bulletSpan2).toLong())
}
Посмотрите MarkdownBuilderTest
для других примеров тестов.
Замечание: если вы хотите выполнить итерации по span-ам вне тестов —для большей производительности используйте
Spanned#nextSpanTransition
вместоSpanned#getSpans
.
Span-ы — это мощная концепция, встроенная в функциональность рендеринга текста. Она позволяет стилизовать текст по средством доступа к компонентам TextPaint
и Canvas.
В Android P мы добавили обширную документацию по framework spans , так что перед тем как реализовывать свои span-ы, проверьте какие span-ы доступны.
В будущих статьях мы расскажем больше о работе span-ов “под капотом” и как их эффективно использовать. Оставайтесь с нами!