这是Android UI Fundamentals里的内容
创建自定义视图
创建自定义UI组件首先要继承一个视图类.
首先创建一个简单的自定义视图, 展示一条十字线.
需要做的第一件事是创建一个继承自View的CrossView类.
public CrossView(Context context, AttributeSet attrs) {
super(context, attrs);
}
该构造函数的第二个参数是用来传递XML参数的, 等会儿会讲到. 接下来我们要重写两个基础方法: onMeasure
和 onDraw
.
onMeasure
系统调用onMeasure
方法来决定视图及其子视图的尺寸. 它的两个参数的类型都是int
, 但是这俩参数并非普通的数字, 而是两个MeasureSpec
, MeasureSpec
是一个模式和一个整型尺寸值的结合, 被当成一个整数来实现. 其中模式值有如下几种情况:
模式 | 解释 |
---|---|
UNSPECIFIED | 父视图没有在这个视图上做任何限制, 它可以是任意尺寸 |
AT_MOST | 该视图可以是小于等于MeasureSpec 中尺寸的任意大小 |
EXACTLY | 父视图要求该视图必须是MeasureSpec 指定的尺寸大小 |
当你创建一个自定义视图并重写onMeasure
方法时, 必须正确处理每种情况, 得到相应的尺寸, 然后必须在onMeasure
中调用setMeasureDimensions
方法, 参数就是你决定的尺寸, 如果不调用就会抛出异常.
下面是重写的onMeasure
方法代码.
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(calculateMeasure(widthMeasureSpec), calculateMeasure(heightMeasureSpec));
}
注意其中calculateMeasure
方法是我们自己定义的, 下面我们来完成这个方法.
我们先定义一个默认的尺寸100, 单位是dp(我暂时不确定是不是dp).
private static final int DEFAULT_SIZE = 100;
乘上设备的像素密度, 得到实际显示需要的像素值.
int result = (int) (DEFAULT_SIZE * getResources().getDisplayMetrics().density);
然后我们需要从MeasureSpec中拿到模式和尺寸
int specMode = MeasureSpec.getMode(measureSpec);
int specSize = MeasureSpec.getSize(measureSpec);
接下来我们根据specMode
的情况来判断result
的值到底应该是什么.
MeasureSpec.UNSPECIFIED
此时父控件对自定义视图的尺寸没有要求, 那么我就以默认大小为结果, 也就是说
int result = (int) (DEFAULT_SIZE * getResources().getDisplayMetrics().density);
MeasureSpec.AT_MOST
此时父控件认为最多不能超过指定尺寸值, 那么此时我们选指定值和默认值中最小的那个就行, 无论哪种情况这种选法都是合法的.
result = Math.min(specSize, result);
MeasureSpec.EXACTLY
此时父控件要求子视图必须是给定的尺寸, 那么我们让result
等于它就好
result = specSize;
综合上面的讨论, 最终我们的方法代码如下:
private static final int DEFAULT_SIZE = 100;
private int calculateMeasure(int measureSpec) {
int result = (int) (DEFAULT_SIZE * getResources().getDisplayMetrics().density);
int specMode = MeasureSpec.getMode(measureSpec);
int specSize = MeasureSpec.getSize(measureSpec);
if (specMode == MeasureSpec.EXACTLY) {
result = specSize;
} else if (specMode == MeasureSpec.AT_MOST) {
result = Math.min(specSize, result);
}
return result;
}
onDraw
当视图应当绘制其内容时会调用onDraw
方法. 在重写它之前, 我们先创建一个Paint
对象, 它处理诸如颜色和文本大小之类的事情.
通过CrossView
的构造函数来创建Paint
对象
private Paint mPaint;
public CrossView(Context context, AttributeSet attrs) {
super(context, attrs);
mPaint = new Paint();
mPaint.setAntiAlias(true);
mPaint.setColor(0xffff0000);
}
上面的代码新建了Paint
对象, 并设置抗锯齿和颜色.
接下来重写onDraw
方法, 模板如下, canvas.save()
和canvas.restore()
我就不解释了, 不影响后面的理解.
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.save();
// code goes here
canvas.restore();
}
我们基于视图的尺寸缩放画布, 这样我们可以使用0到1之间的浮点数来作为画线时的坐标
private static final float[] mPoints = {0.5f, 0f,
0.5f, 1f,
0f, 0.5f,
1f, 0.5f};
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.save();
canvas.scale(getWidth(), getHeight());
canvas.drawLines(mPoints, mPaint);
canvas.restore();
}
我们在activity的xml里面加入我们的自定义控件
<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:orientation="vertical"
android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
android:paddingTop="@dimen/activity_vertical_margin"
android:paddingBottom="@dimen/activity_vertical_margin"
tools:context=".MainActivity">
<com.shaw.uitest.CrossView
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<com.shaw.uitest.CrossView
android:layout_marginTop="10dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
运行一下就可以看到文章开头的截图画面了.
向自定义视图中添加自定义属性
有了自定义视图, 我们希望它能通过自定义XML属性来配置, 要做到这一点, 需要先声明属性, 然后在XML布局中添加一个新的命名空间, 最后处理被传递给自定义视图构造函数的AttributeSet对象.
声明属性
在res/values/目录下创建一个attrs.xml(可以是别的名字)的文件, 然后在其中添加如下内容:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="cross">
<attr name="android:color" />
<attr name="rotation" format="string" />
</declare-styleable>
</resources>
declare-styleable
元素有一个name属性, 用来在代码中的引用自定义属性, 每个自定义的属性都使用一个attr
元素来声明, attr
元素有name和format两个属性, name用于引用, format代表它的数据类型, 如果使用了默认的系统属性, 就不需要定义format了, 如果尝试给已有的属性定义一个不同的format, 则工程无法build. 在外层声明的attr
可以被其他declare-styleable
复用, 就和使用系统属性一样, 比如:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<attr name="test" format="string" />
<declare-styleable name="foo">
<attr name="test" />
</declare-styleable>
<declare-styleable name="bar">
<attr name="test" />
</declare-styleable>
</resources>
也可以给属性创建自定义值, 例如
<attr name="enum_attr">
<enum name="value1" value="1" />
<enum name="value2" value="2" />
</attr>
<attr name="flag_attr">
<flag name="flag1" value="0x01" />
<flag name="flag2" value="0x02" />
</attr>
enum
和flag
都要求是整数. 不同之处在于flag
可以使用|
来拼接. 比如android:gravity
的值就是flag.
在XML中使用属性
要使用在我们的XML中的新属性, 首先必须为视图声明namespace. 其实我们经常见到namespace的声明, 比如我们常在activity的xml文件中看到
xmlns:android="http://schemas.android.com/apk/res/android"
这个namespace声明了所有以关键词android
开头的属性都可以在android包中找到. 要使用自定义属性, 需要声明一个带有新包名的新namespace, 下面为CrossView的属性添加一个新的namespace, 并在自定义视图中添加相关的xml配置:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:crossview="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
android:paddingTop="@dimen/activity_vertical_margin"
android:paddingBottom="@dimen/activity_vertical_margin"
tools:context=".MainActivity">
<com.shaw.uitest.CrossView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
crossview:rotation="30"
android:color="#ff0000ff"/>
<com.shaw.uitest.CrossView
android:layout_marginTop="10dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
crossview:rotation="50"
android:color="#ff00ff00"/>
</LinearLayout>
上面声明了所有以crossview
(名字可以用别的)开头的属性都可以在res中找到. 这是Gradle要求的写法.
在代码中使用XML属性
在CrossView
的构造函数中传入了一个AttributeSet
对象, 我们可以通过它获取XML布局中声明的属性.
更新CrossView
的构造函数并添加相应函数和成员变量:
private float mRotation;
public CrossView(Context context, AttributeSet attrs) {
super(context, attrs);
mPaint = new Paint();
mPaint.setAntiAlias(true);
TypedArray arr = getContext().obtainStyledAttributes(attrs, R.styleable.cross);
int color = arr.getColor(R.styleable.cross_android_color, Color.BLACK);
float rotation = arr.getFloat(R.styleable.cross_rotation, 0f);
arr.recycle();
setColor(color);
setRotation(rotation);
}
public void setColor(int color) {
mPaint.setColor(color);
}
public void setRotation(float degree) {
mRotation = degree;
}
同时更新onDraw
的代码
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.save();
canvas.scale(getWidth(), getHeight());
canvas.rotate(mRotation, 0.5f, 0.5f);
canvas.drawLines(mPoints, mPaint);
canvas.restore();
}
我们的旋转中心是画布中心, 而不是左上角.
现在运行这个程序, 截图如下: