Android自定义View详细教程

Catalogue
  1. 1 从构造方法讲起
  2. 2 自定义布局属性
  3. 3 自定义LayoutParams
    1. 3.1 大致明确布局容器的需求,初步定义布局属性
    2. 3.2 继承LayoutParams,定义布局参数类
    3. 3.3 重写generateLayoutParams()
    4. 3.4 在布局文件中使用布局属性
    5. 3.5 在onMeasure和onLayout中使用布局参数
  4. 4 支持layout_margin属性
  5. 5 补充知识:多点触控

自定义View是Android开发的一个重要模块,我们可以将其分为三大类。
一、布局。
二、绘制。
三、触摸反馈。
以上在扔物线老师写的 Hencoder 系列教程中写得已经很详细了,本文是站在此基础上做的一些补充说明。

1 从构造方法讲起

View有四种形式的构造方法,其中四个参数的构造方法是API 21才出现,所以一般我们只需要重写其他三个构造方法即可。它们的参数不一样分别对应不同的创建方式,比如只有一个Context参数的构造方法通常是通过代码初始化控件时使用;而两个参数的构造方法通常对应布局文件中控件被映射成对象时调用(需要解析属性);通常我们让这两个构造方法最终调用三个参数的构造方法,然后在第三个构造方法中进行一些初始化操作。

1
2
3
4
5
6
7
8
9
10
11
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);
//初始化操作
……
}

2 自定义布局属性

如果有些属性我们希望由用户指定,只有当用户不指定的时候才用我们硬编码的值,比如上面的默认尺寸,我们想要由用户自己在布局文件里面指定该怎么做呢?那当然是通过自定义属性,让用户用我们定义的属性啦~
首先我们需要在res/values/styles.xml文件(如果没有请自己新建)里面声明一个我们自定义的属性:

1
2
3
4
5
6
7
<resources>
<!--name为声明的"属性集合"名,可以随便取,但是最好是设置为跟我们的View一样的名称-->
<declare-styleable name="MyView">
<!--声明我们的属性,名称为default_size,取值类型为尺寸类型(dp,px等)-->
<attr name="default_size" format="dimension" />
</declare-styleable>
</resources>

接下来就是在布局文件用上我们的自定义的属性啦~

1
2
3
4
5
6
7
8
9
10
11
12
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:hc="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">

<com.hc.studyview.MyView
android:layout_width="match_parent"
android:layout_height="100dp"
hc:default_size="100dp" />

</LinearLayout>

注意:如果是我们的自定义属性,就需要在根标签(LinearLayout)里面设定命名空间。命名空间的一种写法是xmlns:hc=”http://schemas.android.com/apk/res-auto",res-auto表示自动查找,还有一种写法是xmlns:hc="http://schemas.android.com/apk/com.hc.studyview.MyView",`com.hc.studyview.MyView`为我们的应用程序包名。而命名空间的名称可以随意取,如上就是`hc`。
最后就是在我们的自定义的View里面把我们自定义的属性的值取出来,在构造函数中,还记得有个AttributeSet属性吗?就是靠它帮我们把布局里面的属性取出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
private int defalutSize;
public MyView(Context context, AttributeSet attrs) {
super(context, attrs);
//第二个参数就是我们在styles.xml文件中的<declare-styleable>标签
//即属性集合的标签,在R文件中名称为R.styleable+name
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MyView);
//第一个参数为属性集合里面的属性,R文件名称:R.styleable+属性集合名称+下划线+属性名称
//第二个参数为默认值
defalutSize = a.getDimensionPixelSize(R.styleable.MyView_default_size, 100);

//最后记得将TypedArray对象回收
a.recycle();
}

3 自定义LayoutParams

ViewGroup中有两个内部类ViewGroup.LayoutParams和ViewGroup.MarginLayoutParams,MarginLayoutParams继承自LayoutParams,这两个内部类就是ViewGroup的布局参数类,比如我们在LinearLayout等布局中使用的layout_width\layout_height等以“layout_ ”开头的属性都是布局属性。

