【Android】自定义 View 从入门到精通全攻略

0 阅读13分钟

自定义 View 基础概念

1. View 与 ViewGroup 的区别

  • View:UI 的最小单位,负责绘制和交互(如 TextView、Button)。
  • ViewGroup:容器类,可包含多个 View(如 LinearLayout、RecyclerView)。

2. 自定义 View 的三种类型

类型适用场景实现复杂度
继承 View简单图形绘制(如进度条、图表)高(需自绘)
继承 ViewGroup自定义布局(如流式布局)
组合 View组合已有控件(如带图标的按钮)
public class MyView extends View {
    // 必写构造方法(从XML加载或代码创建时调用)
    public MyView(Context context) {
        super(context);
    }

    // 从XML加载时调用(需声明自定义属性)
    public MyView(Context context, AttributeSet attrs) {
        super(context, attrs);
        // 解析自定义属性
    }

    // 从XML加载且指定样式时调用
    public MyView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    // API 21+ 支持样式主题时调用
    public MyView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
    }
}

自定义 View 三大流程:测量、布局、绘制

1. 测量(onMeasure)

  • 核心目标:确定 View 的宽高尺寸。

  • 关键类MeasureSpec(包含测量模式和尺寸),三种模式:

    • UNSPECIFIED:未指定尺寸(父容器不限制)。
    • EXACTLY:精确尺寸(如match_parent或固定值)。
    • AT_MOST:最大尺寸(如wrap_content)。
  • 实现步骤

    1. 调用super.onMeasure()获取父容器的测量规格。
    2. 根据MeasureSpec计算自定义 View 的宽高(需处理 padding 和子 View 测量)。
    3. 调用setMeasuredDimension(width, height)保存结果。

2. 布局(onLayout)

  • 适用场景:仅 ViewGroup 需要重写,确定子 View 的位置。
  • 核心方法child.layout(left, top, right, bottom)(设置子 View 的四个顶点坐标)。

3. 绘制(onDraw)

  • 绘制流程

    1. Canvas:绘制画布,提供drawRect()drawCircle()drawText()等方法。
    2. Paint:绘制画笔,设置颜色、粗细、抗锯齿等属性。
    3. Path:绘制复杂路径(如贝塞尔曲线、圆弧)。

事件处理与交互

1. 事件分发机制

  • 三层方法

    • dispatchTouchEvent(MotionEvent):事件分发入口,决定事件是否传递给子 View。
    • onInterceptTouchEvent(MotionEvent):ViewGroup 用于拦截事件(View 无此方法)。
    • onTouchEvent(MotionEvent):处理点击、滑动等事件。
  • 经典原则:事件先由父容器分发,子 View 可通过返回true消耗事件。

2. 触摸反馈与滑动

  • 触摸反馈:通过invalidate()刷新视图实现按压效果。

  • 滑动实现

    • scrollTo/scrollBy:滚动自身内容(不改变子 View 位置)。
    • layout():重新布局子 View(改变位置)。
    • Scroller:实现平滑滚动(需配合computeScroll())。

自定义属性与样式

1. 声明自定义属性

  1. res/values/attrs.xml中定义:
<resources>
    <declare-styleable name="MyView">
        <attr name="custom_color" format="color" />
        <attr name="custom_text" format="string" />
        <attr name="custom_radius" format="dimension" />
    </declare-styleable>
</resources>
  1. 在 XML 中使用(需添加命名空间):
<com.example.MyView
    xmlns:app="http://schemas.android.com/apk/res-auto"
    app:custom_color="#FF0000"
    app:custom_text="Hello"
    ... />
  1. 在构造方法中解析:
TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.MyView);
int color = ta.getColor(R.styleable.MyView_custom_color, Color.BLACK);
String text = ta.getString(R.styleable.MyView_custom_text);
ta.recycle(); // 必须回收TypedArray

