超能力文本:两个示例学会自定义Span

自定义Span

前面的文章介绍了一些常见 Span 的使用场景及其使用示例,本文继续来学习自定义Span。那么,系统已经提供很多种类的Span了,为什么还要自定义?

  • 自定义 Span 可以根据具体需求实现更多样化的文本样式、交互效果和文本布局。
  • 当内置的 Span 类无法满足需求时,或者希望实现更定制化的效果时,可以考虑自定义 Span

既然要自定义Span,就要考虑父类用哪个合适。

  • 字符级别影响文本 -> CharacterStyle
  • 段落级别影响文本 -> ParagraphStyle
  • 影响文本外观 -> UpdateAppearance
  • 影响文本测量尺寸 -> UpdateLayout

其中

  • CharacterStyle中的updateDrawState(TextPaint tp) 本质上是改变画笔 TextPaint 的属性;
  • ReplacementSpan 及其扩展类 DynamicDrawableSpan 不满足于只修改TextPaint画笔属性,而是使用圈定 Rect 绘制区域,然后自行绘制这个区域的效果。

不过大部分场景下,我们不用继承到这么深的父类,选择合适的已有Span进行扩展是一个不错的选择。

题外话 :其实可以类比下自定义View,我们不用每次都去继承 View 或者 ViewGroup,选择一个合适已有的父类(如横向排版时可以考虑LinearLayout作为基类)会让整个过程变得简单。

如果系统提供的Span基本符合需求,只需要轻微调整,此时父类就选择已有的Span即可;如果系统提供的Span不符合需要,可以考虑通过继承ReplacementSpan来实现。

下面对两种场景分别给出详细示例。

FontMetricsInt 必知必会

在学习自定义 Span 之前,有必要再复习下FontMetrics,该类描述了 Text 文本的关键指标信息:

kotlin 复制代码
public static class FontMetrics {
   public float   top;    
   public float   ascent;
   public float   descent;
   public float   bottom;    
   public float   leading;
    }

FontMetricsIntFontMetrics 含义相同,只是将类中成员变量都改为int修饰了:

kotlin 复制代码
public static class FontMetricsInt {
   public int   top;
   public int   ascent;
   public int   descent;
   public int   bottom;
   public int   leading;
 }

我们来看下类中的成员变量都是什么含义。

  • Baseline是基线,在Android中,文字的绘制都是从Baseline处开始的,Baseline往上至字符"最高处"的距离我们称之为ascent(上坡度),Baseline往下至字符"最低处"的距离我们称之为descent(下坡度);
  • leading(行间距)则表示上一行字符的descent到该行字符的ascent之间的距离;
  • topbottom文档描述地很模糊,其实这里我们可以借鉴一下TextView对文本的绘制,TextView在绘制文本的时候总会在文本的最外层留出一些内边距,为什么要这样做?因为TextView在绘制文本的时候考虑到了类似读音符号,下图中的A上面的符号就是一个拉丁文的类似读音符号的东西:

top的意思其实就是除了Baseline到字符顶端的距离外还应该包含这些符号的高度,bottom的意思也是一样。

一般情况下我们极少使用到类似的符号所以往往会忽略掉这些符号的存在,但是Android依然会在绘制文本的时候在文本外层留出一定的边距,这就是为什么topbottom总会比ascentdescent大一点的原因。而在TextView中我们可以通过xml设置其属性android:includeFontPadding="false"去掉一定的边距值但是不能完全去掉。

详细参见:Android深入理解文字绘制:FontMetrics字体测量及其TextPaint介绍

示例一: 文本环绕图片

分析上面 UI 效果:段落的前几行进行缩进,余下行不再缩进,在缩进的空间里绘制一张图 。看到这里,基本能想到用哪个父类了,没错,就是LeadingMarginSpan2

kotlin 复制代码
/**
 * @param lineCount 行数
 * @param mFirst 段落前N行margin 单位dp
 * @param mRest 段落剩余行margin 单位dp
 */
