07.20 控件-自定义View与绘图
前几节介绍的布局组件(06 布局组件)和系统控件(07.01 ~ 07.19)已经能覆盖大多数界面需求,但总有系统控件"画不出来"的时候:
- 需要一个水波纹进度球、仪表盘、粒子动画等特殊视觉效果
- 需要控件拥有系统没有的交互方式(如侧滑删除、拖拽排序)
- 需要统一封装一组复杂控件的行为(如折线图表、日历选择器)
这时就需要自定义 View。自定义 View 是 Android UI 进阶的分水岭:它要求理解 View 的绘制流程(测量、布局、绘制)、Canvas/Paint 绘图体系,以及上一节的事件分发机制(08 事件处理)。
# 自定义 View 的三种方式
按继承对象的不同,自定义 View 有三种典型方式,选型取决于需求:
| 方式 | 继承对象 | 适用场景 | 示例 |
|---|---|---|---|
| 继承 View | View | 完全自己绘制,不规则图形/特效 | 进度圆环、水波纹、折线图 |
| 继承已有控件 | TextView / ImageView 等 | 在系统控件基础上微调/扩展 | 带角标的 ImageView |
| 继承 ViewGroup | LinearLayout 等 | 自定义布局规则、组合子元素 | 流式布局、侧滑菜单 |
# View 绘制三大流程(原理)
自定义 View 之前必须理解 View 是如何被"画"到屏幕上的。整个绘制过程分为三大步骤,由 ViewRootImpl 发起:
# 1. onMeasure:测量
决定 View 的大小。系统传入 MeasureSpec(测量规格),View 在其中完成自身测量,最后必须调用 setMeasuredDimension() 存储结果。
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// 默认实现:根据MeasureSpec计算(EXACTLY时取spec值,AT_MOST/UNSPECIFIED时取默认最小值)
setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}
MeasureSpec 是一个 32 位的 int 值,高 2 位是模式,低 30 位是尺寸。三种模式:
| 模式 | 含义 | 对应布局参数 | 典型场景 |
|---|---|---|---|
EXACTLY | 精确大小,子 View 必须是这个尺寸 | match_parent / 精确值 100dp | 父容器已定死大小 |
AT_MOST | 最大不能超过 specSize | wrap_content | 内容自适应,但不超过父容器 |
UNSPECIFIED | 不限制,想要多大给多大 | 少见(ScrollView 对子 View) | 系统内部测量 |
自定义 View 时对 wrap_content 的经典坑:直接继承 View 时,wrap_content 的效果与 match_parent 一样(默认实现把 AT_MOST 当成了 match_parent 处理)。所以自定义 View 通常要重写 onMeasure,对 AT_MOST 模式自行计算内容大小:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int width = measureSize(widthMeasureSpec, mDefaultWidth);
int height = measureSize(heightMeasureSpec, mDefaultHeight);
setMeasuredDimension(width, height);
}
// 根据MeasureSpec与期望值计算最终尺寸
private int measureSize(int measureSpec, int expectSize) {
int mode = MeasureSpec.getMode(measureSpec);
int size = MeasureSpec.getSize(measureSpec);
switch (mode) {
case MeasureSpec.EXACTLY:
// 精确值或match_parent:直接用系统给的尺寸
return size;
case MeasureSpec.AT_MOST:
// wrap_content:取期望值与父容器限制的较小值
return Math.min(expectSize, size);
case MeasureSpec.UNSPECIFIED:
default:
return expectSize;
}
}
# 2. onLayout:定位
决定 View 的位置。View.onLayout 默认是空实现(叶子 View 的位置由父容器调用 child.layout(l, t, r, b) 摆放时确定);自定义 ViewGroup 时必须重写 onLayout,遍历子元素并调用它们的 layout() 方法赋坐标。
// ViewGroup.onLayout:确定每个子元素的位置
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
int childLeft = 0;
int childTop = 0;
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
// 把测量出的宽高摆放到指定坐标(4个参数是相对父容器的左、上、右、下)
child.layout(childLeft, childTop,
childLeft + child.getMeasuredWidth(),
childTop + child.getMeasuredHeight());
// 下一个子元素排到右边(如横向排列)
childLeft += child.getMeasuredWidth();
}
}
# 3. onDraw:绘制
决定 View 长什么样,是自定义 View 的核心舞台。系统传入 Canvas 画布,配合 Paint 画笔完成绘制。
三大流程的调用链:
ViewRootImpl
→ performMeasure() → View.measure() → onMeasure()
→ performLayout() → View.layout() → onLayout()
→ performDraw() → View.draw() → onDraw()
触发重绘的方法也有讲究:
invalidate():重绘(触发 onDraw),必须在主线程,适合自身变化postInvalidate():重绘,可在子线程调用(内部通过 handler 切换)requestLayout():重新走测量 + 布局 + 绘制(大小或位置变化时用,比 invalidate 代价大)
# Paint 与 Canvas:绘图的两把刷子
Paint(画笔)描述"怎么画"——颜色、粗细、样式、字体;Canvas(画布)描述"画什么"——圆、矩形、路径、文字、图片。
# Paint 常用 API
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); // 构造时开启抗锯齿
paint.setColor(Color.parseColor("#3F51B5")); // 画笔颜色
paint.setStyle(Paint.Style.FILL); // 填充样式(见下表)
paint.setStrokeWidth(10); // 画笔宽度(描边时生效)
paint.setAntiAlias(true); // 抗锯齿(图形边缘平滑)
paint.setDither(true); // 防抖动(颜色渐变更柔和)
paint.setAlpha(128); // 透明度 0~255
paint.setTextSize(40); // 文字大小(画文字时)
paint.setTextAlign(Paint.Align.CENTER); // 文字对齐方式
Paint.Style 三种样式:
FILL:填充模式(默认)STROKE:描边模式(只画轮廓线)FILL_AND_STROKE:填充加描边
进阶属性(Shader 着色器)可以实现渐变等高级效果:
// 线性渐变:进度条、背景过渡常用
paint.setShader(new LinearGradient(0, 0, width, 0,
Color.RED, Color.BLUE, Shader.TileMode.CLAMP));
// 圆形渐变(径向):光晕效果
paint.setShader(new RadialGradient(cx, cy, radius,
Color.WHITE, Color.TRANSPARENT, Shader.TileMode.CLAMP));
// 梳理纹理:沙砾/噪点效果
paint.setShader(new BitmapShader(bitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT));
# Canvas 常用 API
绘制类:
canvas.drawColor(Color.WHITE); // 填充画布颜色
canvas.drawCircle(cx, cy, radius, paint); // 圆
canvas.drawRect(l, t, r, b, paint); // 矩形
canvas.drawRoundRect(rect, rx, ry, paint); // 圆角矩形
canvas.drawArc(rect, startAngle, sweepAngle, useCenter, paint); // 弧/扇形
canvas.drawOval(rect, paint); // 椭圆
canvas.drawLine(startX, startY, stopX, stopY, paint); // 线
canvas.drawPath(path, paint); // 路径(任意图形,配合Path)
canvas.drawBitmap(bitmap, x, y, paint); // 图片
canvas.drawText(text, x, y, paint); // 文字
Path 路径(绘制不规则图形的利器):
Path path = new Path();
path.moveTo(0, 100); // 移动画笔到起点(不画线)
path.lineTo(100, 50); // 连线到目标点
path.quadTo(150, 0, 200, 50); // 二阶贝塞尔曲线(水波纹/圆滑过渡常用)
path.cubicTo(250, 100, 300, 100, 350, 50); // 三阶贝塞尔曲线
path.close(); // 闭合路径
canvas.drawPath(path, paint);
变换类(配合 save/restore 使用):
int saveCount = canvas.save(); // 保存当前画布状态
canvas.translate(cx, cy); // 平移坐标系到圆心
canvas.rotate(angle); // 旋转(绕坐标原点,即刚平移的圆心)
canvas.scale(sx, sy); // 缩放
canvas.skew(sx, sy); // 倾斜
// ...在变换后的坐标系上绘制...
canvas.restoreToCount(saveCount); // 恢复到保存时的状态
save/restore 的意义:变换(translate/rotate)作用于整个坐标系,绘制完一段图形后要恢复原坐标系,否则后续绘制都会带着变换。典型场景:画仪表盘时,每一根刻度线都是"旋转 → 画线 → 恢复"。
# 实战一:继承 View 实现进度圆环
一个完整、典型的自定义 View 案例:带进度动画的圆环进度条。包含构造函数、自定义属性、onMeasure、onDraw、属性动画驱动的完整闭环。
# 1. 自定义属性:values/attrs.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="CircleProgressView">
<!-- 圆环背景颜色 -->
<attr name="cpvRingColor" format="color" />
<!-- 进度颜色 -->
<attr name="cpvProgressColor" format="color" />
<!-- 圆环宽度 -->
<attr name="cpvRingWidth" format="dimension" />
<!-- 当前进度(0-100) -->
<attr name="cpvProgress" format="integer" />
<!-- 中心文字 -->
<attr name="cpvText" format="string" />
<!-- 文字颜色 -->
<attr name="cpvTextColor" format="color" />
<!-- 文字大小 -->
<attr name="cpvTextSize" format="dimension" />
</declare-styleable>
</resources>
# 2. 自定义 View 类:CircleProgressView
/**
* 进度圆环自定义View.
*/
public class CircleProgressView extends View {
private static final String TAG = "CircleProgressView";
// 圆环起点角度(-90°即12点钟方向)
private static final int START_ANGLE = -90;
// 进度动画时长
private static final int ANIM_DURATION = 1000;
private Paint mRingPaint; // 圆环背景画笔
private Paint mProgressPaint; // 进度弧画笔
private Paint mTextPaint; // 中心文字画笔
private int mRingColor;
private int mProgressColor;
private int mRingWidth;
private int mTextColor;
private float mTextSize;
private String mText = "";
// 当前进度(动画过程中的实时值)
private int mProgress;
// 进度弧的半径(在onSizeChanged中根据控件大小计算)
private float mRadius;
public CircleProgressView(Context context) {
this(context, null);
}
public CircleProgressView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
// 三个构造函数逐级调用,最终在带attrs的构造中读取自定义属性
public CircleProgressView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context, attrs);
}
private void init(Context context, AttributeSet attrs) {
// 读取xml中配置的自定义属性
TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.CircleProgressView);
mRingColor = ta.getColor(R.styleable.CircleProgressView_cpvRingColor, Color.parseColor("#DDE4EB"));
mProgressColor = ta.getColor(R.styleable.CircleProgressView_cpvProgressColor, Color.parseColor("#3F51B5"));
mRingWidth = (int) ta.getDimension(R.styleable.CircleProgressView_cpvRingWidth, dp2px(12));
mTextColor = ta.getColor(R.styleable.CircleProgressView_cpvTextColor, Color.parseColor("#333333"));
mTextSize = ta.getDimension(R.styleable.CircleProgressView_cpvTextSize, dp2px(28));
mProgress = ta.getInt(R.styleable.CircleProgressView_cpvProgress, 0);
mText = ta.getString(R.styleable.CircleProgressView_cpvText);
ta.recycle(); // 用完必须回收
// 初始化圆环背景画笔
mRingPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mRingPaint.setColor(mRingColor);
mRingPaint.setStyle(Paint.Style.STROKE);
mRingPaint.setStrokeWidth(mRingWidth);
// 初始化进度弧画笔(圆头笔帽)
mProgressPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mProgressPaint.setColor(mProgressColor);
mProgressPaint.setStyle(Paint.Style.STROKE);
mProgressPaint.setStrokeWidth(mRingWidth);
mProgressPaint.setStrokeCap(Paint.Cap.ROUND);
// 初始化中心文字画笔
mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mTextPaint.setColor(mTextColor);
mTextPaint.setTextSize(mTextSize);
mTextPaint.setTextAlign(Paint.Align.CENTER);
}
// 控件大小确定时回调(比onLayout更早,适合计算与大小相关的量)
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
// 半径 = 控件短边的一半 - 圆环宽度的一半(保证圆环不被裁剪)
mRadius = Math.min(w, h) / 2f - mRingWidth / 2f;
}
// 兼容wrap_content:AT_MOST时使用默认尺寸
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(measureSize(widthMeasureSpec, dp2px(120)),
measureSize(heightMeasureSpec, dp2px(120)));
}
private int measureSize(int measureSpec, int expectSize) {
int mode = MeasureSpec.getMode(measureSpec);
int size = MeasureSpec.getSize(measureSpec);
if (mode == MeasureSpec.EXACTLY) {
return size;
}
return mode == MeasureSpec.AT_MOST ? Math.min(expectSize, size) : expectSize;
}
// 核心绘制逻辑
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 圆心即控件中心
float cx = getWidth() / 2f;
float cy = getHeight() / 2f;
// 1. 画圆环背景(整圆)
canvas.drawCircle(cx, cy, mRadius, mRingPaint);
// 2. 画进度弧(起角-90°,扫过角度=进度比例*360°)
RectF rectF = new RectF(cx - mRadius, cy - mRadius, cx + mRadius, cy + mRadius);
float sweepAngle = mProgress * 360f / 100f;
// useCenter=false:只画弧线不画扇形
canvas.drawArc(rectF, START_ANGLE, sweepAngle, false, mProgressPaint);
// 3. 画中心文字(默认显示进度百分比)
String text = mText != null ? mText : mProgress + "%";
// getTextBaseline校正:让文字垂直居中
float textY = cy - (mTextPaint.descent() + mTextPaint.ascent()) / 2f;
canvas.drawText(text, cx, textY, mTextPaint);
}
// 对外提供进度设置接口,内部用属性动画平滑过渡
public void setProgress(int progress) {
if (progress < 0 || progress > 100) {
throw new IllegalArgumentException("progress must be in [0, 100]");
}
ValueAnimator animator = ValueAnimator.ofInt(mProgress, progress);
animator.setDuration(ANIM_DURATION);
animator.setInterpolator(new DecelerateInterpolator());
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mProgress = (int) animation.getAnimatedValue();
// 进度变化只影响绘制内容,调用invalidate触发onDraw即可
invalidate();
}
});
animator.start();
}
private int dp2px(float dp) {
return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,
dp, getResources().getDisplayMetrics());
}
}
# 3. xml 布局中使用
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical">
<com.jiangming.widget.CircleProgressView
android:id="@+id/cpv"
android:layout_width="160dp"
android:layout_height="160dp"
app:cpvProgress="80"
app:cpvProgressColor="#4CAF50"
app:cpvRingColor="#E0E7EE"
app:cpvRingWidth="14dp"
app:cpvTextColor="#333333"
app:cpvTextSize="30dp" />
<Button
android:id="@+id/btn_refresh"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="随机进度" />
</LinearLayout>
# 4. Activity 使用
public class DemoActivity extends AppCompatActivity {
private CircleProgressView mCircleProgressView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_demo);
mCircleProgressView = findViewById(R.id.cpv);
// 启动时展示一个进度动画
mCircleProgressView.setProgress(80);
// 每次点击按钮,随机刷新进度
findViewById(R.id.btn_refresh).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mCircleProgressView.setProgress(new Random().nextInt(101));
}
});
}
}
实现要点复盘(对应上面讲过的每个知识点):
- 三个构造函数逐级委托,最终在带
AttributeSet的构造中读取自定义属性,用完TypedArray必须recycle() onMeasure处理了AT_MOST模式,兼容wrap_contentonSizeChanged中计算半径(依赖控件实际大小,放在这里比在构造函数中更可靠)onDraw中三步绘制:背景圆 → 进度弧 → 中心文字;文字垂直居中用 baseline 校正- 进度动画用
ValueAnimator驱动实时值,每次变化只调invalidate()重绘(不调requestLayout,因为大小没变)
# 实战二:继承已有控件(扩展TextView)
在系统控件基础上扩展,适合"大部分行为都一样,只是加一点东西"的场景。例如给 TextView 加一个左上角的"消息角标":
/**
* 带角标的TextView(继承已有控件扩展).
*/
public class BadgeTextView extends AppCompatTextView {
// 角标数量
private int mBadgeCount;
// 角标画笔
private Paint mBadgePaint;
// 角标文字画笔
private Paint mBadgeTextPaint;
public BadgeTextView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
private void init() {
mBadgePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mBadgePaint.setColor(Color.parseColor("#F44336"));
mBadgePaint.setStyle(Paint.Style.FILL);
mBadgeTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mBadgeTextPaint.setColor(Color.WHITE);
mBadgeTextPaint.setTextSize(sp2px(11));
mBadgeTextPaint.setTextAlign(Paint.Align.CENTER);
}
public void setBadgeCount(int count) {
mBadgeCount = count;
invalidate(); // 角标变化,触发重绘
}
// 重写onDraw:先让父类画完原内容,再叠加角标
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas); // 关键:先执行父类的绘制(文字内容)
if (mBadgeCount <= 0) {
return;
}
// 角标位置:右上角
float cx = getWidth() - dp2px(10);
float cy = dp2px(10);
float radius = dp2px(8);
// 画红色圆点
canvas.drawCircle(cx, cy, radius, mBadgePaint);
// 画角标数字(未读数超过99显示99+)
String text = mBadgeCount > 99 ? "99+" : String.valueOf(mBadgeCount);
float textY = cy - (mBadgeTextPaint.descent() + mBadgeTextPaint.ascent()) / 2f;
canvas.drawText(text, cx, textY, mBadgeTextPaint);
}
private int dp2px(float dp) { /* 同上,省略 */ return 0; }
private int sp2px(float sp) { /* 同上,省略 */ return 0; }
}
继承已有控件的核心套路:重写 onDraw 时先调用 super.onDraw() 保留原有绘制,再用 Canvas/Paint 叠加自己的内容。另一种思路是不调 super.onDraw(),完全自绘(如让 TextView 只画下划线动画)。
# 实战三:自定义 ViewGroup 实现流式布局
流式布局(自动换行排列标签)是自定义 ViewGroup 的经典案例,完整展示 onMeasure + onLayout 的协作:
/**
* 流式布局:子View按行排列,一行放不下自动换行.
*/
public class FlowLayout extends ViewGroup {
// 行间距、列间距
private static final int ROW_SPACING = 20;
private static final int COLUMN_SPACING = 20;
// 记录每一行子View的集合(onLayout时使用)
private final List<List<View>> mAllLines = new ArrayList<>();
// 记录每行的高度
private final List<Integer> mLineHeights = new ArrayList<>();
public FlowLayout(Context context, AttributeSet attrs) {
super(context, attrs);
}
// ---------- 测量:确定自己的大小,并测量每个子View ----------
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// 清空上一轮记录(测量可能被多次触发)
mAllLines.clear();
mLineHeights.clear();
// 解析自己(FlowLayout)的测量规格
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
// 自己实际需要的宽高(wrap_content时使用)
int contentWidth = 0;
int contentHeight = 0;
// 当前行的行宽、行高
int lineWidth = 0;
int lineHeight = 0;
// 当前行子View集合
List<View> lineViews = new ArrayList<>();
// 遍历子View,测量并按"一行放不下就换行"的原则分组
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
// 测量子View(把父容器的MeasureSpec和LayoutParams传下去)
measureChild(child, widthMeasureSpec, heightMeasureSpec);
MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
// 子View实际占用的宽度 = 测量宽 + 左右margin
int childWidth = child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
int childHeight = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
if (lineWidth + childWidth > widthSize) {
// 当前行放不下:换行
// 1. 记录上一行
mAllLines.add(lineViews);
mLineHeights.add(lineHeight);
// 2. 更新总宽高:宽度取最宽行,高度累加行高
contentWidth = Math.max(contentWidth, lineWidth);
contentHeight += lineHeight + ROW_SPACING;
// 3. 新行从当前子View开始
lineViews = new ArrayList<>();
lineViews.add(child);
lineWidth = childWidth;
lineHeight = childHeight;
} else {
// 当前行放得下:加入本行
lineViews.add(child);
lineWidth += childWidth + COLUMN_SPACING;
lineHeight = Math.max(lineHeight, childHeight);
}
}
// 最后一行也要记录
mAllLines.add(lineViews);
mLineHeights.add(lineHeight);
contentWidth = Math.max(contentWidth, lineWidth);
contentHeight += lineHeight;
// 根据测量模式确定最终大小(EXACTLY用系统给的,否则用内容大小)
setMeasuredDimension(
widthMode == MeasureSpec.EXACTLY ? widthSize : contentWidth,
heightMode == MeasureSpec.EXACTLY ? heightSize : contentHeight);
}
// ---------- 定位:把每个子View摆到正确的坐标 ----------
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
int currentTop = 0;
for (int line = 0; line < mAllLines.size(); line++) {
List<View> lineViews = mAllLines.get(line);
int lineHeight = mLineHeights.get(line);
int currentLeft = 0;
// 摆放这一行的每个子View
for (View child : lineViews) {
MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
int childLeft = currentLeft + lp.leftMargin;
int childTop = currentTop + lp.topMargin;
child.layout(childLeft, childTop,
childLeft + child.getMeasuredWidth(),
childTop + child.getMeasuredHeight());
// 下一个子View排在右边
currentLeft += child.getMeasuredWidth() + lp.leftMargin
+ lp.rightMargin + COLUMN_SPACING;
}
// 下一行的起始Y = 当前行Y + 行高 + 行间距
currentTop += lineHeight + ROW_SPACING;
}
}
// 生成子View的默认LayoutParams(使用Margin时必须重写)
@Override
public LayoutParams generateLayoutParams(AttributeSet attrs) {
return new MarginLayoutParams(getContext(), attrs);
}
}
自定义 ViewGroup 的核心套路:
- onMeasure 中"两件事":测量每个子 View(
measureChild);根据子 View 的测量结果 + 自己的布局规则,决定自己的大小 - onLayout 中"一件事":按布局规则给每个子 View 调用
child.layout(l, t, r, b)赋坐标 - 重写
generateLayoutParams系列方法,让子 View 支持layout_margin onMeasure可能被多次触发(父容器不同约束下反复测量),临时集合要先清空
# 视图刷新:invalidate 与 requestLayout
自定义 View 中最常混淆的两个刷新方法:
| 方法 | 触发范围 | 使用场景 |
|---|---|---|
invalidate() | 只触发 onDraw(绘制) | 内容变化(颜色、进度、角度) |
requestLayout() | 触发 measure → layout → draw 完整流程 | 大小/位置变化(内容导致尺寸变化) |
postInvalidate() | 只触发 onDraw,可在子线程 | 子线程中刷新 |
判断口诀:只是"画的东西变了"用 invalidate();"控件要变大变小/挪位置"用 requestLayout()。只调 invalidate() 是不会重新测量和布局的——如果依赖控件大小绘制,注意先改变尺寸再刷新。
# 注意事项与最佳实践
- 不要在 onDraw 中创建对象:
onDraw可能被高频调用(动画中每帧一次),创建Paint/RectF等对象会造成内存抖动甚至触发频繁 GC 引起卡顿。画笔等对象在构造函数或onSizeChanged中初始化。 - 动画刷新用 invalidate(),避免 requestLayout:进度条每帧变化用
invalidate()足矣。 - 开启硬件加速:API 14+ 默认开启,大部分 Canvas API 都支持;个别 API(如
canvas.drawBitmapMesh部分 path 效果)不支持硬件加速,需针对性确认。 - 处理 wrap_content:继承 View 时务必重写
onMeasure处理AT_MOST,否则wrap_content等同于match_parent。 - 处理 padding:系统不会自动帮你处理
padding,onDraw/onMeasure中要自行考虑getPaddingLeft()等值。 - 结合事件分发实现交互:需要跟手拖动、手势识别的自定义 View,结合 08 事件处理中讲的
onTouchEvent、GestureDetector、滑动冲突处理来实现。 - 动画驱动选择:简单属性变化用
ValueAnimator驱动 +invalidate();帧序列绘制用postInvalidateDelayed()或 Choreographer;复杂动效考虑属性动画体系。
# 结束
本节系统介绍了自定义 View 与绘图体系:自定义 View 的三种继承方式、View 绘制三大流程(onMeasure / onLayout / onDraw)与 MeasureSpec 测量模式、Paint 与 Canvas 绘图 API(含 Path 贝塞尔曲线、Shader 着色器、save/restore 坐标变换),并通过三个实战案例(进度圆环、角标 TextView、流式布局 ViewGroup)覆盖了三种自定义方式的完整套路,最后梳理了 invalidate/requestLayout 的区别与最佳实践。自定义 View 的进阶还需要结合事件分发机制与属性动画体系,更多内容请参考官方文档:自定义视图组件 (opens new window) 与 Canvas and Drawables (opens new window)。