2. 样式(Style)与主题(Theme)

  • 样式:定义控件外观(如android:style="@style/MyButtonStyle")。
  • 主题:定义应用或 Activity 的全局风格(通过AndroidManifest.xml设置)。

性能优化与高级技巧

1. 性能优化要点

  • 避免过度绘制:通过Hierarchy Viewer工具检查,减少透明层和重叠绘制。
  • 硬件加速:在 Manifest 中开启android:hardwareAccelerated="true"
  • 缓存机制:使用setDrawingCacheEnabled(true)缓存绘制结果。
  • 减少 onDraw 调用:合并绘制操作,避免频繁调用invalidate()

2. 动画与过渡效果

  • 属性动画(ObjectAnimator) :直接操作 View 属性(如translationXalpha)。
  • 自定义动画:重写Animation类或使用ValueAnimator监听数值变化驱动绘制。
  • 触摸反馈动画:通过StateListAnimator实现按压、选中状态的动画。

3. 适配与兼容性

  • 屏幕适配:使用dpsp单位,或通过Configuration监听屏幕旋转。
  • API 版本兼容:使用ViewCompat工具类处理不同版本差异(如 AndroidX 库)。

实战案例

  • 自定义进度条:通过onDraw绘制圆弧进度,配合ValueAnimator动态更新。
  • 流式布局(FlowLayout) :重写onMeasureonLayout实现子 View 自动换行。
  • 可拖拽 View:结合onTouchEventScroller实现拖拽滑动效果。

1. 自定义进度条

自定义圆形进度条View


/**
 * 自定义圆形进度条视图
 */
public class CircularProgressView extends View {

    // 绘图相关属性
    private Paint paint; // 用于绘制的画笔对象

    // 进度相关属性
    private float progress = 0f; // 当前进度,默认为0
    private float maxProgress = 100f; // 最大进度,默认为100
    private int progressColor = Color.BLUE; // 进度条颜色,默认为蓝色
    private int backgroundColor = Color.GRAY; // 背景颜色,默认为灰色
    private float progressWidth = 10f; // 进度条宽度,默认为10像素
    private float startAngle = -90f; // 圆弧起始角度,默认从顶部开始(-90度)