class TextAroundSpan(
    private var imgInfo: ImgInfo,
    private val lineCount: Int,
    private val mFirst: Int,
    private val mRest: Int = 0,
) : LeadingMarginSpan2 {
    /**
     * 段落缩进的行数
     */
    override fun getLeadingMarginLineCount(): Int = lineCount

    /**
     * @param first true作用于段落中前N行(N为getLeadingMarginLineCount()中返回的值),否则作用于段落剩余行
     */
    override fun getLeadingMargin(first: Boolean): Int =
        if (first) mFirst.dp2px() else mRest.dp2px()


    /**
     * 绘制页边距(leading margin)。在{@link #getLeadingMargin(boolean)}返回值调整页边距之前调用。
     */
    override fun drawLeadingMargin(
        canvas: Canvas?,
        paint: Paint?,
        x: Int,
        dir: Int,
        top: Int,
        baseline: Int,
        bottom: Int,
        text: CharSequence?,
        start: Int,
        end: Int,
        first: Boolean,
        layout: Layout?,
    ) {
        if (canvas == null || paint == null) return
        val drawable: Drawable = imgInfo.drawable
        canvas.save()
        drawable.setBounds(0, 0, imgInfo.width, imgInfo.height)
        canvas.translate(imgInfo.dx, imgInfo.dy)
        drawable.draw(canvas)
        canvas.restore()
    }

    data class ImgInfo(
        val drawable: Drawable,
        val width: Int,
        val height: Int,
        val dx: Float = 1.dp2px().toFloat(),
        val dy: Float = 2.dp2px().toFloat(),
    )
}
  • getLeadingMarginLineCount:段落缩进的行数;
  • getLeadingMargin(boolean)true作用于段落中前N行(N为getLeadingMarginLineCount()中返回的值),否则作用于段落剩余行;
  • drawLeadingMargin :绘制页边距(leading margin),用于绘制空出来的margin,在getLeadingMargin(boolean)返回值调整页边距之前调用。

使用它:

kotlin 复制代码
private const val SPAN_STR =
            "悯农锄禾日当午,汗滴禾下土。谁知盘中餐,粒粒皆辛苦。锄禾日当午,汗滴禾下土。谁知盘中餐,粒粒皆辛苦。锄禾日当午,汗滴禾下土。谁知盘中餐,粒粒皆辛苦。锄禾日当午,汗滴禾下土。谁知盘中餐,粒粒皆辛苦。"

