对于很多Android入门程序猿来说自定义View,都是比较恐惧的,但是这又是高手进阶的必经之路。先总结下自定义View的步骤:
1、自定义View的属性
2、在View的构造方法中获得我们自定义的属性
[ 3、重写onMesure ]
4、重写onDraw
我把3用[]标出了,所以说3不一定是必须的,当然了大部分情况下还是需要重写的。
其中第1,第2点在前面的文章已经有详细的介绍Android自定义属性,不了解的童鞋可以去看看参考下,本文着重介绍第3和第4点。
onMeasure()
onMeasure()方法顾名思义就是用于测量视图的大小的。它接收两个参数,widthMeasureSpec和heightMeasureSpec,这两个值分别用于确定视图的宽度和高度的规格和大小。
MeasureSpec的值由specSize和specMode共同组成的,其中specSize记录的是大小,specMode记录的是规格。specMode一共有三种类型,如下所示:
表示父视图希望子视图的大小应该是由specSize的值来决定的,系统默认会按照这个规则来设置子视图的大小,开发人员当然也可以按照自己的意愿设置成任意的大小。(一般是在布局中设置了明确的值或者是MATCH_PARENT)
表示子视图最多只能是specSize中指定的大小,开发人员应该尽可能小得去设置这个视图,并且保证不会超过specSize。系统默认会按照这个规则来设置子视图的大小,开发人员当然也可以按照自己的意愿设置成任意的大小。(一般为WARP_CONTENT)
表示开发人员可以将视图按照自己的意愿设置成任意的大小,没有任何限制。这种情况比较少见,不太会用到,一般都是父控件是AdapterView,通过measure方法传入的模式。
MeasureSpec是一个int型数字,可能有很多人想不通,一个int型整数怎么可以表示两个东西(大小模式和大小的值),一个int类型我们知道有32位。而模式有三种,要表示三种状 态,至少得2位二进制位。于是系统采用了最高的2位表示模式。如图:
最高两位是00–MeasureSpec.UNSPECIFIED
最高两位是01–MeasureSpec.EXACTLY
最高两位是11–MeasureSpec.AT_MOST
很多人一遇到位操作头就大了,为了操作简便,于是系统给我提供了一个MeasureSpec工具类。
这个工具类有四个方法和三个常量(上面所示)供我们使用:
//这个是由我们给出的尺寸大小和模式生成一个包含这两个信息的int变量,这里这个模式这个参数,传三个常量中的一个。
public static int makeMeasureSpec(int size, int mode)
//这个是得到这个变量中表示的模式信息,将得到的值与三个常量进行比较。
public static int getMode(int measureSpec)
//这个是得到这个变量中表示的尺寸大小的值。
public static int getSize(int measureSpec)
//把这个变量里面的模式和大小组成字符串返回来,方便打日志
public static String toString(int measureSpec)
好了,说完MeasureSpec,我们继续来看onMeasure方法,形参中widthMeasureSpec和heightMeasureSpec这两个值又是从哪里得到的呢?通常情况下,这两个值都是由父视图经过计算后传递给子视图的,说明父视图会在一定程度上决定子视图的大小。
当然,onMeasure()方法是可以重写的,也就是说,如果你不想使用系统默认的测量方式,可以按照自己的意愿进行定制,比如:
public class MyView extends View {
......
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(200, 200);
}
}
这样的话就把View默认的测量流程覆盖掉了,不管在布局文件中定义MyView这个视图的大小是多少,最终在界面上显示的大小都将会是200*200。
需要注意的是,在setMeasuredDimension()方法调用之后,我们才能使用getMeasuredWidth()和getMeasuredHeight()来获取视图测量出的宽高,以此之前调用这两个方法得到的值都会是0(注意区别于而getWidth()和getHeight(),这两个方法要在ViewGroup的onLayout()过程结束后才能获取到)。
由此可见,视图大小的控制是由父视图、布局文件、以及视图本身共同完成的,父视图会提供给子视图参考的大小,而开发人员可以在XML文件中指定视图的大小,然后视图本身会对最终的大小进行拍板。
onDraw()
measure和layout的过程都结束后,接下来就进入到draw的过程了。同样,根据名字你就能够判断出,在这里才真正地开始对视图进行绘制。
这时就会用到Canvas、Paint画图了,具体见前面一篇博客Android的Paint、Canvas和Matrix讲解
好了,原理性东西我们讲过了,下面通过几个简单的例子来验证一下,同样按上面四步写法:
自定义View的属性,首先在res/values/ 下建立一个attrs.xml , 在里面定义我们的属性和声明我们的整个样式。
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="CustomTextView">
<attr name="titleText" format="string" />
<attr name="titleTextColor" format="color" />
<attr name="titleTextSize" format="dimension" />
</declare-styleable>
</resources>
然后在布局中声明我们的自定义View,一定要引入我们的命名空间xmlns:custom=”http://schemas.android.com/apk/res/com.hx.test”
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:custom="http://schemas.android.com/apk/res/com.hx.test"
android:layout_width="match_parent"
android:layout_height="match_parent" >
<com.hx.test.CustomTextView
android:layout_width="200dp"
android:layout_height="100dp"
custom:titleText="CustomTextView"
custom:titleTextColor="#ff0000"
custom:titleTextSize="20sp" />
</RelativeLayout>
在View的构造方法中,获得我们的自定义的属性,我们重写了3个构造方法,默认的布局文件调用的是两个参数的构造方法,所以记得让所有的构造调用我们的三个参数的构造,我们在三个参数的构造中获得自定义属性。
public CustomTextView(Context context, AttributeSet attrs)
{
this(context, attrs, 0);
}
public CustomTextView(Context context)
{
this(context, null);
}
/** * 获得我自定义的样式属性 * * @param context * @param attrs * @param defStyle */
public CustomTextView(Context context, AttributeSet attrs, int defStyle)
{
super(context, attrs, defStyle);
/** * 获得我们所定义的自定义样式属性 */
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.CustomTextView, defStyle, 0);
int n = a.getIndexCount();
for (int i = 0; i < n; i++)
{
int attr = a.getIndex(i);
switch (attr)
{
case R.styleable.CustomTextView_titleText:
mTitleText = a.getString(attr);
break;
case R.styleable.CustomTextView_titleTextColor:
mTitleTextColor = a.getColor(attr, Color.BLACK);
break;
case R.styleable.CustomTextView_titleTextSize:
// 默认设置为16sp,TypeValue也可以把sp转化为px
mTitleTextSize = a.getDimensionPixelSize(attr, (int) TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_SP, 16, getResources().getDisplayMetrics()));
break;
}
}
a.recycle();
/** * 获得绘制文本的宽和高 */
mPaint = new Paint();
mPaint.setTextSize(mTitleTextSize);
mPaint.setColor(mTitleTextColor);
mBound = new Rect();
mPaint.getTextBounds(mTitleText, 0, mTitleText.length(), mBound);
}
我们重写onDraw,onMesure调用系统提供的:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
@Override
protected void onDraw(Canvas canvas) {
mPaint.setColor(Color.GREEN);
canvas.drawRect(0, 0, getMeasuredWidth(), getMeasuredHeight(), mPaint);
mPaint.setColor(mTitleTextColor);
canvas.drawText(mTitleText, getWidth() / 2 - mBound.width() / 2,
getHeight() / 2 + mBound.height() / 2, mPaint);
}
运行效果图:
这已经基本已经实现了自定义View。但是此时如果我们把布局文件的宽和高写成wrap_content,会发现效果并不是我们的预期:
这是因为MATCH_PARENT 时系统返回的specMode的值为AT_MOST,而specSize为全屏,子视图默认占据了specSize中指定大小的范围。这时需要我们重写onDraw来实现自己的逻辑。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
{
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
int width;
int height ;
if (widthMode == MeasureSpec.EXACTLY) {
width = widthSize;
} else {
mPaint.setTextSize(mTitleTextSize);
mPaint.getTextBounds(mTitleText, 0, mTitleText.length(), mBound);
float textWidth = mBound.width();
int desired = (int) (getPaddingLeft() + textWidth + getPaddingRight());
width = desired;
}
if (heightMode == MeasureSpec.EXACTLY) {
height = heightSize;
} else {
mPaint.setTextSize(mTitleTextSize);
mPaint.getTextBounds(mTitleText, 0, mTitleText.length(), mBound);
float textHeight = mBound.height();
int desired = (int) (getPaddingTop() + textHeight + getPaddingBottom());
height = desired;
}
setMeasuredDimension(width, height);
}
我们再来修改下布局文件
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:custom="http://schemas.android.com/apk/res/com.hx.test"
android:layout_width="match_parent"
android:layout_height="match_parent" >
<com.hx.test.CustomTextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
custom:titleText="CustomTextView"
android:padding="10dp"
custom:titleTextColor="#ff0000"
android:layout_centerInParent="true"
custom:titleTextSize="20sp" />
</RelativeLayout>
完全复合我们的预期,现在我们可以对高度、宽度进行随便的设置了,基本可以满足我们的需求。我们还可以在构造函数中对它的点击事件做一些处理:
this.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
mTitleText = randomText();
postInvalidate();
//invalidate();
}
});
private String randomText() {
Random random = new Random();
int randomInt = random.nextInt(5);
return Str[randomInt];
}
其中
private String[] Str = {"Text0", "Text1", "Text2", "Text3", "Text4"};
可以看到点击事件已经生效了,每次当我们点击自定义View时显示的文字内容都发生变化。调用postInvalidate()就是让View重新执行onDraw,其实这里也可以使用invalidate(),都能生效。他们区别在于:
invalidate()得在UI线程中被调动,在工作者线程中可以通过Handler来通知UI线程进行界面更新。而postInvalidate()可以在工作者线程中被调用。
但是还有一个问题,我们看到点击后自定义View的文字变短了,但自定义View的宽却没有相应变窄,这不符合我们写的WRAP_CONTENT的思想。其实这是由于postInvalidate()只是强制View重新走onDraw,而View的宽高是在onMesure中定义的,所以文字改变后需要强制自定义View也要走onMesure,重新测量,继续修改代码:
this.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v){
mTitleText = randomText();
requestLayout();
}
});
完美实现我们的构想。总结一下RequestLayout用法:
RequestLayout:
当view确定自身已经不再适合现有的区域时,该view本身调用这个方法要求parent view重新调用他的onMeasure onLayout来对重新设置自己位置。
特别的当view的layoutparameter发生改变,并且它的值还没能应用到view上,这时候适合调用这个方法。也就是当通过getLayoutParrms().width = XXX的时候,我们需要重新调用RequestLayout。
invalidate:
View类调用迫使view重画(onDraw)。
Demo下载地址