    // 构造方法
    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);

        // 初始化画笔
        paint = new Paint(Paint.ANTI_ALIAS_FLAG); // 创建画笔对象,开启抗锯齿
        paint.setStyle(Paint.Style.STROKE); // 设置为描边模式
        paint.setStrokeCap(Paint.Cap.ROUND); // 设置描边端点为圆形

        // 从自定义属性获取值
        if (attrs != null) {
            // 获取自定义属性集合
            TypedArray a = context.getTheme().obtainStyledAttributes(
                    attrs,
                    R.styleable.CircularProgressView,
                    0, 0);

            try {
                // 从自定义属性中获取进度条颜色,未设置时使用默认值
                progressColor = a.getColor(R.styleable.CircularProgressView_progressColor, Color.BLUE);
                // 从自定义属性中获取背景颜色,未设置时使用默认值
                backgroundColor = a.getColor(R.styleable.CircularProgressView_backgroundColor, Color.GRAY);
                // 从自定义属性中获取进度条宽度,未设置时使用默认值
                progressWidth = a.getDimension(R.styleable.CircularProgressView_progressWidth, 10f);
            } finally {
                // 回收TypedArray资源
                a.recycle();
            }
        }

        // 设置画笔宽度
        paint.setStrokeWidth(progressWidth);
    }

    /**
     * 设置当前进度
     * @param progress 当前进度值
     */
    public void setProgress(float progress) {
        // 确保进度值在合法范围内(0到maxProgress之间)
        this.progress = Math.max(0f, Math.min(progress, maxProgress));
        invalidate();  // 触发视图重绘
    }

    /**
     * 设置最大进度
     * @param maxProgress 最大进度值
     */
    public void setMaxProgress(float maxProgress) {
        // 确保最大进度值至少为1
        this.maxProgress = Math.max(1f, maxProgress);
        invalidate();  // 触发视图重绘
    }

    /**
     * 设置进度条颜色
     * @param color 进度条颜色
     */
    public void setProgressColor(int color) {
        this.progressColor = color;
        invalidate();  // 触发视图重绘
    }

    /**
     * 设置背景颜色
     * @param color 背景颜色
     */
    public void setBackgroundColor(int color) {
        this.backgroundColor = color;
        invalidate();  // 触发视图重绘
    }

    /**
     * 设置进度条宽度
     * @param width 进度条宽度
     */
    public void setProgressWidth(float width) {
        this.progressWidth = width;
        // 更新画笔宽度
        paint.setStrokeWidth(width);
        invalidate();  // 触发视图重绘
    }

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

        // 计算圆心坐标和半径
        float centerX = getWidth() / 2f; // 获取视图宽度的一半作为圆心x坐标
        float centerY = getHeight() / 2f; // 获取视图高度的一半作为圆心y坐标
        float radius = Math.min(centerX, centerY) - progressWidth / 2; // 计算圆弧半径

        // 绘制背景圆环
        paint.setColor(backgroundColor); // 设置画笔颜色为背景色
        // 绘制一个完整的圆作为背景
        canvas.drawCircle(centerX, centerY, radius, paint);

        // 绘制进度圆弧
        paint.setColor(progressColor); // 设置画笔颜色为进度条颜色
        // 计算当前进度对应的圆心角
        float sweepAngle = 360f * (progress / maxProgress);
        // 定义一个矩形区域用于绘制圆弧
        RectF oval = new RectF(
                centerX - radius, // 左坐标
                centerY - radius, // 顶坐标
                centerX + radius, // 右坐标
                centerY + radius  // 底坐标
        );
        // 绘制圆弧,参数包括:绘制区域、起始角度、扫过的角度、是否闭合、画笔
        canvas.drawArc(oval, startAngle, sweepAngle, false, paint);
    }
}

在布局文件中使用

首先在res/values/attrs.xml中定义自定义属性:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    
    <declare-styleable name="CircularProgressView">
        <attr name="progressColor" format="color"/>
        <attr name="backgroundColor" format="color"/>
        <attr name="progressWidth"  format="dimension"/>
    </declare-styleable>
</resources>

然后在布局文件中使用:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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"
    android:gravity="center"
    tools:context=".MainActivity">

    <com.example.myview.CircularProgressView
        android:id="@+id/circularProgressView"
        android:layout_width="200dp"
        android:layout_height="200dp"
        app:progressColor="@color/colorPrimary"
        app:backgroundColor="@color/colorGray"
        app:progressWidth="15dp" />

</LinearLayout>

使用ValueAnimator实现动态更新

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        EdgeToEdge.enable(this);
        setContentView(R.layout.activity_main);
        CircularProgressView progressView = findViewById(R.id.circularProgressView);

        // 设置初始进度
        progressView.setProgress(0);

        // 创建ValueAnimator
        ValueAnimator animator = ValueAnimator.ofFloat(0, 100);
        animator.setDuration(3000);  // 动画时长3秒
        animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                float currentValue = (float) animation.getAnimatedValue();
                progressView.setProgress(currentValue);
            }
        });

        // 启动动画
        animator.start();
    }
}

image.png

2. 流式布局

自定义流式布局 View


/**
 * 自定义流式布局(FlowLayout)
 */
public class CustomFlowLayout extends ViewGroup {

    private int lineSpacing = 0; // 行间距
    private int columnSpacing = 0; // 列间距

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

