自定义View(一)

很多时候系统自带的View组件并不能满足我们的需求,当我们需要特定的显示风格,处理特有的用户交互,封装一个高内聚的视图组件,就需要用到Android给我们提供的自定义View的能力。有了自定义View,我们可以针对特定的应用场景开发出适合该场景的View组件,比如自定义的下拉刷新组件,自定义的进度条组件等等。本片文章主要介绍自定义View的基础知识,为自定义View做充足的准备。

自定义View基础知识

视图(View)表现为显示在屏幕上的各种视图,如TextView、LinearLayout等。视图分为View与ViewGroup,View即单个视图,比如Button、TextView等。ViewGroup包含多个视图,如LinearLayout、RelativeLayout等。View类是Android中各种组件的基类,ViewGroup也是继承自View。

View的部分构造函数如下:

 1public View(Context context) {
 2	super(context);
 3}
 4
 5// 如果View是在.xml里声明的,则调用第二个构造函数
 6// 自定义属性是从AttributeSet参数传进来的【自定义View最常用】
 7public View(Context context, AttributeSet attrs) {
 8  super(context, attrs);
 9}
10
11// 在第二个构造函数里如果View有style属性时才会调用
12public View(Context context, AttributeSet attrs, int defStyleAttr) {
13  super(context, attrs, defStyleAttr);
14}

对于多View的视图,结构是树形结构:最顶层是ViewGroup,ViewGroup下可能有多个ViewGroup或View,如下图:

无论是measure过程、layout过程还是draw过程,永远都是从View树的根节点开始测量或计算(即从树的顶端开始),一层一层、一个分支一个分支地进行(即树形递归),最终计算整个View树中各个View,最终确定整个View树的相关属性。

Android的坐标系定义为:屏幕的左上角为坐标原点,向右为x轴增大方向,向下为y轴增大方向,具体如下图:

View位置(坐标)描述:View的位置由4个顶点决定的(如下A、B、C、D)

Top:子View上边界到父view上边界的距离,Left:子View左边界到父view左边界的距离,Bottom:子View下边距到父View上边界的距离,Right:子View右边界到父view左边界的距离。

View的位置是通过view.getxxx()函数进行获取:

1// 获取Top位置
2public final int getTop() {  
3    return mTop;
4}  
5
6// 其余如下:
7getLeft();      //获取子View左上角距父View左侧的距离
8getBottom();    //获取子View右下角距父View顶部的距离
9getRight();     //获取子View右下角距父View左侧的距离

与MotionEvent中 get()getRaw()的区别

1//get() :触摸点相对于其所在组件坐标系的坐标
2event.getX();
3event.getY();
4
5//getRaw() :触摸点相对于屏幕默认坐标系的坐标
6event.getRawX();
7event.getRawY();

自定义View实际上是将一些简单的形状通过计算,从而组合到一起形成的效果。这会涉及到画布的相关操作(旋转)、正余弦函数计算等,即会涉及到角度(angle)与弧度(radian)的相关知识,不会很复杂,但是有时候遇到稍微复杂一点的情况就可以准备一个草稿本,用手画一下很容易就能搞清楚。

角度 = (弧长 / 周长 )×360°弧度 = 弧长 / 半径R。在常见的数学坐标系中角度增大方向为逆时针,但是在Android中角度增大的方向是顺时针:

再来看看颜色相关的内容,Android中的颜色相关内容包括颜色模式,创建颜色的方式,以及颜色的混合模式等。

颜色模式 解释
ARGB8888 四通道高精度(32位)
ARGB4444 四通道高精度(16位)
RGB565 Android屏幕默认模式(16位)
Alpha8 仅有透明通道(8位)
备注 字母表示通道类型,数值表示该类型用多少位二进制来描述。比如:ARGB8888,表示有四个通道(ARGB),每个对应的通道均用8位来描述。

以ARGB8888为例介绍颜色定义:

A(Alpha,透明度),取值范围0 - 255 ,R(Red)取值范围0 - 255 ,G(Green)取值范围0 - 255 ,B(Blue)取值范围0 - 255 。那么如何在Java中定义颜色:

1// Java中使用Color类定义颜色
2int color = Color.GRAY; // 灰色
3
4// Color类是使用ARGB值进行表示
5int color = Color.argb(127, 255, 0, 0);   // 半透明红色
6int color = 0xaaff0000;                   // 带有透明度的红色

/res/values/color.xml文件中如下定义:

1<?xml version="1.0" encoding="utf-8"?>
2<resources>
3    // 定义了红色(没有alpha(透明)通道)
4    <color name="red">#ff0000</color>
5    
6    // 定义了蓝色(没有alpha(透明)通道)
7    <color name="green">#00ff00</color>
8</resources>

在xml文件中以#开头定义颜色,后面跟十六进制的值,有如下几种定义方式:

1#f00            //低精度 - 不带透明通道红色
2#af00           //低精度 - 带透明通道红色
3
4#ff0000         //高精度 - 不带透明通道红色
5#aaff0000       //高精度 - 带透明通道红色

在Java文件中引用xml中定义的颜色:

1//方法1
2int color = getResources().getColor(R.color.mycolor);
3
4//方法2(API 23及以上)
5int color = getColor(R.color.myColor);    

在xml文件(layout或style)中引用或者创建颜色:

 1<!--在style文件中引用-->
 2<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
 3	<item name="colorPrimary">@color/red</item>
 4</style>
 5
 6<!--在layout文件中引用在/res/values/color.xml中定义的颜色-->
 7android:background="@color/red"     
 8
 9<!--在layout文件中创建并使用颜色-->
10android:background="#ff0000"        

颜色都是用RGB值定义的,而我们一般是无法直观的知道自己需要颜色的值,需要借用取色工具直接从图片或者其他地方获取颜色的RGB值。

自定义View的属性获取

自定义View我们需要分析需要的自定义属性,然后在res/ valus/atrs.xm定义声明,在layout.xml文件中进行使用,在View的构造方法中进行获取,先在values文件夹下新建一个attrs.xml文件,里面就可以编写我们的自定义属性了,需要注意的是自定义属性的类型,比如boolean、string、enum、integer……

在代码中去定义我们的自定义View,顺便在初始化的时候获取对应的属性,下面是activity_main.xml:

 1<?xml version="1.0" encoding="utf-8"?>
 2<RelativeLayout
 3    xmlns:android="http://schemas.android.com/apk/res/android"
 4    xmlns:tim="http://schemas.android.com/apk/res-auto"
 5    xmlns:tools="http://schemas.android.com/tools"
 6    android:layout_width="match_parent"
 7    android:layout_height="match_parent"
 8    tools:context=".MainActivity">
 9
10    <cn.tim.custom_view.TestView
11        android:layout_centerInParent="true"
12        android:background="#FFBB86FC"
13        tim:test_bool="true"
14        tim:test_dimension="100dp"
15        tim:test_enum="top"
16        tim:test_integer="10086"
17				tim:test_string="Tim"
18        android:layout_width="100dp"
19        android:layout_height="100dp"/>
20
21</RelativeLayout>

获取属性的方式一:

 1public class TestView extends View {
 2    private static final String TAG = "TestView";
 3
 4    public TestView(Context context, @Nullable AttributeSet attrs) {
 5        super(context, attrs);
 6        TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.TestView);
 7        boolean booleanTest = typedArray.getBoolean(R.styleable.TestView_test_bool, false);
 8        int integerTest = typedArray.getInteger(R.styleable.TestView_test_integer, -1);
 9        String stringTest = typedArray.getString(R.styleable.TestView_test_string);
10        int enumTest = typedArray.getInt(R.styleable.TestView_test_enum, 0);
11        float dimensionTest = typedArray.getDimension(R.styleable.TestView_test_dimension, 0);
12        Log.i(TAG, "TestView: booleanTest=" + booleanTest);
13        Log.i(TAG, "TestView: integerTest=" + integerTest);
14        Log.i(TAG, "TestView: stringTest=" + stringTest);
15        Log.i(TAG, "TestView: enumTest=" + enumTest);
16        Log.i(TAG, "TestView: dimensionTest=" + dimensionTest);
17        typedArray.recycle();
18    }
19}

