自定义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标记中使用
<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:百分数,在动画资源
<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文件中
到此,整个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-------------------------