自定义View

参考 Android 8.0 源码

简介

  通常来说,自定义组件有三种定义方式:

  • 从零开始定义自定义组件,组件类继承自View;
  • 从已有组件进行扩展,比如继承自ImageView扩展出符合功能需求的组件;
  • 将多个已有组件组合成一个新的组件,比如侧边带字母索引的ListView;

自定义组件的基本结构

  组件主要由两部分构成:组件类和属性定义;
  创建自定义组件类最基本的做法就是继承自类View,其中,有三个构造方法和两个重写的方法很重要;

public class CustomFirstView extends View {

    public CustomFirstView(Context context) {
        super(context);
    }

    public CustomFirstView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public CustomFirstView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
    }

}

  我们定义了一个CustomFirst类,该类继承自View,我们为该类定义了三个构造方法并重写了另外两个方法;

  • 构造方法:
public CustomFirstView(Context context) {
    super(context);
}

public CustomFirstView(Context context, AttributeSet attrs) {
    super(context, attrs);
}

public CustomFirstView(Context context, AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
}

  这三个构造方法的调用场景不一样。第一个只有一个参数,在代码中创建组件时会调用该构造方法,比如创建一个按钮:

Button button = new Button(context);

第二个方法在layout布局文件中使用时调用,参数attrs表示当前配置中的属性集合,例如在xxx_layout.xml中定义一个按钮,安卓会调用第二个构造方法Inflate出Button对象;

<Button android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:text="get"
/>

第三个构造方法是不会自动调用的,当我们在Theme中定义了Style属性时,通常会在第三个构造方法中手动调用;

  • 绘图方法:
@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
}

  用于显示组件的外观,最终的显示结果需要通过Canvas绘制出来。在View类中,该方法没有默认的实现。

  • 测量方法
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}

  这是一个protected方法,意味着该方法主要用于子类的重写和扩展。如果不重写该方法,父类View有自己默认的实现。在Android中,自定义组件的大小都由自身通过onMeasure()方法进行测量。

重写onMeasure()方法

  View类对于onMeasure()方法有自己的默认实现。

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
    getDefaultSize(getSuggestedMinimumHeight(),heightMeasureSpec));
}

  在该方法中,调用了setMeasuredDimension()方法实现应用测量后的高度和宽度,这是必须调用的。这样以后,我们可以调用getMeasuredWidth()和getMeasuredHeight()方法来获取宽度和高度值。

  在大部分情况下,onMeasure()方法都要重写,用于计算组件的宽度值和高度值。定义组件时,必须指定android:layout_width和android:layout_height属性,属性值有三种情况:match_parent、wrap_content、具体值。match_parent表示组件的大小跟随父容器,所在的容器有多大,组件就有多大;wrap_content表示组件的大小由内容决定,比如TextView组件的大小由文字的多少来决定,ImageView组件的大小由图片的大小来决定;如果是一个具体值,直接指定就可以了,单位是dp。

  总体来说,不管是宽度还是高度,都包含了两个信息:模式和大小。模式可能是match_parent、wrap_content、具体值的任意一种,大小则是需要根据不同的模式来进行计算的。其实,match_parent也是一个确定了的具体值。因为match_parent的大小跟随父容器,而容器本身也是一个组件,它会算出自己的大小,所以不需要我们去重复计算了,父容器多大,组件就有多大,View的绘制流程会自动将父容器计算好的大小通过参数传过来的。

  模式使用三个不同的常量来区别:
1 . MeasureSpec.EXACTLY:
  当组件的尺寸指定为match_parent或具体值时,用该常量代表这种尺寸模式。处于该模式的组件尺寸已经是测量过的值,不需要进行计算。
2 . MeasureSpec.AT_MOST:
  当组件的尺寸指定为wrap_content时,用该常量表示,因为尺寸大小和内容有关,所以,我们要根据组件内的内容来测量组件的宽度和高度。比如TextView中的text属性字符串越长,宽度和高度就可能越大。
