欢迎光临
我们一直在努力

Android 自定义 View 进阶实战全解析:从扩展控件与组合控件入门,到 onMeasure 测量、onDraw 绘制、手势交互处理及自定义布局容器实现的完整开发指南

Android 自定义 View 进阶实战全解析:从扩展控件与组合控件入门,到 onMeasure 测量、onDraw 绘制、手势交互处理及自定义布局容器实现的完整开发指南


前言

当系统自带的 TextView、Button、ImageView 和常规布局已经无法覆盖业务需求时,Android 自定义 View 就会从“可选技巧”变成“必须掌握的基础能力”。这篇文章按一个完整练习工程的推进顺序展开,先从增强现有控件的倒计时按钮入手,再过渡到组合控件、AttributeSet 自定义属性、onMeasure 测量、onDraw 绘制、onTouchEvent 手势处理,以及 ViewGroup 容器布局控制。读完之后,你不仅能把自定义控件写出来,还能理解每一步为什么这样做、这些生命周期回调之间如何配合,以及交互型控件和容器型控件分别该把逻辑落在哪一层。

目录

  • Android 自定义 View 实战:从扩展控件、组合控件到测量绘制、触摸手势与自定义容器布局
  • 1. 先建立自定义 View 的实现分类和落点
  • 2. 从扩展控件开始实现验证码倒计时按钮
    • 2.1 继承 TextView 并统一构造方法入口
    • 2.2 在倒计时过程中更新文本、状态和颜色
    • 2.3 重置按钮状态并开放外部配置
  • 3. 用组合控件实现可折叠的 ExpandableTextView
    • 3.1 先搭出内容区和切换按钮的结构
    • 3.2 再补齐展开和收起的交互逻辑
  • 4. 通过 AttributeSet 让组合控件支持 XML 自定义属性
  • 5. 从零开始理解 onMeasure 的尺寸确定逻辑
    • 5.1 识别 MeasureSpec 的模式和尺寸来源
    • 5.2 在不同模式下给出最终测量结果
  • 6. 在 onDraw 中完成基础图形绘制
  • 7. 用数据驱动 PieView 的饼图绘制
    • 7.1 先准备数据模型和外部输入
    • 7.2 根据角度绘制扇形区域
    • 7.3 为每个扇形补上文本标签
    • 7.4 用 invalidate 响应外部状态刷新
  • 8. 在 onTouchEvent 中接管触摸事件
    • 8.1 先完成触摸控件的基础绘制
    • 8.2 理解 MotionEvent 提供的动作和坐标
    • 8.3 onTouchEvent 返回值为什么会影响后续事件
  • 9. 实现双击、上滑和 GestureDetector 辅助手势
    • 9.1 用时间差判断双击
    • 9.2 用位移阈值识别向上滑动
    • 9.3 交给 GestureDetector 统一处理常见手势
  • 10. 把屏幕坐标换算成 PieView 的扇形命中区域
  • 11. 为选中的扇形增加放大反馈
  • 12. 在自定义容器中用 onLayout 控制子 View 位置
    • 12.1 先实现一个简单的横向排布容器
    • 12.2 再实现支持换行和 margin 的 TagViewGroup
    • 12.3 补充理解 ViewGroup 中对子控件的测量
  • 13. 小结
  • 14. 相关代码附录
    • 14.1 扩展控件与组合控件
    • 14.2 图形绘制与手势交互
    • 14.3 自定义容器布局

1. 先建立自定义 View 的实现分类和落点

