实现一个自定义View,你通常会覆盖一些framework层在所有view上调用的标准方法。你不需要重写所有这些方法。事实上,你可以只是重写onDraw(android.graphics.Canvas)就实现最简单的自定义view。
1.继承View,重写onDraw方法
创建一个类MyTextView继承View,发现报错,因为要覆盖他的构造方法(因为View中没有参数为空的构造方法),View有四种形式的构造方法,其中四个参数的构造方法是API 21才出现,所以一般我们只需要重写其他三个构造方法即可。它们的参数不一样分别对应不同的创建方式,比如只有一个Context参数的构造方法通常是通过代码初始化控件时使用;而两个参数的构造方法通常对应布局文件中控件被映射成对象时调用(需要解析属性);通常我们让这两个构造方法最终调用三个参数的构造方法,然后在第三个构造方法中进行一些初始化操作。
public class MyView extends View {
// 需要绘制的文字
private String mText;
// 文本的颜色
private int mTextColor;
//文本的大小
private int mTextSize;
//绘制时控制文本绘制的范围
private Rect mBound;
private Paint mPaint;
public MyTextView(Context context) {
this(context, null);//调用两个参数的构造方法
}
public MyTextView(Context context, AttributeSet attrs) {
//调用三个参数的构造方法
this(context, attrs, 0);
}
public MyTextView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
//初始化属性
mText = “Udf32fA”;
mTextColor = Color.BLACK;
mTextSize = 50;
//初始化画笔
mPaint = new Paint();
mPaint.setTextSize(mTextSize);
mPaint.setColor(mTextColor);
//获得绘制文本的宽和高
mBound = new Rect();
mPaint.getTextBounds(mText, 0, mText.length(), mBound);
}
@Override
protected void onDraw(Canvas canvas) {
//绘制文字
canvas.drawText(mText, getWidth() / 2 - mBound.width() / 2, getHeight() / 2 + mBound.height() / 2, mPaint);
}
}
布局文件:
< LinearLayout xmlns:android=“http://schemas.android.com/apk/res/android” xmlns:tools=“http://schemas.android.com/tools”
android:layout_width=“match_parent”
android:layout_height=“match_parent”>
android:layout_height=“100dip”
android:background="#ff0000"/>
< /LinearLayout>
运行效果:
上面只是重写了一个onDraw方法,文本已经绘制出来,说明到此为止这个自定义控件已经算成功了。可是发现一个问题,如果要绘制另外的文本呢?比如写i love you,那是不是又得重新定义一个自定义控件?跟上面一样,只是需要修改mText就可以了;行,再写一遍,那如果我现在又想改变文字颜色为蓝色呢?在写一遍?这时候就用到了新的知识点:自定义属性
2.自定义属性
要使用属性,首先这个属性应该存在,所以如果我们要使用自己的属性,必须要先把他定义出来才能使用。但我们平时在写布局文件的时候好像没有自己定义属性,但我们照样可以用很多属性,这是因为系统自带一些属性。系统定义的所有属性在sdkplatformsAndroid-xxdataresvaluesattrs.xml这个文件,打开看看一些比较熟悉的:
< declare-styleable name=“View”>
< attr name=“id” format=“reference” />
< attr name=“background” format=“reference|color” />
< attr name=“padding” format=“dimension” />
…
< attr name=“focusable” format=“boolean” />
…
< /declare-styleable>
< declare-styleable name=“TextView”>
< attr name=“text” format=“string” localization=“suggested” />
< attr name=“hint” format=“string” />
< attr name=“textColor” />
< attr name=“textColorHighlight” />
< attr name=“textColorHint” />
…
< /declare-styleable>
< 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>
< attr name=“layout_height” format=“dimension”>
< enum name=“fill_parent” value="-1" />
< enum name=“match_parent” value="-1" />
< enum name=“wrap_content” value="-2" />
< /attr>
< /declare-styleable>
attrs.xml文件中的属性,都是以declare-styleable为一个组合,后面有一个name属性,name属性的值为View 、TextView等等。属性值为View的那一组就是为View定义的属性,属性值为TextView的就是为TextView定义的属性…
因为所有控件都是View的子类,所以为View定义的属性所有的控件都能使用,这就是为什么我们的自定义控件没有定义属性就能使用一些系统属性。但并不是每个控件都能使用所有属性,比如TextView是View的子类,所以为View定义的所有属性它都能使用,但是子类肯定有自己特有的属性,得单独为它扩展一些属性,而单独扩展的这些属性只有它自己能有,View是不能使用的,比如View中不能使用android:text=“”。又比如,LinearLayout中能使用layout_weight属性,而RelativeLayout却不能使用,因为layout_weight是为LinearLayout的LayoutParams定义的。
综上所述,自定义控件如果不自定义属性,就只能使用VIew的属性,但为了给我们的控件扩展一些属性,我们就必须自己去定义。
(1)自定义属性文件attrs.xml:
自定义属性有两种形式,这两种形式的区别就是attr标签后面带不带format属性,如果带format的就是在定义属性,如果不带format的就是在使用已有的属性,name的值就是属性的名字,format是限定当前定义的属性能接受什么值。
比如系统已经定义了android:text属性,我们的自定义控件也需要一个文本的属性,可以有两种方式:
第一种:我们并不知道系统定义了此名称的属性,我们自己定义一个名为text或者mText的属性(属性名称可以随便起的)
< resources>
< declare-styleable name=“MyTextView”>
< /declare-styleable>
< /resources>
第二种:我们知道系统已经定义过名称为text的属性,我们不用自己定义,只需要在自定义属性中申明我要使用这个text属性。
(注意加上android命名空间,这样才知道使用的是系统的text属性)
< resources>
< declare-styleable name=“MyTextView”>
< /declare-styleable>
< /resources>
为什么系统定义了此属性,我们在使用的时候还要声明?因为,系统定义的text属性是给TextView使用的,如果我们不申明,就不能使用text属性。
(2)属性值的类型format
format支持的类型一共有11种:
①reference:参考某一资源ID
属性定义:
< declare-styleable name = “名称”>
< attr name = “background” format = “reference” />
< /declare-styleable>
属性使用:
< ImageView android:background = “@drawable/图片ID”/>
②color:颜色值
属性定义:
< attr name = “textColor” format = “color” />
属性使用:
< TextView android:textColor = “#00FF00” />
③boolean:布尔值
属性定义:
< attr name = “focusable” format = “boolean” />
属性使用:
< Button android:focusable = “true”/>
④dimension:尺寸值
属性定义:
< attr name = “layout_width” format = “dimension” />
属性使用:
< Button android:layout_width = “42dip”/>
⑤float:浮点值
属性定义:
< attr name = “fromAlpha” format = “float” />
属性使用:
< alpha android:fromAlpha = “1.0”/>
⑥integer:整型值
属性定义:
< attr name = “framesCount” format=“integer” />
属性使用:
< animated-rotate android:framesCount = “12”/>
⑦string:字符串
属性定义:
< attr name = “text” format = “string” />
属性使用:
< TextView android:text = “我是文本”/>
⑧fraction:百分数
属性定义:
< attr name = “pivotX” format = “fraction” />
属性使用:
< rotate android:pivotX = “200%”/>
⑨enum:枚举值
属性定义:
< declare-styleable name=“名称”>
< attr name=“orientation”>
< enum name=“horizontal” value=“0” />
< enum name=“vertical” value=“1” />
< /attr>
< /declare-styleable>
属性使用:
< LinearLayout
android:orientation = “vertical”>
< /LinearLayout>
注意:枚举类型的属性在使用的过程中只能同时使用其中一个,不能 android:orientation = “horizontal|vertical"
⑩flag:位或运算
属性定义:
< declare-styleable name=“名称”>
< 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” />
…
< /attr>
< /declare-styleable>
属性使用:
< TextView android:gravity=“bottom|left”/>
注意:位运算类型的属性在使用的过程中可以使用多个值。
⑩混合类型:属性定义时可以指定多种类型值
属性定义:
< declare-styleable name = “名称”>
< attr name = “background” format = “reference|color” />
< /declare-styleable>
属性使用:
< ImageView
android:background = “@drawable/图片ID” />
或者:
< ImageView
android:background = “#00FF00” />
(3)类中获取属性值
先讲一下命名空间,在布局文件中使用属性的时候(android:layout_width=“match_parent”)发现前面都带有一个android:,这个android就是上面引入的命名空间xmlns:android=“http://schemas.android.com/apk/res/android”,表示到android系统中查找该属性来源。只有引入了命名空间,XML文件才知道下面使用的属性应该去哪里找。
如果自定义属性,这个属性应该去我们的应用程序包中找,所以要引入我们应用包的命名空间xmlns:openxu=“http://schemas.android.com/apk/res-auto”,res-auto表示自动查找,还有一种写法xmlns:openxu=“http://schemas.android.com/apk/com.example.openxu.myview”,com.example.openxu.myview是我们的应用程序包名。
现在我们先定义一些属性,并写好布局文件。
首先在resvalues目录下创建attrs.xml,定义自己的属性:
< ?xml version=“1.0” encoding=“utf-8”?>
< resources>
< declare-styleable name=“MyTextView”>
//声明MyTextView需要使用系统定义过的text属性,注意前面需要加上android命名
< attr name=“android:text” />
< attr name=“mTextColor” format=“color” />
< attr name=“mTextSize” format=“dimension” />
< /declare-styleable>
< /resources>
然后,在布局文件中,使用属性(注意引入我们应用程序的命名空间,这样才能找到我们包中的attrs):
< LinearLayout xmlns:android=“http://schemas.android.com/apk/res/android” xmlns:tools=“http://schemas.android.com/tools” xmlns:openxu=“http://schemas.android.com/apk/res-auto”
android:orientation=“horizontal”
android:layout_width=“match_parent”
android:layout_height=“match_parent”>
< com.example.openxu.myview.MyTextView
android:layout_width=“200dip”
android:layout_height=“100dip”
openxu:mTextSize=“25sp”
android:text=“我是文字”
openxu:mTextColor =”#0000ff”
android:background="#ff0000"/>
< /LinearLayout>
最后,需要在构造方法中获取属性值:
public MyTextView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.MyTextView);
String text = ta.getString(R.styleable.MyTextView_android_text);
int mTextColor = ta.getColor(R.styleable.MyTextView_mTextColor, Color.BLACK);
int mTextSize = ta.getDimensionPixelSize(R.styleable.MyTextView_mTextSize, 100);
ta.recycle(); //注意回收
Log.v(“openxu”, “text属性值:"+mText);
Log.v(“openxu”, “mTextColor属性值:”+mTextColor);
Log.v(“openxu”, “mTextSize属性值:”+mTextSize);
}
到此为止,自定义属性就完成了。
(4)AttributeSet和TypedArray
自定义属性的构造方法中获取属性值的时候有两个比较陌生的类AttributeSet 和TypedArray,这两个类是怎么把属性值从布局文件中解析出来的?接下来看一下。
AttributeSet看名字就知道是一个属性的集合,实际上,它内部就是一个XML解析器,帮我们将布局文件中该控件的所有属性解析出来,并以key-value键值对的形式维护起来。其实完全可以只用他通过下面的代码来获取我们的属性就行。
public MyTextView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
int count = attrs.getAttributeCount();
for (int i = 0; i < count; i++) {
String attrName = attrs.getAttributeName(i);
String attrVal = attrs.getAttributevalue(i);
Log.e(“openxu”, “attrName = " + attrName + " , attrVal = " + attrVal);
}
}
log输出:
attrName = background , attrVal = @2131427347
attrName = layout_width , attrVal = 200.0dip
attrName = layout_height , attrVal = 100.0dip
attrName = text , attrVal = 我是文字
attrName = mTextSize , attrVal = 25sp
attrName = mTextColor , attrVal = #0000ff
发现通过Attributeset获取属性的值时,它将布局文件中的值原原本本的获取出来的,比如宽度200.0dip,其实这并不是我们想要的,如果我们接下来要使用宽度值,我们还需要将dip去掉,然后转换成整形,这多麻烦。而且,backgroud我应用了一个color资源ID,它直接给我拿到了这个ID值,前面还加了个@,接下来我要自己获取资源,并通过这个ID值获取到真正的颜色,特别不方便。
我们再换TypedArray试试。
在这里,穿插一个知识点,定义属性的时候有一个declare-styleable,他是用来干嘛的,如果不要它可不可以?答案是可以的,我们自定义属性完全可以写成下面的形式:
< ?xml version=“1.0” encoding=“utf-8”?>
< resources>
< attr name=“mTextColor” format=“color” />
< attr name=“mTextSize” format=“dimension” />
< /resources>
之前的形式是这样的:
< ?xml version=“1.0” encoding=“utf-8”?>
< resources>
< declare-styleable name=“MyTextView”>
< attr name=“android:text” />
< attr name=“android:layout_width” />
< attr name=“android:layout_height” />
< attr name=“android:background” />
< attr name=“mTextColor” format=“color” />
< attr name=“mTextSize” format=“dimension” />
< /declare-styleable>
< /resources>
或者:
< ?xml version=“1.0” encoding=“utf-8”?>
< resources>
//定义属性
< attr name=“mTextColor” format=“color” />
< attr name=“mTextSize” format=“dimension” />
< declare-styleable name=“MyTextView”>
//生成索引
< attr name=“android:text” />
< attr name=“android:layout_width” />
< attr name=“android:layout_height” />
< attr name=“android:background” />
< attr name=“mTextColor” />
< attr name=“mTextSize” />
< /declare-styleable>
< /resources>
我们都知道所有的资源文件在R中都会对应一个整型常量,我们可以通过这个ID值找到资源文件。属性在R中对应的类是public static final class attr,如果我们写了declare-styleable,在R文件中就会生成styleable类,这个类其实就是将每个控件的属性分组,然后记录属性的索引值,而TypedArray正好需要通过此索引值获取属性。
public static final class styleable
public static final int[] MyTextView = {
0x0101014f, 0x7f010038, 0x7f010039
};
public static final int MyTextView_android_text = 0;
public static final int MyTextView_mTextColor = 1;
public static final int MyTextView_mTextSize = 2;
}
使用TypedArray获取属性值:
public MyTextView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.MyTextView);
String mText = ta.getString(R.styleable.MyTextView_android_text);
int mTextColor = ta.getColor(R.styleable.MyTextView_mTextColor, Color.BLACK);
int mTextSize = ta.getDimensionPixelSize(R.styleable.MyTextView_mTextSize, 100);
float width = ta.getDimension(R.styleable.MyTextView_android_layout_width, 0.0f);
float hight = ta.getDimension(R.styleable.MyTextView_android_layout_height,0.0f);
int backgroud = ta.getColor(R.styleable.MyTextView_android_background, Color.BLACK);
ta.recycle(); //注意回收
Log.v(“openxu”, “width:”+width);
Log.v(“openxu”, “hight:”+hight);
Log.v(“openxu”, “backgroud:”+backgroud);
Log.v(“openxu”, “mText:”+mText);
Log.v(“openxu”, “mTextColor:”+mTextColor);
Log.v(“openxu”, “mTextSize:”+mTextSize);ext, 0, mText.length(), mBound);
}
log输出:
width:600.0
hight:300.0
backgroud:-12627531
mText:我是文字
mTextColor:-16777216
mTextSize:100
看看多么舒服的结果,我们得到了想要的宽高(float型),背景颜色(color的十进制)等,TypedArray提供了一系列获取不同类型属性的方法,这样就可以直接得到我们想要的数据类型,而不用像Attributeset获取属性后还要一个个处理才能得到具体的数据,实际上TypedArray是为我们获取属性值提供了方便。
注意一点,TypedArray使用完毕后记得调用 ta.recycle();回收 。
(5)demo举例
①在res/values/下创建一个名为attrs.xml的文件,然后定义如下属性:
< resources>
< attr name=“mText” format=“string” />
< attr name=“mTextColor” format=“color” />
< attr name=“mTextSize” format=“dimension” />
< declare-styleable name=“MyTextView”>
< attr name=“mText”/>
< attr name=“mTextColor”/>
< attr name=“mTextSize”/>
< /declare-styleable>
< /resources>
②然后在布局文件中使用自定义属性,记住一定要引入我们的命名空间
< LinearLayout xmlns:android=“http://schemas.android.com/apk/res/android” xmlns:tools=“http://schemas.android.com/tools”
xmlns:openxu=“http://schemas.android.com/apk/res-auto”
android:layout_width=“match_parent”
android:layout_height=“match_parent”>
android:layout_height=“100dip”
openxu:mTextSize=“25sp”
openxu:mText=“i love you”
openxu:mTextColor ="#0000ff"
android:background="#ff0000"/>
< /LinearLayout>
③在构造方法中获取自定义属性的值:
public MyTextView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
//获取自定义属性的值
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.MyTextView, defStyleAttr, 0);
mText = a.getString( R.styleable.MyTextView_mText);
mTextColor = a.getColor( R.styleable.MyTextView_mTextColor, Color.BLACK);
mTextSize = a.getDimension( R.styleable.MyTextView_mTextSize, 100);
a.recycle(); //注意回收
//利用获取到的属性给paint初始化
mPaint = new Paint();
mPaint.setTextSize(mTextSize);
mPaint.setColor(mTextColor);
//获得绘制文本的宽和高
mBound = new Rect();
mPaint.getTextBounds(mText, 0, mText.length(), mBound);
}
运行效果:
通过运行结果可知,我们已经成功为MyTextView定义了属性,并获取到值。
到此为止,发现自定义控件还是比较简单的嘛。看看结果,跟原生的TextView还有什么差别?接下来做一点小变化:
让绘制的文本长一点openxu:mText=“i love you i love you i love you”,运行结果:
文本长度超出了控件边界,控件太小,不足以显示那么长的文本,我们将宽高改为wrap_content试试:
不是包裹内容吗?怎么填充整个屏幕了?根据顶部官方文档的说明,我们猜想肯定是控件的测量onMeasure方法出了问题,接下来我们学习onMeasure方法。
3.onMeasure方法
(1)MeasureSpec
在学习onMasure方法之前,我们要先了解他的参数中的一个类MeasureSpec,跟踪一下源码,发现它是View中的一个静态内部类,是由尺寸和模式组合而成的一个值,用来描述父控件对子控件尺寸的约束,看看他的部分源码,一共有三种模式,然后提供了合成和分解的方法:
//MeasureSpec封装了父控件对它的子控件的布局要求
public static class MeasureSpec {
private static final int MODE_SHIFT = 30;
private static final int MODE_MASK = 0x3 << MODE_SHIFT;
//父控件不强加任何约束给子控件,它可以是它想要任何大小
public static final int UNSPECIFIED = 0 << MODE_SHIFT;
//父控件已为子控件确定了一个确切的大小,孩子将被给予这些界限,不管子控件自己希望的是多大
public static final int EXACTLY = 1 << MODE_SHIFT;
//父控件会给子控件尽可能大的尺寸
public static final int AT_MOST = 2 << MODE_SHIFT;
//根据所提供的大小和模式创建一个测量规范
public static int makeMeasureSpec(int size, int mode) {
if (sUseBrokenMakeMeasureSpec) {
return size + mode;
} else {
return (size & ~MODE_MASK) | (mode & MODE_MASK);
}
}
//从所提供的测量规范中提取模式
public static int getMode(int measureSpec) {
return (measureSpec & MODE_MASK);
}
//从所提供的测量规范中提取尺寸
public static int getSize(int measureSpec) {
return (measureSpec & ~MODE_MASK);
}
…
}
从源码中我们知道,MeasureSpec其实就是尺寸和模式通过各种位运算计算出的一个整型值,它提供了三种模式,还有三个方法(合成约束、分离模式、分离尺寸)。
这样说起来还是有点抽象,举个例子大家就知道这三种约束到底是什么意思。
我们自定义一个View,为了方便,让它继承Button,布局文件中设置不同的宽高条件,然后在onMeasure方法中打印一下他的参数(int widthMeasureSpec, int heightMeasureSpec):
public class MyView extends Button {
public MyView(Context context) {
this(context, null);
}
public MyView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public MyView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec); //获取宽的模式
int heightMode = MeasureSpec.getMode(heightMeasureSpec); //获取高的模式
int widthSize = MeasureSpec.getSize(widthMeasureSpec); //获取宽的尺寸
int heightSize = MeasureSpec.getSize(heightMeasureSpec); //获取高的尺寸
Log.v(“openxu”, “宽的模式:”+widthMode);
Log.v(“openxu”, “高的模式:”+heightMode);
Log.v(“openxu”, “宽的尺寸:”+widthSize);
Log.v(“openxu”, “高的尺寸:”+heightSize);
}
}
情形1,让按钮包裹内容:
< ?xml version=“1.0” encoding=“utf-8”?>
< LinearLayout xmlns:android=“http://schemas.android.com/apk/res/android” xmlns:tools=“http://schemas.android.com/tools”
android:layout_width=“match_parent”
android:layout_height=“match_parent”>
< com.example.openxu.myview.MyView
android:layout_width=“wrap_content”
android:layout_height=“wrap_content”
android:textSize=“20sp”
android:text=“按钮”
android:background="#ff0000"/>
< /LinearLayout>
log打印:
宽的模式:-2147483648
高的模式:-2147483648
宽的尺寸:1080
高的尺寸:1860
情形2,让按钮填充父窗体:
< ?xml version=“1.0” encoding=“utf-8”?>
< LinearLayout xmlns:android=“http://schemas.android.com/apk/res/android” xmlns:tools=“http://schemas.android.com/tools”
android:layout_width=“match_parent”
android:layout_height=“match_parent”>
< com.example.openxu.myview.MyView
android:layout_width=“match_parent”
android:layout_height=“match_parent”
android:textSize=“20sp”
android:text=“按钮”
android:background="#ff0000"/>
< /LinearLayout>
log打印:
宽的模式:1073741824
高的模式:1073741824
宽的尺寸:1080
高的尺寸:1860
情形3,给按钮的宽设置为具体的值:
< ?xml version=“1.0” encoding=“utf-8”?>
< LinearLayout xmlns:android=“http://schemas.android.com/apk/res/android” xmlns:tools=“http://schemas.android.com/tools”
android:layout_width=“match_parent”
android:layout_height=“match_parent”>
< com.example.openxu.myview.MyView
android:layout_width=“100dip”
android:layout_height=“wrap_content”
android:textSize=“20sp”
android:text=“按钮”
android:background="#ff0000"/>
< /LinearLayout>
log打印:
宽的模式:1073741824
高的模式:-2147483648
宽的尺寸:300
高的尺寸:1860
根据上面的测试发现,约束中分离出来的尺寸就是父控件剩余的宽高大小(除了设置具体的宽高值外);而几种约束中的模式不就是对应我们在布局文件中设置给按钮的几种情况吗?如下:
①UNSPECIFIED(输出值为0),代表父控件没有对子控件施加任何约束,子控件可以得到任意想要的大小。但是布局文件好像必须设置宽高,目前还没找到与之对应的布局参数,使用较少。
②EXACTLY(输出值为1073741824)对应布局参数为match_parent或具体宽高值,代表父控件给子控件决定了确切大小,子控件将被限定在给定的边界里而忽略它本身大小。特别说明如果是填充父窗体,说明父控件已经明确知道子控件想要多大的尺寸了(就是剩余的空间都要了)。
③AT_MOST(输出值为-2147483648)对应布局参数为wrap_content,代表子控件至多达到指定大小的值。包裹内容就是父窗体并不知道子控件到底需要多大尺寸(具体值),需要子控件自己测量之后再让父控件给他一个尽可能大的尺寸以便让内容全部显示但不能超过包裹内容的大小。
那现在分析一下为什么我们自定义的MyTextView设置了wrap_content却填充屏幕呢?
根据View中onMeasure的源码,看一下onMeasure方法默认是怎样为控件测量大小的:
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension( getDefaultSize(getSuggestedMinimumWidth(),widthMeasureSpec), getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}
public static int getDefaultSize(int size, int measureSpec) {
int result = size;
int specMode = MeasureSpec.getMode(measureSpec);
int specSize = MeasureSpec.getSize(measureSpec);
switch (specMode) {
case MeasureSpec.UNSPECIFIED:
result = size;
break;
case MeasureSpec.AT_MOST:
case MeasureSpec.EXACTLY:
result = specSize;
break;
}
return result;
}
onMeasure方法调用了setMeasuredDimension(int measuredWidth, int measuredHeight)方法,而传入的参数已经是测量过的默认宽和高的值了。我们看看getDefaultSize方法是怎么计算测量宽高的:根据父控件给予的约束,发现AT_MOST (相当于wrap_content )和EXACTLY (相当于match_parent )两种情况返回的测量宽高都是specSize,而这个specSize正是我们上面说的父控件剩余的宽高,所以默认onMeasure方法中wrap_content 和match_parent 的效果是一样的,都是填充剩余的空间。
现在要想把wrap_content和match_parent区分开来,只能我们自己重写onMeasure方法了:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
Log.v(“openxu”, “宽的模式:”+widthMode);
Log.v(“openxu”, “高的模式:”+heightMode);
Log.v(“openxu”, “宽的尺寸:”+widthSize);
Log.v(“openxu”, “高的尺寸:”+heightSize);
int width;
int height ;
if (widthMode== MeasureSpec.EXACTLY) {
//如果match_parent或具体的值,直接赋值
width = widthSize;
} else {
//如果是wrap_content,我们要得到控件需要多大的尺寸
float textWidth = mBound.width(); //文本的宽度
//控件的宽度就是文本的宽度加上两边的内边距。内边距就是padding值,在构造方法执行完就被赋值
width = (int) (getPaddingLeft() + textWidth + getPaddingRight());
Log.v(“openxu”, “文本的宽度:”+textWidth + “控件的宽度:”+width);
}
//高度跟宽度处理方式一样
if (heightMode == MeasureSpec.EXACTLY) {
height = heightSize;
} else {
float textHeight = mBound.height();
height = (int) (getPaddingTop() + textHeight + getPaddingBottom());
Log.v(“openxu”, “文本的高度:”+textHeight + “控件的高度:”+height);
}
//保存测量宽度和测量高度
setMeasuredDimension(width, height);
}
布局文件:
< ?xml version=“1.0” encoding=“utf-8”?>
< LinearLayout xmlns:android=“http://schemas.android.com/apk/res/android” xmlns:tools=“http://schemas.android.com/tools” xmlns:openxu=“http://schemas.android.com/apk/res-auto”
android:layout_width=“match_parent”
android:layout_height=“match_parent”>
< view.openxu.com.mytextview.MyTextView
android:layout_width=“wrap_content”
android:layout_height=“wrap_content”
android:padding=“20dip”
openxu:mTextSize=“25sp”
openxu:mText=“i love you i love you i love you”
openxu:mTextColor ="#0000ff"
android:background="#ff0000"/>
< /LinearLayout>
下面是输出的log:
宽的模式:-2147483648
高的模式:-2147483648
宽的尺寸:720
高的尺寸:1230
文本的宽度:652.0控件的宽度:732
文本的高度:49.0控件的高度:129
模拟器是720x1280的,根据log显示,文本的宽度是652,加上两边的内边距,控件的宽度为732,确实实现了包裹内容的效果,运行程序结果如下:
但是发现宽度已经超出了屏幕,还不能像TextView一样换行,需要做一下换行的功能。
下面对onMeasure方法深入理解一下:
(1)onMeasure什么时候会被调用
onMeasure方法的作用是测量控件的大小,什么时候需要测量控件的大小呢?比如,做饭的时候我们炒一碗菜,炒菜的过程我们并不要求知道这道菜有多少分量,只有在菜做熟了我们要拿个碗盛放的时候,我们才需要掂量拿多大的碗盛放,这时候我们就要对菜的分量进行估测。
控件也正是如此,创建一个View(执行构造方法)的时候不需要测量控件的大小,只有将这个view放入一个容器(父控件)中的时候才需要测量,而这个测量方法就是父控件唤起调用的。当控件的父控件要放置该控件的时候,父控件会调用子控件的onMeasure方法询问子控件:“你有多大的尺寸,我要给你多大的地方才能容纳你?”,然后传入两个参数(widthMeasureSpec和heightMeasureSpec),这两个参数就是父控件告诉子控件可获得的空间以及关于这个空间的约束条件(好比我在思考需要多大的碗盛菜的时候我要看一下碗柜里最大的碗有多大,菜的分量不能超过这个容积,这就是碗对菜的约束),子控件拿着这些条件就能正确的测量自身的宽高了。
(2)onMeasure方法执行流程
onMeasure方法是由父控件调用的,所有父控件都是ViewGroup的子类,ViewGroup是一个抽象类,它里面有一个抽象方法onLayout,这个方法的作用就是摆放它所有的子控件(安排位置),因为是抽象类,不能直接new对象,所以我们在布局文件中可以使用View但是不能直接使用 ViewGroup。
在给子控件确定位置之前,必须要获取到子控件的大小(只有确定了子控件的大小才能正确的确定上下左右四个点的坐标),而ViewGroup并没有重写View的onMeasure方法,也就是说抽象类ViewGroup没有为子控件测量大小的能力,它只能测量自己的大小。但是既然ViewGroup是一个能容纳子控件的容器,系统当然也考虑到测量子控件的问题,所以ViewGroup提供了三个测量子控件相关的方法(measuireChildren、measuireChild、measureChildWithMargins),只是在ViewGroup中没有调用它们,所以它本身不具备为子控件测量大小的能力,但是他有这个潜力哦。
为什么都有测量子控件的方法了而ViewGroup中不直接重写onMeasure方法,然后在onMeasure中调用呢?因为不同的容器摆放子控件的方式不同,比如RelativeLayout,LinearLayout这两个ViewGroup的子类,它们摆放子控件的方式不同,有的是线性摆放,而有的是叠加摆放,这就导致测量子控件的方式会有所差别,所以ViewGroup就干脆不直接测量子控件,他的子类要测量子控件就根据自己的布局特性重写onMeasure方法去测量。这么看来ViewGroup提供的三个测量子控件的方法岂不是没有作用?答案是NO,既然提供了就肯定有作用,这三个方法只是按照一种通用的方式去测量子控件,很多ViewGruop的子类测量子控件的时候就使用了ViewGroup的measureChildxxx系列方法;还有一个作用就是为我们自定义ViewGroup提供方便。
测量的时候父控件的onMeasure方法会遍历它所有的子控件,挨个调用子控件的measure方法,measure方法会调用onMeasure,然后会调用setMeasureDimension方法保存测量的大小,一次遍历下来,第一个子控件以及这个子控件中的所有子控件都会完成测量工作;然后开始测量第二个子控件…;最后父控件所有的子控件都完成测量以后会调用setMeasureDimension方法保存自己的测量大小。值得注意的是,这个过程不只执行一次,也就是说有可能重复执行,因为有的时候,一轮测量下来,父控件发现某一个子控件的尺寸不符合要求,就会重新测量一遍。
举个栗子,看下图:
下面是测量的时序图:
(3)从ViewGroup的onMeasure方法到View的onMeasure方法
①ViewGroup中三个测量子控件的方法:
//遍历ViewGroup中所有的子控件,调用measuireChild测量宽高
protected void measureChildren (int widthMeasureSpec, int heightMeasureSpec) {
final int size = mChildrenCount;
final View[] children = mChildren;
for (int i = 0; i < size; ++i) {
final View child = children[i];
if ((child.mViewFlags & VISIBILITY_MASK) != GONE) {
//测量某一个子控件宽高
measureChild(child, widthMeasureSpec, heightMeasureSpec);
}
}
}
//测量某一个child的宽高
protected void measureChild (View child, int parentWidthMeasureSpec, int parentHeightMeasureSpec) {
final LayoutParams lp = child.getLayoutParams();
//获取子控件的宽高约束规则
final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec, mPaddingLeft + mPaddingRight, lp. width);
final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec, mPaddingTop + mPaddingBottom, lp. height);
//测量子控件
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
//测量某一个child的宽高,考虑margin值
protected void measureChildWithMargins (View child, int parentWidthMeasureSpec, int widthUsed, int parentHeightMeasureSpec, int heightUsed) {
final MarginLayoutParams lp = (MarginLayoutParams)child.getLayoutParams();
//获取子控件的宽高约束规则
final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec, mPaddingLeft + mPaddingRight + lp. leftMargin + lp.rightMargin + widthUsed, lp. width);
final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec, mPaddingTop + mPaddingBottom + lp. topMargin + lp.bottomMargin + heightUsed, lp. height);
//测量子控件
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
measureChildren方法就是遍历所有子控件挨个测量,最终测量子控件的方法就是measureChild 和measureChildWithMargins了,我们先了解几个知识点:
a.measureChildWithMargins跟measureChild的区别就是父控件支不支持margin属性
支不支持margin属性对子控件的测量是有影响的,比如我们的屏幕是1080x1920的,子控件的宽度为填充父窗体,如果使用了marginLeft并设置值为100;在测量子控件的时候,如果用measureChild,计算的宽度是1080,而如果是使用measureChildWithMargins,计算的宽度是1080-100 = 980。
b.怎样让ViewGroup支持margin属性?
ViewGroup中有两个内部类ViewGroup.LayoutParams和ViewGroup. MarginLayoutParams,MarginLayoutParams继承自LayoutParams ,这两个内部类就是VIewGroup的布局参数类,比如我们在LinearLayout等布局中使用的layout_widthlayout_hight等以“layout_ ”开头的属性都是布局属性。在View中有一个mLayoutParams的变量用来保存这个View的所有布局属性。
c.LayoutParams和MarginLayoutParams 的关系:
LayoutParams 中定义了两个属性(现在知道我们用的layout_widthlayout_hight的来头了吧?):
< 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 >
< attr name =“layout_height” format=“dimension”>
< enum name =“fill_parent” value="-1" />
< enum name =“match_parent” value="-1" />
< enum name =“wrap_content” value="-2" />
< /attr >
< /declare-styleable >
MarginLayoutParams 是LayoutParams的子类,它当然也延续了layout_widthlayout_hight 属性,但是它扩充了其他属性:
< declare-styleable name =“ViewGroup_MarginLayout”>
//使用已经定义过的属性
< attr name =“layout_width” />
< attr name =“layout_height” />
< attr name =“layout_margin” format=“dimension” />
< attr name =“layout_marginLeft” format= “dimension” />
< attr name =“layout_marginTop” format= “dimension” />
< attr name =“layout_marginRight” format= “dimension” />
< attr name =“layout_marginBottom” format= “dimension” />
< attr name =“layout_marginStart” format= “dimension” />
< attr name =“layout_marginEnd” format= “dimension” />
< /declare-styleable >
是不是对布局属性有了一个全新的认识?原来我们使用的margin属性是这么来的。
d.为什么LayoutParams类要定义在ViewGroup中?
大家都知道ViewGroup是所有容器的基类,一个控件需要被包裹在一个容器中,这个容器必须提供一种规则,控制子控件的摆放,比如你的宽高是多少、距离那个位置多远等。所以ViewGroup有义务提供一个布局属性类,用于控制子控件的布局属性。
e.为什么View中会有一个mLayoutParams 变量?
在自定义属性时,我们会在构造方法中初始化布局文件中的属性值,我们姑且把属性分为两种。一种是本View的绘制属性,比如TextView的文本、文字颜色、背景等,这些属性是跟View的绘制相关的。另一种就是以“layout_”打头的叫做布局属性,这些属性是父控件对子控件的大小及位置的一些描述属性,这些属性在父控件摆放它的时候会使用到,所以先保存起来,而这些属性都是ViewGroup.LayoutParams定义的,所以用一个变量保存着。
(4)getChildMeasureSpec方法
measureChildWithMargins跟measureChild都调用了这个方法,其作用就是通过父控件的宽高约束规则和父控件加在子控件上的宽高布局参数生成一个子控件的约束。我们知道View的onMeasure方法需要两个参数(父控件对View的宽高约束),这个宽高约束就是通过这个方法生成的。有人会问为什么不直接拿着子控件的宽高参数去测量子控件呢?打个比方,父控件的宽高约束为wrap_content,而子控件为match_perent,是不是很有意思,父控件说我的宽高就是包裹我的子控件,我的子控件多大我就多大,而子控件说我的宽高填充父窗体,父控件多大我就多大。最后该怎么确定大小呢?所以我们需要为子控件重新生成一个新的约束规则。只要记住,子控件的宽高约束规则是父控件调用getChildMeasureSpec方法生成。
看一下getChildMeasureSpec方法源码:
public static int getChildMeasureSpec(int spec, int padding, int childDimension) {
int specMode = MeasureSpec.getMode(spec);
int specSize = MeasureSpec.getSize(spec);
int size = Math.max(0, specSize - padding);
int resultSize = 0;
int resultMode = 0;
switch (specMode) {
//Parent has imposed an exact size on us
case MeasureSpec.EXACTLY:
if (childDimension >= 0) {
resultSize = childDimension;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension== LayoutParams.MATCH_PARENT) {
// Child wants to be our size. So be it.
resultSize = size;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension== LayoutParams.WRAP_CONTENT) {
// Child wants to determine its own size. It can’t be bigger than us.
resultSize = size;
resultMode = MeasureSpec.AT_MOST;
}
break;
//Parent has imposed a maximum size on us
case MeasureSpec.AT_MOST:
if (childDimension >= 0) {
// Child wants a specific size… so be it
resultSize = childDimension;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension== LayoutParams.MATCH_PARENT) {
// Child wants to be our size, but our size is not fixed. Constrain child to not be bigger than us.
resultSize = size;
resultMode = MeasureSpec.AT_MOST;
} else if (childDimension== LayoutParams.WRAP_CONTENT) {
// Child wants to determine its own size. It can’t be bigger than us.
resultSize = size;
resultMode = MeasureSpec.AT_MOST;
}
break;
//Parent asked to see how big we want to be
case MeasureSpec.UNSPECIFIED:
if (childDimension >= 0) {
// Child wants a specific size… let him have it
resultSize = childDimension;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension== LayoutParams.MATCH_PARENT) {
// Child wants to be our size… find out how big it should be
resultSize = 0;
resultMode = MeasureSpec.UNSPECIFIED;
} else if (childDimension == LayoutParams.WRAP_CONTENT) {
// Child wants to determine its own size… find out how big it should be
resultSize = 0;
resultMode = MeasureSpec.UNSPECIFIED;
}
break;
}
return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
}
getChildMeasure方法代码不多,也比较简单,就是几个switch将各种情况考虑后生成一个子控件的新的宽高约束。
接下来就是在measureChildWithMarginsh或者measureChild中 调用子控件的measure方法测量子控件的尺寸了。
(5)View的onMeasure
View中onMeasure方法已经默认为我们的控件测量了宽高,我们看看它做了什么工作:
protected void onMeasure( int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension( getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec), getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}
//为宽度获取一个建议最小值
protected int getSuggestedMinimumWidth () {
return (mBackground == null) ? mMinWidth : max(mMinWidth , mBackground.getMinimumWidth());
}
//获取默认的宽高值
public static int getDefaultSize (int size, int measureSpec) {
int result = size;
int specMode = MeasureSpec. getMode(measureSpec);
int specSize = MeasureSpec. getSize(measureSpec);
switch (specMode) {
case MeasureSpec. UNSPECIFIED:
result = size;
break;
case MeasureSpec. AT_MOST:
case MeasureSpec. EXACTLY:
result = specSize;
break;
}
return result;
}
从源码我们了解到:
①如果View的宽高模式为未指定,他的宽高将设置为android:minWidth/Height =”“值与背景宽高值中较大的一个;
②如果View的宽高 模式为 EXACTLY (具体的size ),最终宽高就是这个size值;
③如果View的宽高模式为EXACTLY (填充父控件 ),最终宽高将为填充父控件;
④如果View的宽高模式为AT_MOST (包裹内容),最终宽高也是填充父控件。
也就是说如果我们的自定义控件在布局文件中,只需要设置指定的具体宽高,或者MATCH_PARENT 的情况,我们可以不用重写onMeasure方法。
但如果自定义控件需要设置包裹内容WRAP_ConTENT ,我们需要重写onMeasure方法,为控件设置需要的尺寸;默认情况下WRAP_ConTENT 的处理也将填充整个父控件。
(6)setMeasuredDimension
onMeasure方法最后需要调用setMeasuredDimension方法来保存测量的宽高值,如果不调用这个方法,可能会产生不可预测的问题。