    public CustomFlowLayout(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public CustomFlowLayout(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        // 可以在这里解析自定义属性,设置行间距和列间距
    }

    /**
     * 设置行间距
     * @param lineSpacing 行间距
     */
    public void setLineSpacing(int lineSpacing) {
        this.lineSpacing = lineSpacing;
        requestLayout(); // 请求重新布局
    }

    /**
     * 设置列间距
     * @param columnSpacing 列间距
     */
    public void setColumnSpacing(int columnSpacing) {
        this.columnSpacing = columnSpacing;
        requestLayout(); // 请求重新布局
    }

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

        int maxLineWidth = 0; // 最大行宽
        int totalHeight = 0; // 总高度
        int currentLineWidth = 0; // 当前行的宽度
        int currentLineHeight = 0; // 当前行的高度

        // 遍历所有子视图
        for (int i = 0; i < getChildCount(); i++) {
            View child = getChildAt(i);
            if (child.getVisibility() == GONE) {
                continue;
            }

            // 测量子视图
            measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, totalHeight);
            MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();

            int childWidth = child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
            int childHeight = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;

            // 如果当前行的宽度加上子视图的宽度超过了父视图的宽度,则换行
            if (currentLineWidth + childWidth > sizeWidth - getPaddingLeft() - getPaddingRight()) {
                // 更新最大行宽
                maxLineWidth = Math.max(maxLineWidth, currentLineWidth);
                // 更新总高度
                totalHeight += currentLineHeight + lineSpacing;
                // 重置当前行的宽度和高度
                currentLineWidth = childWidth;
                currentLineHeight = childHeight;
            } else {
                // 累加当前行的宽度
                if (currentLineWidth != 0) {
                    currentLineWidth += columnSpacing;
                }
                currentLineWidth += childWidth;
                // 更新当前行的最大高度
                currentLineHeight = Math.max(currentLineHeight, childHeight);
            }
        }

        // 添加最后一行的高度
        totalHeight += currentLineHeight;
        // 考虑padding
        totalHeight += getPaddingTop() + getPaddingBottom();
        maxLineWidth += getPaddingLeft() + getPaddingRight();

        // 计算最终的宽高
        int measuredWidth = widthMode == MeasureSpec.EXACTLY ? sizeWidth : maxLineWidth;
        int measuredHeight = heightMode == MeasureSpec.EXACTLY ? sizeHeight : totalHeight;

        setMeasuredDimension(measuredWidth, measuredHeight);
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int width = r - l;
        int currentX = getPaddingLeft();
        int currentY = getPaddingTop();
        int currentLineHeight = 0;

        // 遍历所有子视图
        for (int i = 0; i < getChildCount(); i++) {
            View child = getChildAt(i);
            if (child.getVisibility() == GONE) {
                continue;
            }

            MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();

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

            int childWidthWithMargin = childWidth + lp.leftMargin + lp.rightMargin;
            int childHeightWithMargin = childHeight + lp.topMargin + lp.bottomMargin;

            // 如果当前行的宽度加上子视图的宽度超过了父视图的宽度,则换行
            if (currentX + childWidthWithMargin > width - getPaddingRight()) {
                currentX = getPaddingLeft();
                currentY += currentLineHeight + lineSpacing;
                currentLineHeight = 0;
            }

            // 计算子视图的实际位置
            int left = currentX + lp.leftMargin;
            int top = currentY + lp.topMargin;
            int right = left + childWidth;
            int bottom = top + childHeight;

            // 布置子视图
            child.layout(left, top, right, bottom);

            // 更新当前行的宽度和高度
            currentX += childWidthWithMargin + columnSpacing;
            currentLineHeight = Math.max(currentLineHeight, childHeightWithMargin);
        }
    }

    @Override
    protected ViewGroup.LayoutParams generateDefaultLayoutParams() {
        return new MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
    }

    @Override
    protected ViewGroup.LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) {
        return new MarginLayoutParams(p);
    }

    @Override
    protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
        return p instanceof MarginLayoutParams;
    }
}

