文字的测量和绘制

看起来好像是一个个很简单的需求,实则又有很多东西需要学。

垂直居中

静态文本

假设现在需要绘制一串固定内容的文本,这串文本需要垂直居中于屏幕并水平排列,尝试写出下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = 70f.px
textAlign = Paint.Align.CENTER
strokeWidth = 2f.px
}
private val text = "Aiden"

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

canvas.drawText(text, width / 2f, height / 2f, paint) // 尝试在屏幕垂直居中处绘制文本
paint.color = Color.parseColor("#FF0000") // 修改画笔颜色

// 在屏幕垂直居中处绘制一条水平线段
canvas.drawLine(width / 2f - 150f.px, height / 2f, width / 2f + 150f.px, height / 2f, paint)
}

/**
* 为 Float 类型的数据定义一个扩展属性,将这个值的 dp 单位转换为 px 并返回
*/
val Float.px
get() = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, this, Resources.getSystem().displayMetrics
)

程序运行后确实可以绘制出文本和垂直居中的水平线段,但是这个文本是位于线段之上的,也就是本文并不在理想的位置上(因为线段已经垂直居中了,所以如果文本是垂直居中的话,那么文本应该会被线段从中间穿过去)。另外如果使用微信截图或者 QQ 截图来测量文字到屏幕顶部和底部的像素会发现也是不相等的,这是为什么呢。

实际上,回过头去看drawText()的函数签名会发现,其第三个参数的注释是这么写的:

The y-coordinate of the baseline of the text being drawn

就是说这个纵坐标并不是文本中心的纵坐标之类的东西,而是文本基线(baseline)的纵坐标。基线是连接所有字符底部的虚拟线条,而文本会沿着这条线排列(在上面的代码中,水平线段的纵坐标和文本基线的纵坐标是一样的,所以绘制出来的水平线段可以大致看作是所绘制文本的基线)。

所以如果真的要把文字垂直居中,那主要就是调整这个基线的位置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = 70f.px
textAlign = Paint.Align.CENTER
strokeWidth = 2f.px
}
private val text = "Aiden"
private val bounds = Rect() // 用于存储文本边界的坐标

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

paint.getTextBounds(text, 0, text.length, bounds) // 获取文本的边界并存储到 Rect 对象中
canvas.drawText(
text,
width / 2f,
height / 2f - (bounds.top + bounds.bottom) / 2f, // 结合边界进行偏移计算
paint
)
paint.color = Color.parseColor("#FF0000") // 修改画笔颜色

// 在屏幕垂直居中处绘制一条水平线段
canvas.drawLine(width / 2f - 150f.px, height / 2f, width / 2f + 150f.px, height / 2f, paint)
}

// 省略 val Float.px

在上面的改动中,用到了 Paint 对象的getTextBounds(),该函数用于获取使用此 Paint 对象绘制出来的文本的边界,并将其存储在一个 Rect 对象中。在得到文本的边界坐标以后,利用上下边界坐标之和除以 2 得到一个中间坐标,最后在设置基线坐标时,只需要在算出了屏幕中心位置的情况下进行相应的偏移就能让文本位于屏幕垂直居中处了。

动态文本

使用getTextBounds()的方法一般只适用于内容不变的文本,因为getTextBounds()得到的 top 和 bottom 属性是与绘制文本的内容有关的,如果内容发生变化,就会导致所绘制文本的基线的纵坐标频繁变动,从而造成文本在屏幕上上下跳动的情况。

所以针对内容会变化的文本,应该采用 Paint 对象的getFontMetrics()方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = 70f.px
textAlign = Paint.Align.CENTER
strokeWidth = 2f.px
}
private val text = "bgdq"
private val fontMetrics = paint.fontMetrics // 获取当前 Paint 对象的 FontMetrics 对象

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

canvas.drawText(
text,
width / 2f,
height / 2f - (fontMetrics.ascent + fontMetrics.descent) / 2f, // 进行偏移计算
paint
)
paint.color = Color.parseColor("#FF0000") // 修改画笔颜色

// 在屏幕垂直居中处绘制一条水平线段
canvas.drawLine(width / 2f - 150f.px, height / 2f, width / 2f + 150f.px, height / 2f, paint)
}

