😀

ComoposeでのText装飾

2023/11/15に公開

An English version follows

例えば文字列の一部をBOLDにしたい

ComposeViewで実直に実装すると以下のように文字列を分割して目的の文字列と前後の文字列を分割しないといけなくてとにかくめんどくさい。

val annotatedString = buildAnnotatedString {
    append("This is ")
    // Apply bold style to "BOLD"
    withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
        append("BOLD")
    }
    append(" text")
}
BasicText(
    text = annotatedString,
    style = TextStyle(color = Color.Black)
)

AndroidViewだとStringリソースなどにタグを埋め込んでおけば、TextViewにパースした文字列(Spanned)とを渡すだけOK

strings.xml
## This is <b>BOLD</b> text
<string name="test_html_text">This is &lt;b&gt;BOLD&lt;/b&gt; text</string>

&

val htmlString = context.getString(R.string.test_html_text)
textView.text = HtmlCompat.fromHtml(htmlString, HtmlCompat.FROM_HTML_MODE_COMPACT)

どうするか

以下のような文字列の中のHTMLタグを解析して、文字列を装飾するComposable関数を用意する

HtmlText.kt
@Composable
fun HtmlText(
    htmlText: String,
    modifier: Modifier = Modifier,
    textAlign: TextAlign? = null,
    defaultStyle: TextStyle = LocalTextStyle.current,
) {
    val hyperLinkTextColor = MaterialTheme.colorScheme.primary
    val annotatedString = remember(htmlText) {
        val parsedHtml = HtmlCompat.fromHtml(
            htmlText.replace("\n", "<br>"),
            HtmlCompat.FROM_HTML_MODE_COMPACT
        )

        return@remember buildAnnotatedString {
            append(parsedHtml)

            parsedHtml.getSpans(0, parsedHtml.length, CharacterStyle::class.java).forEach { span ->
                when (span) {
                    // Apply styles for URL spans
                    is URLSpan -> {
                        addStyle(
                            style = SpanStyle(
                                color = hyperLinkTextColor,
                                textDecoration = TextDecoration.Underline
                            ),
                            start = parsedHtml.getSpanStart(span),
                            end = parsedHtml.getSpanEnd(span)
                        )
                        addStringAnnotation(
                            tag = URL_TAG,
                            annotation = span.url,
                            start = parsedHtml.getSpanStart(span),
                            end = parsedHtml.getSpanEnd(span)
                        )
                    }

                    // Apply styles for bold and italic spans
                    is StyleSpan -> {
                        val style = when (span.style) {
                            android.graphics.Typeface.BOLD -> SpanStyle(fontWeight = FontWeight.Bold)
                            android.graphics.Typeface.ITALIC -> SpanStyle(fontStyle = FontStyle.Italic)
                            android.graphics.Typeface.BOLD_ITALIC -> SpanStyle(
                                fontWeight = FontWeight.Bold,
                                fontStyle = FontStyle.Italic
                            )

                            else -> null
                        }

                        style?.let {
                            addStyle(
                                style = it,
                                start = parsedHtml.getSpanStart(span),
                                end = parsedHtml.getSpanEnd(span)
                            )
                        }
                    }

                    else -> Unit // NOP
                }
            }
        }
    }

    val context = LocalContext.current
    ClickableText(
        text = annotatedString,
        modifier = modifier,
        style = defaultStyle.merge(
            TextStyle(
                color = MaterialTheme.colors.onSurface,
                textAlign = textAlign
            )
        ),
        onClick = { offset ->
            annotatedString.getStringAnnotations(tag = URL_TAG, start = offset, end = offset)
                .firstOrNull()?.let {
                    val url = it.item
                    context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
                }
        }
    )
}

ここの処理は文字列の中のタグを抽出して実直にstyleを設定してる。

以下のように呼び出すだけ。

strings.xml
## This is <b>BOLD</b>, <i>ITALIC</i>, <a href="https://test.com">HYPER_LINK</a> and <b><i><a href="https://google.com">ALL</a></i></b> text
<string name="test_html_text">This is &lt;b&gt;BOLD&lt;/b&gt;, &lt;i&gt;ITALIC&lt;/i&gt;, &lt;a href=&quot;https://test.com&quot;&gt;HYPER_LINK&lt;/a&gt; and &lt;b&gt;&lt;i&gt;&lt;a href=&quot;https://google.com&quot;&gt;ALL&lt;/a&gt;&lt;/i&gt;&lt;/b&gt; text</string>

&

HtmlText(htmlText = stringResource(id = R.string.test_html_text))


preview

HtmlTextのサンプルの実装では、Bタグ、Iタグ、Aタグに対応している。
タグが重複して適用された場合でもOK。大体の場合でこれくらいで十分なのではないだろうか。
これでComposeViewでもAndroidViewのようにStringリソースにHTMLタグを埋め込むだけで文字列を装飾できるようになった。

