自定义View:文字的测量

579 阅读5分钟

1、为什么文字需要测量

因为Android中文字有基准线(Baseline)概念,且基准线不位于文字的横向的中间,见下图(引用网友博客图,侵删):

image.png

这就导致绘制出来的文本不是居中的,和我们想象的有偏差,见如下代码:

预设常量

private val BG_COLOR = Color.parseColor("#90A5BE")   //背景圆环的颜色
private val HL_COLOR = Color.parseColor("#FF3697")   //高亮圆环的颜色
private val RADIUS = 100f.dp2px  //圆环的半径
private val WIDTH = 20f.dp2px    //圆环的宽度
private val TEXT_SIZE = 50f.dp2px   //绘制的文字的大小

绘制圆环和文本

private val paint = Paint(Paint.ANTI_ALIAS_FLAG)

override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)

    //绘制圆环
    paint.style = Paint.Style.STROKE
    paint.color = BG_COLOR
    paint.strokeWidth = WIDTH
    canvas.drawCircle(width / 2f, height / 2f, RADIUS, paint)

    //绘制进度条
    paint.color = HL_COLOR
    //paint.strokeCap = Paint.Cap.ROUND 让进度条边角圆润的代码
    canvas.drawArc(
        width / 2f - RADIUS,
        height / 2f - RADIUS,
        width / 2f + RADIUS,
        height / 2f + RADIUS,
        -180f,
        180f,
        false,
        paint
    )

    //绘制文字
    val text = "apab"
    paint.textSize = TEXT_SIZE
    paint.textAlign = Paint.Align.CENTER
    paint.style = Paint.Style.FILL
    canvas.drawText(
        text,
        width / 2f,
        height / 2f,
        paint
    )
}

效果展示

image.png

明显文字的中心不在圆心的位置,如何改进?

2、静态文本的绘制

修改绘制文字部分的代码:

//绘制文字
val text = "apab"
val bounds = Rect()
paint.textSize = TEXT_SIZE
paint.textAlign = Paint.Align.CENTER
paint.style = Paint.Style.FILL
paint.getTextBounds(text, 0, text.length, bounds)   //获取文本的矩形大小
Log.d(
    "CustomTextView",
   "BOUNDS:top:${bounds.top},bottom:${bounds.bottom},left:${bounds.left},right:${bounds.right}"
)
canvas.drawText(
    text,
    width / 2f,
    height / 2f - (bounds.top + bounds.bottom) / 2f,  //计算高度时减去文本矩形的一半
    paint
)

效果展示

image.png

文本已经如我们预期的那样展示了。上面代码打印了文本矩形Bounds的4个值分别为:BOUNDS:top:-107,bottom:28,left:6,right:303 说明基线Baseline以上的为负值,以下的为正值。但是这种方式只适合静态文本,如果文本为动态变化的,随着getTextBounds获取值的变化,文本会上下跳动。那么如何解决这个问题?

3、动态文本的绘制

先看下下面这张图(源自网络)

text-measure.png 因为世界上文字种类很多,所以需要定义文本的上边界和下边界,top线和bottom线就是这个意义,限制上下的范围。AscentDescent的范围为文本的核心部分,这也是我们绘制动态文本需要找的二条线,因为这二条线是基于文本框,与Text的Bounds无关,也就不会上下跳动。代码如下:

//绘制文字
val text = "apab"
val fontMetrics = Paint.FontMetrics()
paint.textSize = TEXT_SIZE
paint.textAlign = Paint.Align.CENTER
paint.style = Paint.Style.FILL
paint.getFontMetrics(fontMetrics)   //设置fontMetrics
Log.d(
    "CustomTextView",
    "fontMetrics:ascent:${fontMetrics.ascent},descent:${fontMetrics.descent}"
)
canvas.drawText(
    text,
    width / 2f,
    height / 2f - (fontMetrics.ascent + fontMetrics.descent) / 2f,
    paint
)

效果展示

image.png 这种方式的效果不如getTextBounds精确,所以如果需要展示静态文本,用getTextBounds,如果需要展示动态文本用FontMetrics

引申——文字的贴边:

精确的文字贴边需要drawText时减掉getTextBoundstop或者left来实现,而动态文本需要drawText时减掉FontMetricstop或者ascent来实现。

4、大段文本绘制

尝试用上面的drawText绘制大段文字会发现有个问题,文本不会自动换行,如下代码:

//绘制大段文字
val text =
    "Pride and Prejudice is kind of a literary Rosetta Stone, the inspiration," +
            " basis, and model for so many modern novels. You’re probably more " +
            "familiar with its plot and characters than you think. For a book " +
            "written in the early 19th century, it’s modernity is surprising " +
            "only until you realize that this is the novel that in many ways " +
            "defined what a modern novel is."
