Jetpack Compose 文本如何超链接文本中的某些部分

80

如何在Text组件的某些文本部分添加超链接?

使用buildAnnotatedString可以将链接部分设置为蓝色且带有下划线,如下图所示。但是,如何将该部分转换为超链接?

图片描述

   val annotatedLinkString = buildAnnotatedString {
        val str = "Click this link to go to web site"
        val startIndex = str.indexOf("link")
        val endIndex = startIndex + 4
        append(str)
        addStyle(
            style = SpanStyle(
                color = Color(0xff64B5F6),
                textDecoration = TextDecoration.Underline
            ), start = startIndex, end = endIndex
        )
    }

    Text(
        modifier = modifier
            .padding(16.dp)
            .fillMaxWidth(),
        text = annotatedLinkString
    )

我也可以得到 Spanned,但是有没有办法与 Text 一起使用呢?

val str: Spanned = HtmlCompat.fromHtml(
    "<a href=\"http://www.github.com\">Github</a>", HtmlCompat.FROM_HTML_MODE_LEGACY
)

1
还要考虑触摸目标的大小:https://slack-chats.kotlinlang.org/t/2651976 - Uli
12个回答

82

这个被标记的答案会让新手感到困惑,我提供一个完整的示例

请不要忘记用pop()结束pushStringAnnotation

val annotatedString = buildAnnotatedString {
    append("By joining, you agree to the ")

    pushStringAnnotation(tag = "policy", annotation = "https://google.com/policy")
    withStyle(style = SpanStyle(color = MaterialTheme.colors.primary)) {
        append("privacy policy")
    }
    pop()

    append(" and ")

    pushStringAnnotation(tag = "terms", annotation = "https://google.com/terms")

    withStyle(style = SpanStyle(color = MaterialTheme.colors.primary)) {
        append("terms of use")
    }

    pop()
}

ClickableText(text = annotatedString, style = MaterialTheme.typography.body1, onClick = { offset ->
    annotatedString.getStringAnnotations(tag = "policy", start = offset, end = offset).firstOrNull()?.let {
        Log.d("policy URL", it.item)
    }

    annotatedString.getStringAnnotations(tag = "terms", start = offset, end = offset).firstOrNull()?.let {
        Log.d("terms URL", it.item)
    }
})

最终效果

enter image description here

如果您需要 #标签 和 @提及,请参阅我的其他答案

enter image description here


4
好的!你刚刚忘记在第一次pushStringAnnotation和withStyle方法对之后调用pop()了。 - Zdenek Sima
1
有没有办法为这样的视图添加一些选定状态/涟漪效果?现在它看起来完全静态。 - Kostya Rudenko
2
什么是 pop()?我们为什么需要在这里使用它? - Michael Abyzov

68

要得到完整的答案,您可以使用ClickableText来返回文本的位置,并使用UriHandler在浏览器中打开URI。

val annotatedLinkString: AnnotatedString = buildAnnotatedString {

    val str = "Click this link to go to web site"
    val startIndex = str.indexOf("link")
    val endIndex = startIndex + 4
    append(str)
    addStyle(
        style = SpanStyle(
            color = Color(0xff64B5F6),
            fontSize = 18.sp,
            textDecoration = TextDecoration.Underline
        ), start = startIndex, end = endIndex
    )

    // attach a string annotation that stores a URL to the text "link"
    addStringAnnotation(
        tag = "URL",
        annotation = "https://github.com",
        start = startIndex,
        end = endIndex
    )

}

// UriHandler parse and opens URI inside AnnotatedString Item in Browse
val uriHandler = LocalUriHandler.current

//  Clickable text returns position of text that is clicked in onClick callback
ClickableText(
    modifier = modifier
        .padding(16.dp)
        .fillMaxWidth(),
    text = annotatedLinkString,
    onClick = {
        annotatedLinkString
            .getStringAnnotations("URL", it, it)
            .firstOrNull()?.let { stringAnnotation ->
                uriHandler.openUri(stringAnnotation.item)
            }
    }
)

