xDocxDoc
AI
前端
后端
iOS
Android
Flutter
AI
前端
后端
iOS
Android
Flutter
  • 在 Jetpack Compose 中实现可点击文本链接

在 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
    )
}

代码解析:

  1. TextLayoutResult 缓存文本布局信息,用于后续坐标转换
  2. detectTapGestures 捕获触摸事件并转换为具体坐标
  3. getOffsetForPosition() 将屏幕坐标转换为文本字符偏移量
  4. 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的商品详情页需要实现:

查看[用户协议]和[隐私政策]了解更多
[立即购买]按钮享受新人优惠

要求:

  1. 方括号内文本显示为蓝色带下划线
  2. 点击弹出对应模态窗
  3. 整段文本保持可选择复制功能

完整实现方案

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

性能优化策略

  1. 布局缓存机制
val textLayoutCache = remember(text) { 
    WeakReference<TextLayoutResult>(null) 
}

// 在onTextLayout中更新缓存
onTextLayout = { result ->
    textLayoutCache.get()?.let { cached ->
        if (cached.multiParagraph != result.multiParagraph) {
            textLayoutCache.clear()
        }
    }
    textLayoutCache = WeakReference(result)
}
  1. 点击区域扩展技术

跨平台兼容方案

桌面端特殊处理

@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避免内存泄漏
  • 增量更新文本内容
  • 桌面端增加悬停效果处理
  • 移动端兼容触摸反馈动画
最后更新: 2025/10/11 18:41