评分控件在开发中算是使用率比较高的组件,Android自身也包含默认的评分控件,不过自带的评分控件可定制性并不高,现在就通过自定义View的方式来实现简单评分控件。自定义的评分控件继承自View类型,它需要覆盖View的三个构造函数:只带有Context类型的构造函数通常是开发者在代码中直接new创建,带有Context和AttributeSet的构造函数在LayoutInflater从XML中创建控件使用,其中AttributeSet就包含了开发者在XML里为控件指定的各种属性值,还有一个带有int defStyleAttr的构造函数在XML中开发者指定了style属性会被调用,没有指定的默认值为零代表使用默认的样式。
// RatingBar构造函数代码
public RatingBar(Context context) { // 直接new使用的构造函数
this(context, null); // 调用两个参数构造函数
}
public RatingBar(Context context, AttributeSet attrs) { // LayoutInflater创建时传入XML配置
this(context, attrs, 0); // 调用三参数构造函数
}
// LayoutInflater创建时传入XML配置和style配置
public RatingBar(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initAttributes(context, attrs);
init();
}
为了方便开发者使用控件的属性需要支持XML文件配置属性,也要支持代码直接配置控件的属性值。XML文件的属性需要在attrs.xml文件中定义属性名称和类型,在包含AttributeSet参数的构造函数中解析出定义的属性值。评分控件的属性值包括有评分时展示的图标图片,没有评分时展示的图标图片,评分控件的最大值,评分控件当前值和评分控件星星图片的间距。
<declare-styleable name="RatingBar">
<attr name="emptyImage" format="reference" />
<attr name="fullImage" format="reference" />
<attr name="maxValue" format="integer" />
<attr name="value" format="float" />
<attr name="starPadding" format="dimension" />
</declare-styleable>
解析XML属性值需要使用Context.obtainStyledAttributes()方法,它会从AttributeSet中解析出attrs.xml文件中定义的RatingBar内所有的属性值并封装到TypedArray对象中,注意TypedArray对象内部也使用了享元模式,获取到对象后一定要记得及时地回收,要获取属性值只需要调用TypedArray对应类型的方法并且传递该属性定义时的索引值就可以了。
// RatingBar解析XML文件配置属性
private void initAttributes(Context context, AttributeSet attrs) {
if (attrs != null) { // 在XML中定义的属性解析
TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.RatingBar);
Drawable emptyDrawable = array.getDrawable(R.styleable.RatingBar_emptyImage);
if (emptyDrawable instanceof BitmapDrawable) {
mEmptyBitmap = ((BitmapDrawable) emptyDrawable).getBitmap();
}
Drawable fullDrawable = array.getDrawable(R.styleable.RatingBar_fullImage);
if (fullDrawable instanceof BitmapDrawable) {
mFullBitmap = ((BitmapDrawable) fullDrawable).getBitmap();
}
mStarPadding = array.getDimensionPixelSize(R.styleable.RatingBar_starPadding,
mStarPadding);
mValue = array.getFloat(R.styleable.RatingBar_value, mValue);
mMaxValue = array.getInt(R.styleable.RatingBar_maxValue, mMaxValue);
array.recycle();
}
}
XML属性设置解析完成后还需要为直接代码设置,通常情况下修改了控件属性都需要及时刷新界面确保数据和展示保持一致,如果改变的属性与控件大小或位置有关系就需要调用requestLayout()申请重新布局和绘制,如果只与控件的展示内容有关系就只需要调用invalidate()把当前展示界面设置为非法要求控件重新绘制即可。在评分控件中最常用的就是设置当前的评分值,评分只与界面的展示相关,只需要调用invalidate()要求重新绘制就可以了。
// RatingBar设置评分值代码
public void setValue(float value) {
if (value < 0) {
mValue = 0;
}
if (value > mMaxValue) {
mValue = mMaxValue;
}
mValue = value;
Log.e(TAG, "value = " + mValue);
invalidate(); // 重新绘制评论条界面
}
属性值处理完后就要考虑控件的尺寸问题,measure()方法内部会做一些通用的工作,控件的实际测量工作通常都是在onMeasure()方法中进行的,它包含有两个参数widthMeasureSpec和heightMeasureSpec,它们虽然都是int类型但里面却包含了两种数据,前2个比特代表父控件提供的测量类型值,后面30个比特代表父控件提供的实际尺寸值。
// RatingBar测量展示尺寸
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int height = MeasureSpec.getSize(heightMeasureSpec);
if (heightMode == MeasureSpec.EXACTLY) {
if (height < DEFAULT_MIN_HEIGHT) {
height = DEFAULT_MIN_HEIGHT;
}
} else {
height = DEFAULT_MIN_HEIGHT;
}
int width = height * 5 + 4 * mStarPadding;
setMeasuredDimension(width, height); // 保存下测量值
}
MeasureSpec类是专门用来处理这类数据的封装类,MeasureSpec.getMode()获取前两个比特内的测量类型,主要包含三种类型MeasureSpec.EXACTLY精确测量值,MeasureSpec.AT_MOST控件尺寸最大值,MeasureSpec.UNSPECIFIED未指定值,需要控件自己决定需要多大的尺寸值。onMeasure()方法传递进来的测量规格参数是父控件根据自己的测量结果和子控件设置的LayoutParams布局参数共同确定下来的参考值,它只是提供参考并不代表一定要按照测量规格参数的值来设置控件尺寸,真正测量值在调用setMeasuredDimension()方法后才会真正的生效。在评分控件的测量中如果要求控件展示精确的高度而且不小于最小高度就使用精确值,如果要求AT_MOST或者UNSPECIFIED就使用默认高度值。评分控件内部的星星是正方形,高度值获取到了就知道星星的宽度值,评分控件中5个星星在加上它们中间的间隔补白就是控件的宽度值。
在构造函数中会调用初始init()方法,它内部主要负责初始化绘制相关的对象,想要在控件内部绘制图像就需要使用Paint画笔对象,设置画笔的抗锯齿和防抖动功能使得绘制出来的图片过度更加平滑不会出现很边缘锯齿和过度突兀的问题。由于onDraw()绘制方法会多次调用,在频繁调用的方法内部如果创建本地对象,会产生大量的垃圾对象导致GC操作频繁影响性能。因此onDraw() 方法中的局部对象都可以缓存在控件的属性中。
// RatingBar绘制评分星星
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int height = getMeasuredHeight();
int drawableHeight = (int) (height * (1 - 2 * PADDING_PERCENT));
int drawableWidth = drawableHeight;
int top = (int) (PADDING_PERCENT * height);
int left = 0;
float value = mValue / mMaxValue * 5.0f;
int full = (int) value;
int empty = full + 1;
for (int i = 0; i < full; i++) { // 绘制有分数的星星
left = i * (drawableWidth + mStarPadding);
mRect.set(left, top, left + drawableWidth, top + drawableHeight);
// 第二个参数为空代表绘制星星整体
canvas.drawBitmap(mFullBitmap, null, mRect, mPaint);
}
// 绘制有分数部分
float fullPart = value - full;
left = full * (drawableWidth + mStarPadding);
mRect.set(left, top, (int) (left + drawableWidth * fullPart), top + drawableHeight);
int realWidth = mFullBitmap.getWidth(), realHeight = mFullBitmap.getHeight();
// 只绘制星星mRect部分到mLeftRect区域,也就是灰色部分
mLeftRect.set(0, 0, (int) (realWidth * fullPart), realHeight);
canvas.drawBitmap(mFullBitmap, mLeftRect, mRect, mPaint);
// 绘制无分数部分
float emptyPart = 1.0f - fullPart;
left = (int) (left + drawableWidth * fullPart);
mRect.set(left, top, (int) (left + drawableWidth * emptyPart), top + drawableHeight);
// 只绘制星星mRect部分到mRightRect区域,也就是空白部分
mRightRect.set((int) (realWidth * fullPart), 0, realWidth, realHeight);
canvas.drawBitmap(mEmptyBitmap, mRightRect, mRect, mPaint);
// 绘制没分数的星星
for (int i = empty; i < 5; i++) {
left = i * (drawableWidth + mStarPadding);
mRect.set(left, top, left + drawableWidth, top + drawableHeight);
canvas.drawBitmap(mEmptyBitmap, null, mRect, mPaint);
}
}
onDraw()方法会传入Canvas画布对象,画布对象的drawBitmap()方法可以绘制位图对象。代码4-14展示了评分控件内部绘制评分星星的详细过程,绘制时对于整数类型的评分值可以简单的绘制整数个fullStar和整数个emptyStar,对于小数类型的评分值就稍微复杂一点,小数的整数部分需要绘制fullStar,比小数大的整数部分绘制emptyStar,小数值所在星星前半部分使用fullStar绘制,后半部分需要用emptyStar绘制。Canvas的绘制位图方法drawBitmap()有四个参数,第一个参数bitmap代表需要被绘制到画布上的位图,第二个参数srcRegion代表需要被绘制的bitmap区域,第三个参数dstRegion代表图片要被绘制的目标视图位置,最后的paint参数代表执行绘制时使用的画笔对象。
上图上半部分展示了只有整数评分值的情况,下半部分展示的是小数部分所在的星星绘制,星星的左边部分绘制的是有背景的星星部分,右边部分绘制的是没有背景的星星部分,需要注意星星图片大小realWidth和界面中绘制的星星大小drawableWidth是不完全相同的,图片的大小跟用户提供的素材有关系,而星星绘制的大小和视图测量大小有关系。有背景部分的宽度上占据整个星星的fullPart(value减去full)大小,它在星星图片中的宽度也就是(int) (realWidth * fullPart),可以确定要绘制的部分在整个带背景星星中的srcRegion为(0, 0, (int) (realWidth * fullPart), realHeight),接着考虑这部分的背景星星需要绘制到View视图的位置,前面已经展示了full个有背景星星,而且星星之间的距离为mStarPadding,绘制目标的左边位置为full * (drawableWidth + mStarPadding),右边位置为每个星星在视图中的宽度值乘以fullPart的值,最终目标dstRegion即为(full * (drawableWidth + mStarPadding), top, (int) (left + drawableWidth * fullPart), top + drawableHeight)。右部分无背景星星的绘制与之类似,不再赘述。
当用户用手指触摸评分控件时评分值会随着用户手指的位置而改变,当用户手指离开评分控件时评分值应该更新成用户手指最后接触地方所代表的的评分值。View.dispatchTouchEvent()方法就能够获取用户手指的触摸事件,可以把控件的内部分成五个矩形等分,用户手指在某个矩形内部就判定当前的评分值为所在矩形索引。
// RatingBar用户触摸交互代码
public boolean dispatchTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
Log.e(TAG, "x = " + x + ", y = " + y);
changeValue(x, y);
return super.dispatchTouchEvent(event);
}
private void changeValue(int x, int y) {
mTmpRect.set(0, 0, getWidth(), getHeight());
int height = getMeasuredHeight();
int drawableHeight = (int) (height * (1 - 2 * PADDING_PERCENT));
int drawableWidth = drawableHeight;
int left = 0;
Log.e(TAG, "total rect = " + mTmpRect.flattenToString());
if (mTmpRect.contains(x, y)) {
for (int i = 0; i < 5; i++) { // 判断用户触摸位置在哪个星星展示的位置
left = (drawableWidth + mStarPadding) * i;
mTmpRect.set(left, 0, left + drawableWidth, height);
Log.e(TAG, "current rect = " + mTmpRect.flattenToString());
if (mTmpRect.contains(x, y)) { // 用户手指在mTmpRect矩形内
float point = 1.0f * (x - left) / drawableWidth;
Log.e(TAG, "point = " + point);
float value = (i + point) / 5 * mMaxValue;
Log.e(TAG, "value = " + value);
setValue(value); // 设置评分控件的值
break;
}
}
}
}
以上就是自定义的评分控件实现,涉及到自定义属性,控件大小测量,控件内容绘制和用户交互等多个接口。
自定义评分控件Demo