5
如何将字符串资源与此方法结合使用,这似乎是处理硬编码字符串的好方法。 - Guanaco Devs
1
@GuanacoDevs 请查看我下面的回答! - Chantell Osejo
@ChantellOsejo 这似乎是一种可行的方法,你可以获得更多的控制。然而,这个答案给了我一个更简单的方法。 - Guanaco Devs
很好的答案。只是想指出,如果您需要在应用程序中简单地运行一个函数(比如移动到另一个片段),您可以省略addStringAnnotation,并直接从ClickableText构造函数中的onClick方法调用该函数。 - AndroidDev
@Thracian 如果我们的应用程序中有多个语言环境,那么计算“start”和“end”索引将会很困难。您知道在这种情况下我该如何实现吗? - Arpit Patel
显示剩余3条评论

22

对于任何寻找可重复使用的复制粘贴解决方案的人,

创建一个名为LinkText.kt的新文件,并复制粘贴此代码。

data class LinkTextData(
    val text: String,
    val tag: String? = null,
    val annotation: String? = null,
    val onClick: ((str: AnnotatedString.Range<String>) -> Unit)? = null,
)

@Composable
fun LinkText(
    linkTextData: List<LinkTextData>,
    modifier: Modifier = Modifier,
) {
    val annotatedString = createAnnotatedString(linkTextData)

    ClickableText(
        text = annotatedString,
        style = MaterialTheme.typography.body1,
        onClick = { offset ->
            linkTextData.forEach { annotatedStringData ->
                if (annotatedStringData.tag != null && annotatedStringData.annotation != null) {
                    annotatedString.getStringAnnotations(
                        tag = annotatedStringData.tag,
                        start = offset,
                        end = offset,
                    ).firstOrNull()?.let {
                        annotatedStringData.onClick?.invoke(it)
                    }
                }
            }
        },
        modifier = modifier,
    )
}

@Composable
private fun createAnnotatedString(data: List<LinkTextData>): AnnotatedString {
    return buildAnnotatedString {
        data.forEach { linkTextData ->
            if (linkTextData.tag != null && linkTextData.annotation != null) {
                pushStringAnnotation(
                    tag = linkTextData.tag,
                    annotation = linkTextData.annotation,
                )
                withStyle(
                    style = SpanStyle(
                        color = MaterialTheme.colors.primary,
                        textDecoration = TextDecoration.Underline,
                    ),
                ) {
                    append(linkTextData.text)
                }
                pop()
            } else {
                append(linkTextData.text)
            }
        }
    }
}

用法

LinkText(
    linkTextData = listOf(
        LinkTextData(
            text = "Icons made by ",
        ),
        LinkTextData(
            text = "smalllikeart",
            tag = "icon_1_author",
            annotation = "https://www.flaticon.com/authors/smalllikeart",
            onClick = {
                Log.d("Link text", "${it.tag} ${it.item}")
            },
        ),
        LinkTextData(
            text = " from ",
        ),
        LinkTextData(
            text = "Flaticon",
            tag = "icon_1_source",
            annotation = "https://www.flaticon.com/",
            onClick = {
                Log.d("Link text", "${it.tag} ${it.item}")
            },
        )
    ),
    modifier = Modifier
        .padding(
            all = 16.dp,
        ),
)

屏幕截图,

屏幕截图

注意

  1. 我正在使用可组合项手动处理网页。如果不需要手动控制,请使用UriHandler或其他可替代方案。
  2. 根据需要在LinkText中设计可点击和其他文本样式。

tag 的用途是什么? - Marat
@Marat,tag就像是id。它用于标识注释 - 文档:https://developer.android.com/reference/kotlin/androidx/compose/ui/text/AnnotatedString.Builder#pushStringAnnotation(kotlin.String,kotlin.String) - Abhimanyu
我明白了。非常感谢。 - Marat
@Abhimanyu 点击下划线文本时无法工作。 - SpaceDevs

