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 里填内容的问题。

相关推荐
事圆则缓3 小时前
Android 开启线程的几种方式:Thread、线程池、HandlerThread 与协程
android
半摆烂日常18 小时前
低代码平台API对接实践:接口鉴权与数据同步的完整实现
android·低代码·rxjava
事圆则缓18 小时前
volatile 使用场景全解析:它能保证什么,又解决不了什么
android
蒲公英内测分发19 小时前
智能投影仪参加海外展会,未上架的 Android 配套 App 怎么让客户限时试用?
android
ii_best19 小时前
按键精灵手机端开发安卓版实战:手写一个可最小化、可拖动的「运行日志悬浮窗」(附完整 源码 + 踩坑记录)
android·ios·智能手机·自动化·ai编程·按键精灵
终端安全笔记19 小时前
安卓做 MDM 管控,先分清三种注册入口
android·安全·智能手机
乌萨达20 小时前
手机模拟器安卓怎么用?电脑安装应用、玩手游与账号同步指南
android·智能手机·电脑·雷电模拟器
美狐美颜SDK开放平台21 小时前
Android与iOS直播APP美颜有什么区别?视频美颜SDK开发详解
android·ios·音视频·视频美颜sdk
春猿火1 天前
某市-2026【网安·论道】misc-afterimage_note-wp
android