Как стилизовать текст на Compose через Spannable
Всем привет от Андроид-команды банка ДОМ.РФ! Сегодня поговорим о стилизации. Иногда нужно добавить возможность стилизовать разные части текста. Как правило, для этого используют AnnotatedString. Мы можем форматировать текст как просто кусками, так и задавать определенные позиции, подсвечивать ссылки, обрабатывать клики на них. Этот подход замечательно работает, когда мы имеем готовый текст и заранее знаем, где и каким образом мы будем стилизовать. Но давайте попробуем рассмотреть две задачи:
У нас есть блок текста, который откуда-то приходит, и чтобы поддержать множество возможных комбинаций стилей, текст приходит как html разметка.
С бэкенда приходит совершенно рандомный текст, в котором нам нужно отыскать ссылку и стилизовать ее.
Что может прийти в голову? Написать свою реализацию для форматирования текста. Но только представьте, сколько всего нужно учесть. При этом никто не избавит нас от фикса багов. Или же пойти другим путем и попробовать найти готовое решение и немного его доработать под Compose.
Html текст и Spannable
val htmlText = "Для просмотра нового о compose \n" +
"нажмите на этот текст, там много интересного"
// Spanned съедает спецсимволы \n, поэтому заменяем его на тег переноса
val formattedText = Html.fromHtml(htmlText.replace("\n", "
"), Html.FROM_HTML_MODE_COMPACT)
Html.fromHtml возвращает искомый нами Spanned. Пару слов стоит сказать, что такое Spannable. Это довольно старая концепция, которая широко используется для стилизации текста. TextView умеет напрямую работать с ним в отличие от public api jetpack Compose. Теперь нам нужно конвертировать наши Spans стили в то, что съест Compose. Для этого нам понадобится AnnotatedString.
const val URL_TAG = "url"
fun Spanned.toAnnotateString(
baseSpanStyle: SpanStyle?,
linkColor: Color
): AnnotatedString {
return buildAnnotatedString {
val spanned = this@toAnnotateString
append(spanned.toString())
baseSpanStyle?.let { addStyle(it, 0, length) }
getSpans(0, spanned.length, Any::class.java).forEach { span ->
val start = getSpanStart(span)
val end = getSpanEnd(span)
when (span) {
is StyleSpan -> when (span.style) {
Typeface.BOLD -> addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end)
Typeface.ITALIC -> addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, end)
Typeface.BOLD_ITALIC -> addStyle(
SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic),
start,
end
)
}
is UnderlineSpan -> addStyle(SpanStyle(textDecoration = TextDecoration.Underline), start, end)
is ForegroundColorSpan -> addStyle(SpanStyle(color = Color(span.foregroundColor)), start, end)
is URLSpan -> {
addStyle(
SpanStyle(
textDecoration = TextDecoration.Underline,
color = linkColor
), start, end
)
addStringAnnotation(URL_TAG, span.url, start, end)
}
}
}
}
}
Для удобства оформил в экстеншен Spanned.toAnnotateString. Задаем базовый стиль для всего текста. Если нужно — и дальше перебираем Spans, стилизуя текст c помощью addStyle. Можно заметить URLSpan, он в дальнейшем нам еще пригодится. Тех спанов, что есть в экстеншене, хватает на нашем проекте. Вы же можете поиграться сами, придумав стили для других. Теперь посмотрим всю реализацию вместе с обработкой нажатий на ссылки, это уже стандартный подход на Compose.
@Composable
fun HtmlTextField(
modifier: Modifier = Modifier,
baseSpanStyle: SpanStyle? = null,
isHighlightLink: Boolean = false,
style: TextStyle = LocalTextStyle.current,
onUrlClick: ((url: String) -> Unit)? = null
) {
val htmlText = "Для просмотра нового о compose \n" +
"нажмите на этот текст, там много интересного"
val formattedText = Html.fromHtml(htmlText.replace("\n", "
"), Html.FROM_HTML_MODE_COMPACT)
val uriHandler = LocalUriHandler.current
val linkColor = if (isHighlightLink) Color.Blue else Color.Unspecified
val annotatedString = formattedText.toAnnotateString(baseSpanStyle = baseSpanStyle, linkColor = linkColor)
ClickableText(
modifier = modifier,
text = annotatedString,
style = style,
) { offset ->
annotatedString.getStringAnnotations(URL_TAG, offset, offset).firstOrNull()?.let {
onUrlClick?.let { click -> click(it.item) } ?: uriHandler.openUri(it.item)
}
}
}
Так выглядит стилизованный html в Compose
ClickableText мы используем для того чтобы найти ссылку и обработать нажатие. Выглядит хорошо, теперь перейдем ко второй задаче.
Нам поможет Linkify
У TextView есть замечательный атрибут autoLink, который за нас ищет и хайлайтит ссылки, а также обрабатывает клики по ним. Если покопаться, то он использует Linkify, в этом классе нас интересует метод addLinks, он принимает Span на вход и вторым параметром — тип маски.
@Composable
fun Message() {
val someText = "Хочу открыть https://developer.android.com, что нового?"
val uriHandler = LocalUriHandler.current
val spannedText = someText.toSpannable() // превращаем в Spannable, так как Linkify работает со Spannable
Linkify.addLinks(spannedText, Linkify.WEB_URLS) // Ищем и размечаем URLSpan
val annotatedString = spannedText.toAnnotateString(
baseSpanStyle = SpanStyle(
color = Color.Black,
),
linkColor = Color.Blue
)
ClickableText(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
text = annotatedString,
style = MaterialTheme.typography.bodyLarge,
onClick = { offset ->
annotatedString.getStringAnnotations(URL_TAG, offset, offset).firstOrNull()?.let {
uriHandler.openUri(it.item)
}
}
)
}
Стилизованный текст с кликабельной ссылкой
Заключение
При помощи Spannable и Linkify мы можем облегчить себе задачи по форматированию любого текст на лету и добавлять стили и из своей дизайн-системы. Возможно, вы как-то по-другому решали эту задачу, пишите в комментариях, будет очень интересно почитать.