// 省略 val Float.px

FontMetrics类则是一个关于文本绘制的辅助类,用于提供关于文本字体和布局的详细信息。它同时又是一个静态类,包含了 5 个属性:

  • top:一个负数,表示基线以上的最大距离,具体距离由当前文本字体中最高的字形决定。(The maximum distance above the baseline for the tallest glyph in the font at a given text size.)
  • ascent:一个负数,表示文本的上行高度,也许可以理解为显示文本的核心区域。(The recommended distance above the baseline for singled spaced text.)
  • descent:一个正数,表示文本的下行高度,与 ascent 相对应。(The recommended distance below the baseline for singled spaced text.)
  • bottom:一个正数,表示基线以下的最大距离,与 top 相对应。(The maximum distance below the baseline for the lowest glyph in the font at a given text size.)
  • leading:行间距。(The recommended additional space to add between lines of text.)

在上面的代码中,通过调用 Paint 对象的fontMetrics属性获取到了一个 FontMetrics 对象,此外也可以通过构造函数手动实例化一个 FontMetrics 对象,然后将其作为参数传进 Paint 对象的getFontMetrics()中,这样一来,top 、bottom 等属性就会存储进手动实例化的这个 FontMetrics 对象中,效果是一样的。

在拥有 FontMetrics 对象后,就只需要在绘制文本时,对计算基线纵坐标的方式进行一点修改就好了,具体就是将原来的bounds.top换成fontMetrics.ascent,把bounds.bottom换成fontMetrics.descent

那么 FontMetrics 类中的 top / bottom 属性和通过getTextBounds()获取到的 top / bottom 属性有什么区别呢?

  • FontMetrics 的 top / bottom 属性描述的是当前字体的最高和最低边界,这些边界是相对于基线的,通常是一个固定值。它们与具体的文本内容无关,只与字体和字体大小有关。
  • getTextBounds()用于计算所绘制文本的实际边界,并将计算出的值保存在某个 Rect 对象中,而被保存的 top / bottom 属性虽然也是相对于基线的,但是这两个值会与所绘制文本的具体内容有关。

文本贴边

上面提到了 FontMetrics 类的 top / bottom 属性和通过getTextBounds()获取到的 top / bottom 属性的区别,但是getTextBounds()不仅有 top / bottom 属性,它还有 left / right 属性,对应的就是文本区域的左边界和右边界。这两个属性一般可以在希望文本紧贴屏幕边缘时用到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = 70f.px
}
private val text = "bgdq"
private val fontMetrics = paint.fontMetrics
private val bounds = Rect()

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

paint.getTextBounds(text, 0, text.length, bounds)
canvas.drawText(text, -bounds.left.toFloat(), -fontMetrics.top, paint)
}

// 省略 val Float.px

在调用drawText()时为其第二个参数传入绘制文本的左边界,并在变量前添加负号,使其产生一个向左的偏移,就可以做到让文本紧贴屏幕左侧。如果此时文本与屏幕间仍存在空隙,那么可能是字体本身的原因。

除了设置drawText()的第二个参数使文本紧贴屏幕左侧以外,设置第三个参数也可以让文本紧贴屏幕顶部或是底部。以顶部为例,根据之前的代码可以知道,至少有 3 种设置方式:

  • FontMetrics 类的top属性;
  • FontMetrics 类的ascent属性;
  • 通过getTextBounds()获取到的top属性;

例如上面的用例就使用了 FontMetrics 类的top属性,这样一来,尽管文本和屏幕顶部仍然存在较大的间隙,但是可以保证在该字体下,就算是最高的字形也可以完整显示出来。而如果设置成 FontMetrics 类的ascent属性则不行,如果设置成通过getTextBounds()获取到的top属性,那这个文本将会贴紧屏幕顶部。三种设置方式会分别得到三种不同的效果,根据实际情况使用即可。

多行绘制

StaticLayout

StaticLayout是 Android 中的一个文本布局类,用于在屏幕上绘制多行文本:

1
2
3
4
5
6
7
8
9
10
11
12
13
private val text =
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam accumsan venenatis aliquam. Donec vel lacus quis ligula pellentesque imperdiet eget in eros. Nullam volutpat rhoncus nunc, eu faucibus dolor pellentesque eu. Nullam viverra finibus quam, eget fringilla est blandit vel. Proin magna urna, fermentum imperdiet scelerisque sed, fermentum at mauris. Morbi a imperdiet arcu. Nulla condimentum tortor risus. Pellentesque aliquet eros et dapibus maximus. Curabitur auctor porttitor tellus in convallis."
private val paint = TextPaint(Paint.ANTI_ALIAS_FLAG)

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

paint.textSize = 20f.px
StaticLayout.Builder.obtain(text, 0, text.length, paint, width)
.setAlignment(Layout.Alignment.ALIGN_NORMAL).build().draw(canvas)
}

// 省略 val Float.px

可以通过 StaticLayout 类的构造函数来得到一个 StaticLayout 对象,但是在高版本的 API 中,这种方法被弃用了,取而代之的是利用StaticLayout.Builder来创建对象:

  • 在上面的代码中,先调用了obtain(),该函数接收 5 个参数,分别是需要绘制的文本内容、从文本的第几个字开始绘制、绘制到哪里结束、一个 TextPaint 对象、折行宽度,其中这里的文本是在 https://www.lipsum.com/ 上生成的无意义文本,而折行宽度用的是屏幕宽度。
  • setAlignment(@NonNull Alignment alignment)用于设置文本的对其方式。
  • setLineSpacing(float spacingAdd, @FloatRange(from = 0.0) float spacingMult)用于设置行间距。
  • setIncludePad(boolean includePad)表示在计算文本布局时是否包括额外的内边距。
  • 在参数设置完成后,调用build()来返回一个 StaticLayout 对象。
  • 调用draw()并传入一个 Canvas 对象来完成绘制。

breakText()

Paint 对象的breakText()用于在给定的最大宽度下测量和截取文本,只要知道了从哪个文字开始绘制,到哪个文字为止,就可以配合 Canvas 对象的drawText()来绘制多行文本了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private val text =
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam accumsan venenatis aliquam. Donec vel lacus quis ligula pellentesque imperdiet eget in eros. Nullam volutpat rhoncus nunc, eu faucibus dolor pellentesque eu. Nullam viverra finibus quam, eget fringilla est blandit vel. Proin magna urna, fermentum imperdiet scelerisque sed, fermentum at mauris. Morbi a imperdiet arcu. Nulla condimentum tortor risus. Pellentesque aliquet eros et dapibus maximus. Curabitur auctor porttitor tellus in convallis."
private val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = 20f.px
}
private val fontMetrics = paint.fontMetrics

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

var start = 0 // 从第几个字开始绘制
var count: Int // 每一次最多绘制几个字
val measuredWidth = floatArrayOf()
var verticalOffset = -fontMetrics.top // 每一行文本的纵向偏移量
while (start < text.length) {
// 测量每一行可以绘制多少个字
count = paint.breakText(text, start, text.length, true, width.toFloat(), measuredWidth)
canvas.drawText(text, start, start + count, 0f, verticalOffset, paint) // 绘制一行文本
start += count // 更新下一次绘制的文字的位置
// 更新下一行文本的纵向偏移量,fontSpacing 会根据当前字体和字体大小返回合适的行间距
verticalOffset += paint.fontSpacing
}
}

// 省略 val Float.px

主要记录一下上面用到的 Paint 对象的breakText(),该函数接收 6 个参数:

  1. CharSequence text:需要测量和截取的文本。
  2. int start:文本开始测量的起始位置。
  3. int end:文本测量的结束位置。
  4. boolean measureForwards:表示测量的方向,true 为从起始位置向前测量;false 为从结束位置向后测量。具体来说就是对于从左到右的文本,该值应被设置为true,而对于从右到左的文本,应该将其设置为false
  5. float maxWidth:最大宽度,超过该宽度的文本将被截断。
  6. float[] measuredWidth:一个长度为 1 的浮点数数组,用于接收测量后的实际宽度。返回的宽度将存储在 measuredWidth[0] 中。

