自定义View 1-3drawText()文字的绘制
1 Canvas 绘制文字的方式
Canvas
的文字绘制方法有三个:drawText()
drawTextRun()
和 drawTextOnPath()
。
1.1 drawText(String text, float x, float y, Paint paint)
drawText()
是 Canvas
最基本的绘制文字的方法:给出文字的内容和位置, Canvas
按要求去绘制文字。
val text = "Hello HenCoder" |
1.2 drawTextRun()
drawTextRun()
是在 API 23 新加入的方法。它和 drawText()
一样都是绘制文字,但加入了两项额外的设置——上下文和文字方向——用于辅助一些文字结构比较特殊的语言的绘制。
- 额外设置一:上下文。
有些语言的文字,字符的形状会互相之间影响:一个字你单独写是一个样,和别的字放在一起写又是另外一个样。
不过我们就不用管它为什么这么神奇了,也不用替阿拉伯人操心这么复杂的文字他们使用起来会不会很痛苦,人家都已经用了几百上千年了。我还说回到 drawTextRun()
。 drawTextRun()
除了文字的内容和位置之外,还可以设置文字的上下文(也就是要绘制的文字的左边和右边是什么文字,虽然这些文字并不会被绘制出来),从而让同样的文字可以按需表现出不同的显示效果。
- 额外设置二:文字方向。
除了上下文, drawTextRun()
还可以设置文字的方向,即文字是从左到右还是从右到左排列的。
介绍完这两类额外设置,来看一下具体的方法吧:
drawTextRun(CharSequence text, int start, int end, int contextStart, int contextEnd, float x, float y, boolean isRtl, Paint paint)
参数:text
:要绘制的文字start
:从那个字开始绘制end
:绘制到哪个字结束contextStart
:上下文的起始位置。contextStart
需要小于等于 start
contextEnd
:上下文的结束位置。contextEnd
需要大于等于 end
x
:文字左边的坐标y
:文字的基线坐标isRtl
:是否是 RTL(Right-To-Left,从右向左)
1.3 drawTextOnPath()
具体的方法很简单:
drawTextOnPath(String text, Path path, float hOffset, float vOffset, Paint paint) |
参数里,需要解释的只有两个: hOffset
和 vOffset
。它们是文字相对于 Path
的水平偏移量和竖直偏移量,利用它们可以调整文字的位置。例如你设置 hOffset
为 5, vOffset
为 10,文字就会右移 5 像素和下移 10 像素。
1.4 StaticLayout
额外讲一个 StaticLayout
。这个也是使用 Canvas
来进行文字的绘制,不过并不是使用 Canvas
的方法。
Canvas.drawText()
只能绘制单行的文字,而不能换行。它:
val text = "Lorem Ipsum is simply dummy text of the printing and typesetting industry."; |
到了 View 的边缘处,文字继续向后绘制到看不见的地方,而不是自动换行
- 不能在换行符
\n
处换行
val text = "a\nbc\ndefghi\njklm\nnopqrst\nuvwx\nyz"; |
如果需要绘制多行的文字,你必须自行把文字切断后分多次使用 drawText()
来绘制,或者——使用 StaticLayout
。
StaticLayout
并不是一个 View
或者 ViewGroup
,而是 android.text.Layout
的子类,它是纯粹用来绘制文字的。 StaticLayout
支持换行,它既可以为文字设置宽度上限来让文字自动换行,也会在 \n
处主动换行。
val text1 = "Lorem Ipsum is simply dummy text of the printing and typesetting industry." |
StaticLayout
的构造方法是 StaticLayout(CharSequence source, TextPaint paint, int width, Layout.Alignment align, float spacingmult, float spacingadd, boolean includepad),其中参数里:
width
是文字区域的宽度,文字到达这个宽度后就会自动换行;align
是文字的对齐方向;spacingmult
是行间距的倍数,通常情况下填 1 就好;spacingadd
是行间距的额外增加值,通常情况下填 0 就好;includepad
是指是否在文字上下添加额外的空间,来避免某些过高的字符的绘制出现越界。
如果你需要进行多行文字的绘制,并且对文字的排列和样式没有太复杂的花式要求,那么使用 StaticLayout
就好。
2 Paint 对文字绘制的辅助
Paint
对文字绘制的辅助,有两类方法:设置显示效果的和测量文字尺寸的。
2.1 设置显示效果类
2.1.1 setTextSize(float textSize)
设置文字大小
paint.textSize = 18f |
2.1.2 setTypeface(Typeface typeface)
paint.typeface = Typeface.DEFAULT |
2.1.3 setFakeBoldText(boolean fakeBoldText)
是否使用伪粗体。
paint.isFakeBoldText = true |
之所以叫伪粗体( fake bold ),因为它并不是通过选用更高 weight 的字体让文字变粗,而是通过程序在运行时把文字给「描粗」了。
2.1.4 setStrikeThruText(boolean strikeThruText)
paint.textSize = 100f |
2.1.5 setUnderlineText(boolean underlineText)
是否加下划线。
paint.textSize = 100f |
2.1.6 setTextSkewX(float skewX)
paint.textSize = 100f |
2.1.7 setTextScaleX(float scaleX)
设置文字横向放缩。也就是文字变胖变瘦。
paint.textSize = 100f |
2.1.8 setLetterSpacing(float letterSpacing)
设置字符间距。默认值是 0。
paint.textSize = 100f |
2.1.9 setFontFeatureSettings(String settings)
paint.textSize = 100f |
2.1.10 setTextAlign(Paint.Align align)
设置文字的对齐方式。一共有三个值:LEFT
CETNER
和 RIGHT
。默认值为 LEFT
。
paint.textSize = 50f |
2.1.11 setTextLocale(Locale locale) / setTextLocales(LocaleList locales)
2.1.11 setTextLocale(Locale locale) / setTextLocales(LocaleList locales)
设置绘制所使用的 Locale
。
Locale
直译是「地域」,其实就是你在系统里设置的「语言」或「语言区域」(具体名称取决于你用的是什么手机),比如「简体中文(中国)」「English (US)」「English (UK)」。有些同源的语言,在文化发展过程中对一些相同的字衍生出了不同的写法(比如中国大陆和日本对于某些汉字的写法就有细微差别。注意,不是繁体和简体这种同音同义不同字,而真的是同样的一个字有两种写法)。系统语言不同,同样的一个字的显示就有可能不同。你可以试一下把自己手机的语言改成日文,然后打开微信看看聊天记录,你会明显发现文字的显示发生了很多细微的变化,这就是由于系统的 Locale
改变所导致的。
Canvas
绘制的时候,默认使用的是系统设置里的 Locale
。而通过 Paint.setTextLocale(Locale locale)
就可以在不改变系统设置的情况下,直接修改绘制时的 Locale
。
paint.textSize = 50f |
另外,由于 Android 7.0 ( API v24) 加入了多语言区域的支持,所以在 API v24 以及更高版本上,还可以使用 setTextLocales(LocaleList locales)
来为绘制设置多个语言区域。
2.1.12 setHinting(int mode)
设置是否启用字体的 hinting (字体微调)。
现在的 Android 设备大多数都是是用的矢量字体。矢量字体的原理是对每个字体给出一个字形的矢量描述,然后使用这一个矢量来对所有的尺寸的字体来生成对应的字形。由于不必为所有字号都设计它们的字体形状,所以在字号较大的时候,矢量字体也能够保持字体的圆润,这是矢量字体的优势。不过当文字的尺寸过小(比如高度小于 16 像素),有些文字会由于失去过多细节而变得不太好看。 hinting 技术就是为了解决这种问题的:通过向字体中加入 hinting 信息,让矢量字体在尺寸过小的时候得到针对性的修正,从而提高显示效果
2.1.13 setElegantTextHeight(boolean elegant)
设置是否开启文字的 elegant height 。开启之后,文字的高度就变优雅了(误)。下面解释一下所谓的 elegant height:
在有些语言中,可能会出现一些非常高的字形:
这些比较高的文字,通常都有两个版本的字体:一个原始版本,一个压缩了高度的版本。压缩版本可以保证让这些「大高个」文字在和普通文字(例如拉丁文字)放在一起的时候看起来不会显得太奇怪。事实上,Paint
绘制文字时是用的默认版本就是压缩版本,就像上图这样。
不过有的时候,开发者会需要使用它们的原始(优雅)版本。使用 setElegantTextHeight()
就可以切换到原始版本:
paint.isElegantTextHeight = true |
2.1.14 setSubpixelText(boolean subpixelText)
是否开启次像素级的抗锯齿( sub-pixel anti-aliasing )。
次像素级抗锯齿这个功能解释起来很麻烦,简单说就是根据程序所运行的设备的屏幕类型,来进行针对性的次像素级的抗锯齿计算,从而达到更好的抗锯齿效果
不过,和前面讲的字体 hinting 一样,由于现在手机屏幕像素密度已经很高,所以默认抗锯齿效果就已经足够好了,一般没必要开启次像素级抗锯齿,所以这个方法基本上没有必要使用。
2.2 测量文字尺寸类
不论是文字,还是图形或 Bitmap
,只有知道了尺寸,才能更好地确定应该摆放的位置。由于文字的绘制和图形或 Bitmap
的绘制比起来,尺寸的计算复杂得多,所以它有一整套的方法来计算文字尺寸。
2.2.1 float getFontSpacing()
获取推荐的行距。
即推荐的两行文字的 baseline 的距离。这个值是系统根据文字的字体和字号自动计算的。它的作用是当你要手动绘制多行文字(而不是使用 StaticLayout)的时候,可以在换行的时候给 y
坐标加上这个值来下移文字。
val texts = arrayOf("第一行文本", "第二行文本", "第三行文本") |
2.2.2 FontMetircs getFontMetrics()
获取 Paint
的 FontMetrics
。
FontMetrics
是个相对专业的工具类,它提供了几个文字排印方面的数值:ascent
, descent
, top
, bottom
, leading
。
如图,图中有两行文字,每一行都有 5 条线:top
, ascent
, baseline
, descent
, bottom
。(leading
并没有画出来,因为画不出来,下面会给出解释)
baseline
: 上图中黑色的线。前面已经讲过了,它的作用是作为文字显示的基准线。ascent
/descent
: 上图中绿色和橙色的线,它们的作用是限制普通字符的顶部和底部范围。
普通的字符,上不会高过ascent
,下不会低过descent
,例如上图中大部分的字形都显示在ascent
和descent
两条线的范围内。具体到 Android 的绘制中,ascent
的值是图中绿线和baseline
的相对位移,它的值为负(因为它在baseline
的上方);descent
的值是图中橙线和baseline
相对位移,值为正(因为它在baseline
的下方)。top
/bottom
: 上图中蓝色和红色的线,它们的作用是限制所有字形( glyph )的顶部和底部范围。
除了普通字符,有些字形的显示范围是会超过ascent
和descent
的,而top
和bottom
则限制的是所有字形的显示范围,包括这些特殊字形。例如上图的第二行文字里,就有两个泰文的字形分别超过了ascent
和descent
的限制,但它们都在top
和bottom
两条线的范围内。具体到 Android 的绘制中,top
的值是图中蓝线和baseline
的相对位移,它的值为负(因为它在baseline
的上方);bottom
的值是图中红线和baseline
相对位移,值为正(因为它在baseline
的下方)。leading
: 这个词在上图中没有标记出来,因为它并不是指的某条线和baseline
的相对位移。leading
指的是行的额外间距,即对于上下相邻的两行,上行的bottom
线和下行的top
线的距离,也就是上图中第一行的红线和第二行的蓝线的距离(对,就是那个小细缝)。
FontMetrics
提供的就是 Paint
根据当前字体和字号,得出的这些值的推荐值。它把这些值以变量的形式存储,供开发者需要时使用。
FontMetrics.ascent
:float 类型。FontMetrics.descent
:float 类型。FontMetrics.top
:float 类型。FontMetrics.bottom
:float 类型。FontMetrics.leading
:float 类型
另外,ascent
和 descent
这两个值还可以通过 Paint.ascent()
和 Paint.descent()
来快捷获取。
FontMetrics 和 getFontSpacing():
从定义可以看出,上图中两行文字的 font spacing (即相邻两行的 baseline
的距离) 可以通过 bottom \- top + leading
(top
的值为负,前面刚说过,记得吧?)来计算得出。
但你真的运行一下会发现, bottom \- top + leading
的结果是要大于 getFontSpacing()
的返回值的。
两个方法计算得出的 font spacing 竟然不一样?
这并不是 bug,而是因为 getFontSpacing()
的结果并不是通过 FontMetrics
的标准值计算出来的,而是另外计算出来的一个值,它能够做到在两行文字不显得拥挤的前提下缩短行距,以此来得到更好的显示效果。所以如果你要对文字手动换行绘制,多数时候应该选取 getFontSpacing()
来得到行距,不但使用更简单,显示效果也会更好。
getFontMetrics()
的返回值是 FontMetrics
类型。它还有一个重载方法 getFontMetrics(FontMetrics fontMetrics)
,计算结果会直接填进传入的 FontMetrics
对象,而不是重新创建一个对象。这种用法在需要频繁获取 FontMetrics
的时候性能会好些。
另外,这两个方法还有一对同样结构的对应的方法 getFontMetricsInt()
和 getFontMetricsInt(FontMetricsInt fontMetrics)
,用于获取 FontMetricsInt
类型的结果。
2.2.3 getTextBounds(String text, int start, int end, Rect bounds)
获取文字的显示范围。
参数里,text
是要测量的文字,start
和 end
分别是文字的起始和结束位置,bounds
是存储文字显示范围的对象,方法在测算完成之后会把结果写进 bounds
。
paint.textSize = 100f |
它有一个重载方法 getTextBounds(char[] text, int index, int count, Rect bounds)
,用法非常相似,不再介绍。
2.2.4 float measureText(String text)
canvas.drawText(text, offsetX, offsetY, paint) |
咦,前面有了 getTextBounds()
,这里怎么又有一个 measureText()
?
如果你用代码分别使用 getTextBounds()
和 measureText()
来测量文字的宽度,你会发现 measureText()
测出来的宽度总是比 getTextBounds()
大一点点。这是因为这两个方法其实测量的是两个不一样的东西。
getTextBounds
: 它测量的是文字的显示范围(关键词:显示)。形象点来说,你这段文字外放置一个可变的矩形,然后把矩形尽可能地缩小,一直小到这个矩形恰好紧紧包裹住文字,那么这个矩形的范围,就是这段文字的 bounds。measureText()
: 它测量的是文字绘制时所占用的宽度(关键词:占用)。前面已经讲过,一个文字在界面中,往往需要占用比他的实际显示宽度更多一点的宽度,以此来让文字和文字之间保留一些间距,不会显得过于拥挤。上面的这幅图,我并没有设置setLetterSpacing()
,这里的 letter spacing 是默认值 0,但你可以看到,图中每两个字母之间都是有空隙的。另外,下方那条用于表示文字宽度的横线,在左边超出了第一个字母H
一段距离的,在右边也超出了最后一个字母r
(虽然右边这里用肉眼不太容易分辨),而就是两边的这两个「超出」,导致了measureText()
比getTextBounds()
测量出的宽度要大一些。
在实际的开发中,测量宽度要用 measureText()
还是 getTextBounds()
,需要根据情况而定。不过你只要掌握了上面我所说的它们的本质,在选择的时候就不会为难和疑惑了。
measureText(String text)
也有几个重载方法,用法和它大同小异,不再介绍。
2.2.5 getTextWidths(String text, float[] widths)
获取字符串中每个字符的宽度,并把结果填入参数 widths
。
这相当于 measureText()
的一个快捷方法,它的计算等价于对字符串中的每个字符分别调用 measureText()
,并把它们的计算结果分别填入 widths
的不同元素。
getTextWidths()
同样也有好几个变种,使用大同小异,不再介绍。
2.2.6 int breakText(String text, boolean measureForwards, float maxWidth, float[] measuredWidth)
这个方法也是用来测量文字宽度的。但和 measureText()
的区别是, breakText()
是在给出宽度上限的前提下测量文字的宽度。如果文字的宽度超出了上限,那么在临近超限的位置截断文字。
paint.textSize = 100f |
2.2.7 光标相关
对于 EditText
以及类似的场景,会需要绘制光标。光标的计算很麻烦,不过 API 23 引入了两个新的方法,有了这两个方法后,计算光标就方便了很多。
2.2.7.1 getRunAdvance(CharSequence text, int start, int end, int contextStart, int contextEnd, boolean isRtl, int offset)
对于一段文字,计算出某个字符处光标的 x
坐标。 start
end
是文字的起始和结束坐标;contextStart
contextEnd
是上下文的起始和结束坐标;isRtl
是文字的方向;offset
是字数的偏移,即计算第几个字符处的光标。
val length = text.length |
其实,说是测量光标位置的,本质上这也是一个测量文字宽度的方法。上面这个例子中,start
和 contextStart
都是 0, end
contextEnd
和 offset
都等于 text.length()
。在这种情况下,它是等价于 measureText(text)
的,即完整测量一段文字的宽度。而对于更复杂的需求,getRunAdvance()
能做的事就比 measureText()
多了。
2.2.7.2 getOffsetForAdvance(CharSequence text, int start, int end, int contextStart, int contextEnd, boolean isRtl, float advance)
给出一个位置的像素值,计算出文字中最接近这个位置的字符偏移量(即第几个字符最接近这个坐标)。
方法的参数很简单: text
是要测量的文字;start
end
是文字的起始和结束坐标;contextStart
contextEnd
是上下文的起始和结束坐标;isRtl
是文字方向;advance
是给出的位置的像素值。填入参数,对应的字符偏移量将作为返回值返回。
getOffsetForAdvance()
配合上 getRunAdvance()
一起使用,就可以实现「获取用户点击处的文字坐标」的需求。