在 Jetpack Compose 中实现可点击文本链接
可点击链接在Compose文本中的实现原理
在Android原生开发中,TextView
通过LinkMovementMethod
实现超链接点击,但在声明式UI框架Jetpack Compose中,需要采用全新的事件处理范式。核心挑战在于如何在保持文本可选择性的同时,实现特定区域的精准点击检测。
Compose文本事件处理机制
@Composable
fun SelectableText(
text: AnnotatedString,
modifier: Modifier = Modifier,
onLinkClick: (String) -> Unit = {}
) {
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
val pointerHandler = remember {
mutableStateOf<PointerInputHandler?>(null)
}
// 文本布局测量回调
val onTextLayout = { result: TextLayoutResult ->
layoutResult.value = result
}
// 构建带有点击检测的修饰符
val clickModifier = if (pointerHandler.value != null) {
Modifier.pointerInput(Unit) {
detectTapGestures { pos ->
layoutResult.value?.let { layout ->
// 获取点击位置的字符偏移量
val offset = layout.getOffsetForPosition(pos)
// 检测链接注解范围
text.getStringAnnotations(offset, offset)
.firstOrNull { it.tag == "URL" }
?.let { annotation -> onLinkClick(annotation.item) }
}
}
}
} else Modifier
// 组合文本组件
BasicText(
text = text,
modifier = modifier.then(clickModifier),
onTextLayout = onTextLayout
)
}
代码解析:
TextLayoutResult
缓存文本布局信息,用于后续坐标转换detectTapGestures
捕获触摸事件并转换为具体坐标getOffsetForPosition()
将屏幕坐标转换为文本字符偏移量getStringAnnotations()
检索特定位置的文本注解
高级注解处理系统
class LinkAnnotationParser : AnnotationParser {
override fun parse(
annotation: String,
attributes: Map<String, String>
): Annotation {
// 解析自定义链接格式 https://example.com
return when (annotation) {
"Link" -> {
val url = attributes["url"] ?: ""
StringAnnotation("URL", url, annotation)
}
else -> StringAnnotation("", "", annotation)
}
}
}
// 使用示例
val annotatedString = buildAnnotatedString {
append("访问我们的")
withAnnotation(
tag = "Link",
annotation = "url" to "https://example.com"
) {
append("官方网站")
}
}
关键技术点:
- 自定义
AnnotationParser
实现非标准标记解析 buildAnnotatedString
DSL构建富文本内容- 注解元数据与文本范围的动态关联
实战案例:混合内容文本渲染
场景描述
在电商App的商品详情页需要实现:
查看[用户协议]和[隐私政策]了解更多
[立即购买]按钮享受新人优惠
要求:
- 方括号内文本显示为蓝色带下划线
- 点击弹出对应模态窗
- 整段文本保持可选择复制功能
完整实现方案
enum class TextLinkType { TERMS, PRIVACY, PURCHASE }
@Composable
fun PolicyText() {
val context = LocalContext.current
val annotatedText = remember {
buildAnnotatedString {
append("查看")
pushStringAnnotation(
tag = "LINK",
annotation = TextLinkType.TERMS.name
)
withStyle(SpanStyle(color = Color.Blue, textDecoration = TextDecoration.Underline)) {
append("[用户协议]")
}
pop()
append("和")
pushStringAnnotation(
tag = "LINK",
annotation = TextLinkType.PRIVACY.name
)
withStyle(SpanStyle(color = Color.Blue, textDecoration = TextDecoration.Underline)) {
append("[隐私政策]")
}
pop()
append("了解更多\n")
pushStringAnnotation(
tag = "LINK",
annotation = TextLinkType.PURCHASE.name
)
withStyle(SpanStyle(
color = Color(0xFF6200EA),
fontWeight = FontWeight.Bold
)) {
append("[立即购买]")
}
pop()
append("按钮享受新人优惠")
}
}
CustomSelectableText(
text = annotatedText,
onLinkClick = { linkType ->
when (TextLinkType.valueOf(linkType)) {
TextLinkType.TERMS -> showTermsDialog(context)
TextLinkType.PRIVACY -> showPrivacyDialog(context)
TextLinkType.PURCHASE -> navigateToCheckout()
}
}
)
}
// 增强版文本组件
@Composable
fun CustomSelectableText(
text: AnnotatedString,
onLinkClick: (String) -> Unit
) {
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
Text(
text = text,
modifier = Modifier
.fillMaxWidth()
.pointerInput(Unit) {
detectTapGestures { offset ->
layoutResult.value?.let { layout ->
val position = layout.getOffsetForPosition(offset)
text.getStringAnnotations(position, position)
.firstOrNull { it.tag == "LINK" }
?.let { onLinkClick(it.item) }
}
}
},
onTextLayout = { layoutResult.value = it }
)
}
性能优化策略
- 布局缓存机制
val textLayoutCache = remember(text) {
WeakReference<TextLayoutResult>(null)
}
// 在onTextLayout中更新缓存
onTextLayout = { result ->
textLayoutCache.get()?.let { cached ->
if (cached.multiParagraph != result.multiParagraph) {
textLayoutCache.clear()
}
}
textLayoutCache = WeakReference(result)
}
- 点击区域扩展技术
跨平台兼容方案
桌面端特殊处理
@Composable
actual fun CustomSelectableText(
text: AnnotatedString,
onLinkClick: (String) -> Unit
) {
val interactionSource = remember { MutableInteractionSource() }
Text(
text = text,
modifier = Modifier
.hoverable(interactionSource)
.onHover { isHovered ->
// 桌面端悬停效果处理
if (isHovered) {
// 显示手型光标
}
}
.clickable { /* 基础点击处理 */ },
onTextLayout = { /* ... */ }
)
}
高级应用场景
动态链接更新
@Composable
fun DynamicLinkText(links: Map<String, String>) {
val dynamicText = remember(links) {
buildAnnotatedString {
append("相关链接: ")
links.forEach { (text, url) ->
pushStringAnnotation("URL", url)
withStyle(linkStyle) { append(text) }
pop()
append(" ")
}
}
}
SelectableText(dynamicText)
}
安全链接检测
fun SecurityLinkParser(
defaultParser: AnnotationParser
) : AnnotationParser = object : AnnotationParser by defaultParser {
override fun parse(annotation: String, attributes: Map<String, String>): Annotation {
if (annotation == "Link") {
val url = attributes["url"] ?: ""
if (!isSecureDomain(url)) {
throw SecurityException("Unsafe domain detected")
}
}
return defaultParser.parse(annotation, attributes)
}
private fun isSecureDomain(url: String): Boolean {
return URLUtil.isHttpsUrl(url) &&
!url.contains("untrusted.com")
}
}
总结
- 使用
detectTapGestures
捕获原始触摸事件 - 通过
TextLayoutResult
实现坐标到文本偏移的精确转换 - 利用
getStringAnnotations
检索文本注解元数据 - 布局结果缓存减少重复计算
- 使用
WeakReference
避免内存泄漏 - 增量更新文本内容
- 桌面端增加悬停效果处理
- 移动端兼容触摸反馈动画