3 . MeasureSpec.UNSPECIFIED:
  未指定尺寸,这种情况不多,一般情况下,父控件为AdapterView时,通过measure方法传入。

  获取当前组件的尺寸模式和尺寸大小:
  在onMeasure()方法的参数中,参数widthMeasureSpec和heightMeasureSpec看起来只是两个整数,其实每个参数都包含了两个值:模式和尺寸。int类型占用4个字节,每个字节8位,一共32位。参数widthMeasureSpec和heightMeasureSpec的前2位代表模式,后30位则表示大小。
  获取widthMeasureSpec和heightMeasureSpec的前2位与后30位,通过位运算符就可以得到。下面以widthMeasureSpec为例:

//获取尺寸模式;
widthMeasureSpec & 0x3 << 30

//获取尺寸大小;
widthMeasureSpec << 2 >> 2; 

  View类的内部类MeasureSpec类中提供了两个方法来获取模式和大小:

//获取模式;
int mode = MeasureSpec.getMode(widthMeasureSpec);

//获取大小;
int size = MeasureSpec.getSize(widthMeasureSpec);

  下面举例来说明onMeasure()方法的实现思路:

public class CustomFirstView extends View {

    public CustomFirstView(Context context) {
        super(context);
    }

    public CustomFirstView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public CustomFirstView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
//        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int width = measureWidth(widthMeasureSpec);
        int height = measureHeight(heightMeasureSpec);
        setMeasuredDimension(width, height);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
    }

    private int measureWidth(int widthMeasureSpec) {
        int mode = MeasureSpec.getMode(widthMeasureSpec);
        int size = MeasureSpec.getSize(widthMeasureSpec);
        int width = 0;
        switch (mode) {
            case MeasureSpec.EXACTLY:
                //宽度为match_parent或者具体值时,直接将size作为组件的宽度;
                width = size;
                break;
            case MeasureSpec.AT_MOST:
                //宽度为wrap_content,宽度需要计算。也就是在这里要根据具体的业务实现来设置宽度值;
                break;
            case MeasureSpec.UNSPECIFIED:
                break;
        }
        return width;
    }

    private int measureHeight(int heightMeasureSpec) {
        int mode = MeasureSpec.getMode(heightMeasureSpec);
        int size = MeasureSpec.getSize(heightMeasureSpec);
        int height = 0;

        switch (mode) {
            case MeasureSpec.EXACTLY:
                //高度为match_parent或者具体值时,直接将size作为组件的高度;
                height = size;
                break;
            case MeasureSpec.AT_MOST:
                //高度为wrap_content,高度值需要计算。也就是在这里要根据具体的业务实现来设置高度值;
                break;
            case MeasureSpec.UNSPECIFIED:
                break;
        }
        return height;
    }

}

组件属性

  在上面的例子中,我们把要显示的文本定义为了常量,在实际中,这是不可取的,我们应该可以随意定义文本信息,这就需要我们来设置组件的属性了。

自定义属性基础

  安卓系统定义的属性在源码/framework/base/core/res/res/values/attrs.xml文件中。

  自定义属性主要有以下几个步骤:
1 . 在res/values/attrs.xml文件中为指定组件定义decalre-styleable标记,并将所有的属性都定义在该标记中;
2 . 在layout文件中使用自定义属性;
3 . 在组件类的构造方法中读取属性值;

  在res/values目录下,创建attrs.xml文件,内容大概如下:

<declare-styleable name="CustomFirstView">
    <attr name="" format=""/>
</declare-styleable>

  组件的属性都应该定义在declare-styleable标记中,该标记的name属性值一般来说都是组件类的名称(上面代码中是CustomFirstView),也可以取别的名字,但是一般我们会取组件名。组件的属性都定义在declare-styleable标记内,成为declare-styleable标记的子标记,每个属性由两部分组成--属性名和属性类型。属性通过attr来标识,属性名为name,属性类型为format。属性类型有如下所示:

属性类型 解释

1 . string:字符串

<declare-styleable name="TextView">
    <attr name="text" format="string" localization="suggested" />
    <attr name="hint" format="string" />
</declare-styleable>
android:text="hello world"

2 . boolean:布尔值

<declare-styleable name="TextView">
    <attr name="singleLine" format="boolean" />
</declare-styleable>
android:singleLine="true"

3 . color:颜色

<declare-styleable name="View">
    <attr name="background" format="reference|color" />
