Android 通用的下拉刷新,重温事件传递

192 阅读10分钟
原文链接: www.apkbus.com

Android的事件传递可谓比较重要,不管是自定义控件以及项目中还是面试中都会用到,在这个大家都爱开源的时代相信网上肯定有不少关于这方便的文章了,但是本着知行合一的原则还是决定自己折腾下。

源码地址:github.com/Hemumu/HlRe…

通过自定义一个通用的下拉刷新控件来重温下Android的事件传递,为什么说是通用的呢?因为他可以包含AbsListView的子类以及ScrollView和它的子类甚至一个TextView

前言

图片中的事件传递是从左往右从上至下的传递,上中下层分别为Activity,ViewGroup,View。箭头的上面字代表方法返回值。dispatchTouchEventonTouchEvent的框里有个【true---->消费】的字,表示的意思是如果方法返回true,那么代表事件就此消费,不会继续往别的地方传了,事件终止。从图中可以看出如果事件不被中段那么事件是按照一个U型图来走的。整个事件流向应该是从Activity---->ViewGroup--->View 从上往下调用dispatchTouchEvent方法,一直到叶子节点(View)的时候,再由View--->ViewGroup--->Activity从下往上调用onTouchEvent方法。如果dispatchTouchEventonTouchEvent返回true即消费事件,那么事件就终止了谁也不会在收到这个事件。

自定义下拉刷新控件

首页下拉刷新有一个头布局和一个内容布局那么他肯定是一个ViewGroup我们新建一个类RefreshLayout继承FrameLayout。这个类就是我们核心类了,重写dispatchTouchEvent我们就可以控制事件的分发。

首先我们来看初始化方法

    private void init() {
        //使用isInEditMode解决可视化编辑器无法识别自定义控件的问题
        if (isInEditMode()) {
            return;
        }

        if (getChildCount() > 1) {
            throw new RuntimeException("只能拥有一个子控件");
        }

        //在动画开始的地方快然后慢;
        decelerateInterpolator = new DecelerateInterpolator(10);

    }

初始化中已经做了很详细的注释了就不多解释了。接下来我们在重写onAttachedToWindow方法

    @Override
    protected void onAttachedToWindow() {
        super.onAttachedToWindow();

        FrameLayout headViewLayout = new FrameLayout(getContext());
        LayoutParams layoutParams = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0);
        layoutParams.gravity = Gravity.TOP;
        headViewLayout.setLayoutParams(layoutParams);
        mHeadLayout = headViewLayout;
        this.addView(mHeadLayout);
        //获得子控件
        mChildView = getChildAt(0);
        if (mChildView == null) {
            return;
        }
        mChildView.animate().setInterpolator(new DecelerateInterpolator());//设置速率为递减
        mChildView.animate().setUpdateListener(//通过addUpdateListener()方法来添加一个动画的监听器
                new ValueAnimator.AnimatorUpdateListener() {
                    @Override
                    public void onAnimationUpdate(ValueAnimator animation) {
                        int height = (int) mChildView.getTranslationY();//获得mChildView当前y的位置
                        mHeadLayout.getLayoutParams().height = height;
                        mHeadLayout.requestLayout();//重绘
                    }
                }
        );
    }

新建了一个FrameLayout并且添加到了父容器中,这个FrameLayout就是我们头布局,我们可以通过添加布局到FrameLayout来自定义刷新的head。方法如下

    /**
     * 添加头部vieww
     *
     * @param header
     */
    public void addHeadView(View header) {
       
          mHeadLayout.addView(header);
           
    }

接着为ChildView添加了动画的插值器为递减并且添加了动画的更新时间,这里是当用户下拉之后手指松开后ChildView按照递减的动画回到顶部,并且head的高度随之改变。

在下拉刷新的时候必要的一个环节就是判断控件是否滑动到顶部,如果滑动到顶部就显示head否则就将事件交给ChildView去处理。添加一个方法来判断控件是否下拉到顶部

    /**
     * 用来判断是否可以下拉
     *
     * @return boolean
     */
    public boolean canChildScrollUp() {
        if (mChildView == null) {
            return false;
        }
        if (Build.VERSION.SDK_INT < 14) {
            if (mChildView instanceof AbsListView) {
                final AbsListView absListView = (AbsListView) mChildView;
                return absListView.getChildCount() > 0
                        && (absListView.getFirstVisiblePosition() > 0 || absListView.getChildAt(0)
                        .getTop() < absListView.getPaddingTop());
            } else {
                return mChildView.getScrollY() > 0;
            }
        } else {
            return ViewCompat.canScrollVertically(mChildView, -1);
        }
    }

