Android 自定义 View

经过前面几篇文章 View 基础 View 的测量过程 View 的布局和绘制 Android 滑动原理与方式 Android 事件分发与滑动冲突 一步比一步深入的分析 View 知识,终于迎来的最后的大 BOSS - 自定义 View。其实只说自定义 View 这或许没有什么难的,但是如果要是跟其他 View 有了交互,有了滑动冲突,有了位置冲突,这时候要是没有前面几篇的基础,那绝对懵圈。所以前面几篇的重要性不言而喻。本篇文章将从自定义 View 的分类和各类自定义 View 的实现讲起,以一个小 Demo 将前面几篇的内容连接起来,所以如果想真正系统了解 Android 中 View 相关知识的话,建议出门左转,看了前面几篇再看这篇。好了,话不多说,首先让我们分析一下自定义 View 常见的种类。

一、自定义 View 的分类

一般情况下可以将自定义 View 分为 5 种类型

  1. 自定义类继承 View 类,主要用于实现一些不规则的效果,通过重写 onDraw() 方法实现。

  2. 自定义类继承 ViewGroup ,主要用于实现让子 View 按照自己指定的规则放置,通过重写 onMeasure、 onLayout 等方法实现自己的测量与布局并完成子 View 的测量和布局过程

  3. 自定义类继承已有的 View 类,例如 TextView、Button,通过重写 onDraw() 等方法在原来控件功能的基础上添加功能

  4. 自定义类继承已有的 ViewGroup 类,例如 LinearLayout、ViewPager,通过重写其中的方法实现需要的效果

  5. 自定义类继承已有的 ViewGroup 类,并在其中添加其他 View,形成自定义的组合控件,例如项目中通用的 TopBar

开发过程中遇到的主要是这 5 种自定义 View,其实很多效果的实现可以采用上面 5 种里面的一种或几种实现,但是我们在实现时要争取选择最高效、代价最小的方式来实现。

下面不会讲 Paint、Canvas 等细节,只是从一定的高度来看自定义 View 的整个流程,如果涉及到具体效果的实现方式那就太细节了,网上也有很多文章详细的讲一步一步实现一个什么效果。但本篇文章可能不太一样,因为不管是哪种自定义 View ,其实现流程大体是相似的。只有掌握了如何去自定义一个 View ,在遇到不会的效果时才能思维清晰的去思考每一步的实现过程,从而完成想要的效果。

二、自定义 View 的实现步骤

  1. 根据需要实现的效果确定自己自定义 View 的分类,并创建新的类

  2. 定义 attr.xml 文件,声明所有可以通过 xml 指定的自定义 View 属性

  3. 重写构造方法,在构造方法中获取 xml 中设置的属性

  4. 声明所有可以修改的属性的修改或获取接口,声明一些特定事件回调接口 (set()/get()/Listener 等系列方法)

  5. 重写 onMeasure 完成自己的测量,如果是 ViweGroup 需要重写 onMeasure 完成子 View 的测量,具体如何测量请看文章 View 的测量过程

  6. 如果是 ViewGroup 需要重写 onLayout 完成子 View 的布局,也就是将每个子 View 放置在规定的位置,这里可以看文章 View 的布局和绘制

  7. 如果需要处理滑动冲突或者响应触摸事件,就重写 onInterceptTouchEvent() 或者 onTouchEvent() 等方法并按照业务添加逻辑。详情看文章 Android 事件分发和滑动冲突

  8. 重写 onDraw() 等方法实现绘制需要实现的效果

  9. 在 xml 布局文件中使用该 View, 并设置一些想要的属性,或在 Java 代码中为 View 设置相应属性或者添加事件回调

以上 9 个步骤满足了大多数自定义 View 的实现流程,可能看起来比较简单但是其思想确是最关键的。这里附加一张图来表示
Android 自定义 View 流程图

下面介绍一下自定义 Viwe 时需要注意的问题

三、自定义 View 需要注意的问题

  1. 支持 wrap_content 属性,如果需要支持 wrap_content 属性则需要在 onMeasure 方法中根据内容的大小确定 View 的测量宽高,如果是 ViewGroup 在 onMeasure 中完成子 View 的测量时还需要考虑支持子 View 中设置的 margin 属性

  2. 支持 padding 属性,默认情况下 View 或者 ViewGroup 是不支持 padding 属性的,如果需要支持 padding 属性就要 onLayout 中布局子 View 和 onDraw 方法中绘制内容时考虑 padding 属性的影响

  3. 尽量不要在 View 中使用 Handler,因为 View 内部提供了 post 系列的方法,可以代替 Handler 的作用

  4. View 中如果有动画或者线程,需要及时停止,onDetachedFromWindow 方法被回调时是停止动画或者线程很好的时机,当包含当前 View 的 Activity 退出或者当前 View 被 remove 时,View 的 onDetachedFromWindow 方法会被回调。