</declare-styleable>
android:background="#123456"

android:background="@color/colorPrimary"

4 . dimension:尺寸,可以带单位,比如长度通常为dp,字体大小通常为sp;

<declare-styleable name="ViewGroup_Layout">
    <attr name="layout_width" format="dimension">
        <enum name="fill_parent" value="-1" />
        <enum name="match_parent" value="-1" />
        <enum name="wrap_content" value="-2" />
    </attr>
</declare-styleable>

<declare-styleable name="ViewGroup_MarginLayout">
    <attr name="layout_marginLeft" format="dimension"  />
</declare-styleable>    
android:layout_width="match_parent"

android:layout_width="20dp"

android:layout_marginLeft="30dp"

5 . enum:枚举值,需要在attr标记中使用标记定义枚举值,例如sex作为性别,有两个枚举值:MALE和FEMALE;

<declare-styleable name="Theme">
    <attr name="orientation">
        <enum name="horizontal" value="0" />
        <enum name="vertical" value="1" />
    </attr>
</declare-styleable>
android:orientation="vertical"

6 . flag:标识位,常见的gravity属性就是该类型。flag类型的属性也有一个子标记

<declare-styleable name="Theme">
    <attr name="gravity">
        <flag name="top" value="0x30" />
        <flag name="bottom" value="0x50" />
        <flag name="left" value="0x03" />
        <flag name="right" value="0x05" />
        <flag name="center_vertical" value="0x10" />
        <flag name="fill_vertical" value="0x70" />
        <flag name="center_horizontal" value="0x01" />
        <flag name="fill_horizontal" value="0x07" />
        <flag name="center" value="0x11" />
        <flag name="fill" value="0x77" />
        <flag name="clip_vertical" value="0x80" />
        <flag name="clip_horizontal" value="0x08" />
        <flag name="start" value="0x00800003" />
        <flag name="end" value="0x00800005" />
    </attr>
</declare-styleable>
android:gravity="center"

7.float:浮点数

<declare-styleable name="View">
    <attr name="alpha" format="float" />    
</declare-styleable>
android:alpha="0.3"

8 . fraction:百分数,在动画资源等标记中,fromX、fromY等属性就是fraction类型的属性;

<declare-styleable name="RotateDrawable">
    <attr name="pivotX" format="float|fraction" />
    <attr name="pivotY" format="float|fraction" />
</declare-styleable>
android:pivotX="30%"
android:pivotY="50%"

9 . integer:整数

<declare-styleable name="GridView">
    <attr name="numColumns" format="integer" min="0">
</declare-styleable>
android:numColumns="4"

9 . reference:引用,引用另一个资源,比如android:paddingRight="@dimen/activity_margin"就是引用了一个尺寸资源;

<declare-styleable name="RelativeLayout_Layout">
    <attr name="layout_toLeftOf" format="reference" />
</declare-styleable>
android:layout_toLeftOf="@id/time_tv"

自定义属性使用

  我们在attrs.xml文件中定义我们要用的属性,如下:

<declare-styleable name="CustomFirstView">
    <attr name="text" format="string"/>
</declare-styleable>

  定义好我们需要的属性的名称和类型后,属性就可以在layout文件中使用了。这里需要注意的是:首先要在layout文件中定义好属性的命名空间(xmlns)。默认情况下,xml文件中的根元素的命名空间如下:

xmlns:android="http://schemas.android.com/apk/res/android"

  默认的命名空间是"android",是由上面这一行代码决定的。对于自定义属性来说,必须定义为其他的命名空间,且有一定的要求:

xmlns:kang="http://schemas.android.com/apk/res-auto"

  在上面这一行代码中,其中kang是自定义的命名空间,也可以使用其他值来代替。后面的http://schemas.android.com/apk/res-auto是固定的,有了这个命名空间以后,使用前面在attrs.xml定义的text属性,就应该这样用:

kang:text="hello world"

  目前,我们使用Android Studio,一般在layout文件中生成根元素的时候,就会自动生成xmlns:app="http://schemas.android.com/apk/res-auto"这一句代码,我们就可以直接使用app这个命名空间了,也就不需要再自定义命名空间了。