该函数返回一个整数,表示在不超过最大宽度的情况下可以绘制的文本的长度。在上面的代码中,把这个整数存储到了变量count中,在绘制文本时,使用变量start这个起始位置加上count这个绘制长度,就可以得到绘制的结束位置了。

此外还有一个需要注意的地方就是在使用 FontMetrics 类时,应该在什么位置设置 Paint 对象的字体大小。以上面的代码为例,只有在执行fontMetrics = paint.fontMetrics这行代码前设置了 Paint 的字体大小,FontMetrics 才会在测量时使用新的大小,否则它将采用默认的字体大小进行测量,得出来的 top 等属性的结果将是不符合预期的。

图文并茂

接下来,在上面代码的基础上进行改动,实现一个类似于新闻页面的效果。也就是在多行文本间插入一张图片,在文本接触到图片前,文本会自动折行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
private val text =
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam accumsan venenatis aliquam. Donec vel lacus quis ligula pellentesque imperdiet eget in eros. Nullam volutpat rhoncus nunc, eu faucibus dolor pellentesque eu. Nullam viverra finibus quam, eget fringilla est blandit vel. Proin magna urna, fermentum imperdiet scelerisque sed, fermentum at mauris. Morbi a imperdiet arcu. Nulla condimentum tortor risus. Pellentesque aliquet eros et dapibus maximus. Curabitur auctor porttitor tellus in convallis."
private val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = 20f.px
}
private val imageSize = 150f.px // 图片大小
private val imageTopSidePosition = 50f.px // 图片与屏幕顶部的距离
private val bitmap = betterGetBitmap(imageSize.toInt(), R.drawable.dedsec_logo) // 获取图片
private val fontMetrics = paint.fontMetrics

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

canvas.drawBitmap(bitmap, width - imageSize, imageTopSidePosition, paint) // 绘制图片
var start = 0 // 从第几个字开始绘制
var count: Int // 每一次要绘制几个字
var verticalOffset = -fontMetrics.top // 每一行文本的纵向偏移量
var maxWidth: Float // 最大宽度,决定了文本截断的位置
while (start < text.length) {
maxWidth =
if (verticalOffset + fontMetrics.bottom > imageTopSidePosition && verticalOffset + fontMetrics.top < imageTopSidePosition + imageSize) (width - imageSize)
else width.toFloat()

// 测量每一行可以绘制多少个字
count = paint.breakText(text, start, text.length, true, maxWidth, null)

canvas.drawText(text, start, start + count, 0f, verticalOffset, paint) // 绘制一行文本
start += count // 更新下一次绘制的起始位置

// 更新下一行文本的纵向偏移量,fontSpacing 会根据当前字体和字体大小返回合适的行间距
verticalOffset += paint.fontSpacing
}
}

/**
* 根据需求加载图片,以节省资源提高性能
* @param requireWidth 需要返回的图片的宽度
* @param picId 需要读取的图片
*/
private fun betterGetBitmap(requireWidth: Int, picId: Int): Bitmap {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true // 只获取图片的宽度和高度信息,而不加载整个图片
BitmapFactory.decodeResource(resources, picId, this) // 读取宽度和高度
inJustDecodeBounds = false // 置为 false ,以便后续加载整个图片
inDensity = outWidth // 设置读取到的图片的密度
inTargetDensity = requireWidth // 设置目标密度
}
return BitmapFactory.decodeResource(resources, picId, options) // 按照目标重新读取图片并返回
}

实现“文本遇到图片自动折行”的主要思路其实就是限制特定那几行文本的绘制宽度,也就是当某一行文本的纵向偏移量(基线纵坐标)大于图片顶部的位置,或者小于图片底部的位置(图片顶部的位置加上图片的高度)的时候,就代表该行文本如果直接采用屏幕宽度进行截断,就一定会和图片接触,这时候就要在绘制该行文本时,将其宽度限制到接触图片之前,也就是屏幕宽度减去图片的宽度,在这里将文本截断,就不会与图片接触了。

图文并茂


文字的测量和绘制
http://example.com/post/Measuring-and-drawing-text-in-Android/
发布于
2023年4月27日
更新于
2023年5月19日
许可协议