文章目录
- Android 自定义 View 入门
- 方法一:继承系统控件,只改行为
- 两个必懂的细节
- 方法二:继承系统控件,叠加绘制
- 方法三:组合现有控件
- 方法四:完全自绘
- 怎么选
- 进阶:自定义属性
- 总结
Android 自定义 View 入门
自定义 View 这个词在面试题里出现率极高,但很多人对它的理解是错位的——
以为自定义 View 就是"继承 View,重写 onDraw,拿 Canvas 画图",
然后被onMeasure的三种模式和滑动冲突劝退。
实际上自绘只是自定义 View 的一种,而且是需求里占比最小的一种。
大多数"这个控件差一点不合我意"的场景,继承系统控件改一改就够了。
这篇把自定义 View 的几种做法都过一遍,附完整代码。
方法一:继承系统控件,只改行为
最小成本的做法。不碰任何绘制,只重写逻辑方法,把不合意的行为改掉。
举个例子。系统的跑马灯要求 TextView 拿到焦点才滚动,
在 RecyclerView 的 item 里焦点归属不稳定,跑马灯经常不生效。
写一个子类重写isFocused(),永远返回 true,问题解决:
publicclassMarqueeTextViewextendsAppCompatTextView{publicMarqueeTextView(Contextcontext){super(context);}publicMarqueeTextView(Contextcontext,@NullableAttributeSetattrs){super(context,attrs);}publicMarqueeTextView(Contextcontext,@NullableAttributeSetattrs,intdefStyleAttr){super(context,attrs,defStyleAttr);}@OverridepublicbooleanisFocused(){returntrue;}}XML 里把<TextView>换成全限定名就行:
<com.example.myqqmusic.MarqueeTextViewandroid: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 底下加一条线:
publicclassUnderlineTextViewextendsAppCompatTextView{privatefinalPaintlinePaint=newPaint(Paint.ANTI_ALIAS_FLAG);publicUnderlineTextView(Contextcontext,AttributeSetattrs){super(context,attrs);linePaint.setColor(0xFFFF5252);linePaint.setStrokeWidth(dp(2));}@OverrideprotectedvoidonDraw(Canvascanvas){super.onDraw(canvas);// 先让系统把文字画了canvas.drawLine(0,getHeight()-dp(2),getWidth(),getHeight()-dp(2),linePaint);}privatefloatdp(intv){returnv*getResources().getDisplayMetrics().density;}}核心是super.onDraw(canvas)这一行——先让父类把原本的内容画完,再往 Canvas 上叠自己的。
忘写这行,文字就没了,屏幕上只剩一条线。
进度条上画百分比数字、头像上画未读角标,全是这个套路。
方法三:组合现有控件
不继承单一控件,把几个控件组装进一个 ViewGroup,对外封装成一个整体。
比如底部导航的 Tab 项 = 图标 + 文字 + 未读红点,
每个页面都写这三个控件太啰嗦,包成一个TabItemView:
publicclassTabItemViewextendsLinearLayout{privatefinalImageViewicon;privatefinalTextViewlabel;privatefinalViewbadge;publicTabItemView(Contextcontext,@NullableAttributeSetattrs){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);}publicvoidsetIcon(intresId){icon.setImageResource(resId);}publicvoidsetLabel(CharSequencetext){label.setText(text);}publicvoidshowBadge(booleanshow){badge.setVisibility(show?VISIBLE:GONE);}}inflate(context, R.layout.view_tab_item, this)第三个参数传this,
布局就直接挂到这个 ViewGroup 下面,不用再 addView。
XML 里用起来和普通控件一模一样:
<com.example.myqqmusic.TabItemViewandroid:id="@+id/tab_home"android:layout_width="wrap_content"android:layout_height="wrap_content"/>tabHome.setIcon(R.drawable.ic_home);tabHome.setLabel("首页");tabHome.showBadge(true);日常开发里这种"自定义 View"出现得最多——封装的是复用性,不是绘制。
方法四:完全自绘
前三档都有现成内容可依赖,这一档没有。继承View,从白纸开始画。
圆环进度条是最典型的入门案例:
publicclassRingProgressViewextendsView{privatestaticfinalintDEFAULT_SIZE=200;// dpprivatefinalPaintringPaint=newPaint(Paint.ANTI_ALIAS_FLAG);privatefloatprogress=0.6f;publicRingProgressView(Contextcontext,@NullableAttributeSetattrs){super(context,attrs);ringPaint.setStyle(Paint.Style.STROKE);ringPaint.setStrokeWidth(dp(10));ringPaint.setColor(0xFF4A90D9);ringPaint.setStrokeCap(Paint.Cap.ROUND);}@OverrideprotectedvoidonMeasure(intwidthMeasureSpec,intheightMeasureSpec){// 不处理 wrap_content 的话,它表现和 match_parent 一样setMeasuredDimension(resolveSize((int)dp(DEFAULT_SIZE),widthMeasureSpec),resolveSize((int)dp(DEFAULT_SIZE),heightMeasureSpec));}@OverrideprotectedvoidonDraw(Canvascanvas){floatcx=getWidth()/2f;floatcy=getHeight()/2f;floatradius=(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);}publicvoidsetProgress(floatp){progress=p;invalidate();// 触发重绘}privatefloatdp(intv){returnv*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声明:
<resources><declare-styleablename="RingProgressView"><attrname="ringColor"format="color"/><attrname="ringWidth"format="dimension"/></declare-styleable></resources>构造方法里读取:
publicRingProgressView(Contextcontext,@NullableAttributeSetattrs){super(context,attrs);TypedArrayta=context.obtainStyledAttributes(attrs,R.styleable.RingProgressView);intcolor=ta.getColor(R.styleable.RingProgressView_ringColor,0xFF4A90D9);floatwidth=ta.getDimension(R.styleable.RingProgressView_ringWidth,dp(10));ta.recycle();// TypedArray 是池化复用的,用完必须还ringPaint.setStrokeWidth(width);ringPaint.setColor(color);}XML 里就能配了:
<com.example.myqqmusic.RingProgressViewandroid:layout_width="wrap_content"android:layout_height="wrap_content"app:ringColor="#FF4A90D9"app:ringWidth="12dp"/>到这里,这个自绘控件就和开源库里的组件一个用法了。
总结
自定义 View 是个光谱,从浅到深四种做法:
改一个方法 → 叠一层绘制 → 组合封装 → 完全自绘前三种都是"站在系统控件的肩膀上",只有第四种是从零开始。
入门路径建议从第一种写起:找个系统控件不合意的行为,重写一个方法,
跑起来,看看 XML 引用自定义类的完整流程。这套流程和最深一档是完全一样的,
先把它跑顺,剩下的只是往onDraw里填内容的问题。