11

8

最简单、最干净的解决方案:

在此输入图片描述

@Composable
fun AnnotatedClickableText() {
  val termsUrl = "https://example.com/terms"
  val privacyUrl = "https://example.com/privacy"
  val annotatedText = buildAnnotatedString {
    append("You agree to our ")
    withStyle(style = SpanStyle(color = Color.Blue, fontWeight = FontWeight.Bold)) {
      appendLink("Terms of Use", termsUrl)
    }
    append(" and ")
    withStyle(style = SpanStyle(color = Color.Blue, fontWeight = FontWeight.Bold)) {
      appendLink("Privacy Policy", privacyUrl)
    }
  }

  ClickableText(
    text = annotatedText,
    onClick = { offset ->
      annotatedText.onLinkClick(offset) { link ->
        println("Clicked URL: $link")
        // Open link in WebView.
      }
    }
  )
}

fun AnnotatedString.Builder.appendLink(linkText: String, linkUrl: String) {
  pushStringAnnotation(tag = linkUrl, annotation = linkUrl)
  append(linkText)
  pop()
}

fun AnnotatedString.onLinkClick(offset: Int, onClick: (String) -> Unit) {
  getStringAnnotations(start = offset, end = offset).firstOrNull()?.let {
    onClick(it.item)
  }
}

注意这2个扩展方法,它们可以使链接创建更加简单。


8
我该如何在Text组件的某些文本部分添加超链接?
with(AnnotatedString.Builder()) {
    append("link: Jetpack Compose")
    // attach a string annotation that stores a URL to the text "Jetpack Compose".
    addStringAnnotation(
        tag = "URL",
        annotation = "https://developer.android.com/jetpack/compose",
        start = 6,
        end = 21
    )
}

标签:用于区分注释的标记

注释:附加的字符串注释

开始:范围的包含起始偏移量

结束:范围的独占结束偏移量

来源


1
除了“URL”之外,还有哪些标签? - Thracian
抱歉,我误解了。我刚学到这个。它需要4个参数。谢谢你的好问题。 - Arda Kazancı
我尝试使用annotatedString()并将其设置为Text,在清单中添加了Internet权限,但它不起作用,我的意思是当您触摸文本时什么也没有发生。您能否检查一下? - Thracian
你需要使用一个URL处理程序。 - Arda Kazancı
1
如何使这些“开始”和“结束”数字不是硬编码的? - Lucas Sousa

5
如果你想要从 strings.xml 文件中使用 @StringRes,可以使用以下代码:

enter image description here

假设你有以下字符串资源:
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="disclaimer">By joining you agree to the privacy policy and terms of use.</string>
    <string name="privacy_policy">privacy policy</string>
    <string name="terms_of_use">terms of use</string>
</resources>

您可以像这样使用它:

HighlightedText(
    text = stringResource(id = R.string.disclaimer),
    highlights = listOf(
        Highlight(
            text = stringResource(id = R.string.privacy_policy),
            data = "https://stackoverflow.com/legal/privacy-policy",
            onClick = { link ->
                // do something with link
            }
        ),
        Highlight(
            text = stringResource(id = R.string.terms_of_use),
            data = "https://stackoverflow.com/legal/terms-of-use",
            onClick = { link ->
                // do something with link
            }
        )
    )
)

以下是 Composable 的源代码:
data class Highlight(
    val text: String,
    val data: String,
    val onClick: (data: String) -> Unit
)