private fun processTagSpan() {
   val imgDrawable = ResourcesCompat.getDrawable(resources, R.drawable.icon_flower, null)
   val builder = SpannableStringBuilder(SPAN_STR)
   builder.setSpan(TextAroundSpan(TextAroundSpan.ImgInfo(imgDrawable!!, 90.dp2px(), 90.dp2px()), 4, 100), 0, 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
   tvSpan.text = builder
}

执行结果就是上面的效果图,可以看到通过继承已有的Span实现了我们想要的效果。

示例二: 文本内部打Tag标签

现有的 Span 无法满足需求,考虑使用ReplacementSpan来实现上述效果,ReplacementSpan本身是个抽象类,需要实现内部的 getSizedraw 两个方法:

  • getSize :返回当前Span需要的宽度。子类可以通过更新Paint.FontMetricsInt的属性来设置Span的高度。如果Span覆盖了整个文本,并且高度没有设置,那么draw方法将不会调用。
  • draw :将Span 绘制到 Canvas 中,有了CanvasPaint 后,就可以绘制我们想要的效果了。

下面是具体实现代码:

kotlin 复制代码
/**
 * 自定义Tag Span
 *
 * @property tagColor tag外框颜色
 * @property tagRadius tag圆角半径
 * @property tagStrokeWidth tag外框宽度
 * @property tagMarginLeft tag外框左侧的margin
 * @property tagMarginRight tag外框右侧的margin
 * @property tagPadding tag内侧文字padding
 * @property txtSize 文字大小
 * @property txtColor 文字颜色
 */
class TagSpan(
    private val tagColor: Int = Color.RED,
    private val tagRadius: Float = 2.dp2px().toFloat(),
    private val tagStrokeWidth: Float = 1.dp2px().toFloat(),
    private val tagMarginLeft: Float = 0.dp2px().toFloat(),
    private val tagMarginRight: Float = 5.dp2px().toFloat(),
    private val tagPadding: Float = 2.dp2px().toFloat(),
    private val txtSize: Float = 14.sp2px().toFloat(),
    private val txtColor: Int = Color.RED,
) : ReplacementSpan() {

    private var mSpanWidth = 0 //包含了Span文字左右间距在内的宽度

    /**
     * 返回Span的宽度。子类可以通过更新Paint.FontMetricsInt的属性来设置Span的高度。
     * 如果Span覆盖了整个文本,并且高度没有设置,那么draw(Canvas, CharSequence, int, int, float, int, int, int, Paint)方法将不会调用。
     *
     * @param paint Paint画笔
     * @param text 当前文本
     * @param start Span开始索引
     * @param end Span结束索引
     * @param fm Paint.FontMetricsInt,可能是空
     * @return 返回Span的宽度
     */
    override fun getSize(
        paint: Paint,
        text: CharSequence?,
        start: Int,
        end: Int,
        fm: Paint.FontMetricsInt?,
    ): Int {
        if (text.isNullOrEmpty()) return 0
        paint.textSize = txtSize
        //测量包含了Span文字左右间距在内的宽度
        mSpanWidth = (paint.measureText(text, start, end) + getTxtLeftW() + getTxtRightW()).toInt()
        return mSpanWidth
    }

    /**
     * 将Span绘制到Canvas中
     *
     * @param canvas Canvas画布
     * @param text 当前文本
     * @param start Span开始索引
     * @param end Span结束索引
     * @param x Edge of the replacement closest to the leading margin.
     * @param top 行文字显示区域的Top
     * @param y Baseline基线
     * @param bottom 行文字显示区域的Bottom  当在XML中设置lineSpacingExtra时,这里也会受影响
     * @param paint Paint画笔
     */
    override fun draw(
        canvas: Canvas,
        text: CharSequence?,
        start: Int,
        end: Int,
        x: Float,
        top: Int,
        y: Int,
        bottom: Int,
        paint: Paint,
    ) {
        if (text.isNullOrEmpty()) return
        paint.run {
            color = tagColor
            isAntiAlias = true
            isDither = true
            style = Paint.Style.STROKE
            strokeWidth = tagStrokeWidth
        }
        //文字高度
        val txtHeight = paint.fontMetricsInt.descent - paint.fontMetricsInt.ascent
        //1、绘制标签
        val tagRect = RectF(
            x + getTagLeft(), top.toFloat(),
            x + mSpanWidth - tagMarginRight, (top + txtHeight).toFloat()
        )
        canvas.drawRoundRect(tagRect, tagRadius, tagRadius, paint)

        //2、绘制文字
        paint.run {
            color = txtColor
            style = Paint.Style.FILL
        }
        // 计算Baseline绘制的Y坐标 ,计算方式:画布高度的一半 - 文字总高度的一半
        val baseY = tagRect.height() / 2 - (paint.descent() + paint.ascent()) / 2
        //绘制标签内文字
        canvas.drawText(text, start, end, x + getTxtLeftW(), baseY, paint)
    }

    private fun getTagLeft(): Float {
        return tagMarginLeft + tagStrokeWidth
    }

    /**
     * Span文字左侧所有的间距
     */
    private fun getTxtLeftW(): Float {
        return tagPadding + tagMarginLeft + tagStrokeWidth
    }

    /**
     * Span文字右侧所有的间距
     */
    private fun getTxtRightW(): Float {
        return tagPadding + tagMarginRight + tagStrokeWidth
    }
}

主要思路:

  • 首先在getSize中通过 paint.measureText() 来获取 Span 文字的宽度,注意还要加上Span文字左右的paddingmargin
  • 接着在 draw 中将 Span 绘制到 Canvas 中,通过文字的宽度和左右padding来确定Tag边框的范围并绘制出来。确定了Tag边框的范围,找到边框中心然后继续绘制文字即可。

Tips :在使用组合Span时,如果在ReplacementSpan中改变了Span的宽度,需要最先设置 ReplacementSpan,再设置其它Span,避免出现位置错乱问题。

相关推荐
雨白2 小时前
Jetpack系列(二):Lifecycle与LiveData结合,打造响应式UI
android·android jetpack
kk爱闹4 小时前
【挑战14天学完python和pytorch】- day01
android·pytorch·python
每次的天空5 小时前
Android-自定义View的实战学习总结
android·学习·kotlin·音视频
恋猫de小郭6 小时前
Flutter Widget Preview 功能已合并到 master,提前在体验毛坯的预览支持
android·flutter·ios
断剑重铸之日7 小时前
Android自定义相机开发(类似OCR扫描相机)
android
随心最为安7 小时前
Android Library Maven 发布完整流程指南
android
岁月玲珑7 小时前
【使用Android Studio调试手机app时候手机老掉线问题】
android·ide·android studio
还鮟11 小时前
CTF Web的数组巧用
android
小蜜蜂嗡嗡13 小时前
Android Studio flutter项目运行、打包时间太长
android·flutter·android studio
aqi0013 小时前
FFmpeg开发笔记(七十一)使用国产的QPlayer2实现双播放器观看视频
android·ffmpeg·音视频·流媒体