布局文件

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="16dp">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="默认间距的流式布局:"
        android:textSize="16sp"
        android:layout_marginBottom="8dp"/>

    <com.example.myview.CustomFlowLayout
        android:id="@+id/customFlowLayout1"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="#EEEEEE"
        android:padding="8dp">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="标签1"
            android:padding="8dp"
            android:background="#3F51B5"
            android:textColor="#FFFFFF"
            android:layout_margin="4dp"/>

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="这是一个较长的标签2"
            android:padding="8dp"
            android:background="#3F51B5"
            android:textColor="#FFFFFF"
            android:layout_margin="4dp"/>

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="短标签3"
            android:padding="8dp"
            android:background="#3F51B5"
            android:textColor="#FFFFFF"
            android:layout_margin="4dp"/>

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="标签4"
            android:padding="8dp"
            android:background="#3F51B5"
            android:textColor="#FFFFFF"
            android:layout_margin="4dp"/>

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="这是一个非常非常长的标签5"
            android:padding="8dp"
            android:background="#3F51B5"
            android:textColor="#FFFFFF"
            android:layout_margin="4dp"/>

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="中等长度的标签6"
            android:padding="8dp"
            android:background="#3F51B5"
            android:textColor="#FFFFFF"
            android:layout_margin="4dp"/>

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="标签7"
            android:padding="8dp"
            android:background="#3F51B5"
            android:textColor="#FFFFFF"
            android:layout_margin="4dp"/>
    </com.example.myview.CustomFlowLayout>

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="自定义间距的流式布局:"
        android:textSize="16sp"
        android:layout_marginTop="24dp"
        android:layout_marginBottom="8dp"/>

    <com.example.myview.CustomFlowLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="#EEEEEE"
        android:padding="8dp"
        android:id="@+id/customFlowLayout2">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="标签1"
            android:padding="8dp"
            android:background="#FF5722"
            android:textColor="#FFFFFF"
            android:layout_margin="4dp"/>

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="这是一个较长的标签2"
            android:padding="8dp"
            android:background="#FF5722"
            android:textColor="#FFFFFF"
            android:layout_margin="4dp"/>

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="短标签3"
            android:padding="8dp"
            android:background="#FF5722"
            android:textColor="#FFFFFF"
            android:layout_margin="4dp"/>

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="标签4"
            android:padding="8dp"
            android:background="#FF5722"
            android:textColor="#FFFFFF"
            android:layout_margin="4dp"/>

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="这是一个非常非常长的标签5"
            android:padding="8dp"
            android:background="#FF5722"
            android:textColor="#FFFFFF"
            android:layout_margin="4dp"/>

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="中等长度的标签6"
            android:padding="8dp"
            android:background="#FF5722"
            android:textColor="#FFFFFF"
            android:layout_margin="4dp"/>

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="标签7"
            android:padding="8dp"
            android:background="#FF5722"
            android:textColor="#FFFFFF"
            android:layout_margin="4dp"/>
    </com.example.myview.CustomFlowLayout>
</LinearLayout>

应用布局设置参数

public class CustomFlowLayoutActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        EdgeToEdge.enable(this);
        setContentView(R.layout.activity_custom_flow_layout);
        CustomFlowLayout flowLayout1 = findViewById(R.id.customFlowLayout1);


        CustomFlowLayout flowLayout2 = findViewById(R.id.customFlowLayout2);
        flowLayout2.setLineSpacing(8);  // 设置行间距为16dp
        flowLayout2.setColumnSpacing(16); // 设置列间距为12dp
    }
}

image.png

3. 可拖拽 View

自定义可拖拽 View

/**
 * 支持拖拽和回弹效果的自定义View
 * 通过Scroller类实现平滑的动画效果,限制在父容器范围内移动
 */
public class DraggableCustomView extends View {
    // 处理平滑滚动的辅助类
    private Scroller mScroller;
    // 记录触摸事件的上一个坐标
    private int mLastX;
    private int mLastY;
    // 拖拽边界
    private int mLeftBound;
    private int mRightBound;
    private int mTopBound;
    private int mBottomBound;

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