在API14以后ViewCompat中有一个方法canScrollVertically检测一个 View 在给定的方向(up or down)能否竖直滑动,负数表示检测上滑,正数表示下滑。而在API14以下我们就得自己背锅了,AbsListView通过getFirstVisiblePosition是否为0或者距离顶部的位置来判断,其他则通过getScrollY()来判断了。

接下来我们看最重要的dispatchTouchEvent事件的分发直接上代码

   /**
     * 控件在顶端时最后的Y坐标
     */
    float mLastY;
    /**
     * 头部移动的距离
     */
    private float mCurrentPos;


       @Override
    public boolean dispatchTouchEvent(MotionEvent e) {
        //当前正在刷新
        if (isRefreshing) {
            return super.dispatchTouchEvent(e);
        }
        switch (e.getAction()) {
            case MotionEvent.ACTION_DOWN:
                mLastY = e.getY();
                mCurrentPos = 0;
                break;
            case MotionEvent.ACTION_MOVE:
                float currentY = e.getY();
                float dy = currentY - mLastY;//手指移动的距离
                //上拉
                if (mLastY - currentY > 0) {//最后的Y坐标大于当前的Y坐标
                    if (mCurrentPos != 0) {
                        mCurrentPos = dy * mResistance;
                        mCurrentPos = Math.max(0, mCurrentPos);
                        changeView(mCurrentPos);
                    } else {
                        return super.dispatchTouchEvent(e);
                    }
                 //下拉
                } else {
                    if (!canChildScrollUp()) { //是否滑动到顶部
                        mCurrentPos = dy * mResistance;
                        mCurrentPos = Math.max(0, mCurrentPos);
                        changeView(mCurrentPos);
                    } else {
                        mLastY = e.getY();
                        return super.dispatchTouchEvent(e);
                    }
                }
                return true;
            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP:
                if (mChildView != null) {
                    if (mChildView.getTranslationY() >= mHeadHeight) {//手指松开后head达到刷新高度
                        mChildView.animate().translationY(mHeadHeight).start();
                        pullToRefreshListener.onRefresh(this);
                        isRefreshing = true;
                    } else if (mChildView.getTranslationY() > 0) 
                        mChildView.animate().translationY(0).start();
                }
        }
        return super.dispatchTouchEvent(e);
    }

    /**
     * 改变ChildView和head的高度
     * @param pos
     */
    private void changeView(float pos ){
        mChildView.setTranslationY(pos);
        mHeadLayout.getLayoutParams().height = (int) (mCurrentPos);
        mHeadLayout.requestLayout();
    }

代码没有优化请见谅(实在太懒),从上往下看,首先在ACTION_DOWN的是适合我们记录下了当前按下的Y坐标,重置了head移动的距离。在ACTION_MOVE中分两种情况

  • ACTION_MOVE的方向向下,canChildScrollUp() 返回值为 true,则可以移动, headerChildView 向下移动,否则,事件交由父类处理。

  • ACTION_MOVE 的方向向上,如果当前位置大于起始位置,则可以移动,HeaderChildView 向上移动,否则,事件交由父类处理。

这里我们用一个mCurrentPos字段来记录当前header的移动距离。当headerChildView可以移动的时候我们默认返回了true也就是消费了事件,事件将不再传递。ChildView是可以滑动的控件他们就将拿到事件也就不会在滑动。反之我们调用了super.dispatchTouchEvent(e)上面为我们说了这个方法就是让事件继续向下传递。

这里我们提供一个完成刷新的方法finishRefreshing

    /**
     * 刷新结束
     */
    public void finishRefreshing() {
        if (mChildView != null) {
            mChildView.animate().translationY(0).start();
        }
        isRefreshing = false;
    }

接着新建一个SunshineRefresh继承我们刚写的RefreshLayout

public class SunshineRefresh extends RefreshLayout {
    public SunshineRefresh(Context context) {
        super(context);
        initSunshine();
    }

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

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

    private void initSunshine() {
        setHeadHeight(DensityUtil.dip2px(getContext(), 80));

        final View headerView = LayoutInflater.from(getContext()).inflate(R.layout.view_header, null);
        final SunshineView sunshineView  = (SunshineView) headerView.findViewById(R.id.sunshine);
        addHeadView(headerView);

        setPullToRefreshListener(new PullToRefreshListener() {
            @Override
            public void onRefresh(RefreshLayout refreshLayout) {
                sunshineView.startAnim();
                sunshineView.postDelayed(new Thread(){
                    @Override
                    public void run() {
                        finishRefreshing();
                        sunshineView.stopAnim();
                    }
                },2000);
            }
        });

    }
    
}