val fontMetrics = Paint.FontMetrics()
paint.textSize = TEXT_SIZE
paint.textAlign = Paint.Align.LEFT
paint.style = Paint.Style.FILL
paint.getFontMetrics(fontMetrics)   //设置fontMetrics
canvas.drawText(
    text,
    0f,
    -fontMetrics.top,  //调整上边距
    paint
)

效果展示

image.png

文字超出了边界。那么大段文字应该如何绘制?

方法一:需要用到StaticLayout。如下:

//文本
private const val MULTILINE_TEXT =
    "Pride and Prejudice is kind of a literary Rosetta Stone, the inspiration," +
            " basis, and model for so many modern novels. You’re probably more " +
            "familiar with its plot and characters than you think. For a book " +
            "written in the early 19th century, it’s modernity is surprising " +
            "only until you realize that this is the novel that in many ways " +
            "defined what a modern novel is."
private val TEXT_SIZE = 15f.dp2px   //绘制的文字的大小

绘制的代码

private var paint = TextPaint(Paint.ANTI_ALIAS_FLAG)
private lateinit var staticLayout: StaticLayout
init {
    paint.textSize = TEXT_SIZE
}

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    super.onSizeChanged(w, h, oldw, oldh)
    //StaticLayout设置参数
    staticLayout =
        StaticLayout.Builder
            .obtain(MULTILINE_TEXT, 0, MULTILINE_TEXT.length, paint, width)
            .build()
}

@SuppressLint("DrawAllocation")
override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
    //绘制
    staticLayout.draw(canvas)
}

效果展示

image.png

方法二:手动控制换行


private const val MULTILINE_TEXT =   //文本
    "Pride and Prejudice is kind of a literary Rosetta Stone, the inspiration," +
            " basis, and model for so many modern novels. You’re probably more " +
            "familiar with its plot and characters than you think. For a book " +
            "written in the early 19th century, it’s modernity is surprising " +
            "only until you realize that this is the novel that in many ways " +
            "defined what a modern novel is."
private val TEXT_SIZE = 15f.dp2px   //绘制的文字的大小

@RequiresApi(Build.VERSION_CODES.M)
class MultilieTextView(context: Context, attributeSet: AttributeSet) : View(context, attributeSet) {

    private var hasDrawTextLength = 0   //已经绘制的文本的长度
    private var count = 0   //计数
    private var verticalOffset: Float = 0f   //垂直方向的偏移量
    private var measureWidth = floatArrayOf(0f)
    private var paint = Paint(Paint.ANTI_ALIAS_FLAG)
    private var fontMetrics = Paint.FontMetrics()

    init {
        paint.textSize = TEXT_SIZE
        paint.getFontMetrics(fontMetrics)
        verticalOffset = -fontMetrics.top  //初始Y轴的偏移量,这个容易错
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        //绘制文本
        while (hasDrawTextLength < MULTILINE_TEXT.length) {   //没画完就不停
            //计算能画多少个
            count = paint.breakText(
                MULTILINE_TEXT,
                hasDrawTextLength,  //计算的文本起点
                MULTILINE_TEXT.length,  //计算的文本终点
                true,   //是否向前画
                width.toFloat(),   //绘制的宽度
                measureWidth   //Float数组
            )
            //绘制
            canvas.drawText(
                MULTILINE_TEXT,
                hasDrawTextLength,   //绘制的起点
                hasDrawTextLength + count,   //绘制的终点
                0f,  //因为从最左侧开始绘制X轴就是0
                verticalOffset,  //Y轴上的偏移量
                paint
            )
            hasDrawTextLength += count   //把计算出的绘制的量,赋值给已经绘制的长度
            verticalOffset += paint.fontSpacing   //Y轴上的偏移量用官方给的Api
        }

    }
}

效果展示

image.png 这样会把一个完整的单词拆开,后续再想办法优化。

5、图文混排

基于上面的方法二,我们可以做图文混排,思路就是先绘制图,然后绘制文字的时候避开图片即可。

先绘制位图


private val IMAGE_TOP_PADDING = 80f.dp2px   //图片距离顶部的距离
private val IMAGE_WIDTH = 180f.dp2px   //图片的宽

