You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Android自定义ReplacementSpan实现chip多行显示错位求解决方案

自定义ReplacementSpan实现Chip样式多行排版异常解决方案

原问题描述

我正在使用自定义ReplacementSpan为特定词汇绘制背景(在词汇周边添加内边距,词汇由首尾特殊符号标识,类似HTML标签的逻辑),也可修改这些词汇的字号(本次场景下暂未用到该能力)。该实现单行文本次展示完全正常,效果符合预期:
chip_that_works_right
该效果是通过重写getSize方法调整FontMetrics实现的:调整top和bottom值增加背景所需内边距,同时调整返回的宽度值增加对应边距,代码如下:

override fun getSize(paint: Paint, text: CharSequence, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int {
    if (textSize != null) {
        paint.textSize = textSize
    }

    if (fm != null) {
        val newMetrics = paint.fontMetricsInt
        fm.descent = newMetrics.descent
        fm.ascent = newMetrics.ascent
        fm.leading = newMetrics.leading
        fm.top = (newMetrics.top - strokeWidth - padding).roundToInt()
        fm.bottom = (newMetrics.bottom + strokeWidth + padding).roundToInt()
    }

    return (padding + strokeWidth + paint.measureText(
        text.subSequence(start + 1, end - 1).toString().uppercase()
    ) + padding + strokeWidth).roundToInt()
}

onDraw方法负责在Canvas上绘制背景矩形和文本内容。

问题出现在多行文本场景下。我清楚ReplacementSpan的内容不会自动换行/拆分,整个Span会作为整体换行,这也是我预期且需要的效果。目前的问题是多行场景下chip内部的文本位置出现异常:getSize()返回的字体 metrics 对应的top/bottom值不符合预期,第一行的文本显示在chip底部,第二行的文本显示在chip顶部:
broken multiline
看起来是多行场景下的行高没有适配额外的内边距。我尝试让自定义ReplacementSpan实现LineHeightSpan接口,但没有效果,因为LineHeightSpan必须作用于整个段落才能生效。

目前最接近可用的实现是设置固定高度的LineHeightSpan,代码如下:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
    mySpan.setSpan(LineHeightSpan.Standard(71),0,narrativeString.length-1,Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}

但这并不是合理的解决方案:它无法根据ReplacementSpan的高度动态调整行高,而且效果存在偏差(背景矩形比预期更高,文本位置偏下而非居中):
almost but not quite

请问除了为每个词汇创建独立View并放入FlexboxLayout这类布局中之外,有没有其他方案可以实现正确的效果?


问题更新

我尝试了Zain建议的方案,按照他提供的文章和代码库实现后仍然存在问题。首先,水平内边距不会影响「词汇」的宽度,如果两个相邻词汇都添加了chip样式,两者的内边距会出现重叠。其次,垂直内边距不会实际修改行高:如果内边距超出当前行高,背景会和其他行的内容或者相邻行的chip重叠,超出TextView边界的内边距(比如第一行上方、最后一行下方的部分)会被裁剪。


解决方案

无需引入额外布局组件,通过以下4点调整即可解决所有问题:

  • 修正FontMetrics赋值逻辑:你当前只调整了fm.top和fm.bottom,没有同步修改ascent/descent,这是文本垂直错位的核心原因。TextView的文本绘制基线基于ascent/descent计算,必须把上下内边距同步到这两个属性,修正后的getSize代码如下:
    override fun getSize(paint: Paint, text: CharSequence, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int {
        if (textSize != null) {
            paint.textSize = textSize
        }
        val contentText = text.subSequence(start + 1, end - 1).toString().uppercase()
        val contentWidth = paint.measureText(contentText)
        val extraVertical = (strokeWidth + padding).roundToInt()
        val extraHorizontal = (strokeWidth + padding).roundToInt() * 2
    
        if (fm != null) {
            val rawFm = paint.fontMetricsInt
            // 同步调整ascent/descent,把上下内边距算入
            fm.ascent = rawFm.ascent - extraVertical
            fm.descent = rawFm.descent + extraVertical
            fm.top = fm.ascent
            fm.bottom = fm.descent
            fm.leading = rawFm.leading
        }
        return (contentWidth + extraHorizontal).roundToInt()
    }
    
  • 对齐基线调整绘制逻辑:绘制文本时不要写死Y坐标,基于当前行的baseline计算偏移,保证文本在Chip内部垂直居中:
    override fun draw(
        canvas: Canvas,
        text: CharSequence?,
        start: Int,
        end: Int,
        x: Float,
        top: Int,
        y: Int, // 该参数为当前行的基线Y坐标
        bottom: Int,
        paint: Paint
    ) {
        val contentText = text?.subSequence(start + 1, end - 1)?.toString()?.uppercase() ?: return
        // 绘制背景圆角矩形
        val bgLeft = x
        val bgTop = y + paint.ascent() - padding - strokeWidth
        val bgRight = x + paint.measureText(contentText) + (padding + strokeWidth) * 2
        val bgBottom = y + paint.descent() + padding + strokeWidth
        val radius = 8.dpToPx() // dp转px的扩展方法可自行实现
        canvas.drawRoundRect(bgLeft, bgTop, bgRight, bgBottom, radius, radius, backgroundPaint)
        // 绘制文本,水平方向加左内边距,垂直方向直接用基线坐标即可
        canvas.drawText(contentText, x + padding + strokeWidth, y.toFloat(), textPaint)
    }
    
  • 解决相邻Chip间距重叠问题:在每个Chip Span的前后各插入一个宽度等于padding + strokeWidth的SpaceSpan,手动增加相邻Chip、Chip和普通文本之间的间距,避免内容重叠。
  • 解决上下边距裁剪问题:给承载文本的TextView设置上下内边距,数值等于Chip的垂直边距(padding + strokeWidth),同时设置android:clipToPadding="false",避免第一行上方、最后一行下方的背景被裁剪。如果存在多行Chip高度不一致的情况,遍历所有Span拿到最大Chip高度,给整个Spannable设置对应高度的动态LineHeightSpan即可。

内容的提问来源于stack exchange,提问作者Mark

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.10.01 20:15:03