canvas 种绘制文字一向是个难题,关于文字大小,轮廓,宽高的问题更是涉及到好几个到API,尤其是我们要绘制多行文字时更是如此,老实说我看到这几个到API后都是懵的,找些好些资料,然后自己测试不同文字打印数据才搞明白,大家跟着我一起来吧,希望能把下面这几个到API讲明白,也让大家不在想我一样糊度~
FontMetrics 文字模型
也可以叫矩阵 FontMetrics,我们给 paint 画笔设置完文字大小就可以获取到这个文字矩阵 对象,文字绘制的基础全部依赖于此,我们首先搞懂这个 FontMetrics 才是王道喵喵喵
FontMetrics 模型图:这是我能找到的最好的图了
这2张图一起看才行哦~
FontMetrics 文字绘制模型涉及到5个概念:
- top
- bottom
- ascent
- descent
- baseline
咱们来一个个的说,说的明白了,之后就好做了,就不会再迷糊了
-
baseline
baseline 也叫基准线,是文字绘制的基准,文字以文字中心内容为核心以baseline为起点进行居中对齐,典型的就是带圈的小写字母了,大家从上面的图中能看出端倪。文字剩下的部分,有的向上占据空间,比如 i,有的向下占据空间,比如 j 。canvas 绘制文字时就是以baseline 的值作为文字 Y坐标的基准
-
ascent 和 descent
ascent 和 descent 是成对来说的,ascent 叫文字的上坡度,descent 叫文字的下坡度,绘制文字据对不会超出 ascent - descent 的范围,上标,下标,音标除外
文字占据 ascent - descent 空间的情况分3种:
- a,I
典型小写字母,只会占据 baseline - ascent 的部分空间 - A
典型大写字母,会占据 baseline - ascent 的全部空间,也就是撑满从 baseline - ascent - j,g
向下占据空间的典型字母,会像 a 一样占据部分 baseline - ascent 的空间,但是向下绘制的部分会占据部分 baseline - descent 的空间,向下最多的如 j 是会占据全部 baseline - descent 的空间 - 我
中文基本会填满 ascent - descent 的空间,但是又不会 100% 填满,上下会多少流出一些空隙
-
top - bottom
top-ascent 叫上标,bottom-descent 叫下标,一般绘制文字不会占这块的空间,但是上标,下标,音标会占用这块空间,好比上图中的罗马字符。top-ascent 和 bottom-descent 上下2块的空间除了绘制特殊部分,基本是作为文字上下分割空间存在的
-
FontMetrics 的坐标值
我们从画笔 paint 可以获取的矩阵中的值,baseline 处为0,向上为负数,向下为正数,这里要清楚
基本上 FontMetrics 文字矩阵就是这样了,大家明白这几条是干啥的就好了~
绘制文字涉及到的 API
这个是重点了啦,大伙都是在这里迷糊的,下面所列 API 功能相近,容易混
// canvas 绘制文字
public void drawText (String text, float x, float y, Paint paint)
// 获取文字矩阵和其中的参数
val fontMetrics = paint.fontMetrics
val top = fontMetrics.top.toInt()
val bottom = fontMetrics.bottom.toInt()
val ascent = fontMetrics.ascent.toInt()
val descent = fontMetrics.descent.toInt()
val leading = fontMetrics.leading.toInt()
// 获取文字占据的大小
paint.getTextBounds(text.toString(), 0, text.length, rect)
// 测量文字的宽度
val measureWidth = paint.measureText(text.toString())
// 获取指定宽度下可以绘制的字符数
var text3: String = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
val breakText = mPaint.breakText(text3, true, 600f, null)
// textview 获取行高
text_name.lineHeight
// paint 画笔获取行高
paint.getFontMetrics(fontMetrics)
paint.getFontMetricsInt(paint.getFontMetricsInt())
paint.getFontSpacing()
// textview 设置行间距
android:lineSpacingExtra="10px"
android:lineSpacingMultiplier="1.5"
// StaticLayout 绘制多行文字辅助类,可以实现在指定宽度限制下绘制多行文字,文字可以实动实现换行
var staticLayout = StaticLayout(text, mPaint, width, Layout.Alignment.ALIGN_NORMAL, 0f, 0f, false)
staticLayout.draw(canvas)
这几个 API ok 了,之后自定义 view 绘制文字部分基本平趟儿~
getTextBounds 获取的是哪块尺寸
好多人对 getTextBounds 这个方法有误解,认为 getTextBounds 获取的就是文字的大小,其实不然 getTextBounds 获取的是文字的轮廓。
什么是轮廓,就是真实大小,不是 FontMetrics 某段尺寸,是真实的文字占据多大就是多少。前提说过不同类型的字符占据的 FontMetrics 区域是不一样的,那么 getTextBounds 返回的大小也是不一样的。
我们来测试几个不同的字符,看看相关的数据都是怎么样的:
这里选2个字符:a 、 A ,很明显能看出 getTextBounds 获取的文字大小是不同的
所以我们使用 getTextBounds 要注意场合,不同的字符获取到的此存是不一样的,有其是我们要是用 getTextBounds 计算行高的时候获取的数据肯定是不对的。
Textsize 设置的是哪部分的值
texeview 的 setTextSize() 方法会指到 paint 的 setTextSize() 方法,最终会调用本地 c 的方法,从代码上我们看不出我们给文字设置完大小指的是 FontMetrics 的哪里,那么我们从结果触发了
我们打印几个不同文字尺寸的 FontMetrics 看看:
从结果上看:textsize 的大小 = baseline 到 top 和 ascent 的中间。我说之前我量的文字大小总是小一点呢,所以大伙在量文字大小之后加一点大小才准确
获取文字宽度
FontMetrics 里面没有设计文字宽度,这个很正常,大家想啊,FontMetrics 是单个字符标准,实际文字字数不固定,FontMetrics 当然表示不了了
那我们怎么获取文字宽度呢,有2个 Paint 的 API 可供选择:
- measureText
- getTextBounds
Paint 的这2个方法都可以获取指定数量字符的宽度,getTextBounds 获取的是文字轮廓,measureText 是专门计算文字宽度的,从结果上来看 measureText 获取的结果比 getTextBounds 要多 1-2 个像素,关于这点我找到一个图:
图中红线是 getTextBounds 获取的部分,粉线是 measureText 获取的部分,自然 measureText 获取的数据要多一些,这算是留边吧,实际上我看所有人都推荐用 measureText
textview 中的行高
为啥我会写 textview 呢,是因为cavans 绘制文字时我不知道应该以哪个作为标准行高,是 top - bottom,还是 ascent - descent ,所以去 textview 那瞧瞧
获取行高的 API 有4个:
// textview 获取行高
text_name.lineHeight
// paint 画笔获取行高
paint.getFontMetrics(fontMetrics)
paint.getFontMetricsInt(paint.getFontMetricsInt())
paint.getFontSpacing()
paint 的3个方法,getFontMetricsInt 获取的数据最准确,textview 的 getLineHeight 内部调的也是 getFontMetricsInt 这个方法
textview getLineHeight 有这个获取行高的 API ,我跟进去,源码里面太复杂,没看出来是获取的 FontMetrics 的哪块值。
然后我还是从结果入手,打印了几次结果搞清楚了,先说下结果:
-
textview 即使在 padding = 0时,依然在上留了 top - ascent ,在下留了 descent - bottom 大小的边距
打印文字2个不同大小时的矩阵信息和 view 大小,从结果能看出来 view 的 height - LineHeight = ( top - ascent)+ (descent - bottom)基本相等,这里用的是绝对值。
-
textview 的 LineHeight = ascent - bottom 的距离
上面的图就不行了,还是相同的文字大小,我们从单行增加到 3行:
文字大小是 20px 时,view 的高是 76,view 默认的边距是 28-24=4 ,实际内容高度是 72 ,除以3 = 24 ,正好是 LineHeight 的大小
文字大小我们再换成 28px,view 的高是 104,view 默认的边距是 38-33=5 ,实际内容高度是 99 ,除以3 = 33 ,正好是 LineHeight 的大小
为啥不包含 ascent - top 这块上标的高度呢,我估计是上标一般也用不到,而且看数值上标大小也不小了,既然用不到就不要了,下标 descent - bottom 的大小不大,正好作为每行默认的行间距
有其得到启发,我们 cavans 绘制文字时还是学习 textview 以 ascent - bottom 的大小为行高是最合适的
textview 的行间距
textview 有2个参数可以设置行间距:
android:lineSpacingExtra="10px"
android:lineSpacingMultiplier="1.5"
- lineSpacingExtra 设置的行间距的绝度数值,默认 = 0
- lineSpacingMultiplier 设置的是行间距的倍数,默认 = 1.0
上文提到的 textview 的行高计算方式都是在没有设置行间距时的算法,那么这2个值我们要是设置之后行间距怎么算呢,公式就是下面这个啦:
LineHeight = LineHeight(原来的行间距) * lineSpacingMultiplier 倍数+ lineSpacingExtra
那么我们不用 textview 用 canvas 绘制文字时,行间距就根据我们自己的习惯来吧,因为推荐使用 textview 的行高计算方式,所以我们绘制出来的文字默认是带一些行间距的,如果需要我们可以加一个行间距的偏移量进来即可
canvas 单行文字居中
终于到重头戏了,就是我们用 canvas 画文字时最常见的居中问题,先来单行的,再来多行的。
看图例,我画了一个示意图,红线是 view 中心,黄线是文字的 baseline 基线,在文字居中时,基线的情况就是这样子的,我们算的就是 baseline 基线相对 view 中心线的偏移量
要算这个值我们是离不开 FontMetrics 的
首先别忘了 FontMetrics 中 baseline 为0,top 和 ascent 的值都是负数,这里我们取 ascent - descent 的距离为行高,因为文字不会超过这个范围的
因为文字要居中,所以 view 的中心线也就是文字的中心线,也就是行高的一半 = ( - ascent + descent )/ 2 。中心线到 baseline 距离 = ascent 的高度 - 行高的一半 = - ascent - ( - ascent + descent )/ 2
最后计算下得到最后的公式:
-ascent / 2 - descent / 2
代码如下:
class MyTextview : View {
var mPaint: TextPaint = TextPaint()
constructor(context: Context?) : super(context)
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes)
init {
mPaint.textSize = 58f
mPaint.isAntiAlias = true
mPaint.strokeWidth = 1f
}
override fun onDraw(canvas: Canvas?) {
canvas?.drawColor(Color.BLUE)
var text: String = "AAAgggg我我哦我我我我我"
var drawTextX: Float = 0f
var drawTextY: Float = 0f
// 拿到文字的宽度
val textWidth = getTextWidth(text, mPaint)
// baseline x 坐标
drawTextX = (width / 2 - textWidth / 2)
// baseline y 坐标 = view 的中心 + baseline 的偏移量
drawTextY = (height / 2 + calculateBaselineOffsetY(mPaint))
mPaint.color = Color.WHITE
canvas?.drawText(text, 0, text.length, drawTextX, drawTextY, mPaint)
// 绘制2条参考线
mPaint.color = Color.RED
canvas?.drawLine(0f, (height / 2).toFloat(), width.toFloat(), (height / 2).toFloat(), mPaint)
mPaint.color = Color.YELLOW
canvas?.drawLine(0f, drawTextY, width.toFloat(), drawTextY, mPaint)
}
// 拿到文字的宽度
fun getTextWidth(text: String, paint: Paint): Float {
if (TextUtils.isEmpty(text)) {
return 0f
}
return paint.measureText(text)
}
// 计算 baseline 的相对文字中心的偏移量
fun calculateBaselineOffsetY(paint: Paint): Float {
val fontMetrics = paint.fontMetrics
val ascent = fontMetrics.ascent
val descent = fontMetrics.descent
return -ascent / 2 - descent / 2
}
}
canvas 多行文字居中
多行和单行计算起来没什么太大区别,这里我来个简单点的,之后会写一个模拟 textview 的例子,那里会复杂一些
这里绘制3行同样的文字,每行起点的 X 坐标是相同的。我使用 ascent - bottom 的距离做为一行的高度,这样第二行第三行绘制时 Y 坐标就是在前一行的基础上加上个行高就行了
那么重点就是计算第一行的绘制坐标了,X 坐标不用说了,Y 坐标的计算也不难,这里我们知道了一行的高度,一共有几行,那么我们就可以计算出文字总得高度
var textlist = arrayListOf("AAAgggg我我哦我我我我我", "AAAgggg我我哦我我我我我", "AAAgggg我我哦我我我我我")
var totalHeight = getLineHeight(mPaint) * textlist.size
fun getLineHeight(paint: Paint): Float {
val fontMetrics = paint.fontMetrics
val ascent = fontMetrics.ascent
val bottom = fontMetrics.bottom
return Math.abs(ascent) + Math.abs(bottom)
}
然后我们可以计算出文字顶部的 Y 坐标,那么所有文字居中时 Y 坐标就出来了
var centerY = height / 2 - getLineHeight(mPaint) * textlist.size / 2
然后我们再算第一行文字 baseline 的偏移量也就好算了,baseline 距离每行最顶部有 ascent 的距离,这里我们再加上一个 ascent 的偏移量就是绘制第一行文字 Y 的起始坐标
var fristTextStartY = height / 2 - getLineHeight(mPaint) * textlist.size / 2 + getFontMetricsAscent(mPaint)
这个不难写,因为这里有几行,每行文字分割,每行文字 X 轴起始坐标都不用考虑,只算 Y 的值,也是尽量简单点,好理解嘛,之后有个例子会复杂一些,把上述参数考虑进去
代码如下:
class MyTextview3 : View {
var mPaint: TextPaint = TextPaint()
constructor(context: Context?) : super(context)
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes)
init {
mPaint.textSize = 50f
mPaint.isAntiAlias = true
mPaint.strokeWidth = 1f
}
override fun onDraw(canvas: Canvas?) {
canvas?.drawColor(Color.BLUE)
var textlist = arrayListOf("AAAgggg我我哦我我我我我", "AAAgggg我我哦我我我我我", "AAAgggg我我哦我我我我我")
var drawTextX: Float = 0f
var drawTextY: Float = 0f
// 计算第一行文字绘制的起始位置
drawTextX = (width / 2 - getTextWidth(textlist[0], mPaint) / 2)
drawTextY = (height / 2 - getLineHeight(mPaint) * textlist.size / 2 + getFontMetricsAscent(mPaint))
// 绘制 view 中心线
mPaint.color = Color.RED
canvas?.drawLine(0f, (height / 2).toFloat(), width.toFloat(), (height / 2).toFloat(), mPaint)
// 绘制文字
mPaint.color = Color.WHITE
for ((index, text) in textlist.withIndex()) {
// 绘制每行文字时,添加 Y 轴向下偏移量 = 自身行数-1的行高,第一行是0,所以没有偏移量,或者大家在循环里自己一行一行的填也可以
canvas?.drawText(text, 0, text.length, drawTextX, drawTextY + index * getLineHeight(mPaint), mPaint)
}
}
fun getTextWidth(text: String, paint: Paint): Float {
if (TextUtils.isEmpty(text)) {
return 0f
}
return paint.measureText(text)
}
fun getLineHeight(paint: Paint): Float {
val fontMetrics = paint.fontMetrics
val ascent = fontMetrics.ascent
val bottom = fontMetrics.bottom
return Math.abs(ascent) + Math.abs(bottom)
}
fun getFontMetricsAscent(paint: Paint): Float {
return Math.abs( paint.fontMetrics.ascent )
}
}
breakText
var text3: String = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
val breakText = mPaint.breakText(text3, true, 600f, null)
这个方法让我们设置一个最大宽度在不超过这个宽度的范围内返回实际测量值否则停止测量,参数很多但是都很好理解,text表示我们的字符串,measureForwards表示向前还是向后测量,maxWidth表示一个给定的最大宽度在这个宽度内能测量出几个字符,measuredWidth为一个可选项,可以为空,不为空时返回真实的测量值
这个方法在一些结合文本处理的应用里比较常用,比如文本阅读器的翻页效果,我们需要在翻页的时候动态折断或生成一行字符串,这就派上用场了~~~
StaticLayout
系统中有一个StaticLayout方法,可以在设置宽度,当前行文本超过此宽度后,进行自动换行,提供ALIGN_CENTER(居中)、ALIGN_NORMAL(标准)、ALIGN_OPPOSITE(与标准相反)三种对齐方式。
StaticLayout 的使用不难,看下面的 API 介绍就 OK ~
var text = "AAAAAAA"
var width: Int = 150
var staticLayout = StaticLayout(text, mPaint, width, Layout.Alignment.ALIGN_NORMAL, 0f, 0f, false)
staticLayout.draw(canvas)
// 可以获取文字在指定宽度限制下所需空间
staticLayout.width
staticLayout.height
// 若是使用 StaticLayout 绘制文字居中,可以通过移动 canvas 实现文字位置的变化
canvas?.save()
canvas?.translate(staticLayout.getWidth() / 2.toFloat(), staticLayout.getHeight() / 2.toFloat());
staticLayout.draw(canvas)
canvas?.restore()
文字方面的自定义 view 例子
- Android中TextView文字镂空效果的实现
- TextView、AppCompatTextView文字段落居中文末补缺对齐
- 利用PorterDuffXfermode绘制图片文字
- Android自定义控件:文字闪光移动效果
- Android 可折叠TextView
- Android 自定义进度条
- Android自定义控件 带文字提示的SeekBar