Draw Text in Deep

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。

file

文本测量

文本的测量是非常复杂,因为要适配全球几百种语言不同的排版,除了前面提到的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如图所示。

file

如果是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的右边开始绘制。

通过下面这个例子,可以很清楚的了解这一原理。

file

文本的居中绘制

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的坐标。

file

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群英传

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容