在正式写代码之前,先把自定义 View 的三种实现路线区分清楚,后面所有案例都会落在这三个方向里。

  • 完全自定义 View:直接继承 View,自己处理测量、绘制和交互,适合图表、画板、特殊手势区域这类系统控件无法替代的场景。
  • 组合控件:继承 ViewGroup 或常用容器,把多个已有控件重新组合成一个新组件,适合折叠文本、复合输入区、工具条这类结构型需求。
  • 扩展控件:继承 TextView、Button 这类现有控件,只补充业务能力,不重写整套绘制流程,适合倒计时按钮、清空输入框、带状态的文本按钮。
  • 后面的正文会依次覆盖这三类:先做扩展控件 CountDownButton,再做组合控件 ExpandableTextView,最后进入完全自定义 View 和容器型 ViewGroup 的测量、绘制与交互实现。

    2. 从扩展控件开始实现验证码倒计时按钮

    扩展控件的关键不是“重写得多复杂”,而是尽量复用系统控件原有能力,只把业务差异补进去。验证码倒计时按钮就是一个典型例子,文本显示、点击能力和状态切换本来都属于 TextView 的既有能力,我们只需要在合适的时机插入倒计时逻辑。

    2.1 继承 TextView 并统一构造方法入口

    这个控件选择继承 AppCompatTextView,原因很直接:它已经具备兼容包下更稳定的文本控件能力,我们不需要重新处理文本绘制,只需要新增倒计时状态。

    实战里先把三个构造方法全部补齐,并明确每个构造方法的触发时机:

    public class CountDownButton extends AppCompatTextView {

    //用java new控件的时候,会被触发
    public CountDownButton(@NonNull Context context) {
    this(context, null);

    }

    //xml中声明会被触发
    public CountDownButton(@NonNull Context context, @Nullable AttributeSet attrs) {
    this(context, attrs, 0);
    }

    //一般不会被自动调用
    public CountDownButton(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    reset();//初始化
    }
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/CountDownButton.java

    这里最重要的不是“构造方法写全了”,而是把前两个构造方法都指向第三个构造方法。这样无论控件通过 Java new 出来,还是在 XML 中声明,最终都会走同一个初始化入口。初始化样式、默认文本和默认状态只需要写一次,后面不会出现某个构造方法漏初始化的问题。

    2.2 在倒计时过程中更新文本、状态和颜色

    倒计时按钮的核心目标有三个:开始计时后不可点击、每秒刷新剩余时间、结束后恢复原状。为了让这三个动作形成完整闭环,计时器必须放到成员变量里,而不是写成局部变量。只有成为成员变量,外部和重置逻辑才能在合适时机安全取消它。

    private long totalTime = 60 * 1000l;//默认倒计时时长60s
    private long interval = 1 * 1000l;//每次间隔1s
    private CountDownTimer mCountDownTimer;

    /**
    * 开始倒计时
    */

    public void startCountDown() {
    setEnabled(false);//不允许点击
    setTextColor(Color.GRAY);//灰色样式

    mCountDownTimer = new CountDownTimer(totalTime, interval) {

    @Override
    public void onTick(long millisUntilFinished) {
    //每秒钟这里被触发一次
    //获取当前的秒数(从毫秒转成秒)
    long l = millisUntilFinished / 1000;
    //最新要显示的内容
    String text = String.format("%d秒后重试", l);
    setText(text);
    }

    @Override
    public void onFinish() {
    //倒计时完成会被触发
    reset();
    }
    };
    mCountDownTimer.start();
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/CountDownButton.java

    这段实现里有几个细节不能被一句“启动倒计时”带过去:

  • setEnabled(false) 先把按钮禁用,避免倒计时过程中重复点击导致创建多个计时器实例。
  • setTextColor(Color.GRAY) 立即切换视觉状态,让用户在点击之后马上看到“当前不可操作”的反馈。
  • new CountDownTimer(totalTime, interval) 中,totalTime 决定总时长,interval 决定多久更新一次。
  • onTick(long millisUntilFinished) 给的是毫秒值,先换算成秒再拼接文本,才能得到“xx 秒后重试”的展示结果。
  • onFinish() 不直接写一堆恢复代码,而是统一走 reset(),这样完成态和主动释放态可以共用同一套收尾逻辑。
  • 2.3 重置按钮状态并开放外部配置

    计时器结束后,不能只把文本改回去,还要把旧计时器取消并置空,否则下一轮启动时就可能持有脏对象。重置逻辑必须把“释放对象”和“恢复界面”同时做完。

    /**
    * 重置按钮状态
    */

    private void reset() {
    if (mCountDownTimer != null) {
    mCountDownTimer.cancel();
    mCountDownTimer = null;
    }
    setEnabled(true);
    setText("获取验证码");
    setTextColor(Color.BLUE);
    }

    public void setTotalTime(long totalTime) {
    this.totalTime = totalTime;
    }

    public void setInterval(long interval) {
    this.interval = interval;
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/CountDownButton.java

    这样处理之后,控件既能在内部完成完整状态收尾,也能让宿主页面按业务需要设置倒计时总时长和刷新间隔。

    布局里直接声明这个控件:

    <?xml version="1.0" encoding="utf-8"?>
    <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".Custom1Activity">

    <com.ls.customviewbyjavaproject.CountDownButton
    android:id="@+id/cdb_text"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_centerInParent="true" />

    </RelativeLayout>

    项目路径:CustomViewByJavaProject/app/src/main/res/layout/activity_custom1.xml

    页面里通过点击事件触发控件内部的倒计时逻辑:

    public class Custom1Activity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_custom1);

    CountDownButton cbdtext = findViewById(R.id.cdb_text);
    cbdtext.setTotalTime(30 * 1000l);
    cbdtext.setInterval(1000l);
    cbdtext.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
    cbdtext.startCountDown();
    }
    });
    }
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/Custom1Activity.java

    这里的分工非常清楚:Activity 只负责绑定事件和传入配置,真正的按钮状态切换、文本刷新和恢复逻辑全部留在自定义控件内部。

    3. 用组合控件实现可折叠的 ExpandableTextView

    和倒计时按钮不同,折叠文本不再是给现有控件补一小段逻辑,而是需要把“内容文本”和“展开按钮”组合成一个整体组件。这类需求更适合走组合控件路线。

    3.1 先搭出内容区和切换按钮的结构

    目标效果如下:左侧展示正文,右侧提供展开入口,默认只展示两行,超出部分出现省略号。

    image-20260328071234068

    实现时选择继承 LinearLayout,因为它天然适合管理多个子控件。先把布局结构搭出来,再在内部补交互。

    /**
    * 组合控件 实现折叠textView
    */

    public class ExpandableTextView extends LinearLayout {

    private TextView contentTextView;
    private boolean isExpanded;//是否展开

    public ExpandableTextView(Context context) {
    this(context, null);
    }

    public ExpandableTextView(Context context, @Nullable AttributeSet attrs) {
    super(context, attrs);
    init(attrs);
    }

    /**
    * 初始化操作
    */

    private void init(AttributeSet attrs) {
    //设置排布方向
    setOrientation(VERTICAL);

    //初始化内容显示的textView
    contentTextView = new TextView(getContext());
    contentTextView.setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT));
    contentTextView.setMaxLines(2);//设置默认最多显示两行
    contentTextView.setEllipsize(TextUtils.TruncateAt.END);//超出的部分使用省略号显示
    addView(contentTextView);

    TextView toggleButton = new TextView(getContext());
    LayoutParams layoutParams = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
    layoutParams.gravity = Gravity.RIGHT;
    toggleButton.setLayoutParams(layoutParams);
    toggleButton.setText("展开");
    toggleButton.setTextColor(Color.BLUE);
    addView(toggleButton);
    }
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/ExpandableTextView.java

    这一步的重点在于把组件内部职责拆开:

  • contentTextView 只负责显示正文内容。
  • toggleButton 只负责切换折叠状态。
  • setMaxLines(2) 和 setEllipsize(…) 先把默认折叠态定下来。
  • getContext() 取到的是当前自定义控件运行时的上下文,因此内部创建 TextView 时可以直接复用宿主环境。
  • 3.2 再补齐展开和收起的交互逻辑

    结构搭好以后,再把文本设置能力和展开收起能力补齐,让这个组合控件既能被页面设置内容,也能自己完成折叠态切换。

    public void setText(String content) {
    if (contentTextView != null) {
    contentTextView.setText(content);
    }
    }

    toggleButton.setOnClickListener(new OnClickListener() {
    @Override
    public void onClick(View v) {
    //打开状态取反
    isExpanded = !isExpanded;
    //根据状态决定文本显示行数
    contentTextView.setMaxLines(isExpanded ? Integer.MAX_VALUE : 2);

    toggleButton.setText(isExpanded ? "收起" : "展开");
    }
    });

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/ExpandableTextView.java

    这里的状态切换是一个标准的“单一状态源”写法:isExpanded 只记录当前是否展开,内容最大行数和按钮文本都由这个状态派生出来。这样状态和界面是一一对应的,不会出现按钮文案和内容展示不一致的问题。

    在布局中使用这个组合控件:

    <?xml version="1.0" encoding="utf-8"?>
    <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".Custom1Activity">

    <com.ls.customviewbyjavaproject.ExpandableTextView
    android:id="@+id/etv_text"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_centerInParent="true"
    android:layout_margin="16dp" />

    </RelativeLayout>

    项目路径:CustomViewByJavaProject/app/src/main/res/layout/activity_custom2.xml

    页面里像使用普通 TextView 一样给它设置长文本:

    public class Custom2Activity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_custom2);
    ExpandableTextView expandableTextView = findViewById(R.id.etv_text);
    expandableTextView.setText("这是一个长文本,用来测试!这是一个长文本,用来测试!这是一个长文本,用来测试!这是一个长文本,用来测试!这是一个长文本,用来测试!这是一个长文本,用来测试!这是一个长文本,用来测试!这是一个长文本,用来测试!这是一个长文本,用来测试!");
    }
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/Custom2Activity.java

    收起状态如下:

    image-20260328074822987

    展开状态如下:

    image-20260328074801840

    如果后续要把展开态开放给外部控制,可以继续为 isExpanded 补 get/set 方法,但组件内部这套结构和交互闭环已经完整成立了。

    4. 通过 AttributeSet 让组合控件支持 XML 自定义属性

    到这里为止,ExpandableTextView 只能在 Java 代码里设置文本。要想让它像系统控件一样在 XML 里直接声明属性,就必须接入 attrs.xml 和 AttributeSet。

    先创建 res/values/attrs.xml:

    image-20260328075254633

    在里面声明自定义属性集合:

    <?xml version="1.0" encoding="utf-8"?>
    <resources>

    <declare-styleable name="ExpandableTextView">
    <attr name="my_text" format="string" />
    <attr name="my_color" format="color" />
    </declare-styleable>

    </resources>

    项目路径:CustomViewByJavaProject/app/src/main/res/values/attrs.xml

    然后在布局里通过 app: 命名空间使用这些属性:

    <?xml version="1.0" encoding="utf-8"?>
    <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".Custom1Activity">

    <com.ls.customviewbyjavaproject.ExpandableTextView
    android:id="@+id/etv_text"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_centerInParent="true"
    android:layout_margin="16dp"
    app:my_color="@android:color/holo_red_dark"
    app:my_text="hduashodasiodasisaidis" />

    </RelativeLayout>

    项目路径:CustomViewByJavaProject/app/src/main/res/layout/activity_custom2.xml

    当控件以 XML 方式被解析时,系统会走带 AttributeSet 的构造方法,我们就可以在初始化阶段把这些预设值读出来:

    public ExpandableTextView(Context context, @Nullable AttributeSet attrs) {
    super(context, attrs);
    init(attrs);
    }

    TypedArray typedArray = getContext().obtainStyledAttributes(attrs, R.styleable.ExpandableTextView);
    String content = typedArray.getString(R.styleable.ExpandableTextView_my_text);
    int color = typedArray.getColor(R.styleable.ExpandableTextView_my_color, Color.BLACK);

    contentTextView.setText(content);
    contentTextView.setTextColor(color);

    typedArray.recycle();//释放、回收

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/ExpandableTextView.java

    这一步有三个细节必须交代清楚:

  • obtainStyledAttributes(attrs, R.styleable.ExpandableTextView) 会把当前 XML 上传进来的属性和我们在 attrs.xml 中声明的资源表绑定起来。
  • 读取时用到的资源名会自动变成 ExpandableTextView_my_text、ExpandableTextView_my_color 这种“控件名 + 属性名”的组合。
  • TypedArray 是可复用对象,读取结束后必须 recycle(),否则就是资源泄漏。
  • 有了这层接入后,布局预览和运行效果都可以直接读取 XML 里的预设值:

    image-20260328080713714

    5. 从零开始理解 onMeasure 的尺寸确定逻辑

    当前面的扩展控件和组合控件都还能复用系统控件时,测量问题并不突出。但一旦开始直接继承 View,宽高就不再由系统替你兜底,onMeasure() 会成为第一道必须掌握的关口。

    5.1 识别 MeasureSpec 的模式和尺寸来源

    先定义一个最基础的自定义 View,把构造方法和画笔初始化准备好:

    public class MyCustomView extends View {

    private static final String TAG = "MyCustomView";
    private Paint mPaint;

    public MyCustomView(Context context) {
    this(context, null);
    }

    public MyCustomView(Context context, @Nullable AttributeSet attrs) {
    super(context, attrs);
    initPaint();
    }
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/MyCustomView.java

    接着在 onMeasure() 里把宽高模式和尺寸都拆出来看:

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

    int widthMode = MeasureSpec.getMode(widthMeasureSpec);
    int widthSize = MeasureSpec.getSize(widthMeasureSpec);

    int heightMode = MeasureSpec.getMode(heightMeasureSpec);
    int heightSize = MeasureSpec.getSize(heightMeasureSpec);

    // 处理宽度
    switch (widthMode) {
    case MeasureSpec.EXACTLY:
    break;
    case MeasureSpec.AT_MOST:
    break;
    case MeasureSpec.UNSPECIFIED:
    break;
    }

    setMeasuredDimension(widthSize, heightSize);
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/MyCustomView.java

    这里要先理解 MeasureSpec 是父布局给子控件下发的约束,而不是子控件自己拍脑袋决定尺寸:

  • EXACTLY 表示父布局已经给了精确值,常见于固定 dp 或 match_parent。
  • AT_MOST 表示你可以在一个最大范围内自己决定,常见于 wrap_content。
  • UNSPECIFIED 表示父布局暂时不做明确限制,这类情况更少见,通常出现在特殊容器或滚动场景中。
  • 把这个控件放进页面里进行验证:

    <com.ls.customviewbyjavaproject.MyCustomView
    android:layout_width="match_parent"
    android:layout_height="wrap_content"/>

    项目路径:CustomViewByJavaProject/app/src/main/res/layout/activity_custom3.xml

    控制台会看到多轮测量日志:

    image-20260329085416651

    这不是异常,而是父布局和系统布局流程为了确定最终尺寸,会多次测量子控件。尤其当高度是 wrap_content 时,父布局需要先拿到一个结果,再决定后续布局。

    5.2 在不同模式下给出最终测量结果

    理解了模式之后,就要根据不同模式算出当前控件真正要展示的尺寸,而不是直接照搬父布局传进来的原始值。

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int width, height;

    int widthMode = MeasureSpec.getMode(widthMeasureSpec);
    int heightMode = MeasureSpec.getMode(heightMeasureSpec);

    int widthSize = MeasureSpec.getSize(widthMeasureSpec);
    int heightSize = MeasureSpec.getSize(heightMeasureSpec);

    if (widthMode == MeasureSpec.EXACTLY) {
    width = widthSize;
    } else if (widthMode == MeasureSpec.AT_MOST) {
    width = Math.min(300, widthSize);
    } else {
    width = 300;
    }

    if (heightMode == MeasureSpec.EXACTLY) {
    height = heightSize;
    } else if (heightMode == MeasureSpec.AT_MOST) {
    height = Math.min(300, heightSize);
    } else {
    height = 300;
    }

    setMeasuredDimension(width, height);
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/MyCustomView.java

    这段写法体现的是完全自定义 View 最常见的测量套路:

  • 先定义一个控件期望尺寸。
  • 如果父布局给的是精确值,就服从父布局。
  • 如果是 AT_MOST,就在“自身期望尺寸”和“父布局上限”之间取更小值。
  • 最后通过 setMeasuredDimension(width, height) 提交测量结果。
  • 6. 在 onDraw 中完成基础图形绘制

    完成测量后,下一步就是绘制。onDraw() 不是随便找个地方画图,而是在控件已经拿到尺寸后,把视觉内容真正落到 Canvas 上。

    先准备一支画笔:

    private void initPaint() {
    //初始化画笔
    mPaint = new Paint();
    mPaint.setColor(Color.BLUE);//颜色
    mPaint.setStyle(Paint.Style.STROKE);//设置填充样式
    mPaint.setAntiAlias(true);//设置抗锯齿
    mPaint.setStrokeWidth(10f);//设置画笔宽度10px
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/MyCustomView.java

    然后在 onDraw() 里逐步演示常见图形绘制 API:

    @Override
    protected void onDraw(@NonNull Canvas canvas) {
    super.onDraw(canvas);
    //画点
    canvas.drawPoint(200, 200, mPaint);
    mPaint.setColor(Color.GREEN);
    //画一组点 两个值为一组
    canvas.drawPoints(new float[]{300, 300, 400, 400, 500, 600}, mPaint);

    //画一根线
    mPaint.setColor(Color.RED);
    canvas.drawLine(300, 500, 700, 800, mPaint);

    //圆角矩形
    mPaint.setColor(Color.BLACK);
    canvas.drawRoundRect(400, 1100, 700, 1600, 100, 100, mPaint);

    //圆形
    canvas.drawCircle(500, 500, 300, mPaint);

    //圆弧
    mPaint.setColor(Color.MAGENTA);
    RectF rectF = new RectF(100, 1700, 800, 2200);
    canvas.drawArc(rectF, 0, 90, false, mPaint);

    mPaint.setTextSize(40);
    canvas.drawText("hello,world!", 300, 200, mPaint);
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/MyCustomView.java

    圆弧部分需要额外说明,因为它是后面饼图的基础:

  • RectF 先定义圆弧所在的外接矩形。
  • drawArc(rectF, startAngle, sweepAngle, useCenter, paint) 中,起始角度和扫过角度共同决定绘制范围。
  • useCenter 为 true 时会连到圆心形成扇形,为 false 时更像一段弧线。
  • 圆弧的几种视觉差异如下:

    image-20260329100346776

    image-20260329100439900

    image-20260329100608388

    7. 用数据驱动 PieView 的饼图绘制

    基础绘制 API 熟悉以后,就可以把这些能力组合成一个真正可用的图表控件。这里的目标是实现一个可配置、可交互的 PieView。

    image-20260329100831709

    7.1 先准备数据模型和外部输入

    饼图不是写死的图形,而是根据外部数据决定每个扇形的比例和颜色,所以第一步先建立数据类和外部传值方法。

    public static class PieData {
    private float value;
    private float angle;
    private int color;
    private float percentage;

    public PieData(int value, int color) {
    this.value = value;
    this.color = color;
    }
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/PieView.java

    页面中把数据源交给饼图控件:

    PieView pieViwe = findViewById(R.id.pie_view);

    ArrayList<PieView.PieData> pies = new ArrayList<>();
    pies.add(new PieView.PieData(30, Color.RED));
    pies.add(new PieView.PieData(10, Color.GREEN));
    pies.add(new PieView.PieData(20, Color.BLUE));
    pies.add(new PieView.PieData(40, Color.YELLOW));

    pieViwe.setDatas(pies);
    pieViwe.setStartAngle(90);

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/Custom3Activity.java

    控件内部接收数据后,要先把百分比和角度算出来,再触发重绘:

    public void setDatas(ArrayList<PieData> pies) {
    if (pies == null && pies.isEmpty()) {
    return;//数据有问题,直接返回
    }
    mDatas = pies;
    initDatas();
    invalidate();//刷新控件的视图
    }

    private void initDatas() {
    if (mDatas == null && mDatas.isEmpty()) {
    return;//数据有问题,直接返回
    }
    float sumValue = 0;//数值总和
    for (int i = 0; i < mDatas.size(); i++) {
    PieData pieData = mDatas.get(i);
    sumValue += pieData.getValue();
    }

    for (int i = 0; i < mDatas.size(); i++) {
    PieData pieData = mDatas.get(i);
    float percentage = pieData.getValue() / sumValue;//百分比
    float angle = percentage * 360;//角度
    pieData.setPercentage(percentage);
    pieData.setAngle(angle);
    }
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/PieView.java

    这里的逻辑顺序不能乱:

  • 先校验数据源是否可用。
  • 再计算总值,避免每个元素都重复遍历求和。
  • 再把每项的 value 转成 percentage 和 angle。
  • 最后调用 invalidate(),通知系统当前控件内容已经变了,需要重新走绘制流程。
  • 7.2 根据角度绘制扇形区域

    有了数据还不够,饼图要想画对,必须先把圆心、半径和绘制区域算准。这些计算不应该写在每次 onDraw() 中重复执行,而应该在尺寸变化时提前准备好。

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    super.onSizeChanged(w, h, oldw, oldh);
    mWdith = w;
    mHeight = h;

    //用宽高中比较小的那个值当成是半径的依据,*0.8指的是不要让饼图顶住屏幕
    mRadius = (float) (Math.min(mWdith, mHeight) / 2 * 0.8);
    //饼形图的绘制区域
    mRectF = new RectF(mRadius, mRadius, mRadius, mRadius);
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/PieView.java

    然后在 onDraw() 中按顺序把每个扇形画出来:

    @Override
    protected void onDraw(@NonNull Canvas canvas) {
    super.onDraw(canvas);

    if (mDatas != null && mDatas.isEmpty()) {
    return;
    }
    //将画布原点坐标移动到view的正中间
    canvas.translate(mWdith / 2, mHeight / 2);
    float currentStartAngle = mStartAngle;//当前的起始角度

    for (int i = 0; i < mDatas.size(); i++) {
    PieData pieData = mDatas.get(i);
    mPaint.setColor(pieData.getColor());
    canvas.drawArc(mRectF, currentStartAngle, pieData.getAngle(), true, mPaint);
    currentStartAngle += pieData.getAngle();
    }
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/PieView.java

    这一步的核心逻辑是:

  • 先把画布原点移动到控件中心,这样后续所有圆形图形都以中心点为基准。
  • currentStartAngle 记录当前扇形从哪里开始画。
  • 每画完一个扇形,就把当前角度偏移到下一个扇形的起点。
  • 得到的饼图效果如下:

    image-20260329143422963

    7.3 为每个扇形补上文本标签

    只画出颜色块还不够,用户通常还需要看到每个扇形对应的比例文本。这个标签不能随便摆,而是要落在扇形中间角度对应的位置。

    image-20260329144129004

    实现时先算出当前扇形的中间角度,再用极坐标转直角坐标把文本放到合适位置:

    //是否显示文本标签
    if (mShowTextLabel) {
    //百分比
    float percentage = pieData.getPercentage();
    String percentageText = String.format("%.1f%%", percentage * 100);

    //扇形中间的角度
    float textAngle = currentStartAngle + pieData.getAngle() / 2;
    //文本距离圆心的半径
    float textRadius = radius * 0.6f;
    //通过极坐标、平面直角坐标系的转换,得到坐标系中的x y
    float x = (float) (textRadius * Math.cos(Math.toRadians(textAngle)));
    float y = (float) (textRadius * Math.sin(Math.toRadians(textAngle)));

    mPaint.setColor(Color.BLACK);
    mPaint.setTextSize(textSize);
    mPaint.setTextAlign(Paint.Align.CENTER);
    //绘制文本
    canvas.drawText(percentageText, x, y, mPaint);
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/PieView.java

    这段代码里每个变量都有明确职责:

  • textAngle 是当前扇形的中心角,而不是起始角。
  • textRadius 控制文本离圆心的距离,0.6f 表示放在扇形中部,既不会贴边,也不会挤到中心。
  • Math.cos 和 Math.sin 负责把角度和半径换算成实际绘制坐标。
  • setTextAlign(Paint.Align.CENTER) 让传入的 (x, y) 更接近文本的居中锚点。
  • 加上文本标签后的效果如下:

    image-20260329144848599

    7.4 用 invalidate 响应外部状态刷新

    页面中通过 CheckBox 控制是否显示标签:

    <?xml version="1.0" encoding="utf-8"?>
    <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".Custom1Activity">

    <com.ls.customviewbyjavaproject.PieView
    android:id="@+id/pie_view"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />

    <CheckBox
    android:id="@+id/check_box"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:text="是否显示文本" />

    </RelativeLayout>

    项目路径:CustomViewByJavaProject/app/src/main/res/layout/activity_custom3.xml

    宿主页面监听勾选变化:

    CheckBox checkBox = findViewById(R.id.check_box);

    checkBox.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
    @Override
    public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
    //根据勾选状态,决定是否显示文本标签
    pieViwe.isShowTextLabel(isChecked);
    }
    });

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/Custom3Activity.java

    如果控件内部只是改成员变量,不主动请求重绘,界面不会自动更新:

    image-20260329153414605

    因此控件内部要在状态变化后立刻调用 invalidate():

    private boolean mShowTextLabel;//是否显示文本标签

    public void isShowTextLabel(boolean isShowTextLabel) {
    mShowTextLabel = isShowTextLabel;
    invalidate();//刷新控件的视图
    // postInvalidate();//子线程被调用
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/PieView.java

    再次勾选后,文本标签就会跟着刷新出来:

    image-20260329153738667

    8. 在 onTouchEvent 中接管触摸事件

    绘制解决的是“控件长什么样”,触摸事件解决的是“控件如何响应用户”。当一个控件要处理点击、双击、长按、滑动等强交互动作时,onTouchEvent() 就必须进入核心链路。

    8.1 先完成触摸控件的基础绘制

    在处理手势前,先准备一个最基础的交互控件:有背景色、能在中心绘制文本、已经具备自己的测量和尺寸变化处理。

    public class MyTouchView extends View {

    private int mX;
    private int mY;
    private Paint mPaint;
    private String mText = "手势处理";

    public MyTouchView(Context context) {
    this(context, null);
    }

    public MyTouchView(Context context, @Nullable AttributeSet attrs) {
    super(context, attrs);

    setBackgroundColor(Color.RED);//直接指定view的背景色
    initPaint();
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    super.onSizeChanged(w, h, oldw, oldh);
    mX = w / 2;
    mY = h / 2;
    }

    @Override
    protected void onDraw(@NonNull Canvas canvas) {
    super.onDraw(canvas);
    canvas.drawText(mText, mX, mY, mPaint);
    }
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/MyTouchView.java

    页面中引入这个控件后,可以先看到一个基础可交互区域:

    image-20260329174412292

    8.2 理解 MotionEvent 提供的动作和坐标

    onTouchEvent(MotionEvent event) 收到的不是一个简单点击回调,而是一整个手势序列中的离散事件。最常见的动作包括:

  • ACTION_DOWN:手指按下。
  • ACTION_MOVE:手指移动。
  • ACTION_UP:手指抬起。
  • ACTION_CANCEL:事件被中断或取消。
  • 坐标也分两种:

  • event.getX()、event.getY():相对于当前 View 左上角的坐标。
  • event.getRawX()、event.getRawY():相对于整块屏幕左上角的坐标。
  • 先把这些动作和坐标打印出来,确认事件流实际长什么样:

    @Override
    public boolean onTouchEvent(MotionEvent event) {
    //获取用户的事件类型
    int action = event.getAction();
    //获取到当前相对于View的坐标
    float x = event.getX();
    float y = event.getY();

    switch (action) {
    case MotionEvent.ACTION_DOWN:
    break;
    case MotionEvent.ACTION_MOVE:
    break;
    case MotionEvent.ACTION_UP:
    break;
    case MotionEvent.ACTION_CANCEL:
    break;
    }

    return super.onTouchEvent(event);
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/MyTouchView.java

    第一次运行时,只能看到按下日志:

    image-20260329175711243

    8.3 onTouchEvent 返回值为什么会影响后续事件

    只看到 ACTION_DOWN,看不到后续移动和抬起,不是因为系统没派发,而是因为当前控件没有声明“我要继续消费后续事件”。

    如果在 ACTION_DOWN 阶段返回了 false,系统会判断这个控件对本次手势不感兴趣,于是同一手势序列后面的 MOVE、UP 就不会再给它。

    因此当控件需要完整接管一段触摸序列时,最简单的做法是返回 true:

    @Override
    public boolean onTouchEvent(MotionEvent event) {
    switch (event.getAction()) {
    case MotionEvent.ACTION_DOWN:
    return true;
    case MotionEvent.ACTION_MOVE:
    return true;
    case MotionEvent.ACTION_UP:
    return true;
    default:
    return false;
    }
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/MyTouchView.java

    返回 true 之后,按下、移动、抬起这整段事件序列都会继续回到当前控件:

    image-20260329180210540

    image-20260329181852839

    这就是为什么 onTouchEvent 的返回值不是形式参数,而是事件分发链路中的控制开关。

    9. 实现双击、上滑和 GestureDetector 辅助手势

    掌握基础事件流之后,就可以开始把更具体的交互语义落下来,比如双击识别、上滑手势,以及把常见手势交给系统辅助类统一处理。

    9.1 用时间差判断双击

    双击识别最直接的做法,是记录当前点击时间和上一次点击时间,如果时间差小于阈值,就认定这是一次双击。

    private long mLastTapTime;
    private static final int DOUBLE_TAP_TIMEOUT = 500;//双击的间隔时间(毫秒)

    @Override
    public boolean onTouchEvent(MotionEvent event) {

    int action = event.getAction();
    switch (action) {
    case MotionEvent.ACTION_DOWN:
    //获取用户当前点击的时间
    long currentTimeMillis = System.currentTimeMillis();

    if (currentTimeMillis mLastTapTime <= DOUBLE_TAP_TIMEOUT) {
    mText = "我被双击了!";
    invalidate();
    }

    //每次点击完记录上次的点击时间
    mLastTapTime = currentTimeMillis;
    break;
    }

    return true;
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/MyTouchView.java

    这段逻辑里有两个不能省略的步骤:

  • 先用当前时间减去上一次点击时间判断是否在双击阈值内。
  • 再把这次点击时间写回 mLastTapTime,让它成为下一次判断的参考。
  • 双击后的视觉反馈如下:

    image-20260329183325556

    对应日志如下:

    image-20260329183245801

    9.2 用位移阈值识别向上滑动

    上滑手势的识别思路和双击不同,它不是看时间差,而是看位移差。先记录按下时的 Y 坐标,再在移动过程中比较当前位置和起始位置的差值。

    微信语音“上滑取消”的交互就是这类需求:

    image-20260329183932437

    实现代码如下:

    private float mDownY;//记录按下的Y坐标
    private boolean mIsSwipeUpDetected = false;//记录是否检测到向上滑动

    @Override
    public boolean onTouchEvent(MotionEvent event) {
    int action = event.getAction();

    switch (action) {
    case MotionEvent.ACTION_DOWN:
    mDownY = event.getY();
    mIsSwipeUpDetected = false;
    return true;//我要消费事件
    case MotionEvent.ACTION_MOVE:
    float moveY = event.getY();//每次最新移动的y坐标
    if (mDownY moveY > 200 && !mIsSwipeUpDetected) {
    //向上滑动
    mIsSwipeUpDetected = true;
    mText = "需要向上滑动";
    invalidate();
    }
    break;
    case MotionEvent.ACTION_UP:
    case MotionEvent.ACTION_CANCEL:
    mIsSwipeUpDetected = false;
    break;
    }
    return super.onTouchEvent(event);
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/MyTouchView.java

    这套写法能稳定工作的原因在于:

  • mDownY 只在 ACTION_DOWN 时记录一次,作为整段手势的基准点。
  • mDownY – moveY > 200 保证位移足够大,避免轻微抖动被误判成上滑。
  • mIsSwipeUpDetected 保证同一段手势只触发一次,不会在持续滑动时反复命中。
  • 向上滑动后的日志如下:

    image-20260329185440074

    9.3 交给 GestureDetector 统一处理常见手势

    如果控件需要的只是双击、单击、长按、滑动这类常见手势,就没必要每次都手写时间戳和状态机,GestureDetector 已经把这些能力封装好了。

    它的常见监听入口如下:

    image-20260329224211885

    在初始化阶段创建一个手势辅助类:

    private void initGestureDetector() {
    //手势辅助类
    mGestureDetector = new GestureDetector(getContext(), new GestureDetector.SimpleOnGestureListener() {

    @Override
    public boolean onDown(@NonNull MotionEvent e) {
    return true;//当前的手势辅助类对事件感兴趣,需要消费当前事件
    }

    @Override
    public boolean onDoubleTap(@NonNull MotionEvent e) {
    //捕捉到了双击手势 默认事件间隔300ms
    mText = "双击了!";
    invalidate();
    return true;//表示消费当前的触摸事件
    }
    });
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/MyTouchView.java

    然后把 onTouchEvent() 的结果交给它参与决定:

    private GestureDetector mGestureDetector;//手势辅助类

    @Override
    public boolean onTouchEvent(MotionEvent event) {
    return super.onTouchEvent(event) || mGestureDetector.onTouchEvent(event);
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/MyTouchView.java

    这时真正的分工就变成了:

  • GestureDetector 负责解释常见手势语义。
  • 当前控件只在命中手势后更新文本和刷新 UI。
  • onDown() 必须返回 true,否则后续双击之类的复合手势就不会继续派发给这个监听器。
  • 10. 把屏幕坐标换算成 PieView 的扇形命中区域

    饼图交互的难点不在于“能不能收到双击”,而在于收到双击以后,怎么判断用户点中的到底是哪一个扇形。

    在 PieView 中,绘图时已经把画布原点移动到控件中心,但 MotionEvent 给出的点击坐标仍然是以控件左上角为基准的。因此第一步必须做坐标换算。

    双击事件入口如下:

    @Override
    public boolean onDoubleTap(@NonNull MotionEvent e) {
    //用户双击的坐标位置
    float x = e.getX();
    float y = e.getY();
    //判断坐标在哪个扇形区域
    checkIfInsideSlice(x, y);
    return true;
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/PieView.java

    真正的命中判断逻辑写在 checkIfInsideSlice() 中:

    private PieData mSelectPieData;//当前选中的区域

    private void checkIfInsideSlice(float x, float y) {
    //将点击的坐标转换为相对于饼图中心的坐标
    float dx = x mWdith / 2;
    float dy = y mHeight / 2;
    //代表饼图中心到触摸点的方向角度
    double angle = Math.toDegrees(Math.atan2(dy, dx));

    //atan2返回的角度范围是-180~180度,如果是负数,那么转化为0~360度
    if (angle < 0) {
    angle += 360;
    }

    float currentStartAngle = mStartAngle;
    for (PieData pie : mDatas) {
    float endAngle = currentStartAngle + pie.getAngle();
    if (angle >= currentStartAngle && angle <= endAngle) {
    if (mSelectPieData == pie) {
    mSelectPieData = null;
    } else {
    mSelectPieData = pie;
    }
    invalidate();
    return;
    }

    currentStartAngle = endAngle;
    }
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/PieView.java

    这段数学换算的顺序必须完整理解:

  • 先把点击点从“相对控件左上角”换算成“相对饼图圆心”。
  • 再用 atan2(dy, dx) 算出这个点相对 x 轴的夹角。
  • 再把弧度转成角度,并把负角度修正到 0~360 区间。
  • 最后遍历所有扇形,用“当前起始角度到结束角度”的区间判断用户落点命中了哪一块。
  • 只要命中成功,就把当前扇形记录到 mSelectPieData,并触发重绘。

    11. 为选中的扇形增加放大反馈

    命中扇形以后,下一步不是只改一个标记位,而是要把这个选中态反馈真实体现在视觉上。这里的实现方式是:为选中扇形单独准备更大的半径和绘制区域,再在 onDraw() 中按状态切换。

    先在尺寸变化时额外计算一套放大后的半径和绘制矩形:

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    super.onSizeChanged(w, h, oldw, oldh);
    mWdith = w;
    mHeight = h;

    mRadius = (float) (Math.min(mWdith, mHeight) / 2 * 0.8);
    mRectF = new RectF(mRadius, mRadius, mRadius, mRadius);

    mMaxRadius = (float) (Math.min(mWdith, mHeight) / 2 * 0.9);
    mMaxRectF = new RectF(mMaxRadius, mMaxRadius, mMaxRadius, mMaxRadius);
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/PieView.java

    绘制每个扇形时,根据当前是否为选中项切换不同的绘制参数:

    RectF rectF;
    float radius;
    float textSize;
    if (mSelectPieData == pieData) {
    rectF = mMaxRectF;
    radius = mMaxRadius;
    textSize = 60;
    } else {
    rectF = mRectF;
    radius = mRadius;
    textSize = 40;
    }

    canvas.drawArc(rectF, currentStartAngle, pieData.getAngle(), true, mPaint);

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/PieView.java

    这样处理的好处是,选中态不是额外偏移一层复杂坐标,而是直接切换到另一套更大的绘制范围和文本尺寸。再次双击同一扇形时,mSelectPieData 置空,绘制逻辑又会自然回到默认半径。

    最终效果如下:

    image-20260329232222835

    12. 在自定义容器中用 onLayout 控制子 View 位置

    前面的 CountDownButton、PieView 解决的是单个控件自身如何显示和交互。到了容器型自定义 View,重点就不再是“自己怎么画”,而是“子控件怎么摆”。

    onLayout() 的职责非常明确:在测量完成后,给每个子 View 指定最终的左、上、右、下边界。

    12.1 先实现一个简单的横向排布容器

    最基础的例子就是让子控件从左到右依次排开。这里先用 MyRightLayout 演示一个简单版本。

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {

    int left = 0;
    int top = 0;

    for (int i = 0; i < getChildCount(); i++) {
    View childAt = getChildAt(i);
    if (childAt.getVisibility() == GONE) {
    continue;
    }

    int width = childAt.getMeasuredWidth();
    int height = childAt.getMeasuredHeight();

    //决定子控件左 上 右 下 四个方位
    childAt.layout(left, top, left + width, top + height);

    //每次循环 准备下一个view的初始位置
    left += width;
    }
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/MyRightLayout.java

    这个实现的思路很直接:

  • left 记录当前子控件的起始横坐标。
  • 每摆完一个子控件,就把 left 往右推进一个子控件宽度。
  • 子控件的坐标全部依赖前面测量阶段的 getMeasuredWidth() 和 getMeasuredHeight()。
  • 对应布局示例:

    <?xml version="1.0" encoding="utf-8"?>

    <com.ls.customviewbyjavaproject.MyRightLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".Custom1Activity">

    <View
    android:layout_width="100dp"
    android:layout_height="200dp"
    android:layout_marginLeft="15dp"
    android:background="@color/black" />

    <View
    android:layout_width="100dp"
    android:layout_height="200dp"
    android:layout_marginLeft="15dp"
    android:background="@color/material_dynamic_secondary30" />

    <View
    android:layout_width="100dp"
    android:layout_height="200dp"
    android:layout_marginLeft="15dp"
    android:background="@color/design_default_color_error" />

    </com.ls.customviewbyjavaproject.MyRightLayout>

    项目路径:CustomViewByJavaProject/app/src/main/res/layout/activity_custom4.xml

    12.2 再实现支持换行和 margin 的 TagViewGroup

    仅仅横向排列还不够,很多标签流布局都要求“装不下就换行”,而且必须考虑子控件的 margin。这时就需要在 onMeasure() 和 onLayout() 中同时处理行宽、行高和换行时机。

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int widthSize = MeasureSpec.getSize(widthMeasureSpec);
    int widthMode = MeasureSpec.getMode(widthMeasureSpec);
    int heightMode = MeasureSpec.getMode(heightMeasureSpec);

    int width = 0;
    int height = 0;
    int lineWidth = 0;
    int lineHeight = 0;

    for (int i = 0; i < getChildCount(); i++) {
    View child = getChildAt(i);
    MarginLayoutParams params = (MarginLayoutParams) child.getLayoutParams();
    measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0);

    int childWidth = child.getMeasuredWidth();
    int childHeight = child.getMeasuredHeight();

    lineWidth += childWidth + params.leftMargin + params.rightMargin;
    lineHeight = Math.max(lineHeight, childHeight + params.topMargin + params.bottomMargin);

    if (lineWidth > widthSize) {
    width = Math.max(width, lineWidth (childWidth + params.leftMargin + params.rightMargin));
    height += lineHeight;
    lineWidth = childWidth + params.leftMargin + params.rightMargin;
    lineHeight = childHeight + params.topMargin + params.bottomMargin;
    }
    }

    width = Math.max(width, lineWidth);
    height += lineHeight;

    setMeasuredDimension(
    widthMode == MeasureSpec.EXACTLY ? widthSize : width,
    heightMode == MeasureSpec.EXACTLY ? heightSize : height
    );
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/TagViewGroup.java

    测量阶段主要解决的是“容器自己需要多大”。逻辑顺序如下:

  • 逐个测量子控件,让子控件先拿到自己的尺寸。
  • 把子控件宽度和左右 margin 计入当前行宽。
  • 用当前行中最高的子控件作为这一行的行高。
  • 如果新子控件放不下,就先结算上一行的宽高,再开启下一行。
  • 布局阶段再把这些行内结果落实成每个子控件的坐标:

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
    int left = getPaddingLeft();
    int top = getPaddingTop();

    int lineHeight = 0;
    int lineWidth = 0;

    for (int i = 0; i < getChildCount(); i++) {
    View child = getChildAt(i);
    MarginLayoutParams params = (MarginLayoutParams) child.getLayoutParams();

    int childWidth = child.getMeasuredWidth();
    int childHeight = child.getMeasuredHeight();

    if (lineWidth + childWidth + params.leftMargin + params.rightMargin > getWidth() getPaddingLeft() getPaddingRight()) {
    left = getPaddingLeft();
    top += lineHeight;
    lineWidth = 0;
    lineHeight = 0;
    }

    child.layout(left + params.leftMargin, top + params.topMargin,
    left + params.leftMargin + childWidth, top + params.topMargin + childHeight);

    lineWidth += childWidth + params.leftMargin + params.rightMargin;
    lineHeight = Math.max(lineHeight, childHeight + params.topMargin + params.bottomMargin);
    left += childWidth + params.leftMargin + params.rightMargin;
    }
    }

    @Override
    public LayoutParams generateLayoutParams(AttributeSet attrs) {
    return new MarginLayoutParams(getContext(), attrs);
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/TagViewGroup.java

    这里的关键点有三个:

  • measureChildWithMargins(…) 和 MarginLayoutParams 配套使用,才能把外边距纳入测量和布局。
  • 换行判断必须用“当前行已占宽度 + 新子控件宽度 + margin”去和容器可用宽度比较。
  • generateLayoutParams(…) 必须返回 MarginLayoutParams,否则 XML 中写的 layout_marginLeft 这类属性不会被正确解析。
  • 对应布局示例中放入一组按钮,容器会自动换行排列:

    <?xml version="1.0" encoding="utf-8"?>

    <com.ls.customviewbyjavaproject.TagViewGroup xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".Custom1Activity">

    <Button
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="你好" />

    <Button
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="你好吗" />

    <Button
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="你好你好你好" />

    <Button
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="我不好" />

    <Button
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginLeft="10dp"
    android:text="那你是谁" />

    </com.ls.customviewbyjavaproject.TagViewGroup>

    项目路径:CustomViewByJavaProject/app/src/main/res/layout/activity_custom5.xml

    12.3 补充理解 ViewGroup 中对子控件的测量

    容器型自定义 View 还有一个很容易忽略的点:子控件不会平白无故自己去调用 onMeasure(),它们通常是由父容器在 onMeasure() 里主动触发测量的。

    最常见的两种方式如下:

  • measureChild(…):不考虑 margin,适合简单子控件布局。
  • measureChildWithMargins(…):会把外边距一并纳入计算,适合流式布局、复杂容器布局。
  • 这也是为什么 ViewGroup 自定义布局里,onMeasure() 和 onLayout() 必须成对思考:前者决定尺寸,后者决定位置,缺一不可。

    13. 小结

    这套自定义 View 练习串起来之后,可以得到一条很完整的能力链路:

  • 只增强现有控件能力时,优先考虑扩展控件,像 CountDownButton 这样把业务状态封装进 TextView。
  • 需要多个现有控件协同工作时,优先考虑组合控件,像 ExpandableTextView 一样把结构和交互一起封进组件内部。
  • 系统控件无法满足绘制需求时,就要直接继承 View,用 onMeasure() 决定尺寸、用 onDraw() 决定内容、用 onTouchEvent() 决定交互。
  • 如果控件本身承担的是“摆放别人”的职责,就要进入 ViewGroup 语境,用 onMeasure() 计算容器尺寸,用 onLayout() 安排子控件坐标。
  • 掌握这条链路以后,再遇到图表、复合控件、手势控件和自定义布局时,就不会只停留在“知道几个 API”,而是能判断需求应该落在哪一层,并且知道每一层该处理什么问题。

    14. 相关代码附录

    14.1 扩展控件与组合控件

    CountDownButton:

    public class CountDownButton extends AppCompatTextView {

    private long totalTime = 60 * 1000l;//默认倒计时时长60s
    private long interval = 1 * 1000l;//每次间隔1s
    private CountDownTimer mCountDownTimer;

    public CountDownButton(@NonNull Context context) {
    this(context, null);
    }

    public CountDownButton(@NonNull Context context, @Nullable AttributeSet attrs) {
    this(context, attrs, 0);
    }

    public CountDownButton(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    reset();
    }

    public void startCountDown() {
    setEnabled(false);
    setTextColor(Color.GRAY);

    mCountDownTimer = new CountDownTimer(totalTime, interval) {
    @Override
    public void onTick(long millisUntilFinished) {
    long l = millisUntilFinished / 1000;
    String text = String.format("%d秒后重试", l);
    setText(text);
    }

    @Override
    public void onFinish() {
    reset();
    }
    };
    mCountDownTimer.start();
    }

    private void reset() {
    if (mCountDownTimer != null) {
    mCountDownTimer.cancel();
    mCountDownTimer = null;
    }
    setEnabled(true);
    setText("获取验证码");
    setTextColor(Color.BLUE);
    }
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/CountDownButton.java

    ExpandableTextView:

    public class ExpandableTextView extends LinearLayout {

    private TextView contentTextView;
    private boolean isExpanded;

    public ExpandableTextView(Context context) {
    this(context, null);
    }

    public ExpandableTextView(Context context, @Nullable AttributeSet attrs) {
    super(context, attrs);
    init(attrs);
    }

    private void init(AttributeSet attrs) {
    setOrientation(VERTICAL);

    contentTextView = new TextView(getContext());
    contentTextView.setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT));
    contentTextView.setMaxLines(2);
    contentTextView.setEllipsize(TextUtils.TruncateAt.END);
    addView(contentTextView);

    TextView toggleButton = new TextView(getContext());
    LayoutParams layoutParams = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
    layoutParams.gravity = Gravity.RIGHT;
    toggleButton.setLayoutParams(layoutParams);
    toggleButton.setText("展开");
    toggleButton.setTextColor(Color.BLUE);
    addView(toggleButton);

    toggleButton.setOnClickListener(new OnClickListener() {
    @Override
    public void onClick(View v) {
    isExpanded = !isExpanded;
    contentTextView.setMaxLines(isExpanded ? Integer.MAX_VALUE : 2);
    toggleButton.setText(isExpanded ? "收起" : "展开");
    }
    });

    TypedArray typedArray = getContext().obtainStyledAttributes(attrs, R.styleable.ExpandableTextView);
    String content = typedArray.getString(R.styleable.ExpandableTextView_my_text);
    int color = typedArray.getColor(R.styleable.ExpandableTextView_my_color, Color.BLACK);

    contentTextView.setText(content);
    contentTextView.setTextColor(color);
    typedArray.recycle();
    }
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/ExpandableTextView.java

    14.2 图形绘制与手势交互

    PieView:

    public class PieView extends View {

    private ArrayList<PieData> mDatas;
    private PieData mSelectPieData;
    private int mWdith;
    private int mHeight;
    private float mStartAngle = 0;
    private float mRadius;
    private RectF mRectF;
    private Paint mPaint;
    private boolean mShowTextLabel;
    private GestureDetector mGestureDetector;
    private float mMaxRadius;
    private RectF mMaxRectF;

    public PieView(Context context, @Nullable AttributeSet attrs) {
    super(context, attrs);
    init();
    }

    private void init() {
    mPaint = new Paint();
    mPaint.setStyle(Paint.Style.FILL);
    mPaint.setAntiAlias(true);

    mGestureDetector = new GestureDetector(getContext(), new GestureDetector.SimpleOnGestureListener() {
    @Override
    public boolean onDown(@NonNull MotionEvent e) {
    return true;
    }

    @Override
    public boolean onDoubleTap(@NonNull MotionEvent e) {
    checkIfInsideSlice(e.getX(), e.getY());
    return true;
    }
    });
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
    return mGestureDetector.onTouchEvent(event) || super.onTouchEvent(event);
    }
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/PieView.java

    MyTouchView:

    public class MyTouchView extends View {

    private int mX;
    private int mY;
    private Paint mPaint;
    private String mText = "手势处理";
    private GestureDetector mGestureDetector;

    public MyTouchView(Context context, @Nullable AttributeSet attrs) {
    super(context, attrs);
    setBackgroundColor(Color.RED);
    initPaint();
    initGestureDetector();
    }

    private void initGestureDetector() {
    mGestureDetector = new GestureDetector(getContext(), new GestureDetector.SimpleOnGestureListener() {
    @Override
    public boolean onDown(@NonNull MotionEvent e) {
    return true;
    }

    @Override
    public boolean onDoubleTap(@NonNull MotionEvent e) {
    mText = "双击了!";
    invalidate();
    return true;
    }
    });
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
    return super.onTouchEvent(event) || mGestureDetector.onTouchEvent(event);
    }
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/MyTouchView.java

    14.3 自定义容器布局

    MyRightLayout:

    public class MyRightLayout extends ViewGroup {

    public MyRightLayout(Context context, AttributeSet attrs) {
    super(context, attrs);
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
    int left = 0;
    int top = 0;

    for (int i = 0; i < getChildCount(); i++) {
    View childAt = getChildAt(i);
    if (childAt.getVisibility() == GONE) {
    continue;
    }

    int width = childAt.getMeasuredWidth();
    int height = childAt.getMeasuredHeight();
    childAt.layout(left, top, left + width, top + height);
    left += width;
    }
    }
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/MyRightLayout.java

    TagViewGroup:

    public class TagViewGroup extends ViewGroup {

    public TagViewGroup(Context context, AttributeSet attrs) {
    super(context, attrs);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int widthSize = MeasureSpec.getSize(widthMeasureSpec);
    int widthMode = MeasureSpec.getMode(widthMeasureSpec);
    int heightMode = MeasureSpec.getMode(heightMeasureSpec);

    int width = 0;
    int height = 0;
    int lineWidth = 0;
    int lineHeight = 0;

    for (int i = 0; i < getChildCount(); i++) {
    View child = getChildAt(i);
    MarginLayoutParams params = (MarginLayoutParams) child.getLayoutParams();
    measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0);

    int childWidth = child.getMeasuredWidth();
    int childHeight = child.getMeasuredHeight();

    lineWidth += childWidth + params.leftMargin + params.rightMargin;
    lineHeight = Math.max(lineHeight, childHeight + params.topMargin + params.bottomMargin);

    if (lineWidth > widthSize) {
    width = Math.max(width, lineWidth (childWidth + params.leftMargin + params.rightMargin));
    height += lineHeight;
    lineWidth = childWidth + params.leftMargin + params.rightMargin;
    lineHeight = childHeight + params.topMargin + params.bottomMargin;
    }
    }

    width = Math.max(width, lineWidth);
    height += lineHeight;

    setMeasuredDimension(
    widthMode == MeasureSpec.EXACTLY ? widthSize : width,
    heightMode == MeasureSpec.EXACTLY ? heightSize : height
    );
    }

    @Override
    public LayoutParams generateLayoutParams(AttributeSet attrs) {
    return new MarginLayoutParams(getContext(), attrs);
    }
    }

    项目路径:CustomViewByJavaProject/app/src/main/java/com/ls/customviewbyjavaproject/TagViewGroup.java

    赞(0)
    未经允许不得转载:171主机测评 » Android 自定义 View 进阶实战全解析:从扩展控件与组合控件入门,到 onMeasure 测量、onDraw 绘制、手势交互处理及自定义布局容器实现的完整开发指南
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址