上面就是几条比较明显的需要注意的问题,下面是几个在自定义 View 时比较常用的方法:

  • onAttachedToWindow() 当包含 View 的 Activity 启动时调用
  • onFinishInflate() 从 XML 加载组件后回调
  • onSizeChanged() 组件大小改变时回调
  • requestLayout() 申请测量、布局、绘制整个流程
  • computeScroll() 绘制时会调用
  • invalidate()/postInvalidate 重绘,区别是 postInvalidate 可以保证在非 UI 线程调用该方法时绘制发生在 UI 线程
    ...

接下来就是实践环节了,以自定义一个 ViewGroup 为例,效果就以竖向的 ViewPager 的效果为例,首先看一下最终效果,第一次制作 git 图,见谅

StickScrollView

四、自定义 View 实战

效果如图,在第一页和最后一页时不管滑动多大的距离结束抬起手指时都会恢复滑动前位置,滑动时带有一定的粘性,如果滑动距离大于高度的二分之一将会滑动到下一界面,如果不到二分之一将会还原。下面就按照自定义 View 的流程开始吧。

首先,为了简单就不添加自定义属性了,直接到第 5 步,ViewGroup 对子 View 的测量

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        // 测量所有子 View
        // 将所有子 View 的高度叠加
        // 为自己的测量高度赋值

        width = MeasureSpec.getSize(widthMeasureSpec); // 记录每个字 View 的宽度度
        height = MeasureSpec.getSize(heightMeasureSpec); // 记录每个字 View 的宽度度

        int childCount = getChildCount();
        int totalHeight = height * childCount; // 计算 StickScrollView 的高度

        for (int i = 0; i < childCount; i++) { // 遍历所有子 View 调用其 measure 方法,并将 MeasureSpec 传入 
            getChildAt(i).measure(MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY));
        }

        setMeasuredDimension(width, totalHeight); // 由所以子 View 的高度确定自己的高度 
    }

该方法很简单,其主要任务就是测量所有的子 View 并根据所有子 View 的高度确定自己的高度。

接着是布局所有的子 View

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        // 将所有子 View 都放入合适的位置

        int nextTop = 0;
        int childCount = getChildCount();
        for (int i = 0; i < childCount; i++) { // 遍历所有子 View 并根据业务规则将每个子 View 都放在合适的位置
            getChildAt(i).layout(0, nextTop, width, nextTop + height);
            nextTop += height;
        }
    }

布局方法中为每个子 View 计算应该在的位置,并调用每个子 View 的 layout 方法完成布局

由于 ViewGroup 主要任务是管理子 Viwe,所以不用重新绘制方法。但是由于其支持滑动,所以需要拦截触摸事件,并根据事件类型做出相应的滑动处理。如图所示,我们的滑动过程是渐进的滑动,这里使用 Scroller 完成弹性的滑动。直接上代码

    private float perScrollY; // 记录上一次的触摸位置
    private float startY; // 记录一次完整的滑动的起始位置
    private int oldScrollY; // 记录一次完整的滑动开始时 ViewGroup 内容的偏移量
    private Scroller scroller = new Scroller(getContext());
    
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) { // 要处理滑动事件,所以拦截所有事件,如果子 View 要处理事件则需要通过根据事件分发规则解决问题
        return true;
    }
    
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        // 事件来临时拿到触摸点
        // 新事件来临时根据触摸点偏移量进行滑动
        
        float y = event.getY();
        float scrollY = y - perScrollY;
        float scrollIndex = y - startY;

        // 滑动的核心代码,根据滑动的距离做相应的处理
        switch (event.getAction()) {
            case MotionEvent.ACTION_MOVE:
                scrollBy(0, (int) -(scrollY));
                perScrollY = y;
                break;
            case MotionEvent.ACTION_DOWN:
                perScrollY = startY = event.getY();
                oldScrollY = getScrollY();
                break;
            case MotionEvent.ACTION_UP:
                int scrollTo = 0;
                if (getScrollY() < 0)
                    scrollTo = oldScrollY - getScrollY();
                else if (getScrollY() > (getChildCount() - 1) * height)
                    scrollTo = -(getScrollY() - (getChildCount() - 1) * height);
                else if (scrollIndex > height / 2) {
                    scrollTo = (getScrollY() / height) * height - getScrollY();
                } else if (scrollIndex < height / 2 && scrollIndex > 0) {
                    scrollTo = oldScrollY - getScrollY();
                } else if (scrollIndex < (-height / 2)) {
                    scrollTo = (getScrollY() / height + 1) * height - getScrollY();
                } else if (scrollIndex > (-height / 2) && scrollIndex < 0)
                    scrollTo = (int) scrollIndex;

                scroller.startScroll(0, getScrollY(), 0, scrollTo);
                invalidate();
                break;
        }
        return true;
    }

    @Override
    public void computeScroll() {
        if (null != scroller && scroller.computeScrollOffset()) {
            scrollTo(scroller.getCurrX(), scroller.getCurrY());
            invalidate();
        }
    }

