EDIT: There is a bug that prevents accessibility services from properly reading embedded links such as these prior to Jetpack Compose 1.3.0. Even after 1.3.0, there is another bug where the onClick() function is not called by the Accessibility Service (Talkback). See this Google Issue. I would recommend utilizing the AndroidView + old-school TextView option I outline below if your app needs to be accessible, at least until the linked issue is addressed.
--
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
)
addUrlAnnotation(
urlAnnotation = UrlAnnotation(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, and works with accessibility services properly:
@Composable
fun CompatHtmlTextView(@StringRes htmlStringResource: Int) {
val html = stringResourceWithStyling(htmlStringResource).toString()
AndroidView(factory = { context ->
android.widget.TextView(context).apply {
text = fromHtml(html)
}
})
}
@Composable
@ReadOnlyComposable
fun stringResWithStyling(@StringRes id: Int): CharSequence =
LocalContext.current.resources.getText(id = id)
/**
* Compat method that will use the deprecated fromHtml method
* prior to Android N and the new one after Android N
*/
@Suppress("DEPRECATION")
fun fromHtml(html: String?): Spanned {
return when {
html == null -> {
// return an empty spannable if the html is null
SpannableString("")
}
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N -> {
// FROM_HTML_MODE_LEGACY is the behaviour that was used for versions below android N
// we are using this flag to give a consistent behaviour
Html.fromHtml(html, Html.FROM_HTML_MODE_LEGACY)
}
else -> {
Html.fromHtml(html)
}
}
}
For the Compat option, it's important that you retrieve the string resource as outlined so that the tags are not stripped. You must also format your string resource using CDATA tags, e.g.
<string name="text_with_link"><![CDATA[Visit
<a href="https://www.stackoverflow.com/">Stackoverflow</a>
for the best answers.]]></string>
Failure to use the CDATA tag will not render the string as HTML.