1. 自定义 View 常见的分类
继承 View 重写 onDraw 方法
- 主要用于实现一些不规则的效果,即这种效果不方便通过布局的组合方式来达到,往往需要静态或动态显示一些不规则的图形
- 这种方式需要自己支持 wrap_content,并且 padding 也需要处理
继承 ViewGroup 派生特殊的 Layout
- 主要用于实现自定义的布局,当某种效果看起来很像几种 View 组合在一起的时候,可以采用这种方法
- 这种方法稍微复杂一些,需要合适地处理 ViewGroup 的测量、布局这两个过程,并同时处理子元素的测量和布局过程
继承特定的 View(比如 TextView)
- 一般用于扩展已有的 View 的功能,比较常见也较容易实现
- 不需要自己支持 wrap_content 和 padding 等
继承特定的 ViewGroup(比如 LinearLayout)
- 当某种效果看起来很像几种 View 组合在一起的时候,可以采用这种方法,比较常见
- 不需要自己处理 ViewGroup 的测量和布局这两个过程(方法 2 能实现的效果方法 4 也都能实现,方法 2 更接近 View 的底层)
2. 自定义 View 常见的注意事项
让 View 支持 wrap_content
- 因为直接继承 View 或者 ViewGroup 的控件,如果不在 onMeasure 中对 wrap_content 做特殊处理,那么当外界在布局中使用 wrap_content 时就无法达到预期的效果
- 上一篇博客条目 3.5 有说明
如果有必要,让自定义的 View 支持 padding
- 因为直接继承 View 的控件,如果不在 draw 方法中处理 padding,那么 padding 属性是无法起作用的
- 另外,直接继承自 ViewGroup 的控件需要在 onMeasure 和 onLayout 中考虑 padding 和子元素的 margin 对其造成的影响,否则将导致 padding 和子元素的 margin 失效
尽量不要在 View 中使用 Handler,没必要
- 因为 View 内部本身就提供了 post 系列的方法,完全可以代替 Handler 的作用
- 除非很明确需要使用 Handler 发送消息
View 中如果有线程或者动画,需要及时停止,参考 View#onDetachedFromWindow
- 如果有线程或者动画需要停止时,那么
onDetachdFromWindow()
是一个很好的时机 - 当包含此 View 的 Activity 退出或者当前 View 被 remove 时,View 的
onDetachedFromWindow()
方法会被调用。与之对应的是onAttachdToWindow()
方法,当包含此 View 的 Activity 启动时,View 的onAttachedToWindow()
会被调用 - 当 View 变得不可见时也需要停止线程和动画,如果不及时处理,可能会造成内存泄漏
- 如果有线程或者动画需要停止时,那么
View 带有滑动嵌套情形时,需要处理好滑动冲突
3. Demo: 继承 View 重写 onDraw 方法
自定义一个控件,绘制一个圆,必须要考虑到 wrap_content 和 padding。同时为了提高便捷性和复用性,实现自定义属性对外提供
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// 实现一个具有圆形效果的自定义 View,在自己的中心点以宽高的最小值为直径绘制一个红色的实心圆
public class CircleView extends View {
private int mColor = Color.RED;
private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
public CircleView(Context context) {
super(context);
init();
}
public CircleView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public CircleView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
mPaint.setColor(mColor);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
int radius = Math.min(width, height) / 2;
canvas.drawCircle(width / 2, height / 2, radius, mPaint);
}
}调整布局参数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17<LinearLayout xmlns:android="http://schamas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#ffffff"
android:orientation="vertical" >
<com.szy.chapter_4.ui.CircleView
android:id="@+id/circleView1"
<!--android:layout_width="match_parent"-->
android:layout_width="wrap_content"
android:layout_height="100dp"
android:layout_margin="20dp"
android:padding="20dp"
android:background="#000000" />
</LinearLayout>- 运行之后发现宽度使用 wrap_content 和 使用 match_parent 没有任何区别。即:对于直接继承自 View 的控件,如果不对 wrap_content 做特殊处理,那么使用 wrap_content 就相当于使用 match_parent
- 运行之后发现 padding 没有生效。即:直接继承自 View 和 ViewGroup 的控件,padding 是默认无法生效的,需要自己处理
- 运行之后发现 margin 属性是生效的。即:margin 属性是由父容器控制的,可以直接生效,不需要自己做特殊处理
布局参数问题的解决方案
针对 wrap_content 不生效的问题:这里只需要在 onMeasure 方法中指定一个 wrap_content 模式的默认宽高即可,比如选择 200px 作为默认的宽高
针对 padding 不生效的问题:在绘制的时候考虑一下 padding 即可,修改后 onDraw 方法:
1
2
3
4
5
6
7
8
9
10
11
12// 思路:绘制的时候考虑到 View 四周的空白即可,其中圆心和半径都会考虑到 View 四周的 padding,做相应的调整
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
final int paddingLeft = getPaddingLeft();
final int paddingRight = getPaddingRight();
final int paddingTop = getPaddingTop();
final int paddingBottom = getPaddingBottom();
int width = getWidth() - paddingLeft - paddingRight;
int height = getHeight() - paddingTop - paddingBottom;
int radius = Math.min(width, height) / 2;
canvas.drawCircle(paddingLeft + width / 2, paddingTop + height / 2, radius, mPaint);
}
为提高易用性和复用性,这里提供自定义属性(以 android 开头的属性是系统自带的属性)。添加自定义属性的步骤:
在 values 目录下创建自定义属性的 XML 文件,文件名没有限制,建议以 attrs 开头
1
2
3
4
5
6
7// 文件名为 attrs.xml
<resources>
<declare-styleable name="CircleView">
<attr name="circle_color" format="color" />
</declare-styleable>
</resource>在 View 的构造方法中解析自定义属性的值并做相应处理
1
2
3
4
5
6
7public CircleView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypeArray ta = context.obtainStyledAttributes(attrs, R.styleable.CircleView);
mColor = ta.getColor(R.styleable.CircleView_circle_color, Color.RED); // 如果没有指定属性值,则默认值为红色
ta.recycle(); // 释放资源
init();
}在布局文件中使用自定义的属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17<LinearLayout xmlns:android="http://schema.android.com/apk/res/android"
xmlns:app="http://schema.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#ffffff"
android:orientation="vetical" >
<com.szy.chapter_4.ui.CircleView
android:id="@+id/circleView1"
android:layout_width="wrap_content"
android:layout_height="100dp"
android:layout_margin="20dp"
app:circle_color="@color/light_green"
android:padding="20dp"
android:background="#000000" />
</LinearLayout>- 为了使用自定义属性,必须在布局文件中添加 schemas 声明:
xmlns:app=http://schema.android.com/apk/res-auto
。app 是自定义属性的前缀,可以换其他名字,但 CircleView 中的自定义属性的前缀必须和这里的一致 - 也可以这样声明 schemas:
xmlns:app=http://schema.android.com/apk/res/com.szy.chapter_4
,这种方式会在 apk/res/ 后面附加应用的包名
- 为了使用自定义属性,必须在布局文件中添加 schemas 声明:
CircleView 的完整代码:
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// 此时的 CircleView 已经是一个很规范的自定义的 View
public class CircleView extends View {
private int mColor = Color.RED;
private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
public CircleView(Context context) {
super(context);
init();
}
public CircleView(Context context, AttributeSet atts) {
this(context, attrs, 0);
}
public CircleView(Context context, AttributeSet atts, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypeArray ta = context.obtainStyledAttributes(attrs, R.styleable.CircleView);
mColor = ta.getColor(R.styleable.CircleView_circle_color, Color.RED);
ta.recycle();
init();
}
private void init() {
mPaint.setColor(mColor);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
// 解决 wrap_content 的问题
if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
setMeasureDimension(200, 200);
} else if (widthSpecMode == MeasureSpec.AT_MOST) {
setMeasureDimension(200, heightSpecSize);
} else if (heightSpecMode == MeasureSpec.AT_MOST) {
setMeasureDimension(widthSpecSize, 200);
}
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
final int paddingLeft = getPaddingLeft();
final int paddingRight = getPaddingRight();
final int paddingTop = getPaddingTop();
final int paddingBottom = getPaddingBottom();
// 解决 padding 的问题
int width = getWidth() - paddingLeft - paddingRight;
int height = getHeight() - paddingTop - paddingBottom;
int radius = Math.min(width, height) / 2;
canvas.drawCircle(paddingLeft + width / 2, paddingTop + height / 2, radius, mPaint);
}
}
4. Demo: 继承 ViewGroup 派生特殊的 Layout
自定义一个类似于水平方向的 LinearLayout 的控件,内部的子元素可以进行水平滑动并且子元素的内部还可以进行竖直滑动。注意解决滑动冲突问题以及自身和子元素的 padding、margin 等问题
HorizontalScrollViewEx 的完整代码:
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
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185public class HorizontalScrollViewEx extends ViewGroup {
private static final String TAG = "HorizontalScrollViewEx";
private int mChildrenSize;
private int mChildWidth;
private int mChildIndex;
// 分别记录上次滑动的坐标
private int mLastX = 0;
private int mLastY = 0;
// 分别记录上次滑动的坐标(onInterceptTouchEvent)
private int mLastXIntercept = 0;
private int mLastYIntercept = 0;
private Scroller mScroller;
private VelocityTracker mVelocityTracker;
public HorizontalScrollViewex(Context context) {
super(context);
init();
}
public HorizontalScrollViewEx(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public HorizontalScrollViewEx(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
init();
}
private void init() {
if (mScroller == null) {
mScroller = new Scroller(getContext());
mVelocityTracker = VelocityTracker.obtain();
}
}
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
boolean intercepted = false;
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
intercepted = false;
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
intercepted = true;
}
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastXIntercept;
int deltaY = y - mLastYIntercept;
if (Math.abs(deltaX) > Math.abs(deltaY)) {
intercepted = true;
} else {
intercepted = false;
}
break;
case MotionEvent.ACTION_UP:
intercepted = false;
break;
default:
break;
}
Log.d(TAG, "intercepted=" + intercepted);
mLastX = x;
mLastY = y;
mLastXIntercepted = x;
mLastYIntercepted = y;
return intercepted;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
mVelocityTracker.addMovement(event);
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (mScroller.isFinished()) {
mScroller.abortAnimation();
}
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastX;
int deltaY = y - mLastY;
scrollBy(-deltaX, 0);
break;
case MotionEvent.ACTION_UP:
int scrollX = getScrollX();
mVelocityTracker.computeCurrentVelocity(1000);
float xVelocity = mVelocityTracker.getXVelocity();
if (Math.abs(xVelocity) >= 50) {
mChildIndex = xVelocity > 0 ? mChildIndex - 1 : mChildIndex + 1;
} else {
mChildIndex = (scrollX + mChildWidth / 2) / mChildWidth;
}
mChildIndex = Math.max(0, Math.min(mChildIndex, mChildrenSize - 1));
int dx = mChildIndex * mChildWidth - scrollX;
smoothScrollBy(dx, 0);
mVelocityTracker.clear();
break;
default:
break;
}
mLastX = x;
mLastY = y;
return true;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int measuredWidth = 0;
int measuredHeight = 0;
final int childCount = getChildCount();
measureChildren(widthMeasureSpec, heightMeasureSpec);
int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
int heightSpecMode = MeasureSpec.getMode(heightMeasrueSpec);
if (childCount == 0) {
setMeasuredDimension(0, 0); // 根据 LayoutParams 设置似乎更合理
} else if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
final View childView = getChildAt(0);
measuredWith = childView.getMeasuredWidth() * childCount;
measuredHeight = childView.getMeasuredHeight();
setMeasuredDimension(measuredWidth, measuredHeight);
} else if (heightSpecMode == MeasureSpec.AT_MOST) {
final View childView = getChildAt(0);
// TODO: measuredHeight = childView.getMeasuredHeight();
// TODO: setMeasuredDimension(widthSpecSize, childView.getMeasuredHeight());
} else if (widthSpecMode == MeasureSpec.AT_MOST) {
final View childView = getChildAt(0);
measuredWidth = childView.getMeasuredWidth() * childCount;
setMeasuredDimension(measuredWidth, heightSpecSize);
}
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int childLeft = 0;
final int childCount = getChildCount();
mChildSize = childCount;
for (int i = 0; i < childCount; i++) {
final View childView = getChildAt(i);
if (childView.getVisibility() != View.GONE) {
final int childWidth = childView.getMeasuredWidth();
mChilWidth = childWidth;
childView.layout(childLeft, 0, childLeft + childWidth, childView.getMeasuredHeight());
childLeft += childWidth();
}
}
}
private void smoothScrollBy(int dx, int dy) {
mSroller.startScroll(getScrollX(), 0, dx, 0, 500);
invalidate();
}
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
postInvalidate();
}
}
@Override
protected void onDetachedFromWindow() {
mVelocityTracker.recycle();
super.onDetachedFromWindow();
}
}