Стилизация текста с помощью Span

Перевод статьи @florina.muntenescu Spantastic text styling with Spans

Используйте span, чтобы стилизовать текст в Android! Вы сможете изменить цвет нескольких символов, сделать их кликабельными, изменить размер текста или даже нарисовать свои маркеры для списка с помощью span. Span-ы могут изменить свойства TextPaint, могут рисовать на Canvas, или даже изменить текстовый лайаут и изменить высоту строки текста. Span-ы это объекты разметки, которые могут быть прикреплены или откреплены от текста; они могут быть применены к целому параграфу или части текста.

Давайте рассмотрим, как использовать span, что предоставляют span-ы “из коробки”, как проще создать свой span и наконец как их тестировать:

Стилизация текста в Android

Применение 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.

Слева: Текст с одним простым стилем. TextView с textSize=”32sp” и textStyle=”bold”. Справа: Текст с мультистилями. Текст с 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
Слева: Использование HTML тегов. По центру: использование BulletSpan с дефолтным размеров bullet (маркеры элементов списка). Справа: Использование BulletSpan на Android P или кастомная реализация.

Вы можете комбинировать простые стили и мульти стили. Вы можете рассматривать стили, которые вы применяете к 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
Комбинирование стилей с XML атрибутами и span-ами

Применение Span-ов

При использовании span-ов, вы будете работать с одним из следующих классов: SpannedString, SpannableString or SpannableStringBuilder. Разница между ними заключается в том, что текст или объекты разметки являются изменяемыми или неизменяемыми во внутренней структуре, которую они используют: SpannedString и SpannableString используют линейные массивы для хранения добавляемых span-ов, аSpannableStringBuilder использует дерево отрезков.

Как решить какой класс нужно использовать:

  • Если у вас есть неизменяемый текст, к которому вы хотите применить стили ? -> SpannedString
  • Если ваш стилизованный текст будет меняться -> SpannableStringBuilder
  • Установить небольшое кол-во span-ов (<~10)? -> SpannableString
  • Установить большое кол-во span-ов (>~10) -> SpannableStringBuilder

Например, если вы работаете с текстом, который не изменяется, но к которому вы хотите прицепить span-ы, вы должны использовать SpannableString.

╔════════════════════════╦══════════════╦════════════════╗
ClassMutable TextMutable 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)”)
Слева: Текст с ForegroundColorSpan. Спарва: Текст с ForegroundColorSpan и Spannable.SPAN_EXCLUSIVE_INCLUSIVE

Если 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)
Текст к несколькими span-ами: ForegroundColorSpan(Color.RED) и StyleSpan(BOLD)

Framework spans

Android framework определяет несколько интерфейсов и абстрактных классов, которые проверяются во время измерения и во время рендеринга. У этих классов есть методы, которые позволяют span-у получить доступ к объектам как например TextPaint или Canvas.

Android framework предлагает более 20 классов и интерфейсов в пакете android.text.stylepackage для работы со span-ами, производных от главных интерфейсов и абстрактных классов. Мы сгруппировали span-ы на группы:

  • Исходя из того, изменяет ли span только внешний вид, а также текстовую метрику / макет
  • Исходя из того, влияют ли они на текст по символам или на уровне абзаца
Span категории: символы и параграфы, внешний вид и метрика

Span-ы которые влияют на представление и метрику текста

Первая категория влияет на представление текста на уровне симовлов: цвет текста или фона, подчеркивание, зачеркивание, и т.д. — заставляет перерисовать текст без его перекомпоновки (пересчета размера view, которое отображает текст). Эти span-ы имплементят UpdateAppearance и расширяют CharacterStyle. CharacterStyle субклассы определяют как рисовать текст посредством предоставления доступа к TextPaint.

span-ы влияющие на представление

Span-ы влияющие на метрику — изменяют размеры лайаута и требуют повторного рендеринга компонентов.

Например, span который влияет на размер текста требует изменение размеров лайаута и перекомпоновки компонентов. Эти span-ы обычно расширяют MetricAffectingSpan класс. Этот абстрактый класс позволяет субклассам определить как span влияет на измерение текста (это делает через предоставление доступа к TextPaint). Поскольку MetricAffectingSpan расширяет CharacterSpan, подклассы влияют на внешний вид текст на уровне символов.

Metric affecting span-ы которые влияют на метрику текста (размеры)

У вас может быть соблазн всегда пересоздавать 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 .

Слева: ForegroundColorSpan — измнение внешнего вида без изменения размеров TextView . Справа: RelativeSizeSpan — Изменение размеров Textview

Character vs paragraph affecting spans

Span могут влиять на текст как на символьном уровне, изменяя например цвет фона, стиль, размер символов, так и на уровне параграфа, например изменяя выравнивание текста в параграфе целиком. В зависимости от того, как нужно стилизовать текст span-ы расширяют CharacterStyle или имплиментят ParagraphStyle. Span-ы которые расширяют ParagraphStyle должны быть прикреплены от первого символа до последнего символа параграфа, иначе вы не стилизация текста не будет отображена. В Android параграфы определяются с новой строки с помощью символа перевода каретки (\n) .

В Android параграфы начинаются после символа перевода каретки (‘\n’) .
Span-ы изменяющие параграф целиком.

Например, 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)
Left: BackgroundColorSpan — character affecting span. Right: QuoteSpan — paragraph affecting span

Создание кастомных 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) / 2f
inOrder.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-ов “под капотом” и как их эффективно использовать. Оставайтесь с нами!