Android自定义View笔记

Posted 炎之铠

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android自定义View笔记相关的知识,希望对你有一定的参考价值。

自定义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的测量和布局,比较少见。

进入自定义View

  下面是4种自定义View所需的步骤,有一些是必须的,有一些事根据实际需求选择的。

  下面我们来一个一个的来学习,其中重写onDraw()和重写onMeasure()是通过写自定义View的例子学习,重写自身和子类的onMesure()和onLayout()是通过写自定义ViewGroup来学习。

自定义属性

  想要实现自定义的功能,我们有时候就需要一些自己定义的属性,怎么让这些属性可以通过在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">

        <scut.com.learncustomview.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">

    <scut.com.learncustomview.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">
        <scut.com.learncustomview.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"
    >
<scut.com.learncustomview.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"/>
</scut.com.learncustomview.InclinedLayout>

</RelativeLayout>


还有match_parent的时候:

  这样斜向下排列的ViewGroup就完成了,这些只是最简单的一个demo,用于我们熟悉自定义View的步骤,掌握了这些,复杂的自定义View也可以一步一步地完成了。

发布项目到JCenter

  在github上能看到很多人家做好的自定义View,使用人家造好的轮子是一件很方便的事情,而且很多都不用自己手动下载,只有在AndroidStudio里的Gradle添加依赖项complie ‘XXXX’就行了,这是怎样弄的呢?就是作者把项目上传到JCenter或者Maven了,要怎么做才能上传呢?
    读取上面这两篇博文,按上面的的步骤做
http://blog.csdn.net/lmj623565791/article/details/51148825
http://blog.csdn.net/u012375207/article/details/56840217

  我有几点补充:
1. 注册邮箱不能是qq邮箱,163邮箱(不知道是不是所有中国的都不行),别注册错了企业版,企业版右上角会有个30天倒计时的,点进去再点 Cancel Enterprise (trial)就可以换成个人版了,不用重新注册。
2. 要新建一个module把想要上传的内容装起来,不然可能会出错: Could not get unknown property ‘main’ for SourceSet container.
3. 注意要翻墙。。。
4. compile成功后引用是用封装上传的时候的包名,就像我这个上传的Module里面的包是scut.com.ballviewdemo需要import的时候也是import这个。
5. 最后是我这个项目上传之后的结果:

    compile 'com.yanzhikaijky:LearnCustomView:1.0.1'

参考资料:
(除了上面引用的)
《Android开发艺术探索》——任玉刚

以上是关于Android自定义View笔记的主要内容,如果未能解决你的问题,请参考以下文章

Android学习笔记View的工作原理

Android自定义View(三深入解析控件测量onMeasure)

一起Talk Android吧(第四百六十六回:实现自定义View中的测量功能)

Android 自定义View

android 加速度传感器妙用与自定义View

Android自定义View之自定义一个简单的阶梯式布局