为什么LayoutParams 类要定义在ViewGroup中?
大家都知道ViewGroup是所有容器的基类,一个控件需要被包裹在一个容器中,这个容器必须提供一种规则控制子控件的摆放,比如你的宽高是多少,距离那个位置多远等。所以ViewGroup有义务提供一个布局属性类,用于控制子控件的布局属性。

在View中有一个mLayoutParams的变量用来保存这个View的所有布局属性。ViewGroup.LayoutParams有两个属性layout_width和layout_height,因为所有的容器都需要设置子控件的宽高,所以这个LayoutParams是所有布局参数的基类,如果需要扩展其他属性,都应该继承自它。比如RelativeLayout中就提供了它自己的布局参数类RelativeLayout.LayoutParams,并扩展了很多布局参数,我们平时在RelativeLayout中使用的布局属性都来自它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<declare-styleable name= "RelativeLayout_Layout">
<attr name ="layout_toLeftOf" format= "reference" />
<attr name ="layout_toRightOf" format= "reference" />
<attr name ="layout_above" format="reference" />
<attr name ="layout_below" format="reference" />
<attr name ="layout_alignBaseline" format= "reference" />
<attr name ="layout_alignLeft" format= "reference" />
<attr name ="layout_alignTop" format= "reference" />
<attr name ="layout_alignRight" format= "reference" />
<attr name ="layout_alignBottom" format= "reference" />
<attr name ="layout_alignParentLeft" format= "boolean" />
<attr name ="layout_alignParentTop" format= "boolean" />
<attr name ="layout_alignParentRight" format= "boolean" />
<attr name ="layout_alignParentBottom" format= "boolean" />
<attr name ="layout_centerInParent" format= "boolean" />
<attr name ="layout_centerVertical" format= "boolean" />
<attr name ="layout_alignWithParentIfMissing" format= "boolean" />
<attr name ="layout_toStartOf" format= "reference" />
<attr name ="layout_toEndOf" format="reference" />
<attr name ="layout_alignStart" format= "reference" />
<attr name ="layout_alignEnd" format= "reference" />
<attr name ="layout_alignParentStart" format= "boolean" />
<attr name ="layout_alignParentEnd" format= "boolean" />
</declare-styleable >

为什么View中会有一个mLayoutParams变量?
我们在之前学习自定义控件的时候学过自定义属性,我们在构造方法中,初始化布局文件中的属性值,我们姑且把属性分为两种。一种是本View的绘制属性,比如TextView的文本、文字颜色、背景等,这些属性是跟View的绘制相关的。另一种就是以“layout_”打头的叫做布局属性,这些属性是父控件对子控件的大小及位置的一些描述属性,这些属性在父控件摆放它的时候会使用到,所以先保存起来,而这些属性都是ViewGroup.LayoutParams定义的,所以用一个变量保存着。

看了上面的介绍,我们大概知道怎么为我们的布局容器定义自己的布局属性了吧,就不绕弯子了,按照下面的步骤做:

3.1 大致明确布局容器的需求,初步定义布局属性

在定义属性之前要弄清楚,我们自定义的布局容器需要满足那些需求,需要哪些属性,比如,我们现在要实现像相对布局一样,为子控件设置一个位置属性layout_position=”“,来控制子控件在布局中显示的位置。暂定位置有五种:左上、左下、右上、右下、居中。有了需求,我们就在attr.xml定义自己的布局属性(和之前讲的自定义属性一样的操作)。

1
2
3
4
5
6
7
8
9
10
11
12
<?xml version="1.0" encoding= "utf-8"?>
<resources>
<declare-styleable name ="CustomLayout">
<attr name ="layout_position">
<enum name ="center" value="0" />
<enum name ="left" value="1" />
<enum name ="right" value="2" />
<enum name ="bottom" value="3" />
<enum name ="rightAndBottom" value="4" />
</attr >
</declare-styleable>
</resources>

left就代表是左上(按常理默认就是左上方开始,就不用写leftTop了,简洁一点),bottom左下,right 右上,rightAndBottom右下,center居中。属性类型是枚举,同时只能设置一个值。

3.2 继承LayoutParams,定义布局参数类