Here is the blog post in English:

How to Bold Parts of a String

Implementing this directly in ComposeView can be cumbersome as it involves splitting the string and separately handling the target string and the surrounding text. The implementation would look like this:

val annotatedString = buildAnnotatedString {
    append("This is ")
    // Apply bold style to "BOLD"
    withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
        append("BOLD")
    }
    append(" text")
}
BasicText(
    text = annotatedString,
    style = TextStyle(color = Color.Black)
)

With AndroidView, you can simply embed tags in the String resources, and then pass the parsed string (Spanned) to TextView:

string.xml
## This is <b>BOLD</b> text
<string name="test_html_text">This is &lt;b&gt;BOLD&lt;/b&gt; text</string>

&

val htmlString = context.getString(R.string.test_html_text)
textView.text = HtmlCompat.fromHtml(htmlString, HtmlCompat.FROM_HTML_MODE_COMPACT)

What to Do

Prepare a Composable function that parses HTML tags within strings and decorates them as shown below:

HtmlText.kt
@Composable
fun HtmlText(
    htmlText: String,
    modifier: Modifier = Modifier,
    textAlign: TextAlign? = null,
    defaultStyle: TextStyle = LocalTextStyle.current,
) {
    val hyperLinkTextColor = MaterialTheme.colorScheme.primary
    val annotatedString = remember(htmlText) {
        val parsedHtml = HtmlCompat.fromHtml(
            htmlText.replace("\n", "<br>"),
            HtmlCompat.FROM_HTML_MODE_COMPACT
        )

        return@remember buildAnnotatedString {
            append(parsedHtml)

            parsedHtml.getSpans(0, parsedHtml.length, CharacterStyle::class.java).forEach { span ->
                when (span) {
                    // Apply styles for URL spans
                    is URLSpan -> {
                        addStyle(
                            style = SpanStyle(
                                color = hyperLinkTextColor,
                                textDecoration = TextDecoration.Underline
                            ),
                            start = parsedHtml.getSpanStart(span),
                            end = parsedHtml.getSpanEnd(span)
                        )
                        addStringAnnotation(
                            tag = URL_TAG,
                            annotation = span.url,
                            start = parsedHtml.getSpanStart(span),
                            end = parsedHtml.getSpanEnd(span)
                        )
                    }

                    // Apply styles for bold and italic spans
                    is StyleSpan -> {
                        val style = when (span.style) {
                            android.graphics.Typeface.BOLD -> SpanStyle(fontWeight = FontWeight.Bold)
                            android.graphics.Typeface.ITALIC -> SpanStyle(fontStyle = FontStyle.Italic)
                            android.graphics.Typeface.BOLD_ITALIC -> SpanStyle(
                                fontWeight = FontWeight.Bold,
                                fontStyle = FontStyle.Italic
                            )

                            else -> null
                        }

                        style?.let {
                            addStyle(
                                style = it,
                                start = parsedHtml.getSpanStart(span),
                                end = parsedHtml.getSpanEnd(span)
                            )
                        }
                    }

                    else -> Unit // NOP
                }
            }
        }
    }

    val context = LocalContext.current
    ClickableText(
        text = annotatedString,
        modifier = modifier,
        style = defaultStyle.merge(
            TextStyle(
                color = MaterialTheme.colors.onSurface,
                textAlign = textAlign
            )
        ),
        onClick = { offset ->
            annotatedString.getStringAnnotations(tag = URL_TAG, start = offset, end = offset)
                .firstOrNull()?.let {
                    val url = it.item
                    context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
                }
        }
    )
}

This process involves extracting tags from strings and directly setting styles.

Simply call it like this:

strings.xml
## This is <b>BOLD</b>, <i>ITALIC</i>, <a href="https://test.com">HYPER_LINK</a> and <b><i><a href="https://google.com">ALL</a></i></b> text
<string name="test_html_text">This is &lt;b&gt;BOLD&lt;/b&gt;, &lt;i&gt;ITALIC&lt;/i&gt;, &lt;a href=&quot;https://test.com&quot;&gt;HYPER_LINK&lt;/a&gt; and &lt;b&gt;&lt;i&gt;&lt;a href=&quot;https://google.com&quot;&gt;ALL&lt;/a&gt;&lt;/i&gt;&lt;/b&gt; text</string>

&

HtmlText(htmlText = stringResource(id = R.string.test_html_text))


preview

The HtmlText sample implementation supports B, I, and A tags. It works even when tags are applied in combination. This approach should suffice for most cases. Now, like AndroidView, you can decorate strings in ComposeView by simply embedding HTML tags in String resources.

Discussion