自定义View

基础

自定义View的分类

  1. 继承View并重写onDraw方法

这种方法主要用于实现不规则的效果。采用这种方式需要自己支持wrap_content,并且padding要自己处理

  1. 继承ViewGroup

这种方法主要用于实现自定义的布局。采用这种方式需要合适地处理ViewGroup的测量、布局两个过程,并同时处理子元素的测量和布局过程

  1. 继承特定的View

这种方法主要用于扩展某种已有View的功能。这种方法不需要自己支持wrap_content和padding

  1. 继承特定的ViewGroup

一般来说,方法2能实现的效果方法4也能实现,主要差别在于方法2更接近View的底层。采用这种方法不需要自己处理ViewGroup的测量和布局

自定义View的注意事项

  1. 支持wrap_content

直接继承View或ViewGroup的控件,需要在onMeasure中对wrap_content做特殊处理,否则将会和match_parent的效果一样。

  1. 支持padding

直接继承View的控件,需要在draw方法中处理padding,否则padding属性无法生效。直接继承ViewGroup的控件,需要在onMeasure和onLayout中考虑padding和子元素margin对其造成的影响,否则会导致padding和子元素的margin失效。

  1. 尽量少用Handler,除非是要通过Handler发送消息

View内部本身就提供了post系列的方法,这些方法可以代替Handler的作用。所以除非明确地要使用Handler发送消息,否则尽量少用Handler。

  1. 需要及时停止View中的线程或动画

当View变得不可见时,需要及时停止线程和动画,如果不及时处理,可能会造成内存溢出。一般在onDetachedFromWindow方法中停止线程或动画,该方法在包含当前View的Activity退出或当前View被移除的时候回调。

  1. 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
35
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, @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);
}

@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);
//以自己的中心点为圆心,宽高的最小值为直径画一个红色的圆
}

然而,这并不是一个规范的自定义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
@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);

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
@Override
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
<?xml version="1.0" encoding="utf-8"?>
<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
12
public 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。

  1. 对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
    @Override
    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。

  1. 实现onLayout
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    @Override
    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。

  1. 处理滑动冲突

由于这个自定义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;

@Override
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;
}

  1. 弹性滑动到其他页面

在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());
//...
}

@Override
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);
}

@Override
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();
}

  1. 快速滑动时切换页面

除了滑动超过一半以外,如果滑动速度很快时,也会认为用户是要切换页面。需要在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
49
private 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;
}

}

  1. 应用

在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
<?xml version="1.0" encoding="utf-8"?>
<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 进阶之光》
-------------    本文到此结束  感谢您的阅读    -------------
0%