总结一下工作中遇到的一些问题。
最近有个需求是在图片上绘制水印。在设计稿中,水印由图标和文字两部分构成,因为文字是需要动态生成的,所以没办法从设计稿里切图。最后决定图标和文字分开绘制。图标切图之后直接读取资源文件 Drawable 然后画到 Canvas 上,文字部分则使用 Canvas#drawText()
方法来处理。
按照设计稿要求,水印里文字和图标的中心线是一致的,由于两部分是分开绘制,因此为了保证这一点,采用的方法是固定其中一部分,然后调整另外一部分的坐标来完成对齐。
不过无论是固定图标还是文字,最后对齐的时候都离不开文字部分的高度,先来说说文字这边。Canvas 提供了一系列的 drawText()
方法来绘制文字,这里以最简单的一个重载为例:
/** * Draw the text, with origin at (x,y), using the specified paint. The origin is interpreted * based on the Align setting in the paint. * * @param text The text to be drawn * @param x The x-coordinate of the origin of the text being drawn * @param y The y-coordinate of the baseline of the text being drawn * @param paint The paint used for the text (e.g. color, size, style) */ public void drawText(@NonNull String text, float x, float y, @NonNull Paint paint) { super.drawText(text, x, y, paint); }
这个重载使用给定画笔,以 (x,y) 为原点进行文字绘制。暂且先不考虑这里关于对齐的问题。
注意到,这里的原点坐标指的是文字 Baseline(基线)左端的坐标。看下面这张图:
除了 Baseline 之外,还有 Top、Ascent、Mean line、Descent 和 Bottom 这几条线。这些概念最早是在西文排版中出现的,所以从西文字母的角度会更好理解一些。
- Top:在给定的文字大小下,字体中最高的字形在基线上方的最大距离。由于非 ASCII 字符的存在,Top 通常比 Bottom 距离 Baseline 更远,例如 Á
- Ascent:对于单行文字,系统建议的,高于 Baseline 的距离。由于非 ASCII 字符的存在,Ascent 通常比 Descent 距离 Baseline 更远。
- Mean line:没有向上延伸部分的字母的 Top。可以理解为四线三格中的第二线
- Baseline:每一个字母所处的线,可以理解为四线三格中的第三线
- Descent:系统建议的,低于 Baseline 的距离,用于单行文字。可以理解为四线三格中的第四线
- Bottom:在给定的文字大小下,字体中最低字形在基线下方的最大距离
- Leading:行与行之间的空间,基本上是指这一行 Bottom 与下一行 Top 之间的空间
- Line Height(行高):一行文字 Top 和 Bottom 之间的距离
也就是说,最终绘制出来的文字,由于 Baseline 到 Descent 以及 Bottom 还有一段距离,在视觉上(其实实际上也是)来说是会显得比设置的位置要靠下一些的。Paint
提供了一个属性 fontMetrics
来让我们获取这些度量值:
val paint = Paint() paint.textSize = 30.dpFloat // 注意在获取度量值之前需要先设置好文字样式 Log.d(TAG, "drawCanvas: fontMetrics.top = ${paint.fontMetrics.top}, " + "fontMetrics.ascent = ${paint.fontMetrics.ascent}, " + "fontMetrics.descent = ${paint.fontMetrics.descent}, " + "fontMetrics.bottom = ${paint.fontMetrics.bottom}") // drawCanvas: fontMetrics.top = -87.13257, fontMetrics.ascent = -76.538086, fontMetrics.descent = 20.141602, fontMetrics.bottom = 22.357178
值得注意的是,度量值的坐标系是以基线为零点,向上为负,向下为正。另外其实不止度量值的坐标系,在 Paint
中所有与文字相关的坐标系都是如此。借助 fontMetrics
和我们自行指定的基线位置,可以将每一条线具体绘制出来:
// Baseline paint.strokeWidth = 2.dpFloat paint.color = Color.RED canvas.drawPoint(50.dpFloat, base, paint) paint.strokeWidth = 1f canvas.drawLine(50.dpFloat, base, canvasWidth.toFloat(), base, paint) // Top paint.strokeWidth = 2.dpFloat paint.color = Color.GREEN canvas.drawPoint(50.dpFloat, base + paint.fontMetrics.top, paint) paint.strokeWidth = 1f canvas.drawLine(50.dpFloat, base + paint.fontMetrics.top, canvasWidth.toFloat(), base + paint.fontMetrics.top, paint) // Ascent paint.strokeWidth = 2.dpFloat paint.color = Color.BLUE canvas.drawPoint(50.dpFloat, base + paint.fontMetrics.ascent, paint) paint.strokeWidth = 1f canvas.drawLine(50.dpFloat, base + paint.fontMetrics.ascent, canvasWidth.toFloat(), base + paint.fontMetrics.ascent, paint) // Descent paint.strokeWidth = 2.dpFloat paint.color = Color.CYAN canvas.drawPoint(50.dpFloat, base + paint.fontMetrics.descent, paint) paint.strokeWidth = 1f canvas.drawLine(50.dpFloat, base + paint.fontMetrics.descent, canvasWidth.toFloat(), base + paint.fontMetrics.descent, paint) // Bottom paint.strokeWidth = 2.dpFloat paint.color = Color.MAGENTA canvas.drawPoint(50.dpFloat, base + paint.fontMetrics.bottom, paint) paint.strokeWidth = 1f canvas.drawLine(50.dpFloat, base + paint.fontMetrics.bottom, canvasWidth.toFloat(), base + paint.fontMetrics.bottom, paint)
中文和西文混排时,为了文字对齐美观,通常中文字体的基线会比实际字符的底部要偏上一些(不过其实中文并不存在基线这一概念)。最终可以得到下面这幅图:
回到最开始问题来,如果是要用于对齐,那么我们其实需要更精准地确定文字绘制的范围,Paint
提供了一个方法 getTextBounds()
来完成这个操作:
val textRect = Rect().apply { paint.getTextBounds(text, 0, text.length, this) } paint.style = Paint.Style.STROKE textRect.top += baseY.roundToInt() textRect.bottom += baseY.roundToInt() textRect.left += baseX.roundToInt() textRect.right += baseX.roundToInt() canvas.drawRect(textRect, paint)
可以看到,粉色的矩形精准地框出了我们需要的位置,(top, left)
和 (right, bottom)
分别是文字左上角和右下角的坐标。当然,直接获取到的还是相对于文字范围内的坐标,需要经过处理才能用在 Canvas 上。
这样,我们就很容易能确定出文字区域中心点的 Y 坐标了:
val textCenterY = textRect.centerY()
然后以固定文字,调整 Drawable 为例。接下来我们需要绘制 Drawable。Drawable 的绘制首先需要确定绘制范围,也就是目标区域左上角和右下角在 Canvas 中的坐标。
val drawableLeft = 20.dp val drawableTop = ((textCenterY + baseY) - drawable.intrinsicHeight / 2).roundToInt() val drawableRight = drawableLeft + drawable.intrinsicWidth val drawableBottom = drawableTop + drawable.intrinsicHeight
文字区域中心的 Y 坐标、基线的 Y 坐标和 Drawable 顶部在画布中的 Y 坐标(也就是上面的 drawableTop
),三者之间有这么一个关系:
$$
\text{Drawable}顶部在画布中的\text{Y}坐标 = 文字中心\text{Y}坐标 + 文字基线\text{Y}坐标 - 0.5 * \text{Drawable}高度
$$
利用这个关系,我们能就能计算出上面的 drawableTop
。然后给 Drawable 左上角坐标分别加上图片的宽和高,就可以得到右下角的坐标。有了这些,就可以将 Drawable 绘制在 Canvas 上了:
drawable.setBounds(drawableLeft, drawableTop, drawableRight, drawableBottom) drawable.draw(canvas)
绘制的结果如图,可以看到,图片已经相对于矩形区域垂直居中了:
如果是固定图片而让文字相对图片垂直居中,那么只需要在上面的公式中反解出文字基线的 Y 坐标即可。
到这里其实本文想要讲的主要内容就结束了。不过在 Paint
中还有一个方法 measureText()
可以测量出文字的宽度:
Log.d(TAG, "drawCanvas: textRect.width = ${textRect.width()}, measureText = ${paint.measureText(text)}") // drawCanvas: textRect.width = 527, measureText = 530.0
这个方法和 getTextBounds()
返回的宽度是有一点区别的,会比后者稍微宽一些。这是因为 measureText()
会计算首尾字符的空白部分的宽度。在实际情况中,可以根据需求来选择不同的方法进行测量。