Android系统提供了Textview来提供文字的显示,但很多时候开发者还需要使用Canvas来绘制Text,这时候,canvas.drawText()就不像Textview的使用这么简单了,需要掌握文字的测量以及渲染的流程。
Paint.FontMetrics
FontMetrics是文字测量的重要方法,它提供了下面这些变量,来展示文字测量的相关参数:
- baseline:字符绘制基线
- ascent:字符最高点到baseline的距离
- top:字符最高点到baseline的最大距离
- descent:字符最低点到baseline的距离
- bottom:字符最低点到baseline的最大距离
- leading:行间距,即前一行的descent与下一行的ascent之间的距离,单行则为0(注意不是行距)
要注意的是,这些参数都是以baseline为基准,所以在baseline之上的参数均为负值,baseline之下的参数才为正值,且这些值是距离,而非坐标。或者可以理解为baseline.y = 0的时候的坐标值。
top要大于ascent,原因是需要为拉丁语等带符号的语言留出位置
由这些参数,可以定义下面的这些与渲染有关的参数。
-
字体的高度
可以通过descent + Math.abs(ascent)计算得到。
-
行间距(leading)
TextView的行间距调整设置是通过setLineSpacing(add, mult)方法,在xml中,可以通过lineSpacingExtra和lineSpacingMultiplier来设置,在Paint自定义绘制Text中,可以使用Paint.fontMetrics中的leading属性设置
-
行高
即字符所在行的高度 = ascent + descent + leading,即字符的高度 + 行间距,可以通过descent+Math.abs(ascent) + leading得到。如果在TextView中,可以直接通过getLineHeight()方法获取。
-
字符间距(kerning)
对于textView和Paint绘制的Text,可以分别使用各自类中的getLetterSpacing()和setLetterSpacing()方法获取和设置字符间距,对于TextView还可以在布局文件中使用属性letterSpacing进行定义。(注意以上的方法和属性是在API 21引入的,对于之前的版本,只能通过SpannableString类及相应的方法来间接调整。)
通过下面这张图,大家可以非常清楚的了解FontMetrics。
文本测量
文本的测量是非常复杂,因为要适配全球几百种语言不同的排版,除了前面提到的FontMetrics,Android的渲染API还提供了很多测量文本的API。
getFontSpacing()
这个API用于获取推荐的行距。即两行文字间的baseline的距离。
这个值是系统根据文本的字体和字号自动计算的。当你使用drawText一行行绘制文字的时候,可以在换行的时候获取下一行的baseline坐标。
如果使用StaticLayout进行多行文本的绘制,则不需要通过这个API来获取行距
这里有一点需要注意的是,getFontSpacing所获取的行距,与FontMetrics获取的bottom + abs(top) + leading行距是不一样的,这主要是因为这两个API的计算方式不同,系统推荐使用getFontSpacing来获取多行文本绘制时的行距。
getTextBounds()
获取文字的实际显示范围。这个API返回的是当前绘制文字的最小矩形,即能完全包裹文字的矩形范围。
measureText()
与getTextBounds不同,measureText返回的是文字的实际占用位置,即理论上文字应该占用的区域。
getTextWidths()
这个API返回的数组中,包含了每个字符的实际宽度,在排版中,这个宽度也叫“advance width”。它们累加的和,即为measureText返回的长度。
如果所选字体为等宽字体,则每个字符的宽度是相同的,如果非等宽字体,则不同字符的宽度是不同的。
文字渲染Layout
在Android中,文字渲染的基类是Layout类,它包含了文字测量、渲染和布局的所有功能,Layout类有几个子类:
- BoringLayout
- StaticLayout
- DynamicLayout
一般来说,如果待渲染文本是属于Spannable的文本对象,则使用动态布局DynamicLayout,否则,使用isBoring判断是不是单纯的单行布局,如果是则使用BoringLayout,其他情况使用StaticLayout。
BoringLayout用于绘制仅一行文本的场景,它比较重要的地方是,它提供了一个静态方法isBoring来判断一段文字是否能在一行放下,这对于布局渲染是非常有帮助的。
/**
* Returns null if not boring; the width, ascent, and descent if boring.
*/
val boring = BoringLayout.isBoring(drawText, textPaint)
StaticLayout
StaticLayout的使用场景为多行文本的渲染和SpannableString的渲染。
SpannableString是不能通过Paint.getTextBounds或者是Paint.measureText来测量的
StaticLayout的基本使用如下所示。
val spannable = SpannableString(drawText)
spannable.setSpan(RelativeSizeSpan(2f), 0, 3, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
val staticLayout = StaticLayout(
spannable, textPaint, width, Layout.Alignment.ALIGN_NORMAL,
1F, 0F, true
)
val width = staticLayout.getLineWidth(0)
val height = staticLayout.height
Log.d("xys", "line width $width height $height")
staticLayout.draw(canvas)
Demo如图所示。
如果是API26+,可以使用新的API构造StaticLayout,代码如下所示。
// API 26+
val staticLayout = StaticLayout.Builder
.obtain(text, start, end, textPaint, width)
.build()
通过StaticLayout.Builder可以设置一些API26+的额外参数,例如alignment、textDirection、lineSpacing、justificationMode等,其中justificationMode用于多行文本的两边对齐显示。
关于StaticLayout这里有一篇比较好的文章推荐给大家。
https://medium.com/over-engineering/drawing-multiline-text-to-canvas-on-android-9b98f0bfa16a
TextPaint与Paint
TextPaint是Paint的子类,与Paint的使用基本一致,但大多用于StaticLayout或者是用于测量计算时使用。
TextPaint的示例代码如下所示。
String text = "This is some text."
TextPaint myTextPaint = new TextPaint();
mTextPaint.setAntiAlias(true);
mTextPaint.setTextSize(16 * getResources().getDisplayMetrics().density);
mTextPaint.setColor(0xFF000000);
float width = mTextPaint.measureText(text);
float height = -mTextPaint.ascent() + mTextPaint.descent();
TextAlign
TextAlign设置的是文本的对齐方式,一共有三种,LEFT、CETNER和RIGHT,默认值为LEFT,它的作用是在绘制的时候确定绘制的方向,例如设置为LEFT,那么文本绘制的时候,就是从baseline的StartX开始向右绘制文本,如果是CENTER,那么就是从StartX开始,向两边开始绘制文字,同理,RIGHT为StartX向左开始绘制文本,这里要注意的是,TextAlign确定的是方向,而非在显示区域内的对齐方式,它的一个作用是帮助开发者进行居中的绘制,例如设置Paint的TextAlign为CENTER,drawText的时候起点x = canvas.getWidth() / 2即可。文本会根据基准线的中点开始向左右开始绘制文字,最终自然就变成了居中显示了。如果你设定了RIGHT,那么从baseline的StartX的右边开始绘制。
通过下面这个例子,可以很清楚的了解这一原理。
文本的居中绘制
Android中文本的绘制都是使用baseline进行定位的,通过fontMetrics和已知的区域坐标,是可以推算出文字的其它关键坐标的,所以,文本在任意区域的任意位置绘制问题,其实就是一个坐标运算的问题,根据已知变量和fontMetrics的相关参数,来计算baseline的距离,下面就是文本垂直居中的推算过程。
文本的descent:
descentY = baselineY + fontMetrics.descent;
文本的字体高度:
fontHeight = fontMetrics.descent- fontMetrics.ascent
当文本垂直居中时的bottom距离应该为:
descentY=1/2 * height + 1/2 * fontHeight
baselineY = 1/2 * height - 1/2 * ( fontMetrics.ascent + fontMetrics.descent )
此时求得baseline的值,即cavans.drawText()里的y的坐标。
breakText
这个API与BoringLayout中的isBoring方法有些类似,主要是对文中进行一行的测量。
breakText (CharSequence text, int start, int end, boolean measureForwards, float maxWidth, float[] measuredWidth)
这个方法让我们可以设置一个最大宽度,在不超过这个宽度的范围内返回实际测量值,text表示我们的文本字符串,start表示测量字符串的开始位置,end表示测量字符串的结束位置,measureForwards表示测量的方向,maxWidth表示一个给定的最大宽度在这个宽度内能测量出几个字符,measuredWidth为一个可选项,不为空时返回真实的测量值。
类似的方法还有breakText (String text, boolean measureForwards, float maxWidth, float[] measuredWidth)和breakText (char[] text, int index, int count, float maxWidth, float[] measuredWidth)。
这个方法在一些自定义文本绘制的场景下比较常用,例如阅读类APP的文字排版,需要在换行的时候动态折断或生成一行新的字符串。
基本使用方式如下所示。
measuredCount = paint.breakText(text, 0, text.length(), true, showWidth, measuredWidth);
canvas.drawText(text, 0, measuredCount, paint);
通过上面的方法,就得到了当前这一行可以容纳text文本中的多少个字符,如果showWidth不够展示全部的字符,text文本则会被截断,measuredCount就是该截断的位置。
其它
canvas中还有很多其它关于绘制文本的API,都是样式上的参数,这里不详细解释,例如:
- textScaleX
- letterSpacing(API 21+)
- textSkewX
这些都是一些设置文本样式的API,大家自己在Demo中设置下就知道样式了。
整个文章的演示Demo上传到GitHub了,大家可以自己在手机上测试下,加深对文本渲染的了解,地址如下所示。
https://github.com/xuyisheng/TextMatrix
欢迎大家关注我的微信公众号——Android群英传