Всем привет от Андроид-команды банка ДОМ.РФ! Сегодня поговорим о стилизации. Иногда нужно добавить возможность стилизовать разные части текста. Как правило, для этого используют AnnotatedString. Мы можем форматировать текст как просто кусками, так и задавать определенные позиции, подсвечивать ссылки, обрабатывать клики на них. Этот подход замечательно работает, когда мы имеем готовый текст и заранее знаем, где и каким образом мы будем стилизовать. Но давайте попробуем рассмотреть две задачи:

  1. У нас есть блок текста, который откуда-то приходит, и чтобы поддержать множество возможных комбинаций стилей, текст приходит как html разметка.

  2. С бэкенда приходит совершенно рандомный текст, в котором нам нужно отыскать ссылку и стилизовать ее.

Что может прийти в голову? Написать свою реализацию для форматирования текста. Но только представьте, сколько всего нужно учесть. При этом никто не избавит нас от фикса багов. Или же пойти другим путем и попробовать найти готовое решение и немного его доработать под Compose.

Html текст и Spannable

val htmlText = "<b>Для просмотра нового о compose</b> \n<a href=\"https://developer.android.com/jetpack/androidx/releases/compose\" target=\"_blank\">" +
"нажмите на этот текст</a>, там много интересного"
// Spanned съедает спецсимволы \n, поэтому заменяем его на тег переноса <br>
val formattedText = Html.fromHtml(htmlText.replace("\n", "<br>"), 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 = "<b>Для просмотра нового о compose</b> \n<a href=\"https://developer.android.com/jetpack/androidx/releases/compose\" target=\"_blank\">" +
    "нажмите на этот текст</a>, там много интересного"
    val formattedText = Html.fromHtml(htmlText.replace("\n", "<br>"), 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
Так выглядит стилизованный 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 мы можем облегчить себе задачи по форматированию любого текст на лету и добавлять стили и из своей дизайн-системы. Возможно, вы как-то по-другому решали эту задачу, пишите в комментариях, будет очень интересно почитать.

Комментарии (1)


  1. Rusrst
    03.11.2023 11:46

    Радует что постепенно все места которые Гугл нормально не сделал, начинают покрываться рабочими решениями.