    public DraggableCustomView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public DraggableCustomView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context);
    }

    /**
     * 初始化View
     * @param context 上下文
     */
    private void init(Context context) {
        // 初始化Scroller,用于实现平滑滚动效果
        mScroller = new Scroller(context);
    }

    /**
     * 当View尺寸变化时调用,初始化边界值
     */
    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);

        // 设置拖拽边界,默认为父容器的边界
        mLeftBound = 0;
        mRightBound = getParent() != null ?
                ((View) getParent()).getWidth() - getWidth() : w;
        mTopBound = 0;
        mBottomBound = getParent() != null ?
                ((View) getParent()).getHeight() - getHeight() : h;
    }

    /**
     * 处理触摸事件实现拖拽功能
     */
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        // 获取当前触摸点坐标
        int x = (int) event.getX();
        int y = (int) event.getY();

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                // 手指按下时终止任何正在进行的滚动动画
                if (!mScroller.isFinished()) {
                    mScroller.abortAnimation();
                }
                // 记录初始触摸位置
                mLastX = x;
                mLastY = y;
                // 返回true表示消费该触摸事件
                return true;

            case MotionEvent.ACTION_MOVE:
                // 计算与上次触摸点的偏移量
                int dx = x - mLastX;
                int dy = y - mLastY;

                // 计算移动后的新边界位置
                int newLeft = getLeft() + dx;
                int newTop = getTop() + dy;
                int newRight = getRight() + dx;
                int newBottom = getBottom() + dy;

                // 边界检查和修正:如果超出边界则调整偏移量
                if (newLeft < mLeftBound) {
                    dx = mLeftBound - getLeft();
                }
                if (newRight > mRightBound) {
                    dx = mRightBound - getRight();
                }
                if (newTop < mTopBound) {
                    dy = mTopBound - getTop();
                }
                if (newBottom > mBottomBound) {
                    dy = mBottomBound - getBottom();
                }

                // 如果有有效的偏移量,则移动View并刷新
                if (dx != 0 || dy != 0) {
                    // 调整View的位置
                    offsetLeftAndRight(dx);
                    offsetTopAndBottom(dy);
                    // 触发重绘
                    invalidate();
                }

                // 更新最后触摸位置
                mLastX = x;
                mLastY = y;
                break;

            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
                // 手指抬起或取消触摸时,处理边界回弹效果
                int deltaX = 0;
                int deltaY = 0;

                // 计算需要回弹的距离
                if (getLeft() < mLeftBound) {
                    deltaX = mLeftBound - getLeft();
                } else if (getRight() > mRightBound) {
                    deltaX = mRightBound - getRight();
                }

                if (getTop() < mTopBound) {
                    deltaY = mTopBound - getTop();
                } else if (getBottom() > mBottomBound) {
                    deltaY = mBottomBound - getBottom();
                }

                // 如果需要回弹,则启动Scroller动画
                if (deltaX != 0 || deltaY != 0) {
                    mScroller.startScroll(
                            getScrollX(),
                            getScrollY(),
                            -deltaX,
                            -deltaY,
                            500 // 动画持续时间(毫秒)
                    );
                    // 触发重绘以执行动画
                    invalidate();
                }
                break;
        }

        // 调用父类方法处理其他触摸事件
        return super.onTouchEvent(event);
    }

    /**
     * Scroller动画的回调方法,更新View的滚动位置
     */
    @Override
    public void computeScroll() {
        // 如果Scroller动画还在进行中
        if (mScroller.computeScrollOffset()) {
            // 更新View的滚动位置
            scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
            // 继续触发重绘以继续动画
            postInvalidate();
        }
    }
}

image.png

常见问题与避坑指南

  1. 测量尺寸异常:未正确处理wrap_contentpadding,需在onMeasure中手动计算。
  2. 事件冲突:父子 View 同时处理事件时,需合理设置onInterceptTouchEvent返回值。
  3. 内存泄漏:动画未停止时销毁 View,需在onDetachedFromWindow中调用animator.cancel()
  4. ANR 风险:避免在onDraw中执行耗时操作(如创建新对象),可使用postDelay异步处理。