@RequiresApi(Build.VERSION_CODES.M)
class TextWithImageView(context: Context, attributeSet: AttributeSet) :
    View(context, attributeSet) {
    private var paint = Paint(Paint.ANTI_ALIAS_FLAG)

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        //绘制位图
        var bitmap = getAvatar(IMAGE_WIDTH.toInt())
        canvas.drawBitmap(bitmap, width - IMAGE_WIDTH, IMAGE_TOP_PADDING, paint)
    }

    /**
     * 获取符合尺寸宽度的位图
     * @param width 位图的目标宽度
     */
    fun getAvatar(width: Int): Bitmap {
        //获取options对象
        val options = BitmapFactory.Options()
        //配置中设置属性获取图片的长宽设置
        options.inJustDecodeBounds = true
        //对图片进行解码
        BitmapFactory.decodeResource(resources, R.drawable.img_poetry, options)
        //取消获取图片的长宽的设置
        options.inJustDecodeBounds = false
        options.inDensity = options.outWidth   //实际宽度
        options.inTargetDensity = width   //目标宽度
        return BitmapFactory.decodeResource(resources, R.drawable.img_poetry, options)
    }
}

效果展示

image.png

接下来绘制文字的时候,如果Y轴的偏移量与图片有交集就修改绘制的宽度为手机的宽度减去图片的宽度,如果没有交集就绘制手机的宽度,如下面示意图:

screenshot-1642317641527.png

绘制文字

private val IMAGE_TOP_PADDING = 80f.dp2px   //图片距离顶部的距离
private val IMAGE_WIDTH = 180f.dp2px   //图片的宽
private val TEXT_SIZE = 15f.dp2px   //绘制的文字的大小

@RequiresApi(Build.VERSION_CODES.M)
class TextWithImageView(context: Context, attributeSet: AttributeSet) :
    View(context, attributeSet) {
    
    private var hasDrawTextLength = 0   //已经绘制的文本的长度
    private var count = 0   //计数
    private var verticalOffset: Float = 0f   //垂直方向的偏移量
    private var measureWidth = floatArrayOf(0f)
    private var paint = Paint(Paint.ANTI_ALIAS_FLAG)
    private var fontMetrics = Paint.FontMetrics()
    private var content = ""  //要绘制的文本内容
    private var maxWidth = 0   //可以绘制的最大文本宽度

    init {
        paint.textSize = TEXT_SIZE
        paint.getFontMetrics(fontMetrics)
        verticalOffset = -fontMetrics.top  //初始Y轴的偏移量,这个容易错
        content = context.resources.getString(R.string.poetry)
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        //绘制位图
        var bitmap = getAvatar(IMAGE_WIDTH.toInt())
        canvas.drawBitmap(bitmap, width - IMAGE_WIDTH, IMAGE_TOP_PADDING, paint)
        //绘制文本
        while (hasDrawTextLength < content.length) {   //没画完就不停
            //通过判断交集的区域计算文本可以绘制的最大宽度
            if (verticalOffset + fontMetrics.bottom < IMAGE_TOP_PADDING  //Y轴偏移小于图片顶部的padding
                || verticalOffset + fontMetrics.top > IMAGE_TOP_PADDING + bitmap.height //超过了padding加图片高
            ) {
                maxWidth = width  //宽度为手机宽度
            } else {
                maxWidth = width - IMAGE_WIDTH.toInt()  //宽度为手机宽度减去图片的宽度
            }
            //计算能画多少个
            count = paint.breakText(
                content,
                hasDrawTextLength,  //计算的文本起点
                content.length,  //计算的文本终点
                true,   //是否向前画
                maxWidth.toFloat(),   //绘制的宽度
                measureWidth   //Float数组
            )
            //绘制
            canvas.drawText(
                content,
                hasDrawTextLength,   //绘制的起点
                hasDrawTextLength + count,   //绘制的终点
                0f,  //因为从最左侧开始绘制X轴就是0
                verticalOffset,  //Y轴上的偏移量
                paint
            )
            hasDrawTextLength += count   //把计算出的绘制的量,赋值给已经绘制的长度
            verticalOffset += paint.fontSpacing   //Y轴上的偏移量用官方给的Api
        }

    }

    /**
     * 获取符合尺寸宽度的位图
     * @param width 位图的目标宽度
     */
    fun getAvatar(width: Int): Bitmap {
        //获取options对象
        val options = BitmapFactory.Options()
        //配置中设置属性获取图片的长宽设置
        options.inJustDecodeBounds = true
        //对图片进行解码
        BitmapFactory.decodeResource(resources, R.drawable.img_poetry, options)
        //取消获取图片的长宽的设置
        options.inJustDecodeBounds = false
        options.inDensity = options.outWidth   //实际宽度
        options.inTargetDensity = width   //目标宽度
        return BitmapFactory.decodeResource(resources, R.drawable.img_poetry, options)
    }
}

效果展示

image.png

--个人学习笔记--