我们在自定义 View 的时候经常会需要文字测量,使用 canvas.drawText()
实现,但是文字测量绘制也有难点和注意事项。
居中的纵向测量
静态文字和动态文字
文本有分为静态文字和动态文字。静态文字也就是固定不变化的文本,动态文字就是会动态更改的文本。
知道这两个概念对文字测量比较重要,因为这决定了你在不同的场景要用不同的测量 API,达到绘制准确的目的和展示 View 良好的视觉效果。
getTextBounds()
比如我们在绘制一个进度条,我们希望在 View 中间显示文本:
String text = "abab";
canvas.drawText(text, getWidth() / 2f, getHeight() / 2f, paint);
绘制的结果会是这样的:
会发现文字横向是居中了,但是纵向视线效果并没有居中。
当然这并不是错误,而是文字是在基准线上:
文字绘制都有一个基准线 baseline
,它并不是在文字的底部,比如文本修改为 agag
:
文本不同,基准线是固定的,这是 baseline 基准线。
知道文本是在基准线位置纵向往上偏移导致没有居中,那么就需要把偏移移回来。
那这个偏移又要怎么计算出来?我们需要计算文本上边界和下边界的差得到,通过 Paint.getTextBounds(String text, int start, int end, Rect bounds)
获取文本边界值:
paint.getTextBounds(text, 0, text.length, bounds);
// 获取到文本上下边界,有正负号
int left = bounds.left;
int top = bounds.top;
int right = bounds.right;
int bottom = bounds.bottom;
// 获取文本偏移
int offset = (bounds.top + bounds.bottom) / 2;
canvas.drawText(text, getWidth() / 2f, getHeight() / 2f - offset, paint);
需要注意的是,getTextBounds()
适合于测量静态固定的文字,不适合动态文字。如果用这个 API 测量会动态更改的文字,因为拿到的是文本的边界 top 和 bottom 计算就会不断变化,比如文本从 abab 到 ababg 不断变更,这就会导致文本不断跳动的问题。
ascent和descent
我们上面分析到使用 getTextBounds()
只适用于静态文字,在动态文字上显示可能会出现文本跳动问题。那么动态文字要怎么测量才比较精准?就是 ascent
和 descent
。
上面的四根线 top、bottom 、ascent、descent、baseline 基本是固定的,所以使用它们来计算动态文字,就能有一个相对精准的位置。
Paint.FontMetrics fm = new Paint.FontMatrics();
paint.getFontMatrics(fm);
float top = fm.top;
float bottom = fm.bottom;
float ascent = fm.ascent;
float descent = fm.descent;
// 获取文本偏移
float offset = (fm.descent + fm.ascent) / 2;
canvas.drawText(text, getWidth() / 2f, getHeight() / 2f - offset, paint);
对齐
使用 Paint.setTextAlign()
设置文本的对齐方式:
// Paint.Align.LEFT
// Paint.ALgin.TOP
// Paint.Align.RIGHT
// Paint.Align.BOTTOM
paint.setTextAlign(Paint.Align.LEFT);
换行
对于长文本换行,有两种方式:staticLayout
和 breakText()
。
StaticLayout
public class Utils {
public static float dp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, Resources.getSystem().getDisplayMetrics());
}
}
public class ImageTextView extends View {
private static final String text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean justo sem, sollicitudin in" +
"maximus a, vulputate id magna. Nulla non guam a massa sollicitudin commodo fermentum et est. Suspendisse" +
"potenti. Praesent dolor dui, dignissim guis tellus tincidunt, porttitor vulputate nisl. Aenean tempus" +
"lobortis finibus. Quisque nec nisl laoreet, placerat metus sit amet, consectetur est. Donec nec quam tortor." +
"Aenean aliquet dui in enim venenatis, sed luctus ipsummaximus, Nam feugiat nisi rhoncus lacus facilisis" +
"pellentesque nec vitae lorem. Donec et risus eu ligula dapibus lobortis vel vulputate turpis. Vestibulum" +
"ante ipsum primis in faucibus orci luctus et ultrices posere cubilia Curae; In porttitor, risus aliguam" +
"rutrum finibus, ex mi ultricies arcu, quis or nare lectus tortor nec metus. Donec ultricies metus at magna" +
"cursus congue. Nam eu sem eget enim pretium venenatis. Duis nibh ligula, lacinia ac nisi vestibulum," +
"vulputate lacinia tortor.";
private final TextPaint textPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
private StaticLayout staticLayout;
public ImageTextView(Context context) {
this(context, null);
}
public ImageTextView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public ImageTextView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
textPaint.setTextSize(Utils.dp2px(15));
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (staticLayout == null) {
// CharSequence source:文本
// TextPaint paint:文字绘制的画笔
// int width:提供给文本显示的宽度,用于计算自动换行使用
// Layout.Alignment align:
// float spacingmult:纵向是否需要额外的高度,不需要填写1
// float spacingadd:是否需要额外的空格,不需要填写0
// boolean includepad:是否需要加上额外上下间隔,不需要填写false
staticLayout = new StaticLayout(text, textPaint, getWidth(),
Layout.Alignment.ALIGN_NORMAL, 1, 0, false);
}
staticLayout.draw(canvas);
}
}
StaticLayout 会自动帮你分词,剩余空间如果不够显示单词会自动换到下一行。
如果文本当中会嵌套有图片或其他控件就不适合使用了,需要我们自己去定义换行,使用 breakText()
。
breakText()
在文字中嵌套有图片的场景,我们需要自己一行行的去计算和绘制。breakText()
需要我们自己计算文本的显示和换行位置,没有 StaticLayout 的分词效果。
public class Utils {
public static Bitmap getBitmap(Resources res, int width) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(res, R.drawable.test, options);
options.inJustDecodeBounds = false;
options.inDensity = options.outWidth;
options.inTargetDensity = width;
return BitmapFactory.decodeResource(res, R.drawable.test, options);
}
public static float dp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, Resources.getSystem().getDisplayMetrics());
}
}
public class ImageTextView extends View {
private static final String text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean justo sem, sollicitudin in" +
"maximus a, vulputate id magna. Nulla non guam a massa sollicitudin commodo fermentum et est. Suspendisse" +
"potenti. Praesent dolor dui, dignissim guis tellus tincidunt, porttitor vulputate nisl. Aenean tempus" +
"lobortis finibus. Quisque nec nisl laoreet, placerat metus sit amet, consectetur est. Donec nec quam tortor." +
"Aenean aliquet dui in enim venenatis, sed luctus ipsummaximus, Nam feugiat nisi rhoncus lacus facilisis" +
"pellentesque nec vitae lorem. Donec et risus eu ligula dapibus lobortis vel vulputate turpis. Vestibulum" +
"ante ipsum primis in faucibus orci luctus et ultrices posere cubilia Curae; In porttitor, risus aliguam" +
"rutrum finibus, ex mi ultricies arcu, quis or nare lectus tortor nec metus. Donec ultricies metus at magna" +
"cursus congue. Nam eu sem eget enim pretium venenatis. Duis nibh ligula, lacinia ac nisi vestibulum," +
"vulputate lacinia tortor.";
private static final float IMAGE_WIDTH = Utils.dp2px(150);
private static final float IMAGE_OFFSET = Utils.dp2px(100);
private final TextPaint textPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
private final Paint.FontMetrics fontMetrics = new Paint.FontMetrics();
private final float[] cutWidth = new float[1];
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Bitmap bitmap;
public ImageTextView(Context context) {
this(context, null);
}
public ImageTextView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public ImageTextView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
textPaint.setTextSize(Utils.dp2px(15));
textPaint.getFontMetrics(fontMetrics);
bitmap = Utils.getBitmap(getResources(), (int) Utils.dp2px(150));
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawBitmap(bitmap, getWidth() - IMAGE_WIDTH, IMAGE_OFFSET, paint);
int length = text.length();
float fontSpacing = textPaint.getFontSpacing();
float verticalOffset = fontSpacing;
for (int start = 0, count; start < length; start += count, verticalOffset += fontSpacing) {
float usableWidth; // 可绘制文本的宽度
float textTop = verticalOffset + fontMetrics.top;
float textBottom = verticalOffset + fontMetrics.bottom;
if (textTop > IMAGE_OFFSET && textTop < (IMAGE_OFFSET + bitmap.getHeight())
|| textBottom > IMAGE_OFFSET && textBottom < (IMAGE_OFFSET + bitmap.getHeight())) {
// 文字在图片区域,文字要减去图片绘制区域宽度
usableWidth = getWidth() - IMAGE_WIDTH;
} else {
// 文字不在图片区域,绘制到 View 宽度
usableWidth = getWidth();
}
// 返回可以截止绘制的位置count,用于这一行文本绘制的结束位置
// measureForward:是否从左往右测量
// measureWidth:当前行截取完换行时,这一行还有剩余的像素
count = textPaint.breakText(text, start, length, true, usableWidth, cutWidth);
canvas.drawText(text, start, start + count, 0, verticalOffset, textPaint);
}
}
}