基础
自定义View的分类
- 继承View并重写onDraw方法
这种方法主要用于实现不规则的效果。采用这种方式需要自己支持wrap_content,并且padding要自己处理。
- 继承ViewGroup
这种方法主要用于实现自定义的布局。采用这种方式需要合适地处理ViewGroup的测量、布局两个过程,并同时处理子元素的测量和布局过程。
- 继承特定的View
这种方法主要用于扩展某种已有View的功能。这种方法不需要自己支持wrap_content和padding。
- 继承特定的ViewGroup
一般来说,方法2能实现的效果方法4也能实现,主要差别在于方法2更接近View的底层。采用这种方法不需要自己处理ViewGroup的测量和布局。
自定义View的注意事项
- 支持wrap_content
直接继承View或ViewGroup的控件,需要在onMeasure中对wrap_content做特殊处理,否则将会和match_parent的效果一样。
- 支持padding
直接继承View的控件,需要在draw方法中处理padding,否则padding属性无法生效。直接继承ViewGroup的控件,需要在onMeasure和onLayout中考虑padding和子元素margin对其造成的影响,否则会导致padding和子元素的margin失效。
- 尽量少用Handler,除非是要通过Handler发送消息
View内部本身就提供了post系列的方法,这些方法可以代替Handler的作用。所以除非明确地要使用Handler发送消息,否则尽量少用Handler。
- 需要及时停止View中的线程或动画
当View变得不可见时,需要及时停止线程和动画,如果不及时处理,可能会造成内存溢出。一般在onDetachedFromWindow方法中停止线程或动画,该方法在包含当前View的Activity退出或当前View被移除的时候回调。
- View带有滑动嵌套情形时,需要处理好滑动冲突
例子
继承View重写onDraw方法
这种方法主要用于实现一些不规则的效果,一般需要重写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
35public 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, @Nullable AttributeSet attrs) {
super(context, attrs);
init();
}
public CircleView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
mPaint.setColor(mColor);
}
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);
//以自己的中心点为圆心,宽高的最小值为直径画一个红色的圆
}
然而,这并不是一个规范的自定义View。举个例子,CircleView的布局代码如下:1
2
3
4
5
6
7
8
9
10<com.feng.viewtest.CircleView
android:id="@+id/circleView2"
android:layout_width="wrap_content"
android:layout_height="100dp"
android:layout_margin="20dp"
android:padding="20dp"
android:background="@android:color/holo_blue_light"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
运行后,显示结果如下:

从图中可以看出,这个圆的margin属性是有效的,这是因为margin属性是由父容器控制的,我们不需要做特殊处理。但是wrap_content属性不起作用(和match_parent效果一样),而且padding也没有效果(直接继承自View和ViewGroup的控件,padding是默认无法生效的)。所以我们需要做些处理才能使这两个属性生效。
对wrap_content属性的处理:重写onMeasure方法,指定wrap_content属性的宽高1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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);
if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(mWidth, mHeight); //通过mWidth, mHeight来指定wrap_content属性的宽高
} else if (widthSpecMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(mWidth, heightSpecSize);
} else if (heightSpecMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(widthSpecSize, mHeight);
}
}
对padding的处理:在绘制的时候考虑padding问题1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int paddingLeft = getPaddingLeft();
int paddingRight = getPaddingRight();
int paddingTop = getPaddingTop();
int paddingBottom = getPaddingBottom();
int width = getWidth() - paddingLeft - paddingRight; //圆的实际宽高要在最终宽高的基础上减去padding
int height = getHeight() - paddingTop - paddingBottom;
int radius = Math.min(width, height) / 2;
//画圆的时候也要注意padding问题
canvas.drawCircle(paddingLeft + width / 2, paddingTop + height / 2, radius, mPaint);
}
经过处理后,效果如下:

可以看出padding和自己设置的wrap_content都是有效的
自定义属性
如果我们想要让这个圆有多种颜色,并且不用在代码中实现,而是直接在xml布局里面设置,那么就可以使用自定义属性。例如,我们可以自定义一个circle_color属性,通过该属性可以设置圆的颜色。具体步骤如下:
第一步,在values目录下创建自定义属性的XML,如attrs.xml,文件内容如下:1
2
3
4
5
6
<resources>
<declare-styleable name="CircleView">
<attr name="circle_color" format="color"/>
</declare-styleable>
</resources>
第二步,在View的构造方法中解析自定义属性的值并做相应处理1
2
3
4
5
6
7
8
9
10
11
12public CircleView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
//加载自定义属性集合
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.CircleView);
//解析集合的circle_color属性,并设置颜色,如果没有指定该属性则默认是红色
mColor = typedArray.getColor(R.styleable.CircleView_circle_color, Color.RED);
//解析完后通过recycle方法来释放资源
typedArray.recycle();
init();
}
第三步,在布局文件中使用自定义属性1
2
3
4
5
6
7
8
9
10
11<com.feng.viewtest.CircleView
android:id="@+id/circleView2"
android:layout_width="wrap_content"
android:layout_height="100dp"
android:layout_margin="20dp"
android:padding="20dp"
android:background="@android:color/holo_blue_light"
app:circle_color="@android:color/holo_orange_light"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
通过以上三步,运行后发现圆的颜色已经变成了橘黄色,以后想更改圆的颜色只要设置circle_color属性就可以了。
继承ViewGroup
这里自定义一个HorizontalView继承于ViewGroup,其功能为左右滑动切换不同的页面,类似一个简化版的ViewPager。
- 对wrap_content属性进行处理
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
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
//1. 对wrap_content属性进行处理
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
//先测量子元素
measureChildren(widthMeasureSpec, heightMeasureSpec);
//如果没有子元素,则设置宽高为0
if (getChildCount() == 0) {
setMeasuredDimension(0, 0);
}
//判断宽高设置wrap_content的情况
else if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) {
View childOne = getChildAt(0);
int childWidth = childOne.getMeasuredWidth();
int childHeight = childOne.getMeasuredHeight();
setMeasuredDimension(childWidth * getChildCount(), childHeight);
}
else if (widthMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(getChildAt(0).getMeasuredWidth()
* getChildCount(), heightSize);
}
else if (heightMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(widthSize, getChildAt(0).getMeasuredHeight());
}
}
这里没有子元素时,直接将宽高设置为0。正常情况下,我们应该根据LayoutParams中的宽高做相应的处理。另外,这里美丽考虑自己的padding和子元素的margin。
- 实现onLayout
1
2
3
4
5
6
7
8
9
10
11
12
13
14
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int childCount = getChildCount();
int left = 0;
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
//若子View不是GONE状态,就将其放于上一元素右面
if (child.getVisibility() != GONE) {
int width = child.getMeasuredWidth();
child.layout(left, 0, left+width, child.getMeasuredHeight());
left += width;
}
}
}
将状态不为GONE的子元素从左往右,一个紧接一个地放置。这里同样没有考虑自己的padding和子元素的margin。
- 处理滑动冲突
由于这个自定义ViewGroup是水平滑动的,如果里面的View为垂直滑动,则会引起滑动冲突。解决的方法是:如果检测到滑动的方向是水平的,就由该父View来拦截事件,确保父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
private int lastX;
private int lastY;
private int lastInterceptX;
private int lastInterceptY;
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercept = false;
int x = (int) ev.getX();
int y = (int) ev.getY();
switch (ev.getAction()) {
case MotionEvent.ACTION_MOVE:
int deltaX = x - lastInterceptX;
int deltaY = y - lastInterceptY;
if (Math.abs(deltaX) > Math.abs(deltaY)) {
//若为水平滑动,拦截该事件
intercept = true;
}
break;
default:
break;
}
lastX = x;
lastY = y;
lastInterceptX = x;
lastInterceptY = y;
return intercept;
}
- 弹性滑动到其他页面
在onTouchEvent中进行滑动切换页面的处理,这里用到了Scroller。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
private int mLastX;
private int mLastY;
private int mLastInterceptX;
private int mLastInterceptY;
private int mCurrentIndex = 0; //当前子元素索引
private int mChildWidth = 0; //子元素的宽度
private Scroller mScroller;
private void init() {
mScroller = new Scroller(getContext());
//...
}
public boolean onTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_MOVE:
//控件随手指滑动
int deltaX = x - mLastX;
scrollBy(-deltaX, 0);
break;
case MotionEvent.ACTION_UP:
int distance = getScrollX() - mCurrentIndex * mChildWidth;
//如果滑动距离大于子元素宽度的1/2,则切换页面
if (Math.abs(distance) > mChildWidth/2) {
if (distance > 0) {
mCurrentIndex++;
} else {
mCurrentIndex--;
}
}
smoothScrollTo(mCurrentIndex * mChildWidth, 0);
break;
default:
break;
}
mLastX = x;
mLastY = y;
return super.onTouchEvent(event);
}
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
postInvalidate();
}
}
//弹性滑动到指定位置
public void smoothScrollTo(int destX, int destY) {
mScroller.startScroll(getScrollX(), getScrollY(), destX - getScrollX(),
destY - getScrollY(), 1000);
invalidate();
}
- 快速滑动时切换页面
除了滑动超过一半以外,如果滑动速度很快时,也会认为用户是要切换页面。需要在onTouchEvent中处理快速滑动,这里用到VelocityTracker。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
49private VelocityTracker tracker;
private void init() {
//...
tracker = VelocityTracker.obtain();
}
@Override
public boolean onTouchEvent(MotionEvent event) {
//省略其他
switch (event.getAction()) {
//...
case MotionEvent.ACTION_UP:
int distance = getScrollX() - mCurrentIndex * mChildWidth;
//如果滑动距离大于子元素宽度的1/2,则切换页面
if (Math.abs(distance) > mChildWidth/2) {
if (distance > 0) {
mCurrentIndex++;
} else {
mCurrentIndex--;
}
} else {
//获取水平方向的速度
tracker.computeCurrentVelocity(1000);
float xV = tracker.getXVelocity();
//如果速度的绝对值大于50,则认为是快速滑动
if (Math.abs(xV) > 50) {
if (xV > 0) {
mCurrentIndex--;
} else {
mCurrentIndex++;
}
}
}
//避免索引超出范围
mCurrentIndex = mCurrentIndex < 0 ? 0 : mCurrentIndex > getChildCount()-1 ?
getChildCount() - 1 : mCurrentIndex;
smoothScrollTo(mCurrentIndex * mChildWidth, 0);
//重置速度计算器
tracker.clear();
break;
default:
break;
}
}
- 应用
在HorizontalView中嵌入3个ListView,xml的编写如下: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
<RelativeLayout 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"
tools:context=".MainActivity">
<com.feng.customviewgroup.HorizontalView
android:layout_width="match_parent"
android:layout_height="match_parent">
<ListView
android:id="@+id/lv_main_list_one"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
<ListView
android:id="@+id/lv_main_list_two"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
<ListView
android:id="@+id/lv_main_list_three"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</com.feng.customviewgroup.HorizontalView>
</RelativeLayout>
最后在主界面设置各个ListView的数据即可。
参考
- 《Android 开发艺术探索》
- 《Android 进阶之光》