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

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

源码地址https://github.com/Hemumu/HlRefreshLayout 求star

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

前言

首先我们通过一张图来大概了解下Android中的事件传递。图片来自图解 Android 事件分发机制

966283-b9cb65aceea9219b.png

图片中的事件传递是从左往右从上至下的传递,上中下层分别为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>
GIF1.gif

哎哟,不错哦!这时候有好(搞)奇(事)的又说了不是可以刷新任何控件么?好吧,我们添加一个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>


装逼失败.png

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

分析问题原因

既然它没有下拉刷新说明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>

GIF2.gif

这不就成了么?听说你要搞事。既然能下拉刷新说明我们自定义控件的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>

TextView.gif

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>

ImageView.gif

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

总结

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就会传递到哪。

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

The End!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,132评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,802评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,566评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,858评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,867评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,695评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,064评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,705评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,915评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,677评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,796评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,432评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,041评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,992评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,223评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,185评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,535评论 2 343

推荐阅读更多精彩内容