编辑: 在 Jetpack Compose 1.3.0 之前,存在一个导致辅助功能服务未能正确读取嵌入链接的错误。即使在 1.3.0 之后,也存在另一个问题,即 onClick() 函数未被辅助功能服务(Talkback)调用。请参见 此 Google 问题。如果您的应用程序需要可访问性,我建议使用下面我概述的 AndroidView + 老式 TextView 选项,至少在解决链接问题之前。
--
如果您正在使用硬编码字符串,则这里的答案都很好,但对于字符串资源而言不是很有用。以下代码为您提供了与使用 Jetpack Compose 完全构建 HTML 的老式 TextView 类似的功能(无需互操作 API)。99% 的答案来自于此问题的评论,我扩展了其中使用Android 字符串资源注释标签支持 URL 的部分[注意:BulletSpan 在此解决方案中目前不受支持,因为它对我的用例没有必要,我没有花时间解决其缺失]
const val URL_ANNOTATION_KEY = "url"
@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(
annotation = 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,
)
}
使用方法:
@Composable
fun MyComposableView {
LinkableTextView(
id = R.string.my_link_string
)
}
字符串资源:
<string name="my_link_string">Click this
<annotation url="https://www.stackoverflow.com">link</annotation>
to go to web site
</string>
还有一种“笨拙”的方法,就是回退到使用 android.widget.TextView,它具有您所需的行为,并且可以与辅助功能服务正常工作:
@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)
@Suppress("DEPRECATION")
fun fromHtml(html: String?): Spanned {
return when {
html == null -> {
SpannableString("")
}
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N -> {
Html.fromHtml(html, Html.FROM_HTML_MODE_LEGACY)
}
else -> {
Html.fromHtml(html)
}
}
}
对于“Compat”选项,很重要的一点是按照所述检索字符串资源,以便不会去掉标签。您还必须使用CDATA标记格式化您的字符串资源,例如:
<string name="text_with_link"><![CDATA[Visit <a href="https://www.stackoverflow.com/">Stackoverflow</a> for the best answers.]]></string>
未使用CDATA标签将不会将字符串渲染为HTML。