手把手教你自定义View(一):实现QQ运动界面
最近好长一段时间都没有写博客了,这段时间一直在学习自定义View,把任玉刚的《Android开发艺术探索》自定义View章节看了好几遍,决心写篇博客记录一下,巩固一下知识点。今天给大家带来的是QQ运动界面的实现,先看效果图。
![](https://img.haomeiwen.com/i8744053/ee91d6dfefdec8d2.gif)
可以设置字体的颜色,步数。接下来我们一起来看看是怎么实现的把,大体上分为以下四个步骤:
- 自定义View属性
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--自定义view的属性-->
<declare-styleable name="MySportView">
<!--黄色圆环 颜色-->
<attr name="yellowRingColor" format="color"></attr>
<!--红色圆环 颜色-->
<attr name="redRingColor" format="color"></attr>
</declare-styleable>
</resources>
依次定义了黄色圆环和红色圆环的颜色,name是该属性的名字,format是该属性的取值类型,比如颜色是color,字体大小是dimension等等。接下来就是在我们的布局文件中申明我们自定义的属性了。
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:mySportView="http://schemas.android.com/apk/res-auto"
>
<my.zzg.qq.View.MySportView
android:id="@+id/mySportViw"
android:layout_width="match_parent"
android:layout_height="wrap_content"
mySportView:redRingColor="@color/redRing"
mySportView:yellowRingColor="@color/yellowRing"
/>
</LinearLayout>
注意千万不要忘记引入我们的命名空间, xmlns:mySportView="http://schemas.android.com/apk/res-auto"。
自定义了View的属性后,接下来就要获取自定义View的属性了。
- 获取自定义View属性。
public MySportView(Context context) {
this(context, null);
}
public MySportView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public MySportView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
this.context = context;
TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.MySportView, defStyleAttr, 0);
int indexCount = typedArray.getIndexCount();
for (int i = 0; i < indexCount; i++) {
int index = typedArray.getIndex(i);
switch (index) {
case R.styleable.MySportView_redRingColor:
// 默认给 黑色
redRing = typedArray.getColor(index, Color.BLACK);
break;
case R.styleable.MySportView_yellowRingColor:
yellowRing = typedArray.getColor(index, Color.BLACK);
break;
}
}
typedArray.recycle();
init();
}
自定义View需要我们去实现三个构造方法。需要注意的地方:
![](https://img.haomeiwen.com/i8744053/e538fb0f117bb36d.png)
第一个构造函数: 当不需要使用xml声明或者不需要使用inflate动态加载时候,实现此构造函数即可,一般情况下,我们在代码中生成控件使用。
第二个构造函数: 当需要在xml中声明此控件,则需要实现此构造函数,并且在构造函数中把自定义的属性与控件的数据成员连接起来。
第三个构造函数:在第二个构造函数的基础上,接受一个style资源 。
我们可以看到,这三个构造函数之间是一种递进的关系,所以我们在第三个构造函数中获取自定义View的属性了。
第一步通过theme.obtainStyledAttributes方法获得自定义控件的主题样式数组。
public TypedArray obtainStyledAttributes(AttributeSet set,
@StyleableRes int[] attrs, @AttrRes int defStyleAttr, @StyleRes int defStyleRes) {
return mThemeImpl.obtainStyledAttributes(this, set, attrs, defStyleAttr, defStyleRes);
}
我们需要关注一下第二个参数,第二个参数的意思是想要获取的属性集合,也就是我们自定义的View的属性集合。
第二步去遍历这个主题样式数组,获取属性值,也就是我们在xml文件中所写的属性值。
第三步在循环结束的时候,要调用typedArray.recycle()进行资源的回收。
第四步在获取到自定义View的属性值后,去做一些必要的初始化工作。比如初始化画笔颜色等等,需要注意的地方是,不要在onDraw()方法里去做初始化工作,因为onDraw()方法是一个频繁操作的过程,如果在里面频繁的new对象会造成大量的内存浪费,不可取。
- 确定View的大小,重写onMeasure()方法。
如果我们没有重新onMeasure()方法的话,那么系统会调用其默认的onMeasure()方法。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
该方法的作用是测量控件的大小,系统在加载布局文件的时候,会测量各子View的大小,来告诉父View,我需要占用多大空间,父View根据自己的大小分配空间给子View。
为了更好的理解测量的这个过程,我们还需要理解一下 MeasureSpec,MeasureSpec代表了一个32位int值,高2两位代表SpecMode,低30位代表SpecSize。SpecMode是指测量模式,而SpecSize表示在某种测量模式下的大小。
SpecMode模式一共有3种:
MeasureSpec.EXACTLY:父容器已经检测到了子View所需要的精确的大小值,这时子View的最终大小值就是SpecSize所指定的值。一般是在布局文件中设置了明确的数值或match_parent
MeasureSpec.AT_MOST:子视图的大小最多是specSize中的大小;表示子布局限制在一个最大值内,一般为WARP_CONTENT
MeasureSpec.UNSPECIFIED:父视图不对子视图施加任何限制,子视图可以得到任意想要的大小,一般用于系统内部。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int width, height;
if (heightMode == MeasureSpec.EXACTLY) {
// 如果设置了明确值,最终的高就是这个明确值;如果布局中设置的是match_parent,最终的高就是父布局的大小。
height = heightSize;
} else {
// 反之,最终的高为布局的3/4
height = heightSize * 3 / 4;
}
if (widthMode == MeasureSpec.EXACTLY) {
width = widthSize;
} else {
width = widthSize * 1 / 2;
}
mWidth = width;
mHeight = height;
setMeasuredDimension(width, height);
}
我这里高为布局的高的3/4,宽为布局的1/2,具体情况因人而异,最后调用setMeasuredDimension()方法。
- onDraw进行绘制。
绘制View的方法。我们分析我们的View,首先要绘制两个圆弧,先绘制黄色的圆弧,接着绘制红色的圆弧,接着绘制文字。我们一步一步分析。
/***
* 绘制红色圆弧度
* @param canvas
*/
private void drawRedRing(Canvas canvas) {
RectF rectf = new RectF(mWidth * 1 / 4, mWidth * 1 / 4, mWidth * 3 / 4, mWidth * 3 / 4);
canvas.drawArc(rectf, startAngle, currentAngle, false, redRingPaint);
}
绘制圆弧首页要知道圆弧的范围,drawArc()方法接收五个参数,第一个参数的该圆弧所在圆的外接矩形的坐标,第二个参数是圆弧开始的角度,第三个参数是圆弧张开的角度大小,第四个参数为true时,表示在绘制圆弧时,同时绘制圆弧到圆心的连线,通常用来绘制扇形,我们这里传false,第五个参数是画笔。
/***
* 绘制黄色圆环
* @param canvas
*/
private void drawYellowRing(Canvas canvas) {
RectF rectf = new RectF(mWidth * 1 / 4, mWidth * 1 / 4, mWidth * 3 / 4, mWidth * 3 / 4);
canvas.drawArc(rectf, startAngle, sweepAngle, false, yellowRingPaint);
}
绘制黄色圆弧,圆弧的张开角度是一个动态的过程,它的大小随着步数的增加而发生动态变化。
/***
* 绘制 '步数' 这两个字
* @param canvas
*/
private void drawStepText(Canvas canvas) {
// 设置文字可以水平居中显示
canvas.drawText(totalStep,(mWidth-mBound.width())/2,mWidth*1/2+100,stepTextPaint);
}
/***
* 绘制 一共走了多少步数
* @param canvas
*/
private void drawText(Canvas canvas) {
canvas.drawText(currentStepText,(mWidth-mTextBound.width())/2,mWidth*1/2+50,textPaint);
}
绘制文字就显得简单多了,计算好绘制的文字的位置就好了。
在进行重写onDraw()方法的时候,我们需求明确View的坐标位置,然后分析需要调用哪些方法去绘制,一步一步的去绘制,把逻辑搞清楚了,绘制出来应该不难。
- 关于动画的实现
/***
* 执行 红色圆弧 动画
* @param
*/
private void startAnimation(float start, float end, int duration) {
Log.i("当前",end+"");
ValueAnimator valueAnimator = ValueAnimator.ofFloat(start, end);
valueAnimator.setDuration(duration);
valueAnimator.setTarget(currentAngle);
valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
currentAngle = (float) animation.getAnimatedValue();
Log.i("当前的进度",currentAngle+"");
postInvalidate();
}
});
valueAnimator.start();
}
/**
* 执行文字的动画
* @param start
* @param end
* @param duration
*/
private void startTextAnimation(int start, int end, int duration) {
Log.i("当前",end+"");
ValueAnimator valueAnimator = ValueAnimator.ofInt(start, end);
valueAnimator.setDuration(duration);
valueAnimator.setTarget(currentAngle);
valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
currentStepText = (int)animation.getAnimatedValue()+"";
Log.i("当前的进度",currentStepText+"");
postInvalidate();
}
});
valueAnimator.start();
}
这里我采用了属性动画,只需要设置好开始值和一个结束值,并设置动画监听,就能够得到变化的值,并且调用postInvalidate()方法进行重绘,在onDraw方法里进行数值的改变。
最后,我们在View里写一个设置数据的方法供Activity调用:
/***
* 设置所走的步数
* @param totalNum 所有的步数
* @param currentNum 当前的步数
*/
public void setData(int totalNum, int currentNum) {
currentStepText = currentNum+"";
textPaint.getTextBounds(currentStepText,0,currentStepText.length(),mTextBound);
float percent = (float) currentNum/totalNum;
currentAngle = percent*sweepAngle;
Log.i("当前的",currentAngle+"");
startAnimation(0,currentAngle,duration);
startTextAnimation(0,currentNum,duration);
}
然后在Activity里调用:
protected void onCreate(@Nullable Bundle savedInstanceState)
{
setContentView(R.layout.qq_sport_activity);
super.onCreate(savedInstanceState);
mySportView = (MySportView) findViewById(R.id.mySportViw);
mySportView.setData(4066,997);
}
自己根据情况设置值就好了。
总结
走一遍流程下来,我们发现自定义View并没有想象中的那么复杂,我们需要走好其中的几个关键的步骤,第一,测量View的大小,根据自己的情况,选择是wrap_content还是具体的数值还是 match_parent,重新onMeasure()方法,第二,重新onDraw()方法,根据你要绘制什么View,调用不同的绘制方法,需要注意的是View的坐标。只要我们多加练习,就没有什么View能够难得住我们的,加油!
后续代码我会更新到Github上去,欢迎下载。