学会用最优雅的姿式在Compose中显示富文本

本文译自「StyledString: A Better Pattern for Rich Text in Jetpack Compose」,原文链接proandroiddev.com/styledstrin...,由Eury Pérez Beltré发布于2025年7月14日。

在 Jetpack Compose 中设置文本样式看似简单......但其实不然。在本文中,我们将探讨 AnnotatedString 的局限性,以及 StyledString 如何让富文本更易于管理。让我们来详细分析一下。👇

📚 目录

  1. 引言:一个粗体字、一个链接,以及一大堆麻烦
  2. AnnotatedString:样式过多,简洁性不足
  3. StyledString 简介:一个 API 即可设置所有样式
  4. StyledString 底层原理:API 背后的引擎
  5. 结语

引言:一个加粗的单词、一个链接,以及一大堆麻烦

一开始,你拥有了 AnnotatedString 和一个 SpanStyle ,一切看起来都很顺畅。你想加粗一个单词?很简单✅。给某个东西加下划线?没问题。这甚至感觉有点有趣,尤其是在你像官方文档中那样手动构建整个字符串的时候。

但问题是:🧠

当你完全控制字符串时,这种方法非常有效。但当你处理实际内容:动态副本、本地化文本、从其他地方传入的段落,而你只需要设置其中一部分的样式时?

事情很快就变得很糟糕。

突然间,你需要跟踪子字符串、计算索引、应用样式,并连接点击监听器。只需对文本进行一次更改,你的逻辑就会像纸牌屋一样崩溃。🃏

你原本想要的只是加粗一个单词并让链接可点击。现在你深陷于样板代码中,祈祷一切都不会改变。

在这篇文章中,我将解释为什么 AnnotatedString 在实际 UI 中无法很好地扩展,并介绍一个我为了解决这个问题而构建的微型抽象。它叫做 StyledString,它的功能非常强大:💡 它确实做到了:

让Compose 中的文本样式再次变得简单。

AnnotatedString:样式太多,简洁性不足

首先,让我们称赞一下 AnnotatedString。它是一款强大的工具💪.

你可以使用一个 Text 可组合项来创建带样式、可点击、可交互的文本。想要让一个单词加粗,另一个单词像链接一样显示?完全可以。该 API 灵活、底层,并且由 Compose 本身的富文本引擎支持。

问题是,它只有在手动构建整个字符串时才能发挥最佳效果。

文档中的大多数示例如下所示:

Kotlin 复制代码
buildAnnotatedString {
    append("Hello ")
    withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
        append("world")
    }
}

看起来不错,对吧?但棘手的地方就在这里。👀

如果你有一整段动态文本,比如一段本地化的字符串或一个从其他地方拉取的句子,而你只想为其中的部分内容添加样式,该怎么办?

现在你需要处理:

  • 查找要添加样式的子字符串
  • 计算起始和结束索引
  • 手动添加样式或注释
  • 希望文本永远不变,否则一切都会崩溃。

如果你需要多种样式,比如粗体单词、可点击的电子邮件和带下划线的 URL,动态地,事情很快就会变得混乱。🔥 这时,buildAnnotatedString 就会变成一堆脆弱的索引数学运算、重复的逻辑和样板代码,难以阅读,更难以维护。

当然,AnnotatedString 功能强大。但当你的文本是动态的,而你只想为其中的部分内容添加样式时?它很快就会变得索然无味。

StyledString 隆重出场:一个 API 即可实现所有样式

在与 AnnotatedString 纠结了无数次之后,我决定构建一个更好的东西。它并非一个庞大的库,也不是一个完整的样式框架。而是一个简单、兼容 Compose 的抽象,用于解决一个非常具体的问题。

StyledString 来啦!👋

它的目标很简单:让你定义字符串的哪些部分应该被设置样式或可点击,而无需担心 indexOf 、 addStyle 或 AnnotatedString.Builder 。你只需编写文本,告诉它需要设置哪些单词的样式,以及点击后该执行的操作。

它的实际效果如下:

Kotlin 复制代码
// This list can be built in the ViewModel
val styledStrings = persistentListOf(
    StyledString.ClickableEmail(
        highlightedText = "support@example.com",
        email = "support@example.com",
        style = SpanStyle(
            color = Color.Blue,
            textDecoration = TextDecoration.Underline
        )
    ),
    StyledString.ClickableUrl(
        highlightedText = "website",
        url = "https://euryperez.dev",
        style = SpanStyle(
            color = Color.Blue,
            textDecoration = TextDecoration.Underline
        )
    )
)

// In your Compose Screen
StyledText(
    fullText = "Contact us at support@example.com or visit our website",
    styledStrings = styledStrings,
    style = MaterialTheme.typography.label,
    onClick = { styled ->
        when (styled) {
            is ClickableEmail -> openEmailClient(styled.email)
            is ClickableUrl -> openUrl(styled.url)
        }
    }
)

