How to Display Styled Strings in Jetpack Compose

Joseph James (JJ)
4 min readDec 23, 2024

--

Photo by Joseph James: The bliss

If you have styled text in your strings.xml—using tags for bold, italic, underline, and more—you can display it in Jetpack Compose without losing those styles. By converting your Spanned text into an AnnotatedString, Compose can render everything from bold and italic to strikethrough and background colors.

Bonus: This approach is localization-friendly — your translators and localization teams can keep using tags like <b> or <i> in the resource files. You won’t have to constantly update code just because the language or text changes. This keeps your localized strings flexible and robust.

1. Define Your Styled Text in strings.xml

<string name="example_text">
This text includes <b>bold</b>, <i>italic</i>, <u>underlined</u>,
and more styles in one line.
</string>

2. Retrieve the Styled Text with getText()

In your composable, you can do:

@Composable
fun StyledTextExample() {
val context = LocalContext.current
val spanned = context.getText(R.string.example_text) as Spanned
val annotatedString = spanned.toAnnotatedString()

Text(text = annotatedString)
}

This fetches the styled string resource as a Spanned and converts it to an AnnotatedString (see below for the details of toAnnotatedString()).

3. Convert Spanned to AnnotatedString with All the Common Spans

Here’s a comprehensive version of toAnnotatedString() that handles multiple span types:

fun Spanned.toAnnotatedString(): AnnotatedString = buildAnnotatedString {
// Step 1: Copy over the raw text
append(this@toAnnotatedString.toString())
// Step 2: Go through each span
getSpans(0, length, Any::class.java).forEach { span ->
val start = getSpanStart(span)
val end = getSpanEnd(span)
when (span) {
// Bold, Italic, Bold-Italic
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
)
}
}
// Underline
is UnderlineSpan -> {
addStyle(
SpanStyle(textDecoration = TextDecoration.Underline),
start,
end
)
}
// Foreground Color
is ForegroundColorSpan -> {
addStyle(
SpanStyle(color = Color(span.foregroundColor)),
start,
end
)
}
// Background Color
is BackgroundColorSpan -> {
addStyle(
SpanStyle(background = Color(span.backgroundColor)),
start,
end
)
}
// Strikethrough (Line-through)
is StrikethroughSpan -> {
addStyle(
SpanStyle(textDecoration = TextDecoration.LineThrough),
start,
end
)
}
// Relative Size (scales the text)
is RelativeSizeSpan -> {
// For a real-world app, you'd need the base font size to multiply by span.sizeChange.
// Here, for simplicity, let's assume a base size or do a rough conversion:
val baseFontSize = 16.sp
val newFontSize = baseFontSize * span.sizeChange
addStyle(
SpanStyle(fontSize = newFontSize),
start,
end
)
}
// URL or clickable text
is URLSpan -> {
// You can store the URL as an annotation and optionally add a style
addStringAnnotation(
tag = "URL",
annotation = span.url,
start = start,
end = end
)
// Optional: add a style (color or underline) to make it look clickable
addStyle(
SpanStyle(
color = Color.Blue,
textDecoration = TextDecoration.Underline
),
start,
end
)
}
// Subscript
is SubscriptSpan -> {
// Compose doesn't have a built-in subscript style,
// so you'd either skip or handle it with a custom solution
// For demonstration, let's apply a smaller font size
val baseFontSize = 16.sp
addStyle(
SpanStyle(fontSize = baseFontSize * 0.8f, baselineShift = BaselineShift.Subscript),
start,
end
)
}
// Superscript
is SuperscriptSpan -> {
// Similarly, let's demonstrate a smaller font size with a shift
val baseFontSize = 16.sp
addStyle(
SpanStyle(fontSize = baseFontSize * 0.8f, baselineShift = BaselineShift.Superscript),
start,
end
)
}
// You can keep adding more span types as needed
else -> {}
}
}
}

Notes on the Additional Spans

  1. BackgroundColorSpan
    Translated to SpanStyle(background = Color(...)).
  2. StrikethroughSpan
    Uses TextDecoration.LineThrough.
  3. RelativeSizeSpan
    Requires you to decide how you calculate your new font size. We show a simple approach.
  4. URLSpan
    You can store the URL as an annotation (addStringAnnotation) and optionally add a color/underline so the user knows it’s clickable. To handle actual clicks, you’d use a ClickableText or detect annotations when text is tapped.
  5. SubscriptSpan / SuperscriptSpan
    Compose doesn’t have native subscript/superscript styles, but you can approximate them by adjusting font size and using BaselineShift.

4. Display the Styled Text

With your extension function in place, simply call:

@Composable
fun StyledTextExample() {
val context = LocalContext.current
val spanned = context.getText(R.string.example_text) as Spanned
val annotatedString = spanned.toAnnotatedString()

Text(text = annotatedString)
}

Jetpack Compose will now render all those spans — bold, italic, underline, strikethrough, background color, relative sizing, clickable URLs, and more.

Why This Approach Is Good for Localization

  1. No Hardcoded Styles in Code: All styling is in the string resource, so translators can reorder or remove tags without developer involvement.
  2. Preserves Flexibility: If <b> needs to move to a different part of the sentence for a certain language, no code changes are required.
  3. Easy Updates: The localization team is already used to editing strings.xml. They just keep doing the same, adding or removing styling tags as needed.

Wrapping Up

By extending the Spanned.toAnnotatedString() function to handle every span type you need, you can reuse your existing styled text from strings.xml (or anywhere else you get a Spanned) in Jetpack Compose. This allows you to seamlessly move to Compose without losing any of the text formatting or features you’ve already set up.

Happy composing!

--

--

Joseph James (JJ)
Joseph James (JJ)

Responses (1)