@Composable
fun HighlightedText(
    text: String,
    highlights: List<Highlight>,
    modifier: Modifier = Modifier
) {
    data class TextData(
        val text: String,
        val tag: String? = null,
        val data: String? = null,
        val onClick: ((data: AnnotatedString.Range<String>) -> Unit)? = null
    )

    val textData = mutableListOf<TextData>()
    if (highlights.isEmpty()) {
        textData.add(
            TextData(
                text = text
            )
        )
    } else {
        var startIndex = 0
        highlights.forEachIndexed { i, link ->
            val endIndex = text.indexOf(link.text)
            if (endIndex == -1) {
                throw Exception("Highlighted text mismatch")
            }
            textData.add(
                TextData(
                    text = text.substring(startIndex, endIndex)
                )
            )
            textData.add(
                TextData(
                    text = link.text,
                    tag = "${link.text}_TAG",
                    data = link.data,
                    onClick = {
                        link.onClick(it.item)
                    }
                )
            )
            startIndex = endIndex + link.text.length
            if (i == highlights.lastIndex && startIndex < text.length) {
                textData.add(
                    TextData(
                        text = text.substring(startIndex, text.length)
                    )
                )
            }
        }
    }

    val annotatedString = buildAnnotatedString {
        textData.forEach { linkTextData ->
            if (linkTextData.tag != null && linkTextData.data != null) {
                pushStringAnnotation(
                    tag = linkTextData.tag,
                    annotation = linkTextData.data,
                )
                withStyle(
                    style = SpanStyle(
                        color = infoLinkTextColor
                    ),
                ) {
                    append(linkTextData.text)
                }
                pop()
            } else {
                append(linkTextData.text)
            }
        }
    }
    ClickableText(
        text = annotatedString,
        style = TextStyle(
            fontSize = 30.sp,
            fontWeight = FontWeight.Normal,
            color = infoTextColor,
            textAlign = TextAlign.Start
        ),
        onClick = { offset ->
            textData.forEach { annotatedStringData ->
                if (annotatedStringData.tag != null && annotatedStringData.data != null) {
                    annotatedString.getStringAnnotations(
                        tag = annotatedStringData.tag,
                        start = offset,
                        end = offset,
                    ).firstOrNull()?.let {
                        annotatedStringData.onClick?.invoke(it)
                    }
                }
            }
        },
        modifier = modifier
    )
}

4

编辑: 在 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"

/**
 * 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(
                                    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) 

/**
 * 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)
        }
    }
}

对于“Compat”选项,很重要的一点是按照所述检索字符串资源,以便不会去掉标签。您还必须使用CDATA标记格式化您的字符串资源,例如:

<string name="text_with_link"><![CDATA[Visit         <a href="https://www.stackoverflow.com/">Stackoverflow</a>        for the best answers.]]></string>

未使用CDATA标签将不会将字符串渲染为HTML。

这是一个不错的解决方案,直到官方提供更好的方案。 - WeGi

2
如果您无法控制输入文本,请使用此代码。
 val s = buildAnnotatedString {
    for (link in txt.split(' ')) {
        if (link.matches(".*(#\\w+)|(http(s)?://.+).*".toRegex())) {
            withStyle(SpanStyle(color = Color.Cyan)) {
                append(link + ' ')
            }
        } else {
            append(link + ' ')
        }
    }
}
Text(text = s)

正则表达式中,它可以包含更多的#https://,这取决于你所需要的。

注意:这不是可点击的文本,如果你需要一个,请查看下面的代码(不建议用于大段文本)。

val uri = LocalUriHandler.current

 FlowRow {
    for (s in txt.split(' ')) {
        if (s.matches(".*(#\\w+)|(http(s)?://.+).*".toRegex())) {
            ClickableText(
                text = AnnotatedString(s + ' '),
                onClick = { runCatching { uri.openUri(s) } },
                style = TextStyle(color = Color.Cyan)
            )
        } else {
            Text(text = s + ' ')
        }
    }
}

是的,你需要使用Flow_Layout accompanist。

在此输入图片描述


2

如果您只关心打开超链接,可以使用HyperlinkText的动态方法。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接