就是这样。无需手动构建文本,无需索引计算,无需样板代码。只需简洁、易读、声明式的样式,即可与真实文本兼容。

由于 StyledString 支持 Simple 、 ClickableEmail 和 ClickableUrl 等类型,因此它易于在你的应用中扩展和复用。你可以获得可点击的、带样式的文本,而无需牺牲其合理性和可维护性。🙏

StyledString 的底层:API 背后的引擎

让我们揭开它的面纱,逐步了解 StyledString 的工作原理。🪄

当你在 UI 中使用 StyledText 时,🧠它可能感觉像魔法一样神奇,但在幕后,它只是一个简洁、易于组合的架构,旨在减少样式设计的痛苦,而不会增加不必要的复杂性。

本节涵盖了 StyledString 系统的每个部分,从样式的描述方式,到样式的查找、应用和在屏幕上渲染。

🧱 1. 数据模型:StyledString 和 ClickableStyleString

整个实用程序的核心是一个名为 StyledString 的密封接口。我们通过它来对需要以某种方式设置样式的文本片段进行建模。

Kotlin 复制代码
@Immutable
sealed interface StyledString {
    val highlightedText: String
    val style: SpanStyle
 
    // Add `Simple` type
  
    // Add `ClickableEmail` type
  
    // Add `ClickableUrl` type
}

每个 StyledString 都需要两条信息:

  • highlightedText:需要设置样式的文本的确切部分
  • style:定义其外观的 SpanStyle(颜色、下划线、字体粗细等)。

然后,我们定义一些实现此接口的特定类型:

Kotlin 复制代码
@Immutable
data class Simple(
    override val highlightedText: String,
    override val style: SpanStyle,
) : StyledString

这个是纯视觉效果的,它会改变文本的外观,但不响应点击。

然后我们介绍交互类型:

Kotlin 复制代码
@Immutable
data class ClickableEmail(
    override val highlightedText: String,
    val email: String,
    override val style: SpanStyle,
) : StyledString, ClickableStyleString

@Immutable
data class ClickableUrl(
    override val highlightedText: String,
    val url: String,
    override val style: SpanStyle
) : StyledString, ClickableStyleString

它们执行相同的样式设置工作,但还携带额外的数据(例如点击时应打开的 URL 或电子邮件)。更重要的是,它们实现了第二个接口:ClickableStyleString。

Kotlin 复制代码
sealed interface ClickableStyleString

这个小接口意义重大,它让我们能够区分纯视觉样式和应该响应点击的样式。这使得我们的点击处理逻辑简洁且类型安全。💡

你可以轻松添加更多变体,例如 @mentions、#hashtags 或电话号码,只需创建另一个数据类并选择性地实现ClickableStyleString 即可。

🎯 2. 样式和链接:applyStyle

一旦我们知道了哪些文本需要样式,我们就需要一种将这些样式应用于实际的 AnnotatedString 的方法。这就是 applyStyle() 的作用,它是一个简单的扩展函数,它根据 StyledString 的类型应用样式(和点击监听器)。

Kotlin 复制代码
private fun AnnotatedString.Builder.applyStyle(
    styledString: StyledString,
    startIndex: Int,
    endIndex: Int,
    onClick: (ClickableStyleString) -> Unit
) {
    when (styledString) {
        is StyledString.ClickableUrl -> TODO()

        is StyledString.ClickableEmail -> TODO()

        is StyledString.Simple -> TODO()
    }
}

每次匹配每个 StyledString 时,都会调用一次此函数。现在让我们看看它做了什么:

Kotlin 复制代码
is StyledString.ClickableUrl -> {
    val linkAnnotation = LinkAnnotation.Url(
        url = styledString.url,
        styles = TextLinkStyles(style = styledString.style),
        linkInteractionListener = { onClick(styledString) }
    )
    addLink(linkAnnotation, startIndex, endIndex)
}

如果是 URL,我们会创建一个 LinkAnnotation.Url 对象,附加样式,并为其添加一个点击监听器。addLink 负责将其附加到正确的文本范围。

我们执行的操作类似,但针对电子邮件使用的是 LinkAnnotation.Clickable :

Kotlin 复制代码
is StyledString.ClickableEmail -> {
    val linkAnnotation = LinkAnnotation.Clickable(
        tag = styledString.highlightedText,
        styles = TextLinkStyles(style = styledString.style),
        linkInteractionListener = { onClick(styledString) }
    )
    addLink(linkAnnotation, startIndex, endIndex)
}

如果样式只是视觉上的(不可点击),我们会应用常规跨度:

Kotlin 复制代码
is StyledString.Simple -> {
    addStyle(
        style = styledString.style, 
        start = startIndex, 
        end = endIndex
    )
}

