这一篇是记录一下本猿自定义View的一般思路,通过一个炒鸡简单的自定义ProgressBar讲解一些自定义View的基础知识。适合新手,高手勿喷,有好的指点和想法的欢迎评论。
(1)确认需求
写一个自定义View,首先你要确定需求是什么。一般包括外观,事件处理,动画效果。
外观需求:ProgressBar的外观需求非常简单,就是两个矩形(当然也可以是其它形状,这里我们只实现基础的矩形)重叠显示。其中一个固定大小的当背景,一个可变宽度的来显示刻度。
事件处理:ProgressBar不是SeekBar,只是单纯的显示刻度,所以并没有触摸事件处理的需求,只有一个动态改变刻度的事件要处理。
动画效果:也没有,说了炒鸡简单。
(2)确认要继承的父类
通过分析需求,然后决定是继承现有的控件还是继承View类。
继承现有控件一般是因为现有控件大体符合我们的需求,只是有少量不符合,所以我们对现有控件去做扩展。比如自定义圆角图片控件可以去继承ImageView控件,然后在draw的过程加一层圆角的遮罩层就行了。这样我们即可以保留ImageView的原有特性,又达到我们的目的。
继承View类一般是因为并没有适合的原有控件去继承,比如我们想自定义一个抽奖轮盘。
这里我们的ProgressBar要继承View。
不要问我为什么不继承系统的ProgressBar,汗......
这里我们写的ProgressBar只是学习目的,系统的ProgressBar可以看成google工程师们实现的自定义View。
(3)初始化
这一步呢是对View的初始化,之前我在其它博文说过,可以把View及其子类当成普通的Java类。它的特别之处在于系统和View及其子类有一套固定的互动流程,像系统
会去调用View的构造方法新建实例对象,
会去调用measure(int,int)方法让View去测量自身大小,
会去调用layout()方法让View去布局子View相对自身的位置,
会去调用draw()方法去让View将自己画在屏幕的特定范围上。
如果没有系统去做这些事,那么View类只是个普通的Java类。
(这里的系统其实是指系统服务WindowManageService,我们这里不深入)
所以呢首先我们要重写构造方法,View类有3个重载的构造方法,对应不同的场景。一般类似于下面这样:
public BaseProgressBar(Context context) { this(context, null); } public BaseProgressBar(Context context, AttributeSet attrs) { this(context, attrs, 0); } public BaseProgressBar(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(); }
(1)第一个只有一个context参数的构造方法是在代码动态生成时会被调用,当然为了保持代码逻辑和视图的解耦。我们应该尽可能在xml文件中构造视图。
(2)第二个构造方法就是在xml文件中定义时会被调用,其中参数attrs就保存了我们在xml文件中赋于的属性值,我们需要从attrs中读取出这些属性值。这些属性可以是View原有的已定义属性,也可以是我们的自定义属性。调用super方法时,super会去读取一些通用的属性值并保存下来,比如margin、padding和background等。然后呢我们可以再去读取自身特有的属性值,比如TextView有textSize、textColor等。这里我们并没有去自定义属性,不了解的可以自己去查一下,只需要知道等会我们实现的时候那些画笔颜色什么的都是可以通过自定义属性在xml中赋值的。
(3)第三个构造方法留坑,我也没去查过在什么场景下会被调用,好像是在设置了主题样式的时候。
接下来我们要在init()方法中对其它我们需要用到的东西进行初始化,我们需要画两个矩形,所以对应我们要初始化两个矩形及画矩形的画笔:
private void init() { backRectF = new RectF(); fontRectF = new RectF(); paint1 = new Paint(); paint1.setAntiAlias(true); paint1.setColor(0XFFF0F0F0); paint2 = new Paint(paint1); paint2.setColor(0XFFD54321); }
这里可能有人会想着为什么不初始化两个矩形的四个坐标,背景矩形初始状态为(0,0,view的宽度,view的高度)且保持不变,前景矩形初始状态为(0,0,progress/max*view的宽度,view的高度)且随progress的变化而改变。
需要注意的是,构造方法是第一个被调用的方法,这个时候测量、布局等全都还没开始执行,view的宽高还没计算出来,所以矩形的坐标初始化必须在测量之后。
(4)测量自身大小逻辑
其实一般我习惯先写测量自身大小逻辑,画笔什么的需要再添加。测量自身大小逻辑几乎是必须重写的,除非你继承的是已有的控件,并且你的改变不会影响到控件的大小。
前面说过,系统会去调用measure(int,int)方法让View去测量自身大小,而在measure(int,int)方法又会去调用onMeasure(int,int)方法去进行测量。onMeasure(int,int)方法的作用就是不同的View子类都可以去重写这个方法来实现自己的测量逻辑。下面看下ProgressBar的onMeasure(int,int)方法应该差不多是怎么样的:
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int widthMode = MeasureSpec.getMode(widthMeasureSpec); int widthSize = MeasureSpec.getSize(widthMeasureSpec); int hightMode = MeasureSpec.getMode(heightMeasureSpec); int heightSize = MeasureSpec.getSize(heightMeasureSpec); switch (widthMode) { case MeasureSpec.EXACTLY: case MeasureSpec.AT_MOST: mWidth = widthSize; break; case MeasureSpec.UNSPECIFIED: mWidth = getMinimumWidth(); break; } switch (hightMode) { case MeasureSpec.EXACTLY: case MeasureSpec.AT_MOST: mHeight = heightSize > getMaxHeight() ? getMaxHeight() : heightSize; break; case MeasureSpec.UNSPECIFIED: mHeight = getMinimumHeight(); break; } setMeasuredDimension(mWidth, mHeight); onMeasureComplete(); }
onMeasure(int,int)方法有两个参数,这两个参数可以理解为父View对子View的大小建议。这个大小是根据子View在xml文件中设置的layout_width和layout_height以及父View自身的特性决定的。
EXACTLY:当子View为match_parent或具体数值的时候为这种模式,并且size为parent的size。
这种模式表示有确定的大小,一般我们直接采用parent的建议大小就行了
AT_MOST:当子View为wrap_content时为这种模式,size为parent的size。
我们一般就是要处理这种模式,而parent的size只是作为一个最大值限制。比如TextView的宽度设置为wrap_content时,它的宽度应该由其文字内容决定,并且不能超过parent的宽度。
UNSPECIFIED:这种模式表示不确定的,相当于parent告诉子View,你要多大多小都行,我不在意。这个一般是在parent为可滚动容器的情况下。比如ScrollView,ListView等,因为它们是可以垂直滚动的,所以它们对子View的高度不做限制,子View想多高都行。
其实大小的计算是综合parent的建议和View自身的特性决定的。比如TextView要依据文字内容的总宽度、文字高度、行数和行距等,ImageView要考虑图片的原图大小、宽高比例等。不同的控件要考虑的东西都不一样。
这里呢ProgressBar比较简单,
宽度:parent说多宽就多宽。如果parent是横向滚动View,给我们的建议是UNSPECIFIED,当然理论上我们可以要求无限宽,但这显然是不合理的,像TextView在这种情况,宽度呢为刚好能显示文字内容。而我们没有文字内容做为依据,所以可以定义一个最大宽度或最小宽度或默认宽度,随便你喜欢。
高度:高度呢可以设置一个最大值和一个最小值来防止在外观上不符合我们日常看到的ProgressBar,比如高度太小看起来像一条线,高度太大看起来像个巨型按钮。
重申一下,这里测量大小的逻辑只是我个人的理解,每个人的想法可能都不一样。比如我觉得ProgressBar不能太高,所以我就写成上面那样了,但你觉得ProgressBar的高度是可以充满屏幕的,那你可以去按照自己的逻辑去实现,只要不太天马行空,要考虑现实和人的视觉习惯。
(5)进一步初始化
测量完成后,View就有了宽度和高度了。于是前面那些需要View的宽高作为参数的初始化也可以进行了:
private void onMeasureComplete() { backRectF.left = 0; backRectF.right = mWidth; backRectF.top = 0; backRectF.bottom = mHeight; fontRectF.left = 0; fontRectF.right = mProgress * mWidth / mMax; fontRectF.top = 0; fontRectF.bottom = mHeight; }
(6)开始画
这里我们是自定义View而不是ViewGroup,所以不用重写onLayout去对子View布局,我们压根就没有子View。
两个矩形的画笔和坐标都初始化了,两条语句就可以画出来了:
protected void onDraw(Canvas canvas) { canvas.drawRect(backRectF, paint1); canvas.drawRect(fontRectF, paint2); }
跟onMeasure(int,int)方法类似,onMeasure(int,int)方法在测量时会被调用,而onDraw(Canvas canvas)方法在绘制时会被调用。
(7)事件处理
ProgressBar肯定不能永远刻度为0,所以要提供一个方法可以动态改变刻度:
public void setProgress(int progress) { if (progress > mMax) { progress = mMax; } if (this.mProgress == progress) { return; } this.mProgress = progress; fontRectF.right = mProgress * mWidth / mMax; invalidate(); }
这里先进行一些合理性判断:是否超过最大值和是否跟原来的一样。然后改变前景矩形的右边坐标,最后重绘请求方法:invalidate();系统收到invalidate();的请求就会去重绘我们的ProgressBar,onDraw()方法会被重新调用,宽度改变后的前景矩形将会被绘制出来。
这样,一个最基本简单的ProgressBar就完成了。
我这里的重点是自定义View的一般思路(我的思路),并不是为了实现多么酷炫的效果。
当然你可以在这个ProgressBar的基本思路上做很多扩展。
如:
(1)改成画圆角矩形,实现圆角ProgressBar。canvas.drawRoundRect()方法就是画圆角矩形的。同理,可以画圆形、扇形都可以。
(2)圆环ProgressBar,基本思路是画一个大的圆形,然后画一个同心扇形遮挡住一部分,最后画一个小点的同心圆。遮挡的角度由progress决定。
(3)渐变ProgressBar,指颜色渐变,原理也很简单,给画笔添加一个着色器就行了。
(注:着色器,我看别人这么翻译我就跟着说了,实际上就是用不同的方式给物体着色,方式有线性的、圆性的等)
linearGradient = new LinearGradient(0, 0, mWidth, mHeight, 0XFFFF00FF, 0X00FFFF, Shader.TileMode.MIRROR); paint2.setShader(linearGradient);
如上面设置会让我们的ProgressBar颜色呈线性变化。当然圆环的也可以增加颜色渐变,使用不同的着色器就行了。
(4)代码家写的一个NumberProgressBar,其实基本思路都一样,只是为了实现一些酷炫效果,要进行更多的计算。
可以先试着阅读,再尝试自己写。我自己是把它改成了垂直了,有兴趣的也可以自己试一下。
github地址:https://github.com/daimajia/NumberProgressBar
因为源码太简单,我就不上传了。如果你对自定义View不熟悉的话,建议你自己尝试一下实现我说的几种扩展。
我这里重点在讲思路,对于如何去实现的学习,推荐:
http://blog.csdn.net/aigestudio/article/category/2397181