Android自定义View
自定义View的有好几种分类,可以分成4种:
1.特定的View的子类:Android的API已经为我们提供了不少可以使用的View,如TextView、ImageView、Button等等,但是有时候我们需要在这些基础的View上扩展一些功能,例如在Button里绑定一个TextWatch监测若干个EditText的输入情况时,就是继承Button类,在它的子类进行扩展了。这种自定义View实现难度低,不需要自己支持wrap_content和padding等属性,非常常见。
2.特定的ViewGroup子类:Android的API也为我们提供了不少可以使用的ViewGroup,如LinearLayout、RelativeLayout等等,但是有时候我们想把实现同一个需求若干个View组合起来,就可以用这种方式的自定义View来打包了。这种自定义View的实现难度低,也不需要自己处理ViewGroup对每个子View的测量和布局,非常常见。
3.View的子类:View是一个很基础的父类,有一个空的onDraw()方法,继承它首先就是要实现这个方法,在里面利用Canvas画出自己想要的内容,不然View是不会显示任何东西的,使用这种自定义View主要用于实现一些非常规的图形效果,例如一些动态变化的View等等。这种自定义View的实现难度比较高,除了需要自己重写onDraw(),还要自己支持wrap_content和padding等属性,不过这种View也很常见。
4.ViewGroup的子类:ViewGroup是用于实现View的组合布局的基础类,直接继承ViewGroup的子类主要是用于实现一些非常规的布局,即不同于官方API给出的LinearLayout等这些的布局。这种这种自定义View的实现难度高,需要处理好ViewGroup和它子View的测量和布局,比较少见。
** 4种自定义View所需的步骤**
Paste_Image.png
自定义属性
想要实现自定义的功能,我们有时候就需要一些自己定义的属性,怎么让这些属性可以通过在xml上设置呢?只需要在res/value文件夹里新建一个attrs.xml(名字随便,建立位置对就行):
<?xml version="1.0" encoding="utf-8"?>
<resources>
<attr name="Color" format="color"/>
<attr name="inVelocityX" format="integer"/>
<attr name="inVelocityY" format="integer"/>
<attr name="Text" format="string"/>
<attr name="TextColor" format="color"/>
<declare-styleable name="BallView">
<attr name="color"/>
<attr name="inVelocityX" />
<attr name="inVelocityY" />
<attr name="Text" />
<attr name="TextColor"/>
</declare-styleable>
</resources>
BallView就是我demo里面的自定义View名字,在declare-styleable外面声明一些自定义属性和属性的类型format,在里面申明BallView需要哪些属性(当然也可以直接在declare-styleable里面声明属性的format,这样就不需要在外面声明了,但是这样的话这些属性也不能被另一个自定义View重用)。
关于属性的format有很多种,reference,color,boolean等等,想看全部可以参考这里。
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:cust="http://schemas.android.com/apk/res-auto"
android:id="@+id/activity_main"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.zhjohow.customview.BallView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
cust:color="#ff0000"
cust:Text="我是一个球"
cust:TextColor="#ffffff"
cust:TextSize= "34"
cust:inVelocityX="6"
cust:inVelocityY="6"/>
</RelativeLayout>
然后我们就要在自定义View里面获取这些属性了,自定义View的构造函数有4个,自定义View必须重写至少一个构造函数:
public BallView(Context context) {
super(context);
}
public BallView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public BallView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
//API21之后才使用
public BallView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
4个构造函数中:如果View是在Java代码里面new的,则调用第一个构造函数;如果是在xml里声明的,则调用第二个构造函数,我们所需要的自定义属性也就是从这个AttributeSet参数传进来的;第三第四个构造函数不会自动调用,一般是在第二个构造主动调用(例如View有style属性的时候)。如果想深入了解构造函数,可以参考这里和这里 所以,我们就可以重写第二个构造函数那里获取我们在xml设定的自定义属性:
//球的x,y方向速度
private int velocityX = 0,velocityY = 0;
//球的颜色
private int color;
//球里面的文字
private String text;
//文字的颜色
private int textColor;
public BallView(Context context, AttributeSet attrs) {
super(context, attrs);
//获取自定义属性数组
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.BallView, 0, 0);
int n = a.getIndexCount();
for (int i = 0;i < n;i++){
int attr = a.getIndex(i);
switch (attr){
case R.styleable.BallView_inVelocityX:
velocityX = a.getInt(attr,0);
break;
case R.styleable.BallView_inVelocityY:
velocityY = a.getInt(attr,0);
break;
case R.styleable.BallView_color:
color = a.getColor(attr,Color.BLUE);
break;
case R.styleable.BallView_Text:
text = a.getString(attr);
break;
case R.styleable.BallView_TextColor:
textColor = a.getColor(attr,Color.RED);
break;
}
}
}
可以看到输出:
System.out: text:球
System.out: textColor:-1
System.out: velocityX:3
System.out: velocityY:3
System.out: color:-65536
重写onMeasure()
关于重写onMeasure()的解释,我觉得用BallView不合适,于是就另外开了个TestMeasureView进行测试: 下面是没有重写onMeasure()来支持wrap_content的例子:
public class TestMeasureView extends View {
private Paint paint;
public TestMeasureView(Context context) {
super(context);
}
public TestMeasureView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public TestMeasureView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawColor(Color.BLUE);
}
}
在xml上使用这个View:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:cust="http://schemas.android.com/apk/res-auto"
android:id="@+id/activity_main"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.zhjh.customview.TestMeasureView
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</RelativeLayout>
得出的结果是这样的:
这就是为什么View的之类要自己支持wrap_parent的原因了,如果不重写wrap_parent就被当成match_parent。具体原因可以看一下View的Measure过程,这个是必须了解的,下面的图(从链接里面盗的)是关键。 了解Measure过程之后我们发现我们现在这个TestMeasureView的长宽参数是由父View的测量模式(RelativeLayout的EXACTLY)和自身的参数(wrap_content)决定的(AT_MOST),所以我们就可以重写onMeasure()让View支持wrap_content了,下面网上流传很广的方法:@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int hSpeSize = MeasureSpec.getSize(heightMeasureSpec);
int hSpeMode = MeasureSpec.getMode(heightMeasureSpec);
int wSpeSize = MeasureSpec.getSize(widthMeasureSpec);
int wSpeMode = MeasureSpec.getMode(widthMeasureSpec);
int width = wSpeSize;
int height = hSpeSize;
if (wSpeMode == MeasureSpec.AT_MOST){
//在这里实现计算需要wrap_content时需要的宽度,这里我直接当作赋值处理了
width =200;
}
if (hSpeMode == MeasureSpec.AT_MOST){
//在这里实现计算需要wrap_content时需要的高度,这里我直接当作赋值处理了
height = 200;
}
//传入处理后的宽高
setMeasuredDimension(width,height);
}
结果是成功的:
网上的很多都是这样做,通过判断测量模式是否AT_MOST来判断View的参数是否是wrap_content,然而,通过上面的表我们发现View的AT_MOST模式对应的不只是wrap_content,还有当父View是AT_MOST模式的时候的match_parent,如果我们这样做的话,父View是AT_MOST的时候这个自定义View的match_parent不就失效了吗。
测试一下,我们把TestMeasureView长宽参数设置为match_parent,然后在外面再包一个模式为AT_MOST的父View(把父View的宽高都设为wrap_content,这样就确保了模式是AT_MOST,UNSPECIFIED因为不会出现在这里可以忽略):
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:cust="http://schemas.android.com/apk/res-auto"
android:id="@+id/activity_main"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<com.zhjh.customview.TestMeasureView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
</RelativeLayout>
运行一下,结果果然是match_parent失效:
所以说看到的东西要思考一下,才能真正地转化为自己的,然后这个怎么解决呢,很简单,直接在onMeasure里面判断参数是否wrap_content就好:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int hSpeSize = MeasureSpec.getSize(heightMeasureSpec);
int hSpeMode = MeasureSpec.getMode(heightMeasureSpec);
int wSpeSize = MeasureSpec.getSize(widthMeasureSpec);
int wSpeMode = MeasureSpec.getMode(widthMeasureSpec);
int width = wSpeSize;
int height = hSpeSize;
if (getLayoutParams().width == ViewGroup.LayoutParams.WRAP_CONTENT){
//在这里实现计算需要wrap_content时需要的宽
width =200;
}
if (getLayoutParams().height == ViewGroup.LayoutParams.WRAP_CONTENT){
//在这里实现计算需要wrap_content时需要的高
height =200;
}
//传入处理后的宽高
setMeasuredDimension(width,height);
}
然后我把参数设回wrap_content(xml就不贴代码了),结果是正确的:
但是这种方法有一个缺陷,就是可能会将UNSPECIFIED的情况也覆盖掉,但是UNSPECIFIED一般只出现在系统内部的View,不会出现在自定义View,而且当它出现的时候也可以加个判断按情况解决。
重写onDraw()
这里就是利用onDraw()给出的Canvas画出各种东西了,这里是BallView的onMeasure()方法和onDraw(),通过以下代码,可以实现在wrap_content的时候根据字的内容长度画出相应的圆,然后可以根据给出的速度移动,遇到“墙会碰撞”。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int wSpeSize = MeasureSpec.getSize(widthMeasureSpec);
int hSpeSize = MeasureSpec.getSize(heightMeasureSpec);
int width = wSpeSize ;
int height = hSpeSize;
if (getLayoutParams().width == ViewGroup.LayoutParams.WRAP_CONTENT){
//在这里实现计算需要wrap_content时需要的宽高
width = bounds.width();
}else if(getLayoutParams().width != ViewGroup.LayoutParams.MATCH_PARENT){
width = getLayoutParams().width;
}
if (getLayoutParams().height == ViewGroup.LayoutParams.WRAP_CONTENT){
//在这里实现计算需要wrap_content时需要的宽高
height =bounds.height();
}else if(getLayoutParams().height != ViewGroup.LayoutParams.MATCH_PARENT){
height = getLayoutParams().height;
}
//计算半径
radius = Math.max(width,height)/2;
//传入处理后的宽高
setMeasuredDimension((int) (radius*2+1), (int) (radius*2+1));
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawCircle(getWidth()/2,getHeight()/2,radius,paintFill);
//让字体处于球中间
canvas.drawText(text,getWidth()/2,getHeight()/2+bounds.height()/2,paintText);
checkCrashScreen();
offsetLeftAndRight(velocityX);
offsetTopAndBottom(velocityY);
postInvalidateDelayed(10);
}
//检测碰撞,有碰撞就反弹
private void checkCrashScreen(){
if ((getLeft() <= 0 && velocityX < 0)){
velocityX = -velocityX ;
}
if (getRight() >= screenWidth && velocityX > 0){
velocityX = -velocityX ;
}
if ((getTop() <= 0 && velocityY < 0)) {
velocityY = -velocityY ;
}
if (getBottom() >= screenHeight -sbHeight && velocityY > 0){
velocityY = -velocityY ;
}
}
最后结果:
重写自身和子类的onMesure()和onLayout()
上面是以自定义View为例子,这次就以一个自定义ViewGroup做为例子,做一个很简单的可以按照斜向下依次排列View的ViewGroup,类似于LinearLayout。要做一个新的ViewGroup,首先就是要重写它的onMesure()方法,让它可以按照需求测量子View和自身的宽高,还可以在这里支持wrap_content。
onMesure()和onLayout()是干什么的呢?为什么需要重写的是它们?因为View的绘制过程大概是Measure(测量)→Layout(定位)→Draw(绘图)三个过程,至于具体是怎样的呢?可以看工匠若水的这篇文章,看不懂没关系,可以看图。。。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int sizeWidth = MeasureSpec.getSize(widthMeasureSpec);
int sizeHeight = MeasureSpec.getSize(heightMeasureSpec);
// 计算出所有的childView的宽和高
measureChildren(widthMeasureSpec, heightMeasureSpec);
int cCount = getChildCount();
int width = 0;
int height = 0;
//处理WRAP_CONTENT情况,把所有子View的宽高加起来作为自己的宽高
if (getLayoutParams().width == ViewGroup.LayoutParams.WRAP_CONTENT){
for (int i = 0; i < cCount; i++){
View childView = getChildAt(i);
width += childView.getMeasuredWidth();
}
}else {
width = sizeWidth;
}
if (getLayoutParams().height == ViewGroup.LayoutParams.WRAP_CONTENT){
for (int i = 0; i < cCount; i++){
View childView = getChildAt(i);
height += childView.getMeasuredHeight();
}
}else {
height =sizeHeight;
}
//传入处理后的宽高
setMeasuredDimension(width,height);
}
还有通过重写onLayout()把子View一个个排序斜向放好:
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int cCount = getChildCount();
int sPointX = 0;
int sPointY = 0;
int cWidth = 0;
int cHeight = 0;
//遍历子View,根据它们的宽高定位
for (int i = 0; i < cCount; i++){
View childView = getChildAt(i);
//这里使用getMeasuredXXX()方法是因为还没layout完,使用getWidth()和getHeight()获取会得不到正确的宽高
cWidth = childView.getMeasuredWidth();
cHeight = childView.getMeasuredHeight();
//定位
childView.layout(sPointX,sPointY,sPointX + cWidth,sPointY + cHeight);
sPointX += cWidth;
sPointY += cHeight;
}
}
结果: 参数为WRAP_CONTENT的时候,成功地显示了:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:cust="http://schemas.android.com/apk/res-auto"
android:id="@+id/activity_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
>
<com.zhjh.customview.InclinedLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="#000fff">
<TextView
android:layout_width="50dp"
android:layout_height="50dp"
android:text="1"
android:background="#fff000"/>
<TextView
android:layout_width="20dp"
android:layout_height="50dp"
android:text="2"
android:background="#00ff00"/>
<TextView
android:layout_width="50dp"
android:layout_height="30dp"
android:text="3"
android:background="#ff0000"/>
</com.zhjh.customview.InclinedLayout>
</RelativeLayout>
还有match_parent的时候:
这样斜向下排列的ViewGroup就完成了,这些只是最简单的一个demo,用于我们熟悉自定义View的步骤,掌握了这些,复杂的自定义View也可以一步一步地完成了。