自定义 View 入门
掌握 View 绘制三段式、自定义属性、事件分发机制,并手写一个圆形进度条综合示例。
学习目标
- 理解 View 绘制流程:measure / layout / draw
- 能重写 onMeasure / onDraw 实现简单自定义控件
- 会用 attrs.xml + obtainStyledAttributes 暴露自定义属性
- 理解事件分发 dispatchTouchEvent / onInterceptTouchEvent / onTouchEvent 三者关系
- 完成一个圆形进度条综合示例
学习目标
- 能用一句话说清 measure / layout / draw 三阶段分别做什么
- 会重写
onMeasure处理wrap_content,会重写onDraw用 Canvas 画图形 - 能在
attrs.xml里声明自定义属性,并通过obtainStyledAttributes取出 - 能解释
dispatchTouchEvent/onInterceptTouchEvent/onTouchEvent三者调用顺序与返回值含义 - 完成圆形进度条控件:支持自定义颜色、宽度、起始角度,外部调用
setProgress更新
自定义 View 入门
1. View 的绘制流程:measure / layout / draw
每个 View 在屏幕上显示,都要走完这三步:
| 阶段 | 入口方法 | 作用 |
|---|---|---|
| 测量 | measure(widthMeasureSpec, heightMeasureSpec) |
算出自己和子 View 应该多大多高 |
| 摆放 | layout(l, t, r, b) |
把自己/子 View 放到屏幕上的具体位置 |
| 绘制 | draw(canvas) |
用 Canvas 把自己画出来 |
对应我们重写的回调:
public class MyView extends View {
public MyView(Context context) { super(context); }
public MyView(Context context, AttributeSet attrs) { super(context, attrs); }
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// 计算自己的尺寸,调 setMeasuredDimension
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
// 仅 ViewGroup 需要:决定子 View 摆放位置
}
@Override
protected void onDraw(Canvas canvas) {
// 用 Canvas 画自己
}
}
onLayout 只有自定义 ViewGroup 时才需要重写(要决定子 View 位置),单独的 View 直接继承即可。
2. MeasureSpec:测量规则
onMeasure 的入参 widthMeasureSpec 是个打包的 32 位 int:高 2 位是 mode、低 30 位是 size。三种 mode:
| Mode | 含义 | 触发场景 |
|---|---|---|
EXACTLY |
父容器已经确定了精确大小 | match_parent、固定 dp |
AT_MOST |
父容器给了上限,子 View 自己决定 | wrap_content |
UNSPECIFIED |
不限制,想多大就多大 | ScrollView 内部子 View,滚动场景 |
View 默认 onMeasure 的实现:对 AT_MOST 直接取背景可绘制尺寸或 0,所以你写一个 wrap_content 的自定义 View 如果不重写 onMeasure,它会塌成 0×0 或填满父容器——这就是新手最常踩的「自定义 View 看不见」坑。
2.1 一个标准 onMeasure 写法
约定俗成的写法是:默认大小给一个值(比如 200dp),如果是 AT_MOST 取「内容需求 vs 上限」的较小者,EXACTLY 直接用:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int defaultSize = dp(200);
int width = resolveSize(defaultSize, widthMeasureSpec);
int height = resolveSize(defaultSize, heightMeasureSpec);
setMeasuredDimension(width, height);
}
private int dp(int dp) {
float density = getResources().getDisplayMetrics().density;
return (int) (dp * density + 0.5f);
}
resolveSize 是系统工具方法:传一个「期望尺寸」和「MeasureSpec」,它会按 mode 决定最终值(EXACTLY 取 size、AT_MOST 取 min(期望, size)、UNSPECIFIED 取期望)。让自定义 View 适配 wrap_content 是几乎每个自定义 View 都要做的事。
3. onDraw 与 Canvas 基础
onDraw(Canvas canvas) 是真正画图的地方。Canvas 提供一系列 drawXxx 方法:drawCircle / drawRect / drawLine / drawPath / drawText。每个 draw 接收一个 Paint(画笔)作为样式参数。
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int cx = getWidth() / 2;
int cy = getHeight() / 2;
int r = Math.min(getWidth(), getHeight()) / 2 - 8;
paint.setColor(0xFF1976D2);
paint.setStyle(Paint.Style.FILL);
canvas.drawCircle(cx, cy, r, paint);
}
几个要点:
Paint.ANTI_ALIAS_FLAG开抗锯齿,画曲线必备Paint复用:不要在onDraw里new Paint()——onDraw高频调用,对象创建会触发 GC 卡顿。统一在构造函数初始化、字段持有- 不要在
onDraw里 new 对象:包括Rect、Path,统一在字段预创建、onDraw里复用 Paint.setStyle区分FILL(填充)、STROKE(描边)、FILL_AND_STROKE(两者)
4. 自定义属性:attrs.xml
让自定义 View 在 XML 里能配 app:ringColor="#1976D2" 这种属性,需要三步。
4.1 声明属性 res/values/attrs.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="CircularProgressView">
<attr name="ringColor" format="color" />
<attr name="ringWidth" format="dimension" />
<attr name="startAngle" format="integer" />
<attr name="progress" format="float" />
<attr name="trackColor" format="color" />
</declare-styleable>
</resources>
format 取值:color / dimension / integer / float / boolean / string / reference / enum / flag。
4.2 在 View 构造函数读取
public CircularProgressView(Context context, AttributeSet attrs) {
super(context, attrs);
init(context, attrs);
}
private void init(Context context, AttributeSet attrs) {
TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.CircularProgressView);
ringColor = ta.getColor(R.styleable.CircularProgressView_ringColor, 0xFF1976D2);
ringWidth = ta.getDimension(R.styleable.CircularProgressView_ringWidth, dp(12));
startAngle = ta.getInt(R.styleable.CircularProgressView_startAngle, -90);
progress = ta.getFloat(R.styleable.CircularProgressView_progress, 0f);
trackColor = ta.getColor(R.styleable.CircularProgressView_trackColor, 0xFFCCCCCC);
ta.recycle(); // 必须回收,否则会内存泄漏
}
obtainStyledAttributes 返回的 TypedArray 是个对象池,必须 recycle()——否则下次拿不到。
4.3 在布局里使用
<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.example.customview.CircularProgressView
android:id="@+id/progressView"
android:layout_width="200dp"
android:layout_height="200dp"
app:ringColor="@color/brand_primary"
app:ringWidth="14dp"
app:startAngle="-90"
app:progress="0.3"
app:trackColor="#FFEEEEEE" />
</LinearLayout>
xmlns:app 是声明自定义命名空间,指向 res-auto,系统会自动找项目里的 attrs.xml。
5. 事件分发:dispatchTouchEvent / onInterceptTouchEvent / onTouchEvent
手势相关自定义 View 必须理解事件分发。三个方法都返回 boolean,含义微妙:
| 方法 | 谁有 | 作用 | 返回值含义 |
|---|---|---|---|
dispatchTouchEvent |
View / ViewGroup | 派发事件入口 | true 自己消费,不传子;false 不传子也不消费 |
onInterceptTouchEvent |
仅 ViewGroup | 是否拦截、自己处理 | true 拦截、自己消费;false 放行给子 View |
onTouchEvent |
View / ViewGroup | 真正处理触摸 | true 消费,事件不再上抛;false 不消费,回传父级 |
一次 ACTION_DOWN 调用顺序:
Activity.dispatchTouchEvent
→ ViewGroup.dispatchTouchEvent
→ ViewGroup.onInterceptTouchEvent (问自己要不要拦)
→ 子 View.dispatchTouchEvent (没拦,传子)
→ 子 View.onTouchEvent (问子要不要消费)
→ 回到 ViewGroup.onTouchEvent (子不消费,父再问自己)
→ Activity.onTouchEvent (都不消费,回到 Activity)
ViewGroup.onInterceptTouchEvent 默认返回 false,所以普通布局不拦截手势、子 View 正常处理。需要拦截的场景:ScrollView 检测到手指滑动了,就 return true 拦截下来让自己滚动;ViewPager 检测横滑时拦截。
5.1 实战:处理单次点击的写法
下面是处理「按下、抬起算一次点击」的最简写法:
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
downX = event.getX();
downY = event.getY();
return true; // 必须 return true,否则后续 ACTION_MOVE/UP 收不到
case MotionEvent.ACTION_UP:
float dx = Math.abs(event.getX() - downX);
float dy = Math.abs(event.getY() - downY);
if (dx < touchSlop && dy < touchSlop) {
// 视为点击
performClick();
}
return true;
}
return super.onTouchEvent(event);
}
touchSlop 是系统定义的「最小可识别滑动距离」(用 ViewConfiguration.get(context).getScaledTouchSlop() 取),小于它就算点击。performClick() 走标准点击回调,让外部 setOnClickListener 生效。
⚠️ Lint 警告:重写 onTouchEvent 处理点击时,IDE 会提示「自定义 View 处理点击未调用 performClick」。务必同时重写 performClick:
@Override
public boolean performClick() {
super.performClick();
// 你的点击逻辑
return true;
}
6. 综合示例:圆形进度条
把上面所有内容综合,写一个可复用的圆形进度条控件:自定义颜色、宽度、起始角度、可动画更新。
6.1 完整 View 代码
package com.example.customview;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.RectF;
import android.util.AttributeSet;
import android.view.View;
public class CircularProgressView extends View {
private final Paint ringPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint trackPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final RectF arcRect = new RectF();
private int ringColor;
private int trackColor;
private float ringWidth;
private int startAngle;
private float progress; // 0-1
public CircularProgressView(Context context) {
this(context, null);
}
public CircularProgressView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public CircularProgressView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context, attrs);
}
private void init(Context context, AttributeSet attrs) {
TypedArray ta = context.obtainStyledAttributes(
attrs, R.styleable.CircularProgressView);
ringColor = ta.getColor(
R.styleable.CircularProgressView_ringColor, 0xFF1976D2);
trackColor = ta.getColor(
R.styleable.CircularProgressView_trackColor, 0xFFCCCCCC);
ringWidth = ta.getDimension(
R.styleable.CircularProgressView_ringWidth, dp(12));
startAngle = ta.getInt(
R.styleable.CircularProgressView_startAngle, -90);
progress = ta.getFloat(
R.styleable.CircularProgressView_progress, 0f);
ta.recycle();
ringPaint.setStyle(Paint.Style.STROKE);
ringPaint.setStrokeCap(Paint.Cap.ROUND);
ringPaint.setColor(ringColor);
ringPaint.setStrokeWidth(ringWidth);
trackPaint.setStyle(Paint.Style.STROKE);
trackPaint.setStrokeCap(Paint.Cap.ROUND);
trackPaint.setColor(trackColor);
trackPaint.setStrokeWidth(ringWidth);
}
private float dp(int dp) {
float density = getResources().getDisplayMetrics().density;
return dp * density;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int defaultSize = (int) dp(200);
int width = resolveSize(defaultSize, widthMeasureSpec);
int height = resolveSize(defaultSize, heightMeasureSpec);
// 让宽高一致,画正圆
int size = Math.min(width, height);
setMeasuredDimension(size, size);
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
// 计算圆弧外接矩形,向内缩半环宽,避免描边被裁
float inset = ringWidth / 2f;
arcRect.set(inset, inset, w - inset, h - inset);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 画底环
canvas.drawArc(arcRect, startAngle, 360f, false, trackPaint);
// 画进度环
if (progress > 0f) {
float sweep = 360f * Math.min(progress, 1f);
canvas.drawArc(arcRect, startAngle, sweep, false, ringPaint);
}
}
public void setProgress(float progress) {
this.progress = Math.max(0f, Math.min(1f, progress));
invalidate(); // 触发重绘
}
public void setProgressWithAnimation(float target, long durationMs) {
ValueAnimator anim = ValueAnimator.ofFloat(this.progress, target);
anim.setDuration(durationMs);
anim.addUpdateListener(a -> setProgress((float) a.getAnimatedValue()));
anim.start();
}
}
注意几个工程细节:
- 三个构造函数链式调用:自定义 View 标准模板,少写一个会让某些场景(XML 默认 defStyleAttr)崩。
this(...)的链让最终的实现都收敛在第三个构造函数里 onSizeChanged而非onDraw里算 RectF:尺寸变化才需要重算,onDraw高频调用不应做这种工作。避免在 onDraw 里 new 对象,arcRect是字段复用setProgress调invalidate()触发重绘。如果是从非主线程调,必须用postInvalidate()drawArc的useCenter=false:不画到圆心,画完整圆环而不是扇形
6.2 Activity 使用 + 动画
package com.example.customview;
import android.animation.ValueAnimator;
import android.os.Bundle;
import android.widget.Button;
import androidx.appcompat.app.AppCompatActivity;
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
CircularProgressView progressView = findViewById(R.id.progressView);
Button btnAnim = findViewById(R.id.btnAnim);
btnAnim.setOnClickListener(v -> {
// 0.3 -> 0.8 用 1 秒动画
progressView.setProgressWithAnimation(0.8f, 1000);
});
}
}
需要 import android.animation.ValueAnimator;。ValueAnimator 是属性动画核心,会在后续体验优化章节展开。这里直接用最简形式。
6.3 扩展点
这个示例能继续扩展的方向:
- 在中间画文字百分比(再开一个 TextPaint +
canvas.drawText) - 加渐变色(
SweepGradient) - 处理触摸调整进度(重写
onTouchEvent,根据event.getX/Y算角度反推 progress) - 自定义动画插值器(
BounceInterpolator让 100% 时弹一下)
这些留作练习,建议每做一个都对应一种新知识点。
常见坑与最佳实践
wrap_content自定义 View 看不见:默认onMeasure在AT_MOST模式下尺寸退化为背景或 0。重写onMeasure用resolveSize(default, spec)是万能写法。onDraw里 new 对象:Paint、Path、Rect 都该在字段创建。onDraw一秒可能调用几十次,GC 风暴就是从这里来的。- 忘了
TypedArray.recycle():对象池上限,不回收会拿到 null,且 Android Studio 会报警告。所有obtainStyledAttributes后立刻 try-finally recycle。 onInterceptTouchEvent拦了但没消费:父 ViewGroup 返回 true 拦截后,自己onTouchEvent又返回 false 不消费,事件会回到父的父继续问,最终回 Activity——子 View 完全收不到 MOVE。所以拦截后必须保证自己能消费。ACTION_DOWN返回 false:返回 false 表示不消费 DOWN,后续 MOVE/UP 都不会再发给你。新手调试「为什么 move 收不到」多半是这个。- 重写
onTouchEvent没同时重写performClick:Lint 警告,且OnClickListener不会触发。两者必须配对。 - postInvalidate vs invalidate:
invalidate()只能在主线程调,子线程调会崩。后台任务里要重绘用postInvalidate(),让系统切到主线程后再 invalidate。
章节小结
- View 绘制三段式:
measure(量)→layout(摆)→draw(画),重写onMeasure/onLayout/onDraw MeasureSpec三 mode:EXACTLY/AT_MOST/UNSPECIFIED;wrap_content必须重写onMeasureonDraw里用Canvas.drawXxx+Paint画图,Paint 在字段复用、不在 onDraw new 对象- 自定义属性三步:
attrs.xml声明 → 构造函数obtainStyledAttributes读取 →recycle()释放 - 事件分发:
dispatchTouchEvent→onInterceptTouchEvent(ViewGroup)→onTouchEvent;ACTION_DOWN返回 false 后续都收不到 - 圆形进度条综合示例串起 measure / draw / attrs / 动画,是自定义 View 入门里程碑
- 重写
onTouchEvent处理点击时,必须同时重写performClick,否则OnClickListener不工作
下一章预告
UI 基础到这里告一段落,你已经能独立写出任何静态界面和复杂交互。下一部分「核心组件」将走出 UI,进入 Android 应用的四大组件——Activity 生命周期与启动模式、Service 后台任务、BroadcastReceiver 消息广播、ContentProvider 数据共享,让你的 App 真正「活」起来。