Android 图文绘制那些事

总结一下工作中遇到的一些问题。

最近有个需求是在图片上绘制水印。在设计稿中,水印由图标和文字两部分构成,因为文字是需要动态生成的,所以没办法从设计稿里切图。最后决定图标和文字分开绘制。图标切图之后直接读取资源文件 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(基线)左端的坐标。看下面这张图:

Android 101: Typography. One way or another, every developer… | by Orhan Obut | ProAndroidDev

除了 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() 会计算首尾字符的空白部分的宽度。在实际情况中,可以根据需求来选择不同的方法进行测量。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