让自定义 View 支持 ScrollView

看过《Android 开发艺术探索》一书的小伙伴都知道,这本书将自定义 View 分成四个类型,分别是:

  • 继承 View 重写 onDraw 方法
  • 继承 ViewGroup 派生特殊的 Layout
  • 继承已有的 View
  • 继承已有的 ViewGroup

我们本次并不讨论具体的类型应该如何实现,自定义 View 的范围实在是太宽广了,只有想不到,没有做不到。在书中任玉刚大大还提到了自定义 View 应该注意的几个方面:

  • 让 View 支持 wrap_content
  • 让 View 支持 padding
  • 尽量不要在 View 中使用 Handler
  • View 中有线程或者动画,需要及时停止
  • View 有滑动嵌套情形的,需要处理好滑动冲突

这些注意事项都非常有用,即使是一个新手做自定义 View,在本书的指引下,遵循这些标准也能做出可用性较高的自定义 View,比如说我(微笑)。不过我在实践的过程中发现一个任玉刚大大没有提到的方面,那就是让自定义 View 支持 ScrollView,毕竟 ScrollView 已经是个非常常用的布局了。

首先看一个小例子,我们就拿书中的自定义 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
public class CircleView extends View {
private int mColor = getResources().getColor(R.color.colorAccent);
private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);

public CircleView(Context context) {
super(context);
init();
}

public CircleView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}

public CircleView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.CircleView);
mColor = array.getColor(R.styleable.CircleView_circle_color,
getResources().getColor(R.color.colorAccent));
array.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);
if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(200, 200);
} else if (widthSpecMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(200, heightSpecSize);
} else if (heightSpecMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(widthSpecSize, 200);
}
}

@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;
int height = getHeight() - paddingTop - paddingBottom;
int radius = Math.min(width, height) / 2;
canvas.drawCircle(paddingLeft + width / 2, paddingTop + height / 2, radius, mPaint);
}
}

以上代码除了颜色我没有做其他改动,将这个 CircleView 放在纵向的 LinearLayout 中,宽度设置 match_parent,高度设置 wrap_content,背景色设置为黑色,为了比较,在其下面放一个 TextView,我们来看看显示的结果:

还是很正常的,符合我们的预期。

如果在 Layout 最外层套一个 ScrollView,再来看看:

自定义 View 看不见了!首先自定义 View 的外层是 LinearLayout,高度是 match_parent,从常理来分析,ScrollView 内部的高度无限大的,如果内部的 View 的不做精确设置,可能会导致 View 无限大,所以 ScrollView 内部没有设置精确高度的 View 都会无法显示,除非内部做特殊处理。比如下面的 TextView ,设置的高度也是 wrap_content,但它却能显示,为什么呢?按照我们在 onMeasure 方法中的逻辑,如果自定义 View 是大小不定,也就是对应 MeasureSpec.AT_MOST,那么宽高都应该为默认的 200 才对,这样也不会不显示。那么就调试一下看看:

heightMeasureSpec 的值是0,我们知道 MesureSpec 是一个 32 位的 int 值,高 2 位表示测量模式,低 30 位表示在这种模式下的测量值。显然这不属于任何一种 MeasureSpec 已知的模式,所以自定义 View 无法获得测量高度,也就无法显示了。知道了原因就好办了,只需要对 heightMeasureSpec 的值作出识别处理就行了,比如下面是我的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
//避免在 scrollView 里获取不到高度
if (heightMeasureSpec == 0) {
heightMeasureSpec = MeasureSpec.makeMeasureSpec(widthSpecSize, MeasureSpec.AT_MOST);
}
int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);

if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(300, 300);
} else if (widthSpecMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(300, heightSpecSize);
} else if (heightSpecMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(widthSpecSize, widthSpecSize);
}
}

如果无法获取 heightMeasureSpec,就用 widthSpecSize 重新实例化一个 heightMeasureSpec 出来,模式设置为 AT_MOST,值默认与宽度相同,如果获取不到高度,就默认设置为与宽度相同。因为这里是个圆,那么就有个好处,即使宽高设置的都是 match_parent,那么真正的高度也只是最大宽大的值,毕竟在 ScrollView 中高度是不会有 match_parent 的效果的。当然根据自己的 View 的用途最好设置适合的默认值。

看看效果:

再把高度设置为 match_parent

一样的效果,这种在 ScrollView 中就算是一种比较合理的方式,并且完全不会影响自定义 View 在非 ScrollView 布局中的表现。所以除了任玉刚大大提到的 5 点注意事项,我还想再加一条,那就是 让自定义 View 支持 ScrollView