我们可以选择继承ViewGroup.LayoutParams,这样的话我们的布局只是简单的支持layout_width和layout_height;也可以继承MarginLayoutParams,就能使用layout_marginxxx属性了。因为后面我们还要用到margin属性,所以这里方便起见就直接继承MarginLayoutParams了。
覆盖构造方法,然后在有AttributeSet参数的构造方法中初始化参数值,这个构造方法才是布局文件被映射为对象的时候被调用的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public static class CustomLayoutParams extends MarginLayoutParams {
public static final int POSITION_MIDDLE = 0; // 中间
public static final int POSITION_LEFT = 1; // 左上方
public static final int POSITION_RIGHT = 2; // 右上方
public static final int POSITION_BOTTOM = 3; // 左下角
public static final int POSITION_RIGHTANDBOTTOM = 4; // 右下角

public int position = POSITION_LEFT; // 默认我们的位置就是左上角

public CustomLayoutParams(Context c, AttributeSet attrs) {
super(c, attrs);
TypedArray a = c.obtainStyledAttributes(attrs,R.styleable.CustomLayout );
//获取设置在子控件上的位置属性
position = a.getInt(R.styleable.CustomLayout_layout_position ,position );

a.recycle();
}

public CustomLayoutParams( int width, int height) {
super(width, height);
}

public CustomLayoutParams(ViewGroup.LayoutParams source) {
super(source);
}

}

3.3 重写generateLayoutParams()

在ViewGroup中有下面几个关于LayoutParams的方法,generateLayoutParams (AttributeSet attrs)是在布局文件被填充为对象的时候调用的,这个方法是下面几个方法中最重要的,如果不重写它,我们布局文件中设置的布局参数都不能拿到。其他几个方法我们最好也能重写一下,将里面的LayoutParams换成我们自定义的CustomLayoutParams类,避免以后会遇到布局参数类型转换异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
public LayoutParams generateLayoutParams(AttributeSet attrs) {
return new CustomLayoutParams(getContext(), attrs);
}
@Override
protected ViewGroup.LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) {
return new CustomLayoutParams (p);
}
@Override
protected LayoutParams generateDefaultLayoutParams() {
return new CustomLayoutParams (LayoutParams.MATCH_PARENT , LayoutParams.MATCH_PARENT);
}
@Override
protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
return p instanceof CustomLayoutParams ;
}

3.4 在布局文件中使用布局属性

注意引入命名空间xmlns:openxu= “http://schemas.android.com/apk/res/包名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<?xml version="1.0" encoding= "utf-8"?>
<com.openxu.costomlayout.CustomLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:openxu= "http://schemas.android.com/apk/res/com.openxu.costomlayout"
android:background="#33000000"
android:layout_width= "match_parent "
android:layout_height= "match_parent" >

<Button
android:layout_width= "wrap_content"
android:layout_height= "wrap_content"
openxu:layout_position= "left"
android:background= "#FF8247"
android:textColor= "#ffffff"
android:textSize="20dip"
android:padding= "20dip"
android:text= "按钮1" />

<Button
android:layout_width= "wrap_content"
android:layout_height= "wrap_content"
openxu:layout_position= "right"
android:background= "#8B0A50"
android:textColor= "#ffffff"
android:textSize= "18dip"
android:padding= "10dip"
android:text= "按钮2222222222222" />

<Button
android:layout_width= "wrap_content"
android:layout_height= "wrap_content"
openxu:layout_position= "bottom"
android:background= "#7CFC00"
android:textColor= "#ffffff"
android:textSize= "20dip"
android:padding= "15dip"
android:text= "按钮333333" />

</com.openxu.costomlayout.CustomLayout>

3.5 在onMeasure和onLayout中使用布局参数

经过上面几步之后,我们运行程序,就能获取子控件的布局参数了,在onMeasure方法和onLayout方法中,我们按照自定义布局容器的特殊需求,对宽度和位置坐特殊处理。

4 支持layout_margin属性

