摘要
Android 开发的过程中,经常会遇到一些数量统计然后使用一个角标来给用户提示数量,例如微信的消息数量,当当的购物车商品数量等;
分析
实现的方式有很多种,这里采用自定义控件的方式来实现(可以在所需要用到此功能的控件上进行扩展)。
我们可以自定义一个控件,继承自我们需要用到的控件(RadioButton,TextView等),然后我们只需要重写 onDraw(Canvas canvas)
方法,当然如果你想在布局文件中就对数字提示进行一些初始化的操作(背景颜色,位置,文本颜色等),我们可以通过自定义属性,在 attr 文件里面声明然后在 在含有AttributeSet
参数的构造方法里面获取自定义属性的相关的值。
重写 onDraw(Canvas canvas)
的关键在于找到需要绘制圆形(也可以是其它形状,使用canvas.drawPath()
)的圆心,然后就是如何将文本绘制在我们绘制的圆的中间位置,这里我们使用的是canvas.drawText(String text, float x, float y, Paint paint)
这个方法,具体使用下面会详细分析。
效果演示
先上个效果图:
实现过程
- 新建一个类继承我们的目标控件(这里我们选用
TextView
) - 自定义属性
这里我们需要对圆形的背景颜色,半径,以及文本的颜色,大小等属性进行定义。
- 获取之定义属性的值
TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.BadgeTextView);
mBadgeColor = array.getColor(R.styleable.BadgeTextView_badgeColor, DEFAULT_BADGE_COLOR);
mBadgeRadius = (int) array.getDimension(R.styleable.BadgeTextView_badgeRadius, 0);
mBadgeNumber = array.getInt(R.styleable.BadgeTextView_badgeNumber, 0);
mBadgeNumberColor = array.getColor(R.styleable.BadgeTextView_badgeNumberColor, DEFAULT_BADGE_NUMBER_COLOR);
mBadgeNumberSize = (int) array.getDimension(R.styleable.BadgeTextView_badgeNumberSize, 0);
// 及时回收资源
array.recycle();
- 重写
onDraw(Canvas canvas)
方法
关于在super.onDraw();
前面的一堆代码,主要是让TextView
的 drawable 图片以及文本在控件的上下左右居中显示,具体的我都在代码上写明了注释,大致的意思就是计算文本的内容以及图片的尺寸,然后对 Canvas 画布对象进行 tanslate 平移操作,使内容在控件允许显示的范围内居中,我们直接看代码:
@Override
protected void onDraw(Canvas canvas) {
// 获取控件的宽高
mWidth = getMeasuredWidth();
mHeight = getMeasuredHeight();
// badge 圆心的坐标
int cx = 0, cy = 0;
// 获取 TextView 的Drawable 对象,这里我们需要通过计算,得到 drawable 的高度/宽度
Drawable[] drawables = getCompoundDrawables();
if (drawables != null) {
Drawable drawable;
if ((drawable = drawables[0]) != null) { // drawableLeft
// 设置文本垂直对齐
setGravity(Gravity.CENTER_VERTICAL);
// 左边的 Drawable 不为空时,计算需要绘制的内容的宽度
float textWidth = getPaint().measureText(getText().toString());
int drawablePadding = getCompoundDrawablePadding();
int drawableWidth = drawable.getIntrinsicWidth();
// 计算总内容的宽度
float bodyWidth = textWidth + drawablePadding + drawableWidth;
// 移动画布
canvas.translate((getWidth() - bodyWidth) / 2, 0);
// 计算圆心的位置,(注:这里可以根据需求,移动圆心的位置,也可以设置成一个参数来调整位置,这样就不需要翻代码了)
cx = drawableWidth;
cy = mHeight / 2 - drawable.getIntrinsicHeight() / 2;
} else if ((drawable = drawables[1]) != null) { // drawableRight
// 设置文本水平对齐
setGravity(Gravity.CENTER_HORIZONTAL);
// 上面的drawable 不为空时,计算需要绘制的内容的高度
Rect rect = new Rect();
getPaint().getTextBounds(getText().toString(), 0, getText().toString().length(), rect);
int textHeight = rect.height();
int drawablePadding = getCompoundDrawablePadding();
int drawableHeight = drawable.getIntrinsicHeight();
// 计算总内容的高度
float bodyHeight = textHeight + drawablePadding + drawableHeight;
canvas.translate(0, (getHeight() - bodyHeight) / 2);
// 计算圆心的位置,(注:这里可以根据需求,移动圆心的位置,也可以设置成一个参数来调整位置,这样就不需要翻代码了)
cx = (mWidth + drawable.getIntrinsicWidth()) / 2;
cy = mBadgeRadius / 2;
}
}
super.onDraw(canvas);
drawBadge(canvas, cx, cy);
}
/**
* 绘制 Badge
*
* @param canvas
* @param cx
* @param cy
*/
private void drawBadge(Canvas canvas, int cx, int cy) {
if (mBadgeNumber <= 0) { // 如果显示的数量 < 1,则不需要绘制
return;
}
// 设置画圆的颜色
mPaint.setColor(mBadgeColor);
// 绘制圆
canvas.drawCircle(cx, cy, mBadgeRadius, mPaint);
// 将需要绘制的文本转换成字符串,如果超过三位数,则使用省略号替代
String badgeContent = mBadgeNumber < 100 ? String.valueOf(mBadgeNumber) : "···";
// 设置文本的大小
mPaint.setTextSize(mBadgeNumberSize);
// 设置文本的颜色
mPaint.setColor(mBadgeNumberColor);
// 计算包裹此字符串的矩形
Rect rect = new Rect();
mPaint.getTextBounds(badgeContent, 0, badgeContent.length(), rect);
// 设置文本的对齐方式(Paint.Align.LEFT, Paint.Align.CENTER, Paint.Align.RIGHT)
mPaint.setTextAlign(Paint.Align.CENTER);
// 计算文本的基线
Paint.FontMetrics metrics = mPaint.getFontMetrics();
float ascent = metrics.ascent;
float descent = metrics.descent;
double centY = (descent - ascent) / 2;
float baseLineY = (float) (ascent + centY);
mPaint.setStyle(Paint.Style.FILL);
mPaint.setStrokeWidth(1);
// 绘制文本
canvas.drawText(badgeContent, cx, cy - baseLineY, mPaint);
}
代码的主要功能差不多都打了注释,差不多就是让 TextView 的 drawable 图片同文本一起居中显示(这里主要写了 drawableLeft()
和 drawableTop()
这两个基本上算是 TextView 里面用的比较多的),然后就是计算 圆心的位置,这里需要注意的是 Canvas 对象有过 translate(x,y) 平移操作,此时,屏幕的左上角坐标不再是(0, 0),而是(-x,-y),因此计算圆心是基于平移以后的坐标。
接下来就是绘制 Badge 相关的操作:
- 首先需要判断数字如果 < 1 则直接返回,不需要任何绘制,其次,数字如果是 > 99,即三位数,我们应当使用符号来替代 可以使用
99+
或者···
来表示; - 绘制圆形背景,这个简单,圆心我们已经计算好了;
- 拿到要绘制的文本后我们需要对文本的宽高进行计算,Paint 类里面给我们提供了一个方法
mPaint.getTextBounds(String text, int start, int end, Rect bounds)
计算的结果会保存在我们传入的一个 矩形(Rect) 中,调用此方法需要在设置好text一些参数后调用。 - 设置文本的对齐方式,具体的三种在注释上已经写明了,具体使用我们将会下一个模块分析;
- 计算文本绘制的基线,我们也将在下一个模块来分析;
-
最后就是我们的文本绘制了;
到这里,我们的代码层面基本算是完成了,只需要调用并设置值就可以达到上面截图的效果了,关于文本的对齐方式,基线的寻找我们接下来会详细分析;
drawText(String text, float x, float y, Paint paint)
这个方法相信很多人都用过,但是经常用的很头疼,如果对参数不了解的经常会遇到绘制的结果与预想的出入很大,接下来我们重点来看下这个方法;
先看下google提供的参数说明:
第一个第四个参数肯定没有问题,分别是需要绘制的文本和绘制文本的画笔,我们看下其它两个参数:
- x: 文本绘制的原点的 x 坐标
- y: 文本绘制的基线的 y 坐标
卧槽原点是啥,基线又是啥 @A@;
我们先来解释下 x 参数,细心的朋友可能已经注意到了前面我们使用到过一个方法
mPaint.setTextAlign()
,同样我们看下 google 给我们提供的文档:
大致的意思是说 设置需要绘制的文本的对齐方式,他控制了文本相对于其原点的位置, 左对齐表示所有的文本都会被绘制在原点的右边(即,原点决定了文本的左边缘)等; 很显然这个方法已经告诉我们原点是什么,差不多就是一个绘制时我们需要对齐的参照点,接下来我们再看下方法的参数,Paint.Align :
/**
* Align specifies how drawText aligns its text relative to the
* [x,y] coordinates. The default is LEFT.
*/
public enum Align {
/**
* The text is drawn to the right of the x,y origin
*/
LEFT (0),
/**
* The text is drawn centered horizontally on the x,y origin
*/
CENTER (1),
/**
* The text is drawn to the left of the x,y origin
*/
RIGHT (2);
private Align(int nativeInt) {
this.nativeInt = nativeInt;
}
final int nativeInt;
}
源码很简单就是一个枚举类型,而且只有三个实例,分别表示原点为字符串的左侧,中间,右侧;三种对齐方式我们都测试一遍:
新建一个文件 DrawView ,直接继承自 View,设置 把 x 值都设置为控件的中心,只改变对齐方式:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
mWidth = getMeasuredWidth();
mHeight = getMeasuredHeight();
// 在水平和垂直方向上分别绘制中线
mPaint.setColor(Color.BLACK);
canvas.drawLine(mWidth / 2, 0, mWidth / 2, mHeight, mPaint);
canvas.drawLine(0, mHeight / 2, mWidth, mHeight / 2, mPaint);
// 绘制文本
mPaint.setColor(Color.RED);
String text = "Thinking In Java";
canvas.drawText(text, mWidth / 2, mHeight / 2, mPaint);
}
显然 x 参数就是表明了要绘制文本的对齐方式,而且使用方式非常的简洁;
关于 y 参数,我们先来看一张图片
有莫有一种很熟悉的感觉,一般来说每个英文对应着四条横线,而且都是以第三条线条为基准,然后在根据每个字符的规则写上对应的字符,这条线(第三条线)就类似我们的第三个参数 y(baseline),那么我们该如何寻找到这条线,先来看一个 Paint 的静态内部类, FontMetrics,我们可以通过我们定义的 Paint 来获取此对象:
/**
* Class that describes the various metrics for a font at a given text size.
* Remember, Y values increase going down, so those values will be positive,
* and values that measure distances going up will be negative. This class
* is returned by getFontMetrics().
*/
public static class FontMetrics {
/**
* The maximum distance above the baseline for the tallest glyph in
* the font at a given text size.
*/
public float top;
/**
* The recommended distance above the baseline for singled spaced text.
*/
public float ascent;
/**
* The recommended distance below the baseline for singled spaced text.
*/
public float descent;
/**
* The maximum distance below the baseline for the lowest glyph in
* the font at a given text size.
*/
public float bottom;
/**
* The recommended additional space to add between lines of text.
*/
public float leading;
}
源码非常简单,我们主要看下 ascent
和 descent
两个成员变量的注释,大致上的意思是 根据基线,系统推荐的顶部间距和底部间距,看到这里我们在结合之前的英文字母书写规范来理解下这两个参数,ascent
类似于第一条线,descent
类似于第四条线,我们在屏幕上绘制一下这两条线,同时获取下这两个参数的值
可以看出
ascent
< 0 ,
descent
> 0,根据显示器的坐标规则,我们就可以理解为什么了,在测量的时候我们没有指定 baseline 的值,系统默认为0,
ascent
位于baseline的上面,因此是负数,同理
descent
就为正数。
我们在对齐方式的
onDraw()
方法最下面添加以下几行代码:
@Override
protected void onDraw(Canvas canvas) {
// ... 省略对齐方式中的代码
Paint.FontMetrics metrics = mPaint.getFontMetrics();
float ascent = metrics.ascent;
float descent = metrics.descent;
Log.e("TAG", "ascent = " + ascent + " descent = " + descent);
mPaint.setStrokeWidth(3);
mPaint.setColor(Color.BLUE);
// 这里我们指定了 baseline 为 mHeight/2,因此 ascent 需要加上 mHeight/2,descent 同理
canvas.drawLine(0, ascent + mHeight / 2, mWidth, ascent + mHeight / 2, mPaint);
mPaint.setColor(Color.GREEN);
canvas.drawLine(0, descent + mHeight / 2, mWidth, descent + mHeight / 2, mPaint);
}
我们再来看下效果图,嗯哼,好像是那么一回事了:
到这里,我想计算出 baseline 的值应该就不是什么难事了,当然 FontMetrics 类里面还有两个参数,
top
和
bottom
,指的是允许绘制的最大高度和最大的底部,个人理解是数字和英文字符使用
ascent
和
descent
这两个值就够了:
distanceY = (descent - ascent)/ 2 + ascent = (descent + ascent) / 2;
或:
distanceY = (bottom- top)/ 2 + top= (bottom+ top) / 2;
此时的distanceY<0;
通过打印的日志我们可以看出,上面公式计算出来的是
ascent
和
descent
两条线围成的矩形的中心点到 baseline 的距离,而且是个负数,回到我们最开始的需求,在圆内绘制文本,这里的中心肯定就是我们的圆心,中心点的坐标我们已经得到,那么 基线的坐标就等于我们计算出来的|distanceY|+圆心的坐标,即:
baseline = cy + |distanceY|;
或:
baseLine = cy - distanceY;
如果看到这里,那么恭喜你,我扯淡结束了 @A@!