文章目录
- [Android 自定义 View 入门](#Android 自定义 View 入门)
Android 自定义 View 入门
自定义 View 这个词在面试题里出现率极高,但很多人对它的理解是错位的------
以为自定义 View 就是"继承 View,重写 onDraw,拿 Canvas 画图",
然后被 onMeasure 的三种模式和滑动冲突劝退。
实际上自绘只是自定义 View 的一种,而且是需求里占比最小的一种。
大多数"这个控件差一点不合我意"的场景,继承系统控件改一改就够了。
这篇把自定义 View 的几种做法都过一遍,附完整代码。
方法一:继承系统控件,只改行为
最小成本的做法。不碰任何绘制,只重写逻辑方法,把不合意的行为改掉。
举个例子。系统的跑马灯要求 TextView 拿到焦点才滚动,
在 RecyclerView 的 item 里焦点归属不稳定,跑马灯经常不生效。
写一个子类重写 isFocused(),永远返回 true,问题解决:
java
public class MarqueeTextView extends AppCompatTextView {
public MarqueeTextView(Context context) {
super(context);
}
public MarqueeTextView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public MarqueeTextView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public boolean isFocused() {
return true;
}
}
XML 里把 <TextView> 换成全限定名就行:
xml
<com.example.myqqmusic.MarqueeTextView
android:layout_width="180dp"
android:layout_height="wrap_content"
android:singleLine="true"
android:ellipsize="marquee"
android:marqueeRepeatLimit="marquee_forever" />
没有 onDraw,没有 Canvas。显示效果全用系统的,只改了一个方法的返回值。
这类做法常见的重写目标:
| 方法 | 能干什么 |
|---|---|
isFocused() |
骗过焦点检查 |
onTouchEvent() |
改触摸反馈 |
onAttachedToWindow() / onDetachedFromWindow() |
挂载 / 卸载时做点事 |
canScrollVertically() |
返回 false 可以禁掉滑动 |
两个必懂的细节
三个构造方法都要写。分别对应三种创建方式:
- 一个参数:代码里
new MarqueeTextView(context) - 两个参数:XML 布局解析
- 三个参数:主题里指定 style
少写一个不会编译报错,但会埋雷------比如只写了两个参数的构造,
哪天在代码里 new 这个控件就直接崩。
继承 AppCompatTextView 而不是 TextView 。AppCompatActivity 会把 XML 里的
TextView 自动替换成 AppCompat 版本,直接继承原生的 TextView 会丢掉一部分主题适配。
跟 AppCompat 体系走最稳。
方法二:继承系统控件,叠加绘制
系统画的内容要保留,但想往上加东西。比如给 TextView 底下加一条线:
java
public class UnderlineTextView extends AppCompatTextView {
private final Paint linePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
public UnderlineTextView(Context context, AttributeSet attrs) {
super(context, attrs);
linePaint.setColor(0xFFFF5252);
linePaint.setStrokeWidth(dp(2));
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas); // 先让系统把文字画了
canvas.drawLine(0, getHeight() - dp(2), getWidth(), getHeight() - dp(2), linePaint);
}
private float dp(int v) {
return v * getResources().getDisplayMetrics().density;
}
}
核心是 super.onDraw(canvas) 这一行------先让父类把原本的内容画完,再往 Canvas 上叠自己的。
忘写这行,文字就没了,屏幕上只剩一条线。
进度条上画百分比数字、头像上画未读角标,全是这个套路。
方法三:组合现有控件
不继承单一控件,把几个控件组装进一个 ViewGroup,对外封装成一个整体。
比如底部导航的 Tab 项 = 图标 + 文字 + 未读红点,
每个页面都写这三个控件太啰嗦,包成一个 TabItemView:
java
public class TabItemView extends LinearLayout {
private final ImageView icon;
private final TextView label;
private final View badge;
public TabItemView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
setOrientation(VERTICAL);
setGravity(Gravity.CENTER);
inflate(context, R.layout.view_tab_item, this);
icon = findViewById(R.id.iv_icon);
label = findViewById(R.id.tv_label);
badge = findViewById(R.id.v_badge);
}
public void setIcon(int resId) { icon.setImageResource(resId); }
public void setLabel(CharSequence text) { label.setText(text); }
public void showBadge(boolean show) { badge.setVisibility(show ? VISIBLE : GONE); }
}
inflate(context, R.layout.view_tab_item, this) 第三个参数传 this,
布局就直接挂到这个 ViewGroup 下面,不用再 addView。
XML 里用起来和普通控件一模一样:
xml
<com.example.myqqmusic.TabItemView
android:id="@+id/tab_home"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
java
tabHome.setIcon(R.drawable.ic_home);
tabHome.setLabel("首页");
tabHome.showBadge(true);
日常开发里这种"自定义 View"出现得最多------封装的是复用性,不是绘制。
方法四:完全自绘
前三档都有现成内容可依赖,这一档没有。继承 View,从白纸开始画。
圆环进度条是最典型的入门案例:
java
public class RingProgressView extends View {
private static final int DEFAULT_SIZE = 200; // dp
private final Paint ringPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private float progress = 0.6f;
public RingProgressView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
ringPaint.setStyle(Paint.Style.STROKE);
ringPaint.setStrokeWidth(dp(10));
ringPaint.setColor(0xFF4A90D9);
ringPaint.setStrokeCap(Paint.Cap.ROUND);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// 不处理 wrap_content 的话,它表现和 match_parent 一样
setMeasuredDimension(
resolveSize((int) dp(DEFAULT_SIZE), widthMeasureSpec),
resolveSize((int) dp(DEFAULT_SIZE), heightMeasureSpec));
}
@Override
protected void onDraw(Canvas canvas) {
float cx = getWidth() / 2f;
float cy = getHeight() / 2f;
float radius = (Math.min(getWidth(), getHeight()) - dp(10)) / 2f;
// 底环
ringPaint.setColor(0xFFE0E0E0);
canvas.drawCircle(cx, cy, radius, ringPaint);
// 进度弧
ringPaint.setColor(0xFF4A90D9);
canvas.drawArc(cx - radius, cy - radius, cx + radius, cy + radius,
-90, 360 * progress, false, ringPaint);
}
public void setProgress(float p) {
progress = p;
invalidate(); // 触发重绘
}
private float dp(int v) {
return v * getResources().getDisplayMetrics().density;
}
}
三个要点:
onMeasure必须处理 :wrap_content在自绘 View 里默认等于match_parent,
不重写onMeasure的话布局怎么改都是全屏宽。resolveSize()是处理它最省事的方式- 改数据后要
invalidate():它负责让系统知道"这个 View 脏了,下一帧重画"。
只给成员变量赋值不改画面,就是忘了这个 - Paint 在构造方法里初始化 ,不要在 onDraw 里 new------onDraw 每帧都执行,
在里面创建对象会加剧 GC
自绘的能力上限最高,代价也最大:onMeasure 三种模式、padding 自己留、
触摸事件自己算、状态保存自己写。每一个都是独立的坑。
怎么选
回到需求的起点,看系统控件离它差多远:
| 需求特征 | 用哪档 |
|---|---|
| 显示没问题,只是行为不合意 | 方法一,重写逻辑方法 |
| 系统内容要保留,往上加点东西 | 方法二,super.onDraw 后叠加 |
| 几个控件的固定组合,到处复用 | 方法三,组合封装 |
| 形状不规则,系统控件拼不出来 | 方法四,自绘 |
四档不是难度递进关系,是成本递进 。能用一档解决的写四档是浪费------
同一个跑马灯需求,方法一十几行搞定,自绘版本要处理文字测量、匀速滚动、
循环边界、触摸暂停,代码量二十倍起步,顺滑程度还不一定比得过系统的。
进阶:自定义属性
硬编码的颜色写死在 Java 里,换色就要改代码。自定义属性可以让控件在 XML 里配置。
res/values/attrs.xml 声明:
xml
<resources>
<declare-styleable name="RingProgressView">
<attr name="ringColor" format="color" />
<attr name="ringWidth" format="dimension" />
</declare-styleable>
</resources>
构造方法里读取:
java
public RingProgressView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.RingProgressView);
int color = ta.getColor(R.styleable.RingProgressView_ringColor, 0xFF4A90D9);
float width = ta.getDimension(R.styleable.RingProgressView_ringWidth, dp(10));
ta.recycle(); // TypedArray 是池化复用的,用完必须还
ringPaint.setStrokeWidth(width);
ringPaint.setColor(color);
}
XML 里就能配了:
xml
<com.example.myqqmusic.RingProgressView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:ringColor="#FF4A90D9"
app:ringWidth="12dp" />
到这里,这个自绘控件就和开源库里的组件一个用法了。
总结
自定义 View 是个光谱,从浅到深四种做法:
改一个方法 → 叠一层绘制 → 组合封装 → 完全自绘
前三种都是"站在系统控件的肩膀上",只有第四种是从零开始。
入门路径建议从第一种写起:找个系统控件不合意的行为,重写一个方法,
跑起来,看看 XML 引用自定义类的完整流程。这套流程和最深一档是完全一样的,
先把它跑顺,剩下的只是往 onDraw 里填内容的问题。