Android自定义ReplacementSpan实现chip多行显示错位求解决方案
原问题描述
我正在使用自定义ReplacementSpan为特定词汇绘制背景(在词汇周边添加内边距,词汇由首尾特殊符号标识,类似HTML标签的逻辑),也可修改这些词汇的字号(本次场景下暂未用到该能力)。该实现单行文本次展示完全正常,效果符合预期:
该效果是通过重写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顶部:
看起来是多行场景下的行高没有适配额外的内边距。我尝试让自定义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的高度动态调整行高,而且效果存在偏差(背景矩形比预期更高,文本位置偏下而非居中):
请问除了为每个词汇创建独立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