这种分离将所有样式应用逻辑集中在一处。如果你想要支持新的链接类型或行为,只需更新此函数即可。

🔍 3. 匹配文本:findAllOccurrences

在应用样式之前,我们需要找到文本中所有出现指定highlightedText 的位置。这就是此函数的用途。

Kotlin 复制代码
/**
 * Find all occurrences of a substring in a string, optionally ignoring case.
 *
 * @param substring The substring to search for.
 * @param ignoreCase Whether to perform a case-insensitive search.
 * @return A list of indices where the substring was found.
 */
private fun String.findAllOccurrences(
    substring: String,
    ignoreCase: Boolean = false
): List<Int>

这将获取全文,并返回给定子字符串的每个匹配项的起始索引列表。

工作原理如下:

Kotlin 复制代码
if (substring.isEmpty()) return emptyList()

对于空子字符串,快速提前退出。避免奇怪的边缘情况。然后,我们准备进行不区分大小写的搜索(如果需要):

Kotlin 复制代码
val indices = mutableListOf<Int>()
val searchString = if (ignoreCase) this.lowercase() else this
val searchSubstring = if (ignoreCase) substring.lowercase() else substring

现在我们遍历字符串,找到所有匹配项:

Kotlin 复制代码
var startIndex = 0
val maxStartIndex = length - substring.length

while (startIndex <= maxStartIndex) {
    val index = searchString.indexOf(searchSubstring, startIndex)
    if (index == -1) break
    indices.add(index)
    startIndex = index + 1
}

我们最终返回结果:

Kotlin 复制代码
return indices.toList()

这使得我们的样式逻辑保持灵活性和弹性,无论我们设计的单词出现一次还是十几次。

🧠 4. 构建 AnnotatedString:rememberStyledAnnotationString

以下函数将所有内容整合在一起。它接收完整文本和你的StyledString 列表,并返回一个应用了所有样式的 AnnotatedString。

Kotlin 复制代码
@Composable
fun rememberStyledAnnotationString(
    fullText: String,
    styledStrings: ImmutableList<StyledString>,
    ignoreCase: Boolean = false,
    onClick: (ClickableStyleString) -> Unit
): AnnotatedString

我们确保使用 rememberUpdatedState() 来保持点击监听器的最新状态:

Kotlin 复制代码
val currentOnClick by rememberUpdatedState(onClick)

然后我们使用记住来缓存工作,除非输入发生变化:

Kotlin 复制代码
return remember(fullText, styledStrings, ignoreCase) {
  // TODO: build annotated string
}

我们首先附加完整的未样式化的文本。然后,对于每个 StyledString ,我们找到所有匹配的位置并应用样式:

Kotlin 复制代码
buildAnnotatedString {
    append(fullText)

    styledStrings.fastForEach { styledStringInfo ->
        val indices =
            fullText.findAllOccurrences(styledStringInfo.highlightedText, ignoreCase)

        indices.fastForEach { startIndex ->
            val endIndex = startIndex + styledStringInfo.highlightedText.length
            applyStyle(styledStringInfo, startIndex, endIndex, currentOnClick)
        }
    }
}

这个循环使得样式设置能够动态且多目标化。你可以将任何本地化或运行时生成的字符串作为 fullText 传递,它仍然能够正确应用样式。

🧩 5. 可组合项:StyledText

最后,StyledText 可组合项将所有内容连接在一起。

Kotlin 复制代码
@Composable
fun StyledText(
    fullText: String,
    styledStrings: ImmutableList<StyledString>,
    style: TextStyle,
    modifier: Modifier = Modifier,
    onClick: (ClickableStyleString) -> Unit = {},
    ignoreCase: Boolean = false,
) {
    // TODO: Implementation
}

你传入全文、样式以及可选的点击处理程序。它的内部功能如下:

Kotlin 复制代码
val annotatedString = rememberStyledAnnotationString(
    fullText = fullText,
    styledStrings = styledStrings,
    ignoreCase = ignoreCase,
    onClick = onClick
)

这调用了我们刚刚讲过的逻辑。它返回一个带样式的 AnnotatedString 。然后我们渲染它:

Kotlin 复制代码
Text(
    modifier = modifier,
    text = annotatedString,
    style = style
)

它只是一个普通的 Compose Text 。但所有样式逻辑都已预先烘焙。现在,你的 UI 代码保持简洁且声明式。🌚

⚡️ 6. StyledText 实践

现在,让我们来看看 StyledText 的实践,为此,我整理了一个预览,你可以自己测试一下:

