The answers here are all great if you are using hardcoded strings, but they're not very useful for string resources. Here's some code to give you similar functionality to how old-school TextViews would work with HTML built entirely using Jetpack Compose (no interop APIs). Credit for 99% of this answer goes to the comment on this issue, which I extended to use the Android String resource Annotation tag to support URLs. [Note: BulletSpan is not currently supported in this solution as it is not needed for my use case and I didn't take the time to address its absence in the solution I extended]
const val URL_ANNOTATION_KEY = "url"
/**
* Much of this class comes from
* https://issuetracker.google.com/issues/139320238#comment11
* which seeks to correct the gap in Jetpack Compose wherein HTML style tags in string resources
* are not respected.
*/
@Composable
@ReadOnlyComposable
private fun resources(): Resources {
return LocalContext.current.resources
}
fun Spanned.toHtmlWithoutParagraphs(): String {
return HtmlCompat.toHtml(this, HtmlCompat.TO_HTML_PARAGRAPH_LINES_CONSECUTIVE)
.substringAfter("<p dir=\"ltr\">").substringBeforeLast("</p>")
}
fun Resources.getText(@StringRes id: Int, vararg args: Any): CharSequence {
val escapedArgs = args.map {
if (it is Spanned) it.toHtmlWithoutParagraphs() else it
}.toTypedArray()
val resource = SpannedString(getText(id))
val htmlResource = resource.toHtmlWithoutParagraphs()
val formattedHtml = String.format(htmlResource, *escapedArgs)
return HtmlCompat.fromHtml(formattedHtml, HtmlCompat.FROM_HTML_MODE_LEGACY)
}
@Composable
fun annotatedStringResource(@StringRes id: Int, vararg formatArgs: Any): AnnotatedString {
val resources = resources()
val density = LocalDensity.current
return remember(id, formatArgs) {
val text = resources.getText(id, *formatArgs)
spannableStringToAnnotatedString(text, density)
}
}
@Composable
fun annotatedStringResource(@StringRes id: Int): AnnotatedString {
val resources = resources()
val density = LocalDensity.current
return remember(id) {
val text = resources.getText(id)
spannableStringToAnnotatedString(text, density)
}
}
private fun spannableStringToAnnotatedString(
text: CharSequence,
density: Density
): AnnotatedString {
return if (text is Spanned) {
with(density) {
buildAnnotatedString {
append((text.toString()))
text.getSpans(0, text.length, Any::class.java).forEach {
val start = text.getSpanStart(it)
val end = text.getSpanEnd(it)
when (it) {
is StyleSpan -> when (it.style) {
Typeface.NORMAL -> addStyle(
style = SpanStyle(
fontWeight = FontWeight.Normal,
fontStyle = FontStyle.Normal
),
start = start,
end = end
)
Typeface.BOLD -> addStyle(
style = SpanStyle(
fontWeight = FontWeight.Bold,
fontStyle = FontStyle.Normal
),
start = start,
end = end
)
Typeface.ITALIC -> addStyle(
style = SpanStyle(
fontWeight = FontWeight.Normal,
fontStyle = FontStyle.Italic
),
start = start,
end = end
)
Typeface.BOLD_ITALIC -> addStyle(
style = SpanStyle(
fontWeight = FontWeight.Bold,
fontStyle = FontStyle.Italic
),
start = start,
end = end
)
}
is TypefaceSpan -> addStyle(
style = SpanStyle(
fontFamily = when (it.family) {
FontFamily.SansSerif.name -> FontFamily.SansSerif
FontFamily.Serif.name -> FontFamily.Serif
FontFamily.Monospace.name -> FontFamily.Monospace
FontFamily.Cursive.name -> FontFamily.Cursive
else -> FontFamily.Default
}
),
start = start,
end = end
)
is BulletSpan -> {
Log.d("StringResources", "BulletSpan not supported yet")
addStyle(style = SpanStyle(), start = start, end = end)
}
is AbsoluteSizeSpan -> addStyle(
style = SpanStyle(fontSize = if (it.dip) it.size.dp.toSp() else it.size.toSp()),
start = start,
end = end
)
is RelativeSizeSpan -> addStyle(
style = SpanStyle(fontSize = it.sizeChange.em),
start = start,
end = end
)
is StrikethroughSpan -> addStyle(
style = SpanStyle(textDecoration = TextDecoration.LineThrough),
start = start,
end = end
)
is UnderlineSpan -> addStyle(
style = SpanStyle(textDecoration = TextDecoration.Underline),
start = start,
end = end
)
is SuperscriptSpan -> addStyle(
style = SpanStyle(baselineShift = BaselineShift.Superscript),
start = start,
end = end
)
is SubscriptSpan -> addStyle(
style = SpanStyle(baselineShift = BaselineShift.Subscript),
start = start,
end = end
)
is ForegroundColorSpan -> addStyle(
style = SpanStyle(color = Color(it.foregroundColor)),
start = start,
end = end
)
is Annotation -> {
if (it.key == URL_ANNOTATION_KEY) {
addStyle(
style = SpanStyle(color = Color.Blue),
start = start,
end = end
)
addStringAnnotation(
tag = "URL",
annotation = it.value,
start = start,
end = end
)
}
}
else -> addStyle(style = SpanStyle(), start = start, end = end)
}
}
}
}
} else {
AnnotatedString(text = text.toString())
}
}
@Composable
fun LinkableTextView(
@StringRes id: Int,
modifier: Modifier = Modifier,
style: TextStyle = MaterialTheme.typography.body1
) {
val uriHandler = LocalUriHandler.current
val annotatedString = annotatedStringResource(id)
ClickableText(
text = annotatedString,
style = style,
onClick = { offset ->
annotatedString.getStringAnnotations(
tag = "URL",
start = offset,
end = offset
).firstOrNull()?.let {
uriHandler.openUri(it.item)
}
},
modifier = modifier,
)
}
Usage:
@Composable
fun MyComposableView {
LinkableTextView(
id = R.string.my_link_string
)
}
String resource:
<string name="my_link_string">Click this
<annotation url="https://www.stackoverflow.com">link</annotation>
to go to web site
</string>
There is also the "dumb" way of just falling back to using android.widget.TextView which has the behavior you're seeking:
@Composable
fun CompatHtmlTextView(@StringRes htmlStringResource: Int) {
val html = stringResource(htmlStringResource)
AndroidView(factory = { context ->
android.widget.TextView(context).apply {
text = fromHtml(html)
}
})
}