获取属性的方式二:

 1public class TestView extends View {
 2    private static final String TAG = "TestView";
 3
 4    public TestView(Context context, @Nullable AttributeSet attrs) {
 5        super(context, attrs);
 6        TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.TestView);
 7        boolean booleanTest = false;
 8        int integerTest = 0;
 9        String stringTest = null;
10        int enumTest = 0;
11        float dimensionTest = 0;
12        int indexCount = typedArray.getIndexCount();
13        for (int i = 0; i < indexCount; i++) {
14            int index = typedArray.getIndex(i);
15            switch (index){
16                case R.styleable.TestView_test_bool:
17                    booleanTest = typedArray.getBoolean(R.styleable.TestView_test_bool, false);
18                    break;
19                case R.styleable.TestView_test_integer:
20                    integerTest = typedArray.getInteger(R.styleable.TestView_test_integer, -1);
21                    break;
22                case R.styleable.TestView_test_string:
23                    stringTest = typedArray.getString(R.styleable.TestView_test_string);
24                    break;
25                case R.styleable.TestView_test_enum:
26                    enumTest = typedArray.getInt(R.styleable.TestView_test_enum, 0);
27                    break;
28                case R.styleable.TestView_test_dimension:
29                    dimensionTest = typedArray.getDimension(R.styleable.TestView_test_dimension, 0);
30                    break;
31            }
32        }
33        typedArray.recycle();
34
35        Log.i(TAG, "TestView: booleanTest=" + booleanTest);
36        Log.i(TAG, "TestView: integerTest=" + integerTest);
37        Log.i(TAG, "TestView: stringTest=" + stringTest);
38        Log.i(TAG, "TestView: enumTest=" + enumTest);
39        Log.i(TAG, "TestView: dimensionTest=" + dimensionTest);
40    }
41}

这两种方式区别就是第一种方式是有缺点的,用户假设没有在控件中设置值,直接获取的方式如果找不到用户设置的值的时候,就会设置为null,从而覆盖原来的初始值,假设用户使用时写的控件属性如下:

 1<?xml version="1.0" encoding="utf-8"?>
 2<RelativeLayout
 3    xmlns:android="http://schemas.android.com/apk/res/android"
 4    xmlns:tim="http://schemas.android.com/apk/res-auto"
 5    xmlns:tools="http://schemas.android.com/tools"
 6    android:layout_width="match_parent"
 7    android:layout_height="match_parent"
 8    tools:context=".MainActivity">
 9
10    <cn.tim.custom_view.TestView
11        android:layout_centerInParent="true"
12        android:background="#FFBB86FC"
13        tim:test_bool="true"
14        tim:test_dimension="100dp"
15        tim:test_enum="top"
16        tim:test_integer="10086"
17
18        android:layout_width="100dp"
19        android:layout_height="100dp"/>
20
21</RelativeLayout>

所以还是比较推荐使用第二种写法,根据需要获取属性即可。

Measure (测量过程)

自定义View的子二个步骤就是测量,在自定义View的时候,我们需要测量出View的宽和高,在某些情况下,需要多次测量才能确定View最终的宽/高;该情况下,measure过程后得到的宽和高可能不准确,因此最好是在layout过程中通过onLayout()方法去获取最终的宽和高。

ViewGroup 的子类(RelativeLayout、LinearLayout)有其对应的ViewGroup.LayoutParams子类,比如:RelativeLayout的 ViewGroup.LayoutParams子类就是RelativeLayoutParams,它们的作用就是指定视图View的高度和宽度等布局参数。

在测量过程中,需要重点理解的是测量规格,测量规格(MeasureSpec) = 测量模式(mode) + 测量大小(size)。其中测量模式有三种:MeasureSpec.UNSPECIFIED、MeasureSpec.EXACTLY、MeasureSpec.AT_MOST。MeasureSpec是父控件提供给子View的一个参数,作为设定自身大小参考,只是个参考,要多大,还是View自己说了算。

1、UNSPECIFIED(未指定模式),父控件对子控件不加任何束缚,子元素可以得到任意想要的大小。比如ScrollView、ListView……,它的子View可以随意设置大小,无论多高,都能滚动显示,这个时候heightSize就没什么意义。

2、EXACTLY(精确模式),父控件为子View指定确切大小,希望子View完全按照自己给定尺寸来处理,这时的MeasureSpec一般是父控件根据自身的MeasureSpec跟子View的布局参数来确定的。

3、AT_MOST(最大值模式),父为子元素指定最大参考尺寸,希望子View的尺寸不要超过这个尺寸。这种模式也是父控件根据自身的MeasureSpec跟子View的布局参数来确定的,一般是子View的布局参数采用wrap_content的时候。

通过下表可以清晰的对比一下三者的区别:

MeasureSpec 被封装在View类中的一个内部类里:MeasureSpec类,MeasureSpec类用1个变量封装了2个数据:size与mode,通过使用二进制将测量模式与测量大小打包成一个int值来,并提供了打包与解包的方法,使用该措施的目的就是减少对象内存分配。所以待会儿看到这样的代码并不要觉得惊讶:

1int specMode = MeasureSpec.getMode(measureSpec);
2int specSize = MeasureSpec.getSize(measureSpec);
3// 通过Mode和Size生成新的SpecMode
4int measureSpec = MeasureSpec.makeMeasureSpec(specSize, specMode);

