自定义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来完成。

超能力文本:两个示例学会自定义Span
下面对两种场景别离给出详细示例。

FontMetricsInt 必知必会

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

public static class FontMetrics {
   public float   top;    
   public float   ascent;
   public float   descent;
   public float   bottom;    
   public float   leading;
    }

FontMetricsIntFontMetrics 意义相同,仅仅将类中成员变量都改为int润饰了:

public static class FontMetricsInt {
   public int   top;
   public int   ascent;
   public int   descent;
   public int   bottom;
   public int   leading;
 }

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

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

  • Baseline是基线,在Android中,文字的制作都是从Baseline处开端的,Baseline往上至字符“最高处”的间隔咱们称之为ascent(上斜度),Baseline往下至字符“最低处”的间隔咱们称之为descent(下斜度);
  • leading(行距离)则表明上一行字符的descent到该行字符的ascent之间的间隔;
  • topbottom文档描绘地很模糊,其实这儿咱们能够借鉴一下TextView对文本的制作,TextView在制作文本的时分总会在文本的最外层留出一些内边距,为什么要这样做?因为TextView在制作文本的时分考虑到了相似读音符号,下图中的A上面的符号便是一个拉丁文的相似读音符号的东西:
    超能力文本:两个示例学会自定义Span

top的意思其实便是除了Baseline到字符顶端的间隔外还应该包含这些符号的高度,bottom的意思也是相同。

一般情况下咱们极少运用到相似的符号所以往往会忽略掉这些符号的存在,可是Android依然会在制作文本的时分在文本外层留出必定的边距,这便是为什么topbottom总会比ascentdescent大一点的原因。而在TextView中咱们能够经过xml设置其特点android:includeFontPadding="false"去掉必定的边距值可是不能完全去掉。

详细拜见:Android深化理解文字制作:FontMetrics字体丈量及其TextPaint介绍

示例一: 文本盘绕图片

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

分析上面 UI 作用:阶段的前几行进行缩进,余下行不再缩进,在缩进的空间里制作一张图。看到这儿,根本能想到用哪个父类了,没错,便是LeadingMarginSpan2

/**
 * @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)回来值调整页边距之前调用。

运用它:

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

现有的 Span 无法满足需求,考虑运用ReplacementSpan来完成上述作用,ReplacementSpan本身是个抽象类,需求完成内部的 getSizedraw 两个方法:

  • getSize:回来当时Span需求的宽度。子类能够经过更新Paint.FontMetricsInt的特点来设置Span的高度。假如Span覆盖了整个文本,而且高度没有设置,那么draw方法将不会调用。
  • draw:将Span 制作到 Canvas 中,有了CanvasPaint 后,就能够制作咱们想要的作用了。

下面是详细完成代码:

/**
 * 自定义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,防止呈现位置紊乱问题。