1、前言
UI作为用户看得到的东西,已经成为吸引用户的最重要因素了。在android中提供了大量的widget以及主题和属性,加上各种动画,已经可以实现非常多很绚丽的控件了。但是很多情况下,仅仅使用系统提供给我们的控件,总是有那么点缺憾。即每个控件的存在都有自身的特定功能,当我们却是需要这些功能的时候,无疑是很好的选择,但如果我们不需要这些功能,但却需要其中的某些特性呢?这个时候,就需要我们自定义一个新的控件了。关于自定义控件,按照复杂程度,可以分成三大类,
①、仅仅继承某个已存在的widget重写部分方法。
②、组合多个已存在的widget组合成一个复合控件。
③、完全自定义控件,包括外观。
上面的最复杂的就是第三种,也是本文的重点内容。在了解如何自定义控件之前,我们首先得明白View是个什么玩意,它的实现机制是什么,有什么特点,才能更好的去掌握它。接下来就先了解一下View。
2、自定义View的基础知识
View是所有的layout和widget的基础,它是以矩形的形式在屏幕中占领着一定的空间,并且负责处理各种事件以及界面渲染。也就是说,无论是控件还是布局,它都是以矩形的形式在屏幕中出现的,但是通过改变形状,是可以修改控件可见的形状,但记得,它本质上还是矩形。而布局则是看不见的VIew,本质上也是一块矩形。为什么强调它是矩形,因为后面需要用的这个特征。
View根据用途,比如展示图片,展示文字,展示滚动内容.....可以分为很多种类,但是无论是什么类型的View,都有两种方式可以使用它们,一种是通过代码的形式,另外一种是通过布局文件的形式。因为布局文件的形式写法比较符合逻辑与视图的分离模式,并且用起来自由度也高,所以是比较推荐这种方式的。记住,无论你是通过哪种途径产生的View,它们最终都会被添加到视图树里面。这样有助于系统进行事件的拦截处理以及统一管理视图。
一旦我们建立了View,通常会对它进行一些基本的属性设置,监听器设置,或者是焦点处理。但是对于一个需要完全自定义View来说,这些不是重点,重点的是measure,layout,draw这三个方法。如果没有需要的话,前往不要调用这些方法,否则会破坏系统本身对视图的渲染。但如果确实又需要,就需要我们好好理解这些方法之间的联系,处理好界面渲染的关系。所以下面会重点讲解这些。
如果要自定义View,那么接下来的关于View的知识都是需要了解清楚的。虽然关于自定义View的知识很多,但是我们没必要都重写所有的有关自定义View的方法。比如,你可以单单重写onDraw方法,就可以实现一个自己绘图的View。接下来,了解关于会影响自定义View的方方面面。
2.1 View的创建
View的创建有两种形式,一种是代码形式,另外一种是xml布局文件形式,对于XML文件填充的View还需要处理xml里边写的相关属性。对于View的构造函数有以下几个:
1.public View(Context context)
2.public View(Context context, @Nullable AttributeSet attrs)
3.public View(Context context, @Nullable AttributeSet attrs, int defStyleAttr)
4.public View(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes)
第二种:用于通过Xml文件填充的View,AttributeSet是一个属性集,它包含了你在xml文件中指定的属性集合,记得在调用次构造函数的时候,必须调用父类的对应的构造函数,以便系统初始化视图的本身的样式风格。如果,我们自己写了属性集,就需要在这里获取到我们的TypeArray的对象,根据自己定义的stylable名称去获取属性值,以便后续处理。默认情况下,即你没有自定义属性的情况下,使用的是系统提供的主题和属性集。填充完View之后,会调用onFinishInflate()方法。
第三种:也是用于通过xml文件填充的View,但是这个可以指定一个主题风格。
第四种:在第三种的基础上,可以指定一种主题风格并指定它的主题资源。
通常我们只需要重写前面两个构造函数就ok的了。
2.2 View的布局过程
View的布局过程,主要有onMeasure,onlayout,ondraw,onsizechanger这几个方法,其中涉及到测量,布局,绘画各方面。所以很经常自定义View所花的时间就在这上面。同时这也是自定义View的重点所在。接下来我们了解一下这些方法。
2.2.1 onMeasure
这个方法会在View通过视图树去遍历它包括它的所有子View的尺寸要求的时候被调用。在了解onMeasure之前,我们先了解MeasureSpec。
2.2.1.1MeasureSpec
MesusreSpec是当前View的父容器传给它的关于父容器对它的尺寸的要求,这个要求是根据父容器的layoutParam和当前View的宽高综合给出的测量规格。每个MesureSpec都包含两方面的内容,一个是mode,一个是size。其中mode表示当前父容器对view的限制模式,size表示父容器给出的建议尺寸。
view的限制模式有三种:
①、UNSPECIFIED:即父容器不对当前View做任何限制,因此View可以使用任何尺寸。一般用于设置默认的尺寸。
②、EXACTLY:即父容器给当前View指定了一个确定的尺寸,无论你给View设置了什么值,view都将会是指定的尺寸。
③、AT_MOST:即父容器给出了一个最大的尺寸,view设置的尺寸大小不能超过这个范围。
根据上面三种描述,总结一下,假设现在有一个默认尺寸,一个父容器建议的尺寸。第一种情况,可以设置为任何你想要的值,第二种情况只能使用建议的尺寸,第三种情况只能使用小于建议尺寸的值。由于我们声明一个View的时候,一般都会指定宽高的大小,所以我们遇到的情况只会是第二第三种。第二种发生在在布局中指定了确定大小或者match_parent的情况,第三种发生在指定的大小是Wrap_content的情况。标准的使用方法如下:
public static int getDefaultSize(int size, int measureSpec) {
int result = size;
int specMode = MeasureSpec.getMode(measureSpec);
int specSize = MeasureSpec.getSize(measureSpec);
switch (specMode) {
case MeasureSpec.UNSPECIFIED:
result = size;
break;
case MeasureSpec.AT_MOST:
case MeasureSpec.EXACTLY:
result = specSize;
break;
}
return result;
}
2.2.2 onMeasure方法概况
OnMeasure方法在View需要确认它的内容和子View所占据的测量空间的时候调用。一般是measure方法的回调,所以,如果View有重写这个回调方法的话,一定要提供一个有效的测量值。即在根据测量的mode和size综合得出的测量高度和宽度的时候,一定要调用setMeasureDimensions方法将测量的宽高穿进去,以便父容器知道当前View的要求的宽高。此回调方法在View测量的过程中可能会被回调多次,因为有时候所有子View给的测量宽高的综合,当前View并不能满足,所以又需要重新测量一次。
2.2.3 onLayout布局过程
当View需要给所有的子View分配空间和大小的时候调用。一般我们把这个过程交给系统去做就好,layout分为两个过程,分别是measure过程和layout过程,前者用于遍历视图树中子View的所有尺寸要求。注意measure过程可能会不止调用一遍,因为view可以第一次用不确定的值去处理子View的尺寸要求,然后确定了所有View的要求之后,在第二次遍历得到一个确定的值。一旦onMeausre方法被回调之后,就可以获取测量的高度和宽度了。layout过程就是根据测量的尺寸要求,给所有的View包括他的子View进行尺寸分配以及位置的确定。
2.2.4 onSizeChanged
如果需要在布局过程中判断View的尺寸是否发生变化,那么就可以重写这个方法了。这个方法包含四个形参,分别是旧的宽高和现在的宽高。
2.2.5 onDraw渲染内容
当View进行内容的渲染的时候,就会调用此方法。此方法会将View的内容和它的子View在给定的Canvas对象里面进行渲染。注意,调用此方法之前布局必须先确定下来。
2.2.5.1 canvas的理解
前面提到draw的时候,是在canvas上进行绘画的,那么现在来了解一下canvas是什么,可以干什么。
canvas是来承受绘画的内容的,所有在View中呈现的内容,都是先渲染到canvas中,然后再由canvas写进bitmap里面,然后就呈现出来了。因此canvas含有大量的绘制图形,文本的方法,并且还可以进行图形的旋转,移动,放大缩小......即所有相关的绘制方法都包含在这里面了。
2.2.5.2 canvas指定背景
public void setBitmap(@Nullable Bitmap bitmap)
此方法会将指定的bitmap对象作为canvas的背景图,同时,canvas的像素密度也会更新以匹配当前的bitmap的像素密度。
2.2.5.3 获取canvas的宽高
public void setBitmap(@Nullable Bitmap bitmap)
public void setBitmap(@Nullable Bitmap bitmap)
获取当前绘制图像的宽高。
2.2.5.4 canvas的像素密度
public int getDensity()
public void setDensity(int density)
获取像素密度的时候,如果有设置背景图层,则返回的是背景图层的像素密度。如果有设置像素密度,则返回设置的值,如果都没有,则返回一个0,表示没有指定任何像素密度。设置像素密度会影响背景图像的像素密度,同时也会影响canvas的像素密度。
2.2.5.5 canvas状态的保存与恢复
public int save()
public void restore()
sava用于保存当前canvas的状态,restore用于恢复。注意,一定要注意,sava和restore的数量一定要对等,如果restore的调用数次大于save的话,会导致异常。现在述说报攒canvas的状态有什么作用。由于canvas可以执行旋转,移动等影响画布的操作,因此后续的操作都是会基于画布当前的状态来进行的。比如:我现在将画布旋转90读,然后再在画布里画一个矩形,它也是出于90度的状态的。但如果我们在旋转画布之前调用save方法进行状态保存,在旋转画布之后,再调用restore方法,会将画布的旋转状态去除,此后再次画布上的操作就不会受旋转的影响了。后面会有详细的例子说明。
2.2.5.6 对canvas进行旋转移动等操作
移动
public void translate(float dx, float dy)
表示会将当前的画布在x方向是移动dx距离,y方向上移动dy距离。
缩放比例
public void scale(float sx, float sy)
public final void scale(float sx, float sy, float px, float py)
上述两个方法都是用于将当前的画布进行放大缩小,sx,sy表示的是在x,y方向上的缩放比例,px,py表示的是缩放围绕的中心点坐标。
旋转
public void rotate(float degrees)
public final void rotate(float degrees, float px, float py)
将当前画布进行旋转,degress别是旋转的角度,px,py是旋转的中心点坐标。
倾斜
public void skew(float sx, float sy)
将当前画布进行倾斜,sx,sy表示倾斜的范围。
2.2.5.7 对画布进行颜色填充
public void drawRGB(int r, int g, int b)
public void drawARGB(int a, int r, int g, int b)
public void drawColor(@ColorInt int color)
public void drawPaint(@NonNull Paint paint)
2.2.5.8 对画布进行画点
public void drawPoints(@Size(multiple=2) float[] pts, int offset, int count,
@NonNull Paint paint)
public void drawPoints(@Size(multiple=2) @NonNull float[] pts, @NonNull Paint paint)
public void drawPoint(float x, float y, @NonNull Paint paint)
以上方法可以在画布上画小点,pts保存着所以的点的x,y坐标,比如pts[0],pts[1],pts[2].pts[3]分别表示第一第二个点的坐标,offset表示要跳过的数量,比如1,则从pts[2]开始计算。count表示画的点的数量,paint有两个作用,一个是控制点的形状,通过setStrokeCap方法控制,另外一个是paint的strokeWidth是控制点的直径的大小,如果是矩形则是宽。第三个方法是之花一个点。
2.2.5.9 对画布进行画线
public void drawLine(float startX, float startY, float stopX, float stopY,
@NonNull Paint paint)
<pre name="code" class="java">public void drawLines(@Size(min=4,multiple=2) float[] pts, int offset, int count, Paint paint)
public void drawLines(@Size(min=4,multiple=2) @NonNull float[] pts, @NonNull Paint paint)
第一个方法用于画一条线,下面的两个用于话多条线。各参数值结合字面意思以及前面的解释,读者可以自行推测出来。
2.2.5.10 对画布进行其它形状的绘制
1、public void drawRect(@NonNull RectF rect, @NonNull Paint paint)
2、public void drawRect(@NonNull Rect r, @NonNull Paint paint)
3、public void drawRect(float left, float top, float right, float bottom, @NonNull Paint paint)
4、public void drawOval(@NonNull RectF oval, @NonNull Paint paint)
5、public void drawOval(float left, float top, float right, float bottom, @NonNull Paint paint)
6、public void drawCircle(float cx, float cy, float radius, @NonNull Paint paint)
7、public void drawBitmap(@NonNull Bitmap bitmap, float left, float top, @Nullable Paint paint)
8、public void drawText(@NonNull char[] text, int index, int count, float x, float y,
@NonNull Paint paint)
9、public void drawText(@NonNull String text, float x, float y, @NonNull Paint paint)
10、public void drawText(@NonNull String text, int start, int end, float x, float y,
@NonNull Paint paint)
1-3用于绘制矩形,可以直接传递rext,也可以传递矩形四个角的坐标。4-5绘制椭圆形,6绘制圆形,7绘制Bitmap图像,8-10绘制文字。
2.3 View的坐标
View是以矩形的形式出现的,因此他含有一对坐标分别表示它的ledt,top的位置,以及一队尺寸width,height。长宽的单位是像素。我们可以通过getLeft,getTop方法来获取当前View相对于它的父View的距离,比如getLeft返回20,表示当前View距离父View的右边缘20个像素点。getTop同理。当然也有getBottom,getRight方法,也可以以getLedt+getWidth的方式得到getRight。两种方式都可以获得View的右边的坐标。
2.4 View的Size,padding,margin
事实上View有两种Size类型,一种是测量尺寸,一种是布局尺寸。测量尺寸表达的是当前View所期望的尺寸,布局尺寸是父容器结合实际给出的具体尺寸,所以测量尺寸和布局尺寸不一定会相等。获取测量尺寸的方法,getMeasuredWidth和getMeasuredHeight。获取布局尺寸的方法:getWidth,getHeight。
padding表示的是间距,即当前View的内容距离边缘的距离。比如padding=2,表示当前View的内容距离View的边缘有2个像素点。要注意的是,如果是我们自定义View过程需要Draw渲染内容,一定要处理padding,否则即使你给View设置了padding而不去处理它,会导致View根本起不到padding的效果。获取padding值得方法,getPaddingLeft,getPaddingRight,getPaddingTop,getPaddingBottom。
Margin表示的是间隔,即当前View相对于父容器的距离,此特征会有父容器进行处理,所以我们自定义View不必关注这个属性。
三、自定义View的例子
首先是attr_xml文件定义自定义属性:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--定义customerView的画笔粗细和颜色-->
<declare-styleable name="CustomerView">
<attr name="strokeWidth" format="dimension"/>
<attr name="paintColor" format="color"/>
</declare-styleable>
</resources>
package cn.com.chinaweal.customview;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.util.AttributeSet;
import android.view.View;
/**
* 自定义View注意这里继承的是View所以除了系统处理的事件之外任何我们要的东西都要自己写
* Created by Myy on 2016/8/20.
*/
public class CustomerView extends View {
float strokeWidth=1;
int paintColor=Color.RED;
Paint paint;
Context context;
/**
* 次构造函数有代码生成的View调用
* @param context
*/
public CustomerView(Context context) {
super(context);
this.context=context;
init();
}
/**
* 此构造函数一般有xml填充的View调用
*/
public CustomerView(Context context, AttributeSet attrs) {
super(context, attrs);
this.context=context;
//获取自定义的主题的相关信息
TypedArray typedArray=context.obtainStyledAttributes(attrs,R.styleable.CustomerView);
strokeWidth=typedArray.getDimension(R.styleable.CustomerView_strokeWidth,1);
paintColor=typedArray.getColor(R.styleable.CustomerView_paintColor,Color.RED);
init();
}
private void init() {
paint=new Paint();
paint.setColor(paintColor);
paint.setStrokeWidth(strokeWidth);
}
/**
* 重写onMeasure用于得出一个合适的测量尺寸,因为我们继承的是View,所以这些方法必须自己实现
* @param widthMeasureSpec
* @param heightMeasureSpec
*/
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int width=getBetterSize(widthMeasureSpec);
int height=getBetterSize(heightMeasureSpec);
int size=width>height?height:width;//用最小的尺寸作为view的尺寸
setMeasuredDimension(size,size);//一定要调用此方法设置测量尺寸
}
/**
* 根据测量规则获取最佳尺寸
* @param measureSpec
* @return
*/
private int getBetterSize(int measureSpec) {
int size=200;
int mode=MeasureSpec.getMode(measureSpec);
int requestSize=MeasureSpec.getSize(measureSpec);
switch (mode)
{
case MeasureSpec.UNSPECIFIED:size=200;break;
case MeasureSpec.AT_MOST:size=requestSize;break;
case MeasureSpec.EXACTLY:size=requestSize;break;
}
return size;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//绘画基本图形
//记得处理padding
int left=getPaddingLeft();
int right=getPaddingRight();
int bottom=getPaddingBottom();
int top=getPaddingTop();
int width=getMeasuredWidth();
int height=getMeasuredHeight();
//绘制圆形
paint.setFlags(Paint.ANTI_ALIAS_FLAG);//这样画出来的不是实心的
int radius=Math.min(width-left-right,height-bottom-top)/2;//如果不处理padding会导致padding属性失效
canvas.drawCircle(width/2,height/2,radius,paint);
//绘制直线
canvas.drawLine(left,top,width-left-right,height-bottom-top,paint);
//移动画布
canvas.save();
//将画布绕着0,0旋转10度
canvas.rotate(10);
//在旋转后的画布上画图,会发现图也被选择了10度
canvas.drawBitmap(BitmapFactory.decodeResource(context.getResources(),R.mipmap.ic_launcher),20,20,paint);
paint.setColor(Color.GREEN);
//可以发现线也被旋转了十度
canvas.drawLine(left,top,width-left-right,height-bottom-top,paint);
canvas.restore();//恢复状态
/**
* 此时要注意画笔的颜色的改变,在save和restore之间画的图像和线段会随着resotre的调用而被排除在绘制栈外
* 当调用resotre的时候,需要重新绘制save之前绘画的东西,所以当系统需要再次刷新View的时候,级下面调用drawRect的时候
* 系统会重新恢复之前的绘画图像,由于在resotre之后我将paint的颜色修改了,所以之前的绘画图像都会用此paint进行绘制。导致
* 颜色被修改
*/
paint.setColor(Color.YELLOW);
//此时画矩形,发现矩形并没有被旋转10度,因为resotre将画布状态恢复了
//此时会发现之前绘制的灰色的图像变成了黄色
canvas.drawRect(50,50,100,100,paint);
}
}
布局文件:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:context="cn.com.chinaweal.customview.MainActivity">
<cn.com.chinaweal.customview.CustomerView
android:layout_width="match_parent"
android:layout_height="match_parent"
app:strokeWidth="3dp"
android:padding="20dp"
app:paintColor="#efefef"/>
</RelativeLayout>
运行效果:
具体解释请看我的注释。