第18章 自定义View开发:从绘制到交互的完整实践
说实话,自定义View是Android开发里最让我着迷的部分。它就像一张白纸,你想画什么就画什么。我刚开始学的时候,觉得这东西高深莫测,后来才发现——说白了,就是搞清楚系统什么时候叫你画、怎么画、画完怎么响应。
这一章,我会带你走完自定义View的完整流程。从测量到布局,从绘制到触摸,再到自定义属性。嗯,内容不少,但每一步我都会结合我踩过的坑来讲。
核心知识点一览:
- View绘制三件套:onMeasure、onLayout、onDraw
- Canvas画布与Paint画笔的配合使用
- 自定义属性:从attrs.xml到代码获取
- 触摸反馈:onTouchEvent与手势识别
- 自定义ViewGroup:布局与测量规则
18.1 View绘制流程:系统到底在做什么?
你写一个自定义View,系统其实只关心三件事:你有多大?你放在哪?你长什么样?对应的方法就是onMeasure、onLayout、onDraw。我习惯把这叫做「View的三部曲」。
18.1.1 onMeasure:告诉系统你有多大
onMeasure是第一个被调用的。系统会传给你两个参数——widthMeasureSpec和heightMeasureSpec。这两个东西里封装了父布局对你的要求。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
// 处理wrap_content的情况
if (widthMode == MeasureSpec.AT_MOST) {
widthSize = 200; // 默认宽度
}
if (heightMode == MeasureSpec.AT_MOST) {
heightSize = 200; // 默认高度
}
setMeasuredDimension(widthSize, heightSize);
}
我的经验:很多新手自定义View时忘记处理AT_MOST模式。结果在XML里设了wrap_content,View却撑满了全屏。我曾经因为这个被测试小姐姐追着问了一下午……
18.1.2 onLayout:确定你的位置
对于单个View来说,onLayout其实没什么好做的。系统已经帮你算好了。但如果是ViewGroup,这里就是重头戏——你要决定每个子View放在哪。
18.1.3 onDraw:真正开始画了
这是最有趣的部分。系统给你一个Canvas,你拿着Paint往上画就行。
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 画一个圆
Paint paint = new Paint();
paint.setColor(Color.BLUE);
paint.setStyle(Paint.Style.FILL);
canvas.drawCircle(getWidth() / 2f, getHeight() / 2f, 100f, paint);
}
18.2 Canvas与Paint:画布和画笔的配合
Canvas是画布,Paint是画笔。你想想看,画画的时候是不是得先选好笔的颜色、粗细,再往画布上画?Android里也是一样的逻辑。
| Canvas方法 | 作用 | 常用场景 |
|---|---|---|
| drawCircle() | 画圆 | 圆形头像、按钮 |
| drawRect() | 画矩形 | 进度条、背景 |
| drawLine() | 画线 | 分割线、图表 |
| drawText() | 画文字 | 标签、数值显示 |
| drawPath() | 画路径 | 不规则图形、贝塞尔曲线 |
Paint的设置也很关键。我习惯把Paint的配置放在构造函数里,避免在onDraw里重复创建对象——那样会触发GC,导致掉帧。
public class MyView extends View {
private Paint mPaint;
public MyView(Context context, AttributeSet attrs) {
super(context, attrs);
mPaint = new Paint();
mPaint.setColor(Color.RED);
mPaint.setStrokeWidth(5f);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setAntiAlias(true); // 抗锯齿,必开
}
}
注意:不要在onDraw里new对象!onDraw每秒可能被调用60次,每次new对象会导致频繁GC。我曾经在一个自定义View里每次画的时候都new了一个Paint,结果列表滑动时卡成PPT……
18.3 自定义属性:让XML也能配置你的View
你写了一个自定义View,总不能每次都在代码里setColor吧?通过attrs.xml,你可以让用户在XML里直接配置属性。
第一步,在res/values/attrs.xml里声明:
<resources>
<declare-styleable name="MyCustomView">
<attr name="myColor" format="color" />
<attr name="mySize" format="dimension" />
<attr name="myText" format="string" />
</declare-styleable>
</resources>
第二步,在构造函数里读取:
public MyCustomView(Context context, AttributeSet attrs) {
super(context, attrs);
TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.MyCustomView);
int color = ta.getColor(R.styleable.MyCustomView_myColor, Color.BLACK);
float size = ta.getDimension(R.styleable.MyCustomView_mySize, 16f);
String text = ta.getString(R.styleable.MyCustomView_myText);
ta.recycle(); // 别忘了回收!
}
避坑指南:TypedArray用完一定要recycle()。我曾经漏掉这一行,结果在低版本Android上出现了内存泄漏。嗯,从那以后我每次写完都检查一遍。
18.4 触摸反馈处理:让View动起来
光能画还不够,用户点上去你得有反应。触摸反馈的核心是onTouchEvent方法。
@Override
public boolean onTouchEvent(MotionEvent event) {
float x = event.getX();
float y = event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
// 手指按下
mIsPressed = true;
invalidate(); // 触发重绘
return true; // 消费事件
case MotionEvent.ACTION_MOVE:
// 手指移动
updatePosition(x, y);
invalidate();
break;
case MotionEvent.ACTION_UP:
// 手指抬起
mIsPressed = false;
performClick(); // 别忘了触发点击事件
invalidate();
break;
}
return true;
}
你可能会问:为什么ACTION_DOWN要return true?因为系统只把事件分发给消费了ACTION_DOWN的View。如果这里返回false,后续的MOVE和UP都不会收到。
对于复杂的手势(比如滑动、缩放),我建议直接用GestureDetector。手写手势识别太容易出bug了——我试过,真的。
18.5 自定义ViewGroup:布局由你说了算
自定义ViewGroup比自定义View多了一个任务:你要管理子View的位置。核心就是重写onMeasure和onLayout。
18.5.1 onMeasure:测量所有子View
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
// 测量每个子View
measureChild(child, widthMeasureSpec, heightMeasureSpec);
}
// 计算自己的大小
int width = MeasureSpec.getSize(widthMeasureSpec);
int height = MeasureSpec.getSize(heightMeasureSpec);
setMeasuredDimension(width, height);
}
18.5.2 onLayout:摆放子View
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int childCount = getChildCount();
int currentTop = t;
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
int childWidth = child.getMeasuredWidth();
int childHeight = child.getMeasuredHeight();
// 垂直排列,每个子View占一行
child.layout(l, currentTop, l + childWidth, currentTop + childHeight);
currentTop += childHeight;
}
}
关键点:onLayout里用的是getMeasuredWidth(),不是getWidth()。前者是测量后的尺寸,后者是布局后的尺寸。在onLayout执行时,getWidth()可能还没更新。
18.6 实战:做一个带按压效果的圆形按钮
说了这么多,我们来个完整的例子。一个圆形按钮,支持自定义颜色和文字,点击时有缩放效果。
public class CircleButton extends View {
private Paint mPaint;
private int mColor;
private String mText;
private float mScale = 1.0f;
public CircleButton(Context context, AttributeSet attrs) {
super(context, attrs);
TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.CircleButton);
mColor = ta.getColor(R.styleable.CircleButton_cbColor, Color.BLUE);
mText = ta.getString(R.styleable.CircleButton_cbText);
ta.recycle();
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaint.setColor(mColor);
mPaint.setTextAlign(Paint.Align.CENTER);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int size = Math.min(
MeasureSpec.getSize(widthMeasureSpec),
MeasureSpec.getSize(heightMeasureSpec)
);
setMeasuredDimension(size, size);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.save();
canvas.scale(mScale, mScale, getWidth() / 2f, getHeight() / 2f);
// 画圆形背景
mPaint.setStyle(Paint.Style.FILL);
canvas.drawCircle(getWidth() / 2f, getHeight() / 2f,
getWidth() / 2f, mPaint);
// 画文字
mPaint.setColor(Color.WHITE);
mPaint.setTextSize(getWidth() * 0.3f);
canvas.drawText(mText, getWidth() / 2f,
getHeight() / 2f + getWidth() * 0.1f, mPaint);
canvas.restore();
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
mScale = 0.9f;
invalidate();
return true;
case MotionEvent.ACTION_UP:
mScale = 1.0f;
performClick();
invalidate();
break;
}
return true;
}
}
这个例子虽然简单,但涵盖了自定义View的完整流程。你可以在XML里这样用:
<com.example.CircleButton
android:layout_width="100dp"
android:layout_height="100dp"
app:cbColor="#FF5722"
app:cbText="Go" />
最后说一句:自定义View没有想象中那么难。你只要记住三个方法、两个对象、一个文件——onMeasure/onLayout/onDraw、Canvas/Paint、attrs.xml。剩下的就是多画多练。我刚开始的时候,光画一个带阴影的圆就折腾了两天。但现在回头看,那些坑都是财富。
公众号:蓝海资料掘金营,微信deep3321