说起Android 自定义View,网上的博客、视频很多。鸿洋的博客和视频还是很值得推荐的。本文打算结合Sdk源码,来讲解如何自定义一个View。
本文结合TextView的源码,看看怎么实现一个简单的自定义View。如果你想下载源码,可以看看这篇文章,Ubuntu完美下载Android源码。 有源码后,可以使用Source Insight这个工具打开。如果没有Android源码,但是有SDK的jar包源码,那么使用IDE工具中就可以查看SDK的源码!
自定义View的步骤一般有以下4步:
(1). 自定义View的属性;
(2). 在View的构造方法中获取自定义的属性以及属性值;
(3). 重写onMeasure();
(4). 重写onDraw() 。
接下来,我们就结合TextView的源码来实现一个简单的自定义View。
1. 自定义View的属性。
首先看看Android framework源码attrs.xml中有关TextView的属性的代码中是如何实现的,代码示例:
...
...
可以看出,自定义属性,需要用到PS:有关
看完源码后,我们可以仿照源码的写法,来编写自定义属性。在res/values文件夹下的atts.xml,创建我们需要的view属性。如果没有atts.xml,请手动创建。具体代码如下:
以上就是自定义属性,是不是很简单呢!
2. 在View的构造方法中获取自定义的属性以及属性值。
老规矩,还是先看Textview是如何实现的,上代码:
public class TextView extends View implements ViewTreeObserver.OnPreDrawListener {
...
public TextView(Context context) {
this(context, null);
}
public TextView(Context context,
AttributeSet attrs) {
this(context, attrs, com.android.internal.R.attr.textViewStyle);
}
@SuppressWarnings("deprecation")
public TextView(Context context,
AttributeSet attrs,
int defStyle) {
super(context, attrs, defStyle);
...
TypedArray a = theme.obtainStyledAttributes(attrs,
com.android.internal.R.styleable.TextViewAppearance, defStyleAttr, defStyleRes);
...
a = theme.obtainStyledAttributes( attrs, com.android.internal.R.styleable.TextView, defStyleAttr, defStyleRes);
int n = a.getIndexCount();
for (int i = 0; i < n; i++) {
int attr = a.getIndex(i);
switch (attr) {
case com.android.internal.R.styleable.TextView_editable:
editable = a.getBoolean(attr, editable);
break;
case com.android.internal.R.styleable.TextView_inputMethod:
inputMethod = a.getText(attr);
break;
...
}
}
a.recycle();
...
}
...
}
只罗列了重要的代码,但是这些就足够说明问题了。
回到代码,有三个构造方法,分别是一个参数、两个参数、三个参数,并且一个参数的构造方法调用两个参数的构造方法,两个参数的构造方法调用三个参数的构造方法,三个参数的构造方法调用父类的构造方法。那么我们重点看看三个参数的构造方法。其中,
(1). 通过TypedArray获取自定义的属性集合。有关TypedArray的详细说明,可以看这篇文章, Android View(四)-View相关属性详解。
TypedArray a = theme.obtainStyledAttributes(attrs,
com.android.internal.R.styleable.TextViewAppearance, defStyleAttr, defStyleRes);
(2). 分别获取自定义属性。循环从属性集合中获取属性值。
(3). 记得最后要释放TypedArray,调用a.recycle()。
PS:
1. 好多文章在讲解自定义View时,获取属性值这一步的实现可能是底下这一种方式,具体代码如下:
String text = array.getString(R.styleable.BottomWidget_tv_text);
float textSize = array.getDimension(R.styleable.BottomWidget_tv_textSize, 0);
int textColor = array.getColor(R.styleable.BottomWidget_tv_textColor, 0);
int background = array.getDrawable(R.styleable.BottomWidget_iv_background);
array.recycle();
首先这种写法并没有错,但是这种写法有一个坑,就是当某一个属性,没有设置值时,它也会给该属性一个默认值,这样的话,就可能会出问题。所以在此建议,在获取自定义View属性值时,使用循环从属性集合中获取属性值,具体代码如下所示:
for (int i = 0; i < n; i++) {
int attr = a.getIndex(i);
switch (attr) {
case com.android.internal.R.styleable.TextView_editable:
editable = a.getBoolean(attr, editable);
break;
...
}
}
2. 有关构造方法到底是调用自己的方法还是调用父类的。
源码中,我们看到了的现象是,一个参数的构造方法调用两个参数的构造方法,两个参数的构造方法调用三个参数的构造方法,三个参数的构造方法调用父类的构造方法;但是如果我们自定义的View是继承自某一个控件,例如Button,那么建议,构造方法调用的规则是,构造方法调用相应的父类构造方法。因为只有这样,该自定义View才能继承父View的一些样式。
总结:如果我们自定义的View是继承至某一个控件,需要使用到该控件的样式,那么构造方法要调用相应的父类构造方法,代码是‘super(...)’;如果我们是集成自View,那么就可以成‘this(...)’。
下面,我们就根据上面的描述,获取自定义属性值,代码如下,
private int firstColor;//第一种颜色
private int secondeColor;//第二种颜色
private int progress = 1;//当前音量
private int firstColorDefault = Color.BLUE;//默认颜色
private int secondColorDefault = Color.RED;//默认颜色
private int progressDefault = 0;//默认值
private int splitSize = 5;//间隔高度
private int mWidth = 100;//每个小块的宽度
private int mHeight =30;//每个小块的高度
private final int maxProgress = 10;//最大音量
private Paint mPaint;//画笔
private float stockWidth = 5;//描边的宽度
private int stockColor = Color.BLACK;//描边的颜色
private float left = 0;
private float top = 0;
private float right = 0;
private float bottom = 0;
public AduioView(Context context) {
this(context,null);
}
public AduioView(Context context, AttributeSet attrs) {
this(context, attrs,0);
}
public AduioView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
final Resources.Theme theme = context.getTheme();
TypedArray ta = theme.obtainStyledAttributes(attrs, R.styleable.AduioView, defStyleAttr, 0);
int n = ta.getIndexCount();
for (int i = 0; i < n; i++) {
int attr = ta.getIndex(i);
switch (attr) {
case R.styleable.AduioView_firstColor:
firstColor = ta.getColor(attr, firstColorDefault);
break;
case R.styleable.AduioView_secondColor:
secondeColor = ta.getColor(attr, secondColorDefault);
break;
case R.styleable.AduioView_progress:
progress = ta.getInteger(attr, progressDefault);
break;
}
}
ta.recycle();
mPaint = new Paint();
}
获取到自定义属性值后,就可能需要测量以及绘制。那么第三步,我们先绘制,检验一下不测量先绘制的影响
。
3. 重写onDraw() 方法。
首页,还是看看Textview的onDraw()是如何实现的,上代码:
@Override
protected void onDraw(Canvas canvas) {
restartMarqueeIfNeeded();
// Draw the background for this view
super.onDraw(canvas);
...
final int scrollX = mScrollX;
final int scrollY = mScrollY;
final int right = mRight;
final int left = mLeft;
final int bottom = mBottom;
final int top = mTop;
final boolean isLayoutRtl = isLayoutRtl();
final int offset = getHorizontalOffsetForDrawables();
final int leftOffset = isLayoutRtl ? 0 : offset;
final int rightOffset = isLayoutRtl ? offset : 0 ;
final Drawables dr = mDrawables;
if (dr != null) {
/*
* Compound, not extended, because the icon is not clipped
* if the text height is smaller.
*/
int vspace = bottom - top - compoundPaddingBottom - compoundPaddingTop;
int hspace = right - left - compoundPaddingRight - compoundPaddingLeft;
// IMPORTANT: The coordinates computed are also used in invalidateDrawable()
// Make sure to update invalidateDrawable() when changing this code.
if (dr.mShowing[Drawables.LEFT] != null) {
canvas.save();
canvas.translate(scrollX + mPaddingLeft + leftOffset,
scrollY + compoundPaddingTop +
(vspace - dr.mDrawableHeightLeft) / 2);
dr.mShowing[Drawables.LEFT].draw(canvas);
canvas.restore();
}
...
}
...
}
...
onDraw()方法,无非是在画布(Canvas)上使用画笔(Paint)绘制View。
@Override
protected void onDraw(Canvas canvas) {
mPaint.setAntiAlias(true);//设置抗锯齿
mPaint.setColor(stockColor);//设置描边颜色
mPaint.setStrokeWidth(stockWidth);//设置描边宽度
drawOval(canvas);//绘制矩形
}
/*
* 绘制图形
* */
private void drawOval(Canvas canvas) {
left = 0;// 左坐标
right = 100;// 右坐标
bottom = mHeight;// 下坐标
mPaint.setColor(firstColor);//设置画笔的颜色
//循环计算矩形的坐标点,绘制底部矩形
for (int i = 0; i < maxProgress; i++) {
top = i * (mHeight + splitSize);//上坐标(每个矩形的高度+间隔高度)*i
bottom = i * (mHeight + splitSize) + mHeight;// 下坐标(每个矩形的高度+间隔高度)*i+矩形的高度
canvas.drawRect(left, top, right, bottom, mPaint);//绘制矩形 (左上角坐标,右下角坐标,画笔)
}
mPaint.setColor(secondeColor);//设置画笔的颜色
//循环计算矩形的坐标点,绘制第二层矩形
for (int i = 0; i < progress; i++) {
top = mHeight * (maxProgress - i) + (maxProgress - i - 1) * splitSize - mHeight;//上坐标
bottom = mHeight * (maxProgress - i) + (maxProgress - i - 1) * splitSize;// 下坐标
canvas.drawRect(left, top, right, bottom, mPaint);//绘制矩形 (左上角坐标,右下角坐标,画笔)
}
}
代码都有注释,不难理解!如果对
画笔(Paint)
和画布
(Canvas)
还不了解,请看这篇文章
Android 绘图(一) Paint
和
Android 绘图(二) Canvas 。
打开布局xm文件,首先需要在最外层的ViewGroup中加入命名空间,Android Studio中命名空间的写法是这样,‘ xmlns:aduio="http://schemas.android.com/apk/res-auto"’,其中‘aduio’ 是命名空间。如果是在Eclipse中,命名空间的写法,‘xmlns:aduio="http://schemas.android.com/apk/res/cn.xinxing.customeview"’,其中‘aduio’ 是命名空间,‘cn.xinxing.customeview’是应用的包名。下面是xml的代码,
如果你使用
Android Studio
,还可以看到设置的颜色,截图如下,所以
,
推荐使用
Android Studio
。
到这儿,是不是自定义View就完了呢?非也非也!因为,我们知道onMeasure()方法还未重写!但是没有重写onMeasure()方法,好像也没发现有什么问题!此时,我们修改一下布局文件中引入自定义View的属性,例如修改android:layout_height="wrap_content",并且给该View加入了一个黑色背景。再次运行,看效果,截图如下所示,
是不是很奇怪呢?为何设置‘android:layout_height="wrap_content"’后,高度怎么充满父控件了呢?感觉它的值和‘android:layout_height="match_parent"’是一样的?确实是这样的。通过阅读View的源码可以得出,View中的属性‘android:layout_height=" "’’,当设置为‘wrap_content’或者‘match_parent’,其效果都和‘match_parent’一样的,充满父控件;当设置为一个具体的数值,那么效果基本和设置的值保持一致。所以,在自定义View的时候,我们最好重写onMeasure()方法。
(4). 重写onMeasure()方法。
还是先看看Textview的onMeasure()是如何实现的,上代码:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
int width;
int height;
BoringLayout.Metrics boring = UNKNOWN_BORING;
BoringLayout.Metrics hintBoring = UNKNOWN_BORING;
int des = -1;
boolean fromexisting = false;
if (widthMode == MeasureSpec.EXACTLY) {
// Parent has told us how big to be. So be it.
width = widthSize;
} else {
if (mLayout != null && mEllipsize == null) {
des = desired(mLayout);
}
}
...
}
通过MeasureSpec这个类,获取到建议的测量模式和测量值,然后根据View自身的特性,最后计算出适合自己的测量值。有关MeasureSpec这个类,可以查看这篇文章, Android View(三)-MeasureSpec详解。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthMode = MeasureSpec.getMode(widthMeasureSpec);//获取宽度的测试模式
int widthSize = MeasureSpec.getSize(widthMeasureSpec);//获取宽度的测试值
int width;
//如果宽度的测试模式等于EXACTLY,就直接赋值
if (widthMode == MeasureSpec.EXACTLY) {
width = widthSize;
} else {
width = mWidth;//使用我们自己在代码中定义的宽度
//如果宽度的测试模式等于AT_MOST,取测量值和计算值的最小值
if (widthMode == MeasureSpec.AT_MOST) {
width = Math.min(width, widthSize);
}
}
int heightMode = MeasureSpec.getMode(heightMeasureSpec);//获取高度的测试模式
int heightSize = MeasureSpec.getSize(heightMeasureSpec);//获取高度的测试值
int height;
//如果高度的测试模式等于EXACTLY,就直接赋值
if (heightMode == MeasureSpec.EXACTLY) {
height = heightSize;
} else {
//计算出整个View的高度
height = mHeight * maxProgress + (maxProgress - 1) * splitSize;
//如果高度的测试模式等于AT_MOST,取测量值和计算值的最小值
if (heightMode == MeasureSpec.AT_MOST) {
height = Math.min(height, heightSize);
}
}
setMeasuredDimension(width, height);//来存储测量的宽,高值
}
重写onMeasure()方法后,我们再次修改android:layout_height=" "的值,上截图,
(android:layout_height="match_parent") (android:layout_height="wrap_content")
效果很明显,分别设置三种不同的值,效果都基本一致!
至此,自定义View就完成了!
总结:
自定义View的一般步骤就是以上4步,平时按照这几步去实现,就可以了!
PS:例子工程下载路径。
推荐文章:Android View(三)-MeasureSpec详解。
Android View(四)-View相关属性详解。
Android 绘图(一) Paint 。
Android 绘图(二) Canvas。