Kotlin 复制代码
@PreviewLightDark
@Composable
private fun StyledTextPreview() {
    MyTheme {
        Box(
            modifier = Modifier
                .background(color = MaterialTheme.colors.background)
                .padding(16.dp)
        ) {
            // This list can be built in the ViewModel
            val styledStrings = persistentListOf(
                StyledString.ClickableEmail(
                    highlightedText = "support@example.com",
                    email = "support@example.com",
                    style = SpanStyle(
                        color = Color.Gray,
                        textDecoration = TextDecoration.Underline
                    )
                ),
                StyledString.ClickableUrl(
                    highlightedText = "website",
                    url = "https://euryperez.dev",
                    style = SpanStyle(
                        color = Color.Gray,
                        textDecoration = TextDecoration.Underline
                    )
                )
            )

            // In your Compose Screen
            StyledText(
                fullText = "Contact us at support@example.com or visit our website",
                styledStrings = styledStrings,
                style = MaterialTheme.typography.body2,
                color = MaterialTheme.colors.onBackground,
                onClick = { styled ->
                    when (styled) {
                        is StyledString.ClickableEmail -> TODO()
                        is StyledString.ClickableUrl -> TODO()
                    }
                }
            )
        }
    }
}

你将在预览中看到以下内容:

✅ 总结:一个输出简洁的简单引擎

总而言之,我们构建了一个完全可复用的 Compose 实用程序,它:

  • 使用 StyledString 以声明式方式描述样式
  • 安全地区分可视样式和可点击样式
  • 使用 applyStyle 应用 span 和 link
  • 使用 findAllOccurrences 查找多个匹配项
  • 以 Compose 稳定的方式组装所有内容
  • 封装在一个简洁的 API 中:StyledText

无需 indexOf ,无需复杂的范围逻辑,也无需复制粘贴 buildAnnotatedString样板代码。

点击此处 获取完整解决方案。

结语🎯

Jetpack Compose 赋予我们强大的功能,但并非总是最符合人体工程学的开箱即用工具。AnnotatedString 对于一次性需求来说非常棒,但一旦你的 UI 需要多种样式、复用模式或动态点击处理,它就会很快变得冗长。

这就是 StyledString 的用武之地。

它并非取代 AnnotatedString,而是对其进行包装,为你提供一种更安全、更清晰的方式来描述意图:

  • → "将此单词加粗"
  • → "将此短语设为链接"
  • → "为该字符串的每个实例设置样式"

你无需再考虑文本偏移量和跨度范围,而是开始思考含义。结果:代码更简洁、样板更少,开发者体验更佳💆

🧩 易于采用

你无需重构整个应用即可使用 StyledString 。

只需将一两个 Text() 元素替换为 StyledText() 即可。将内联的 buildAnnotatedString { ... } 块替换为 StyledString.Simple 或 ClickableUrl 的简单列表即可。

就这样,你就成功了。✨

🛠️ 易于扩展

还有其他用例吗?

  • 为 #hashtags 设置样式?
  • 处理 @mentions?
  • 自动检测电话号码?
  • 添加图标或背景高亮?

只需创建一个实现 StyledString 的新数据类,并在 applyStyle() 中处理它即可。系统的其余部分保持不变。

这种分离使你的文本逻辑模块化、可测试,并能够适应未来的设计或业务需求。

如果你有什么有趣的想法,别忘了在评论区分享。😉

🫱 轮到你了

现在你已经了解了它的工作原理(以及它实际需要的代码量有多小),那就在下一个 Compose 屏幕中尝试一下吧。不再需要繁琐的 AnnotatedString.Builder 代码。不再需要重复的 span 逻辑。只需描述你想要的内容,剩下的交给 StyledText 处理。

让 Compose 中的文本样式再次变得简单。😎

🤝 感谢阅读

如果你最终在项目中使用了 StyledString,请告诉我!看到这些微型模式在现实世界中落地总是很酷。👀

感谢阅读!如果你觉得这篇文章有用,请考虑分享给其他开发者,点赞或留言。这很有帮助。✌️

欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!

保护原创,请勿转载!

相关推荐
一笑的小酒馆1 小时前
Android12去掉剪贴板复制成功的Toast
android
一笑的小酒馆2 小时前
Android12App启动图标自适应
android
程序员江同学3 小时前
Kotlin 技术月报 | 2025 年 7 月
android·kotlin
某空m5 小时前
【Android】内容提供器
android
Greenland_125 小时前
Android 编译报错 Null extracted folder for artifact: xxx activity:1.8.0
android
_frank2226 小时前
kotlin使用mybatis plus lambdaQuery报错
开发语言·kotlin·mybatis
Bryce李小白6 小时前
Kotlin实现Retrofit风格的网络请求封装
网络·kotlin·retrofit
ZhuYuxi3336 小时前
【Kotlin】const 修饰的编译期常量
android·开发语言·kotlin
jzlhll1236 小时前
kotlin StateFlow的两个问题和使用场景探讨
kotlin·stateflow
Bryce李小白6 小时前
Kotlin 实现 MVVM 架构设计总结
android·开发语言·kotlin