代码中注释已经很清楚啦,所以不在多说了,就这样一个自定义的 ViewGroup 就完成了,但是比较粗糙,像 margin、padding 等都没有考虑,如果要是真正用在项目中这些问题都是需要考虑的。今天的内容就到这里啦。如果有问题欢迎留言一起讨论。以下是全部代码,或者可以通过 github 下载源码 https://github.com/renxuelong/stickscrollview

package com.renxl.customscrollview;

import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.ViewGroup;
import android.widget.Scroller;

/**
 * Created by renxl
 * On 2017/7/6 12:26.
 * http://blog.csdn.net/coderr
 * http://www.jianshu.com/u/e027c09aa4dd
 */

public class StickScrollView extends ViewGroup {

    private int width;
    private int height;

    public StickScrollView(Context context) {
        super(context);
    }

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

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

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        // 测量所有子 View
        // 将所有子 View 的高度叠加
        // 为自己的测量高度赋值

        width = MeasureSpec.getSize(widthMeasureSpec); // 记录每个字 View 的宽度度
        height = MeasureSpec.getSize(heightMeasureSpec); // 记录每个字 View 的宽度度

        int childCount = getChildCount();
        int totalHeight = height * childCount; // 计算 StickScrollView 的高度

        for (int i = 0; i < childCount; i++) { // 遍历所有子 View 调用其 measure 方法,并将 MeasureSpec 传入
            getChildAt(i).measure(MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY));
        }

        setMeasuredDimension(width, totalHeight); // 由所以子 View 的高度确定自己的高度
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        // 将所有子 View 都放入合适的位置

        int nextTop = 0;
        int childCount = getChildCount();
        for (int i = 0; i < childCount; i++) {
            getChildAt(i).layout(0, nextTop, width, nextTop + height);
            nextTop += height;
        }
    }

    private float perScrollY; // 记录上一次的触摸位置
    private float startY; // 记录一次完整的滑动的起始位置
    private int oldScrollY; // 记录一次完整的滑动开始时 ViewGroup 内容的偏移量
    private Scroller scroller = new Scroller(getContext());

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        return true;
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        // 事件来临时拿到触摸点
        // 新事件来临时根据触摸点偏移量进行滑动

        float y = event.getY();
        float scrollY = y - perScrollY;
        float scrollIndex = y - startY;


        switch (event.getAction()) {
            case MotionEvent.ACTION_MOVE:
                scrollBy(0, (int) -(scrollY));
                perScrollY = y;
                break;
            case MotionEvent.ACTION_DOWN:
                perScrollY = startY = event.getY();
                oldScrollY = getScrollY();
                break;
            case MotionEvent.ACTION_UP:
                int scrollTo = 0;
                if (getScrollY() < 0)
                    scrollTo = oldScrollY - getScrollY();
                else if (getScrollY() > (getChildCount() - 1) * height)
                    scrollTo = -(getScrollY() - (getChildCount() - 1) * height);
                else if (scrollIndex > height / 2) {
                    scrollTo = (getScrollY() / height) * height - getScrollY();
                } else if (scrollIndex < height / 2 && scrollIndex > 0) {
                    scrollTo = oldScrollY - getScrollY();
                } else if (scrollIndex < (-height / 2)) {
                    scrollTo = (getScrollY() / height + 1) * height - getScrollY();
                } else if (scrollIndex > (-height / 2) && scrollIndex < 0)
                    scrollTo = (int) scrollIndex;

                scroller.startScroll(0, getScrollY(), 0, scrollTo);
                invalidate();
                break;
        }
        return true;
    }

    @Override
    public void computeScroll() {
        if (null != scroller && scroller.computeScrollOffset()) {
            scrollTo(scroller.getCurrX(), scroller.getCurrY());
            invalidate();
        }
    }
}

推荐阅读更多精彩内容