初始化了一个自定义view也就是我们的header并把他添加到RefreshLayout中通过回调方法去开始header的刷新动画。2秒后停止动画并调用finishRefreshing()完成刷新。

控件就基本完成了,添加一个ListView看下效果
布局

    <com.helin.hlrefreshlayout.view.SunshineRefresh
        android:id="@+id/refresh_layout"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

       <ListView
            android:id="@+id/listview"
            android:layout_width="match_parent"
            android:layout_height="match_parent"></ListView>

    </com.helin.hlrefreshlayout.view.SunshineRefresh>

哎哟,不错哦!这时候有好(搞)奇(事)的又说了不是可以刷新任何控件么?好吧,我们添加一个TextView让你见识一下哥的厉害!

   <com.helin.hlrefreshlayout.view.SunshineRefresh
        android:id="@+id/refresh_layout"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

    <TextView
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:text="听说有人要搞事!!!" />

    </com.helin.hlrefreshlayout.view.SunshineRefresh>

你会发现怎么下拉都没有反应,搞事啊兄弟!既然你诚心诚意的发问了,那我也就老老实实的来解决了。

分析问题原因

既然它没有下拉刷新说明dispatchTouchEvent中的代码根本就没有执行!我们在dispatchTouchEvent中打印日志会发现只有ACTION_DOWN的时候进入了dispatchTouchEventACTION_MOVEACTION_UP时候并没有到dispatchTouchEvent中。那么说明ACTION_DOWNACTION_MOVE,ACTION_UP事件传递的路径不是一样的。

那么我们来设置TextViewOnTouchListener看看事件是怎么传递的

        TextView te = (TextView) findViewById(R.id.textview);
        te.setOnTouchListener(new View.OnTouchListener() {
            @Override
            public boolean onTouch(View v, MotionEvent event) {
                switch (event.getAction()){
                    case MotionEvent.ACTION_DOWN:
                        Log.e("MainActivity","ACTION_DOWN");
                        break;
                    case MotionEvent.ACTION_MOVE:
                        Log.e("MainActivity","ACTION_MOVE");
                        break;
                    case MotionEvent.ACTION_UP:
                        Log.e("MainActivity","ACTION_UP");
                        break;
                }
                return false;
            }
        });

运行后我们在TextView上拖动发现打印日志如下

11-24 17:55:59.829 28531-28531/com.helin.hlrefreshlayout E/MainActivity: ACTION_DOWN

发现只有ACTION_DOWN事件传递了下来。这样根本找不到问题所在,我们新建一个类继承TextView在重写他的dispatchTouchEventonTouchEvent看一看事件是怎么传递的。

public class TestVieww extends TextView {
    public TestVieww(Context context) {
        super(context);
    }

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

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


    @Override
    public boolean dispatchTouchEvent(MotionEvent event) {
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:
                Log.e("dispatchTouchEvent","ACTION_DOWN------");
               
            case MotionEvent.ACTION_MOVE:
                Log.e("dispatchTouchEvent","ACTION_MOVE------");
                break;
        }
         return  true;
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        Log.e("onTouchEvent","onTouchEvent------");
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:
                Log.e("onTouchEvent","ACTION_DOWN------");
            case MotionEvent.ACTION_MOVE:
                Log.e("onTouchEvent","ACTION_MOVE------");
                break;
        }
        return super.onTouchEvent(event);
    }
}

我们在dispatchTouchEvent中拦截了事件,然后我们在TextView中拖动发现日志打印如下

11-24 18:06:45.128 10487-10487/com.helin.hlrefreshlayout E/dispatchTouchEvent: ACTION_DOWN------
11-24 18:06:45.138 10487-10487/com.helin.hlrefreshlayout E/dispatchTouchEvent: ACTION_MOVE------
11-24 18:06:45.158 10487-10487/com.helin.hlrefreshlayout E/dispatchTouchEvent: ACTION_MOVE------
11-24 18:06:45.168 10487-10487/com.helin.hlrefreshlayout E/dispatchTouchEvent: ACTION_MOVE------

发现dispatchTouchEvent拦截到了ACTION_MOVE事件,如果我们在中onTouchEvent返回true也就是拦截事件呢?我们来看看日志

