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等等,想看全部可以参考这里

在attrs.xml声明了属性之后,就可以在View的xml里用了,不过首先要在根ViewGroup里声明变量空间:
<?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也可以一步一步地完成了。

推荐阅读更多精彩内容