xmlns:app="http://schemas.android.com/apk/res-auto"

  接下来,我们需要在CustomFirstView中读取app:text属性。组件运行后,所有属性都将保存在AttributeSet集合中并通过构造方法传入,我们通过TypedArray可以读取出指定的属性值。

private String customText;

public CustomFirstView(Context context, AttributeSet attrs) {
    super(context, attrs);
    //读取属性值;
    TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.CustomFirstView);
    customText = typedArray.getString(R.styleable.CustomFirstView_customText);
    typedArray.recycle();
}

  TypedArray typedArray = context.obtainStyleAttributes(attrs,R.style.CustomFirstView)中参数R.style.CustomFirstView是我们在attrs.xml文件中配置中的name值,TypedArray对象的getString()方法用于读取特定属性的值,在这里读取的R.styleable.CustomFirstView_text是指我们在attrs.xml文件中配置的text属性。TypedArray类中定义了很多getXxx()方法,getXxx()中Xxx代表对应属性的类型,比如getInt()、getColor()等。有些getXxx()方法有两个参数,第二个参数通常是指默认值。最后,需要调用TypedArray的recycle()方法释放资源。

  到此,整个CustomFirstView类的代码如下:

public class CustomFirstView extends View {

    private String customText;
    private Paint mPaint;

    public CustomFirstView(Context context) {
        super(context);
    }

    public CustomFirstView(Context context, AttributeSet attrs) {
        super(context, attrs);

        initView();

        TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.CustomFirstView);
        customText = typedArray.getString(R.styleable.CustomFirstView_customText);
        typedArray.recycle();
    }

    private void initView() {
        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPaint.setTextSize(100);
        mPaint.setColor(Color.RED);
    }

    public CustomFirstView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
//        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        Rect rect = getTextRect();
        int textWidth = rect.width();
        int textHeight = rect.height();
        int width = measureWidth(widthMeasureSpec,textWidth);
        int height = measureHeight(heightMeasureSpec,textHeight);
        setMeasuredDimension(width, height);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //将文字放在正中间;
        Rect textRect = getTextRect();
        int viewWidth = getMeasuredWidth();
        int viewHeight =getMeasuredHeight();
        Paint.FontMetrics fontMetrics = mPaint.getFontMetrics();
        int x = (viewWidth - textRect.width()) / 2;
        int y = (int) (viewHeight / 2 + (fontMetrics.descent - fontMetrics.ascent) / 2 - fontMetrics.descent);
        canvas.drawText(customText,x,y,mPaint);
    }

    private int measureWidth(int widthMeasureSpec,int textWidth) {
        int mode = MeasureSpec.getMode(widthMeasureSpec);
        int size = MeasureSpec.getSize(widthMeasureSpec);
        int width = 0;
        switch (mode) {
            case MeasureSpec.EXACTLY:
                //宽度为match_parent或者具体值时,直接将size作为组件的宽度;
                width = size;
                break;
            case MeasureSpec.AT_MOST:
                //宽度为wrap_content,宽度需要计算。也就是在这里要根据具体的业务实现来设置宽度值;
                width = textWidth;
                break;
            case MeasureSpec.UNSPECIFIED:
                break;
        }
        return width;
    }

    private int measureHeight(int heightMeasureSpec,int textHeight) {
        int mode = MeasureSpec.getMode(heightMeasureSpec);
        int size = MeasureSpec.getSize(heightMeasureSpec);
        int height = 0;

        switch (mode) {
            case MeasureSpec.EXACTLY:
                //高度为match_parent或者具体值时,直接将size作为组件的高度;
                height = size;
                break;
            case MeasureSpec.AT_MOST:
                //高度为wrap_content,高度值需要计算。也就是在这里要根据具体的业务实现来设置高度值;
                height = textHeight;
                break;
            case MeasureSpec.UNSPECIFIED:
                break;
        }
        return height;
    }

    private Rect getTextRect() {
        //根绝Paint设置跌绘制参数计算文字所占的宽度;
        Rect rect = new Rect();
        //文字所占的区域保存在rect中;
        mPaint.getTextBounds(customText, 0, customText.length(), rect);
        return rect;
    }

}

读取来自style和theme中的属性

  组件的属性可以在4个地方定义,如下:
1 . 组件;
2 . 组件的style属性;
3 . theme;
4 . theme的style属性;

  我们通过一个案例来进行学习和讲解。比如我们有一个自定义组件类AttrView,继承自View类。AttrView类有四个属性:attr1、attr2、attr3、attr4。另外,定义了一个属性myStyle,该属性定义在declare-styleable标记之外,类型为reference,用于theme的style属性。这些属性在res/values/attrs.xml文件中,如下:

<resources>
    <declare-styleable name=AttrView>
        <attr name="attr1" format="string"></attr>
        <attr name="attr2" format="string"></attr>
        <attr name="attr3" format="string"></attr>
        <attr name="attr4" format="string"></attr>
    </declare-styleable>
</resources>

  我们将这4个属性应用在不同的场合,分别为组件、组件的style属性、ttheme、theme的style属性。

  我们使用的layout文件:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" 
    xmlns:app="http://schemas.android.com/apk/res-auto"  
    android:orientation="vertical" 
    android:layout_width="match_parent"
    android:layout_height="match_parent"> 
    
    <com.example.kang.AttrView
    android:layout_width="match_parent"
    android:layout_height="match_parent" 
    app:attr1="attr1" 
    style="@style/viewStyle"
    /> 
    
</LinearLayout>

  app:attr1="attr1"使用了属性attr1,style="@style/viewStyle"使用了属性attr2,其中@style/viewStyle定义在res/values/style.xml文件中,当然,该文件还定义了整个App工程的主题(theme),该文件如下:

<resources>
    <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
        <item name="attr3">attr3</item>
        <item name="myStyle">@style/myDefaultStyle</item>
    </style>
    
    <style name="myDefaultStyle">
        <item name="attr4"></item>
    </style>
    
    <style name="viewStyle">
        <item name="attr2">attr2</item>
    </style>
</resources>

  在工程的主题(theme)AppTheme中,使用了属性attr3,同时使用了style属性myStyle,该style属性又引用了@style/myDefaultStyle,myDefaultStyle中使用了属性attr4.总结起来,attr1是组件的直接属性,attr2是组件的style属性引用的属性,attr3是工程主题(theme)属性,attr4时工程主题(theme)的style属性。现在,我们在AttrView类的构造方法中读取这4个属性值:

public AttrView(Context context,AttributeSet attrs){
    this(context,attr,R.attr.myStyle);
}

public AttrView(Context context,AttributeSet attrs,int defStyleAttr){
    super(context,attrs,defStyleAttr);
    TypedArray typedArray = context.obtainStyledAttributes(attrs,R.styleable.AttrView,defStyleAttr,R.style.myDefaultStyle);
    String attr1 = typedArray.getString(R.styleable.AttrView_attr1);
    String attr2 = typedArray.getString(R.styleable.AttrView_attr2);
    String attr3 = typedArray.getString(R.styleable.AttrView_attr3);
    String attr4 = typedArray.getString(R.styleable.AttrView_attr4);
}

  我们在AttrView(Context context,AttributeSet attrs)构造方法中,调用了AttrView(Context context,AttributeSet attrs,int defStyleAttr)构造方法,我们调用了重载方法obtainStyledAttributes(),该方法的原型为:

public final TypedArray obtainStyledAttributes(AttributeSet set, @StyleableRes int[] attrs, @AttrRes int defStyleAttr,@StyleRes int defStyleRes) {
        
}

  在该方法中,参数如下:
1 . set:属性值的集合;
2 . attrs:我们要获取的属性的资源ID的一个数组,我们定义了attr1、attr2、attr3、attr4,这4个属性自动生成的索引会存储到R.styleable.AttrView数组中,该数组就是attrs参数;
3 . defStyleAttr:当前Theme中style属性,如果组件和组件的style属性都没有为View指定属性时,将从Theme的Style中查找相应的属性值;
4 . defStyleRes:指向一个Style的资源ID,但是仅在defStyleAttr为0或defStyleAttr不为0但Theme中没有为defStyleAttr属性赋值时起作用;

  我们读取attr属性的优先级的流程图,如下:

重写onDraw()方法

-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-------------------------last line for now-------------------------