11-24 18:08:45.316 12904-12904/com.helin.hlrefreshlayout E/dispatchTouchEvent: ACTION_DOWN------
11-24 18:08:45.326 12904-12904/com.helin.hlrefreshlayout E/dispatchTouchEvent: ACTION_MOVE------
11-24 18:08:45.326 12904-12904/com.helin.hlrefreshlayout E/onTouchEvent: onTouchEvent------
11-24 18:08:45.326 12904-12904/com.helin.hlrefreshlayout E/onTouchEvent: ACTION_DOWN------
11-24 18:08:45.326 12904-12904/com.helin.hlrefreshlayout E/onTouchEvent: ACTION_MOVE------
11-24 18:08:45.386 12904-12904/com.helin.hlrefreshlayout E/dispatchTouchEvent: ACTION_MOVE------
11-24 18:08:45.386 12904-12904/com.helin.hlrefreshlayout E/onTouchEvent: onTouchEvent------
11-24 18:08:45.386 12904-12904/com.helin.hlrefreshlayout E/onTouchEvent: ACTION_MOVE------

可以看到部分日志如上所示。可以看到都拦截都了ACTION_MOVE事件,现在我们把自定义的这TextView放入我们刚自定的下拉刷新控件中,并且在TextViewdispatchTouchEvent的拦截事件。运行如何呢?

    <com.helin.hlrefreshlayout.view.SunshineRefresh
        android:id="@+id/refresh_layout"
        android:layout_width="match_parent"
        android:layout_height="match_parent">


        <com.helin.hlrefreshlayout.view.TestView
            android:id="@+id/textview"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:text="听说有人要搞事!!!"
            android:padding="10dp" />

    </com.helin.hlrefreshlayout.view.SunshineRefresh>

这不就成了么?听说你要搞事。既然能下拉刷新说明我们自定义控件的dispatchTouchEvent拿到了ACTION_MOVE,ACTION_UP事件,这也就说明TextView中没有消费事件,默认的它只有ACTION_DOWN这也就是我为什么上面的setOnTouchListener()事件中只能拿到ACTION_DOWN,那怎么才能让TextView自己消费事件呢?

查看TextView发现它没有重写dispatchTouchEvent,那就去它的父类View去找,果然在里面找到了。怎么能才能让View去消费事件呢?最后去看这个View源码真的是看的我身体不适,浑身难受,具体过程我就不吐槽了,最后发现View的方法setClickable(),默认像TextView这些控件的isClickable()方法是返回false的,也就是不会去处理ACTION_MOVE,ACTION_UP事件,换句话说就是不去消费这个事件。知道这个就简单了啊,我们只需要一行代码就可以解决这个问题了

mChildView.setClickable(true);

RefreshLayout中拿到ChildView的时候设置他去消费这个事件,那么我们下拉刷新控件里面就能拿到ACTION_MOVE,ACTION_UP事件了,


    <com.helin.hlrefreshlayout.view.SunshineRefresh
        android:id="@+id/refresh_layout"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <TextView
            android:id="@+id/textview"
            android:layout_width="match_parent"
            android:gravity="center"
            android:layout_height="match_parent"
            android:text="这下满意了吧"
            android:padding="10dp" />

    </com.helin.hlrefreshlayout.view.SunshineRefresh>

ImageView

    <com.helin.hlrefreshlayout.view.SunshineRefresh
        android:id="@+id/refresh_layout"
        android:layout_width="match_parent"
        android:layout_height="match_parent">


        <ImageView
            android:id="@+id/textview"
            android:layout_width="match_parent"
            android:gravity="center"
            android:layout_height="match_parent"
            android:src="@mipmap/ic_launcher"
            android:padding="10dp" />

    </com.helin.hlrefreshlayout.view.SunshineRefresh>

其实下拉刷新也没有那么难嘛!

总结

ACTION_MOVE,ACTION_UP事件它们的传递过程如下

在哪个View的 onTouchEvent 返回true,或者dispatchTouchEvent返回true,那么ACTION_MOVEACTION_UP的事件从上往下传到这个View后就不再往下传递了,当然父View也将收到。在 onTouchEvent中消费那么直接传给自己和父View的dispatchTouchEventonTouchEvent。在 dispatchTouchEvent消费就只传递给dispatchTouchEvent和父View的dispatchTouchEventonTouchEvent。并结束本次事件传递过程。 如果没有任何View消费事件那么ACTION_MOVE,ACTION_UP将不会向下传递 。

ACTION_DOWN 事件则是遵循文首中的图片传递流程,事件走到哪那么ACTION_DOWN就会传递到哪。

最后希望大家都有这种“搞事”的精神,不懂就问,看资料。不行就试,一次不行两次三次。知行合一!有什么不对的地方希望大家多指教。