TextView 在布局方面提供了一些特别的属性来控制文本的布局。比如现在要显示一个句子 What is a good time for you?。 TextView 在发现一行布局不足以显示整个文本内容时,会进行自动的换行。
那么 TextView 是如何知道或者说它是如何做到自动换行的呢? 这就要涉及下图所示的内容。
Layout
官方介绍: android.text.Layout 是一个管理屏幕上文本布局的基类。在 TextView 内部,对于会编辑的文本(EditText),会使用 DynamicLayout ,它会随着文本的更改而更新;对于不会更改的文本,会使用 StaticLayout 或 BroingLayout。
getLineXXX
由于文本可能会被显示为多行,所以 Layout 提供了一系列获取每一行文本具体位置的方法。通过 getLineCount()
可以获取一个 Layout 包含了多少行文本,然后通过一系列 getLineXXX(int line)
方法可以获取每行的具体参数。
方法 | 作用 |
---|---|
getLineTop | 当前行文本区域的最顶部在屏幕y方向的位置 |
getLineBottom | 当前行文本区域的最底部在屏幕y方向的位置, 会包含实际的行间距X(最后一行不会包含) |
getLineBaseline | 当前行文字的 baseline |
getLineStart | 绘制在当前行的第一个字符在所有字符中的索引 |
getLineEnd | 绘制在当前行的最后一个字符在所有字符中的索引 |
getLineLeft | 绘制在当前行的第一个字符在屏幕x方向的位置 |
getLineRight | 绘制在当前行的最后一个字符在屏幕x方向的位置 |
getLineAscent | 获取字体头部的额外高度,相对于 Bottom 的偏移 |
getLineDescent | 获取字体底部相对于 Bottom 的偏移 |
getPrimaryHorizontal | 获取文本中某个字符在水平方向上的x位置 |
… |
自己尝试画这些线:
|
|
单行 | 多行 |
---|---|
FontMetricsInt
与文本布局有关的还有一个数据类,FontMetricsInt,它封装了一系列字体相对与 baseline
的偏移量。
|
|
实验
通过继承一个 TextView,可以方便的在布局和绘制相关方法中输出当前 TextView 使用的 Layout。
|
|
然后做以下实验,让 TextView 分别显示长字符串和短字符串,以及使用 EditText。
-
当文本为 What is a good time for you? 时,输出:
D/TAG: [onMeasure] android.text.StaticLayout@b677650 D/TAG: [onMeasure] android.text.StaticLayout@b677650 D/TAG: [onMeasure] android.text.StaticLayout@19ea28b D/TAG: [onMeasure] android.text.StaticLayout@19ea28b D/TAG: [onLayout] android.text.StaticLayout@19ea28b D/TAG: [onDraw] android.text.StaticLayout@19ea28b
会发现在整体的绘制过程中,创建了两个 StaticLayout 对象(b677650 和 19ea28b)。
-
当文本为较短的 a good time 时,输出:
D/TAG: [onMeasure] android.text.BoringLayout@fb6605a D/TAG: [onMeasure] android.text.BoringLayout@fb6605a D/TAG: [onMeasure] android.text.BoringLayout@fb6605a D/TAG: [onMeasure] android.text.BoringLayout@fb6605a D/TAG: [onLayout] android.text.BoringLayout@fb6605a D/TAG: [onDraw] android.text.BoringLayout@fb6605a
因为没有涉及换行的操作,所以在整体的绘制过程中,创建了一个 BoringLayout 对象。
-
使用 EditText 时:
D/TAG: [onMeasure] android.text.DynamicLayout@aa9b261 D/TAG: [onMeasure] android.text.DynamicLayout@aa9b261 D/TAG: [onMeasure] android.text.DynamicLayout@c1d4c99 D/TAG: [onMeasure] android.text.DynamicLayout@c1d4c99 D/TAG: [onLayout] android.text.DynamicLayout@c1d4c99 D/TAG: [onDraw] android.text.DynamicLayout@c1d4c99
会发现在整体的绘制过程中,创建了两个 DynamicLayout 对象(aa9b261 和 c1d4c99)
通过以上实验也部分验证了官方的说法,但是官方对 Layout 的介绍中没有提及 BoringLayout 和 StaticLayout 的区别。于是需要去他们各自的文档中去详细了解。
BoringLayout
官方介绍:BoringLayout 是一种非常简单的 Layout 实现,用于从左到右的单行文本。你可能永远都不想自己做一个。 如果这样做,请确保首先调用
isBoring(CharSequence,TextPaint)
以确保文本符合条件。
所以使用 BoringLayout 的场景是:
- 单行文本
- 从左到右:TextView 有个
textDirection
属性,可以控制文本的显示方向。ltr
就表示从左到右
isBoring
上面提到过判断文本是否符合使用 BoringLayout 的条件,可以通过调用 isBoring
方法。
|
|
isBoring 方法会返回一个 BoringLayout.Metrics 对象,如果返回了 null,则说明目标文本不符合使用 BoringLayout 的条件。
|
|
-
判断文本中是否存在一些特殊字符
1 2 3
if (hasAnyInterestingChars(text, textLength)) { return null; // There are some interesting characters. Not boring. }
-
判断是不是从右到左的方向
1 2 3
if (textDir != null && textDir.isRtl(text, 0, textLength)) { return null; // The heuristic considers the whole text RTL. Not boring. }
-
判断有没有存在 ParagraphStyle
1 2 3 4 5 6 7
if (text instanceof Spanned) { Spanned sp = (Spanned) text; Object[] styles = sp.getSpans(0, textLength, ParagraphStyle.class); if (styles.length > 0) { return null; // There are some PargraphStyle spans. Not boring. } }
-
以上条件都通过之后,就会创建布局相关的参数
1 2 3 4 5 6 7 8 9 10 11 12 13
Metrics fm = metrics; if (fm == null) { fm = new Metrics(); } else { fm.reset(); } TextLine line = TextLine.obtain(); // 从 TextLine 对象池复用一个 TextLine // 将参数注入 TextLine line.set(paint, text, 0, textLength, Layout.DIR_LEFT_TO_RIGHT, Layout.DIRS_ALL_LEFT_TO_RIGHT, false,null); // 将经过测量之后的文本将要占用的宽赋值到 BoringLayout.Metrics#width fm.width = (int) Math.ceil(line.metrics(fm)); TextLine.recycle(line); // 对象回收进池子
TextLine
TextLine 代表一行样式文本,以视觉顺序进行度量,用于渲染。通俗说就是它负责对单行的文本进行宽度测量。
|
|
|
|
measure 最终会调用到 TextPaint#getRunAdvance 去获取文本占用的宽。对于 TextPaint 如何去测量文本占用的宽,这里不再深入。其对应的 native 实现在 Paint.cpp 中。
StaticLayout
StaticLayout 中主要做了测量文本需要多少行的工作。
在测量过程中会对 StaticLayout 中的成员变量 mLineCount
进行 ++
操作。
DynamicLayout
//TODO
lineSpacingExtra
每行文本之间的间隔高度(不会应用到最后一行)
与其相关的还有一个属性是 lineSpacingMultiplier, 它代表希望应用到默认行高的倍数。
TextView 某一行的行高计算:
|
|
mTextPaint.getFontMetricsInt(null)
就是默认行高,是一个根据具体字体和textSize
属性计算出来的值mSpacingAdd
就是lineSpacingExtra
=> 实际行高 = 默认行高 * lineSpaceMultiplier + lineSpaceExtra
实例
- 第一个数字是
lineSpaceExtra
,第二个数字是lineSpaceMultiplier
。 - 红色是 TOP 线,黑色是 BOTTOM 线
default | 0 0 | 100 0 |
---|---|---|
100 3 | 100 1 | 100 0.5 |
---|---|---|
运用
知道了 TextView 中 3 种 Layout 的存在之后,就能在需要的时候直接利用 Layout 提前测量文本的布局信息。
-
使用 BoringLayout
1 2 3 4 5 6 7 8 9 10 11 12
fun measureTextByBoringLayout(width: Int, paint: TextPaint, text: String) { val layout = BoringLayout( text, paint, width, Layout.Alignment.ALIGN_NORMAL, 0f, 0f, BoringLayout.Metrics(), true ) }
-
使用 StaticLayout
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
fun measureTextByStaticLayout(width: Int, paint: TextPaint, text: String): ArrayList<Pair<Int, Int>> { val layout = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val builder = StaticLayout.Builder.obtain( text, 0, text.length, paint, width ) builder.build() } else { StaticLayout( text, 0, text.length, paint, width, Layout.Alignment.ALIGN_NORMAL, 0f, 0f, true ) } }
-
DynamicLayout
1
//TODO
总结
- TextView 的布局是代理给 Layout 的 3 个子类来完成的
- 利用 3 种 Layout,可以提前测量文本是否会存在换行的情况,也能提前拿到文本的最终布局信息
- 在涉及会分配大量对象的场景时,Android SDK 内广泛采用了对象池方法来避免创建大量对象。
- TextLine, obtain recycle
- StaticLayot.Builder, obtain recycle
- DynamicLayout.Builder, obtain recycle