因为这就是获取了测量模式,也获取了测量值:

 1// 测量的代码
 2@Override
 3protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
 4    int widthMode = MeasureSpec.getMode(widthMeasureSpec);
 5    int widthSize = MeasureSpec.getSize(widthMeasureSpec);
 6    int width = 0;
 7    if(widthMode == MeasureSpec.EXACTLY){
 8        width = widthSize;
 9    }else {
10        //int needWidth = measureWidth();
11        // 想要支持Padding
12        int needWidth = measureWidth() + getPaddingLeft() + getPaddingRight();
13        if(widthMode == MeasureSpec.AT_MOST){
14            width = Math.min(needWidth, widthSize);
15        }else { //MeasureSpec.UNSPECIFIED 无限制
16            width = widthSize;
17        }
18    }
19
20    int heightMode = MeasureSpec.getMode(heightMeasureSpec);
21    int heightSize = MeasureSpec.getSize(heightMeasureSpec);
22    int height = 0;
23    if(heightMode == MeasureSpec.EXACTLY){
24        height = heightSize;
25    }else {
26        int needHeight = measureHeight() + getPaddingTop() + getPaddingBottom();
27        if(heightMode == MeasureSpec.AT_MOST){
28            height = Math.min(needHeight, heightSize);
29        }else {
30        		//MeasureSpec.UNSPECIFIED 无限制
31            height = heightSize;
32        }
33    }
34    setMeasuredDimension(width, height);
35}
36
37// 根据显示的内容去计算
38private int measureHeight() {
39    return 0;
40}
41
42// 根据显示的内容去计算
43private int measureWidth() {
44    return 0;
45}

MeasureSpec值的计算,上面说了那么久MeasureSpec,那么MeasureSpec值到底是如何计算得来?其实子View的MeasureSpec值是根据子View的布局参数(LayoutParams)和父容器的MeasureSpec值计算得来的,具体计算逻辑封装在getChildMeasureSpec()方法里。

即子View的大小是由父View的MeasureSpec值和子View的LayoutParams属性共同决定的。关于getChildMeasureSpec()方法里对子View的测量模式 & 大小的判断逻辑有点复杂,具体请看下表:

子视图布局参数 \ 父视图测量模式 EXACTLY AT_MOST UNSPECIFIED
具体数值(dp / px) EXACTLY + childSize EXACTLY + childSize EXACTLY + childSize
match_parent EXACTLY + parentSize(父容器的剩余空间) AT_MOST + parentSize(大小不超过父容器的剩余空间) UNSPECIFIED + 0
wrap_content AT_MOST + parentSize(大小不超过父容器的剩余空间) AT_MOST + parentSize(大小不超过父容器的剩余空间) UNSPECIFIED + 0

其中的规律总结:(以子View为标准,横向观察)

规律前提 子view的 MeasureSpec值
当子View采用具体数值(dp/px)时 测量模式 = EXACTLY
测量大小 = 其自身设置的具体数值
当子View采用match_parent时 测量模式 = 父容器的测量模式
测量大小:
若父容器的测量模式为EXACTLY,那么测量大小等于父容器的剩余空间
若父容器的测量模式为AT_MOST,那么测大小不超过父容器的剩余
当子View采用wrap_content时 测量模式 = AT MOST
测量大小 = 不超过父容器的剩余空间

实际上,上面那段代码的实现和与getDefaultSize()方法是一样的:

 1/**
 2 * Utility to return a default size. Uses the supplied size if the
 3 * MeasureSpec imposed no constraints. Will get larger if allowed
 4 * by the MeasureSpec.
 5 *
 6 * @param size Default size for this view
 7 * @param measureSpec Constraints imposed by the parent
 8 * @return The size this view should be.
 9 */
10public static int getDefaultSize(int size, int measureSpec) {
11    int result = size;
12    int specMode = MeasureSpec.getMode(measureSpec);
13    int specSize = MeasureSpec.getSize(measureSpec);
14
15    switch (specMode) {
16        case MeasureSpec.UNSPECIFIED:
17            result = size;
18            break;
19        case MeasureSpec.AT_MOST:
20        case MeasureSpec.EXACTLY:
21            result = specSize;
22            break;
23    }
24    return result;
25}

getDefaultSize()方法是一种默认计算View的宽高值的方法,所以即使在自定义View的时候不覆写onMeasure,也会走默认的getDefaultSize()方法,使用setMeasuredDimension()方法来存储测量后的View宽高参数。