如果我们自定义的布局参数类继承自MarginLayoutParams,就自动支持了layout_margin属性了,我们需要做的就是直接在布局文件中使用layout_margin属性,然后在onMeasure和onLayout中使用margin属性值测量和摆放子控件。需要注意的是我们测量子控件的时候应该调用measureChildWithMargin()方法,以保证测量子View大小时将margin考虑在内。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
public class TagLayout extends ViewGroup {
private List<Rect> rects = new ArrayList<>();

public TagViewGroup(Context context) {
super(context);
}

public TagViewGroup(Context context, AttributeSet attrs) {
super(context, attrs);
}

public TagViewGroup(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}


@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int lineUsedWidth = 0;//当前行的宽度
int lineUsedHeight = 0;//当前行的高度,取最大的
int usedWidth = 0;//一共使用了的宽度,取最大的
int usedHeight = 0;//一共使用了的高度
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);

for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
measureChildWithMargins(child, widthMeasureSpec, lineUsedWidth, heightMeasureSpec, 0);
MarginLayoutParams params = (MarginLayoutParams) child.getLayoutParams();
if (widthMode != MeasureSpec.UNSPECIFIED && lineUsedWidth + params.leftMargin + params.rightMargin + child.getMeasuredWidth() > widthSize) {//换行
usedHeight += lineUsedHeight;
lineUsedWidth = 0;
lineUsedHeight = 0;
}

Rect rect;
if (rects.size() <= i) {
rect = new Rect();
rects.add(rect);
} else {
rect = rects.get(i);
}
rect.left = params.leftMargin + lineUsedWidth;
rect.top = params.topMargin + usedHeight;
rect.right = rect.left + child.getMeasuredWidth();
rect.bottom = rect.top + child.getMeasuredHeight();

lineUsedWidth += child.getMeasuredWidth() + params.leftMargin + params.rightMargin;
lineUsedHeight = Math.max(lineUsedHeight, child.getMeasuredHeight() + params.bottomMargin + params.topMargin);
usedWidth = Math.max(usedWidth, lineUsedWidth);
}
usedHeight += lineUsedHeight;
setMeasuredDimension(resolveSize(usedWidth, widthMeasureSpec), resolveSize(usedHeight, heightMeasureSpec));
}

@Override
public LayoutParams generateLayoutParams(AttributeSet attrs) {
return new MarginLayoutParams(getContext(), attrs);
}

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
Rect rect = rects.get(i);
child.layout(rect.left, rect.top, rect.right, rect.bottom);
}
}
}

特别注意:measureChildWithMargins 方法的作用是在计算子 view 的尺寸时,将 margin 值也考虑在内,但是参考 getChildMeasureSpec 源码就可以发现,当子 view 的值是一个具体的值时,margin 并不会影响最终计算的值。另外,子 view 的最终的尺寸受多个因素的影响,具体看下面的源码吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
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 = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size;
resultMode = MeasureSpec.UNSPECIFIED;
} else if (childDimension == LayoutParams.WRAP_CONTENT) {
// Child wants to determine its own size.... find out how
// big it should be
resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size;
resultMode = MeasureSpec.UNSPECIFIED;
}
break;
}
//noinspection ResourceType
return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
}

TagLayout有现成的轮子?
笔者从同事那里了解到,Google确实提供了这样一个控件,但我没有使用过,先放在这里,下次有类似需求的时候再试试。google/flexbox-layout

5 补充知识:多点触控

  1. MotionEvent.actionIndex()表示当前引发事件的手指 index,它只在 MotionEvent.ACTION_POINTER_UP MotionEvent.ACTION_POINTER_DOWN 中有用,其它事件中均返回 0。
  2. 多点触控的两个重要成员分别是:index 和 id。index 用来遍历手指,通过 MotionEvent.findPointerIndex(int pointerId) 获取或者从 0...pointerCount 中选择一个 index 值。id 用来追踪手指,通过 MotionEvent.getPointerId(int pointerIndex) 获取。注意,一般的流程是:id 先得到 index 后,再通过 index 访问指定手指的 x , y 值,如getX(index)
  3. move 事件并不是某个手指引发的,而是所有手指只要按下了,那么就不能区分到底是谁正在引发 move 事件。所以即使一个手指按下后静止不动,另一个手指按下后在移动,也不能在 move 事件中定位到到底是哪个手指在移动。
  4. 多点触控开发中要注意在 MotionEvent.ACTION_POINTER_UP MotionEvent.ACTION_POINTER_DOWN 发生时按照业务需求重置 x,y 的状态,因为此时屏幕中的手指数量有变化。