Android 通用的下拉刷新,重溫事件傳遞

Android的事件傳遞可謂比較重要,不管是自定義控件以及項(xiàng)目中還是面試中都會(huì)用到,在這個(gè)大家都愛開源的時(shí)代相信網(wǎng)上肯定有不少關(guān)于這方便的文章了,但是本著知行合一的原則還是決定自己折騰下。

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

通過自定義一個(gè)通用的下拉刷新控件來重溫下Android的事件傳遞,為什么說是通用的呢?因?yàn)樗梢园?code>AbsListView的子類以及ScrollView和它的子類甚至一個(gè)TextView

前言

首先我們通過一張圖來大概了解下Android中的事件傳遞。圖片來自圖解 Android 事件分發(fā)機(jī)制

966283-b9cb65aceea9219b.png

圖片中的事件傳遞是從左往右從上至下的傳遞,上中下層分別為Activity,ViewGroup,View。箭頭的上面字代表方法返回值。dispatchTouchEventonTouchEvent的框里有個(gè)【true---->消費(fèi)】的字,表示的意思是如果方法返回true,那么代表事件就此消費(fèi),不會(huì)繼續(xù)往別的地方傳了,事件終止。從圖中可以看出如果事件不被中段那么事件是按照一個(gè)U型圖來走的。整個(gè)事件流向應(yīng)該是從Activity---->ViewGroup--->View 從上往下調(diào)用dispatchTouchEvent方法,一直到葉子節(jié)點(diǎn)(View)的時(shí)候,再由View--->ViewGroup--->Activity從下往上調(diào)用onTouchEvent方法。如果dispatchTouchEventonTouchEvent返回true即消費(fèi)事件,那么事件就終止了誰也不會(huì)在收到這個(gè)事件。

自定義下拉刷新控件

首頁下拉刷新有一個(gè)頭布局和一個(gè)內(nèi)容布局那么他肯定是一個(gè)ViewGroup我們新建一個(gè)類RefreshLayout繼承FrameLayout。這個(gè)類就是我們核心類了,重寫dispatchTouchEvent我們就可以控制事件的分發(fā)。

首先我們來看初始化方法

    private void init() {
        //使用isInEditMode解決可視化編輯器無法識(shí)別自定義控件的問題
        if (isInEditMode()) {
            return;
        }

        if (getChildCount() > 1) {
            throw new RuntimeException("只能擁有一個(gè)子控件");
        }

        //在動(dòng)畫開始的地方快然后慢;
        decelerateInterpolator = new DecelerateInterpolator(10);

    }

初始化中已經(jīng)做了很詳細(xì)的注釋了就不多解釋了。接下來我們在重寫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());//設(shè)置速率為遞減
        mChildView.animate().setUpdateListener(//通過addUpdateListener()方法來添加一個(gè)動(dòng)畫的監(jiān)聽器
                new ValueAnimator.AnimatorUpdateListener() {
                    @Override
                    public void onAnimationUpdate(ValueAnimator animation) {
                        int height = (int) mChildView.getTranslationY();//獲得mChildView當(dāng)前y的位置
                        mHeadLayout.getLayoutParams().height = height;
                        mHeadLayout.requestLayout();//重繪
                    }
                }
        );
    }

新建了一個(gè)FrameLayout并且添加到了父容器中,這個(gè)FrameLayout就是我們頭布局,我們可以通過添加布局到FrameLayout來自定義刷新的head。方法如下

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

接著為ChildView添加了動(dòng)畫的插值器為遞減并且添加了動(dòng)畫的更新時(shí)間,這里是當(dāng)用戶下拉之后手指松開后ChildView按照遞減的動(dòng)畫回到頂部,并且head的高度隨之改變。

在下拉刷新的時(shí)候必要的一個(gè)環(huán)節(jié)就是判斷控件是否滑動(dòng)到頂部,如果滑動(dòng)到頂部就顯示head否則就將事件交給ChildView去處理。添加一個(gè)方法來判斷控件是否下拉到頂部

    /**
     * 用來判斷是否可以下拉
     *
     * @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中有一個(gè)方法canScrollVertically檢測一個(gè) View 在給定的方向(up or down)能否豎直滑動(dòng),負(fù)數(shù)表示檢測上滑,正數(shù)表示下滑。而在API14以下我們就得自己背鍋了,AbsListView通過getFirstVisiblePosition是否為0或者距離頂部的位置來判斷,其他則通過getScrollY()來判斷了。

接下來我們看最重要的dispatchTouchEvent事件的分發(fā)直接上代碼

   /**
     * 控件在頂端時(shí)最后的Y坐標(biāo)
     */
    float mLastY;
    /**
     * 頭部移動(dòng)的距離
     */
    private float mCurrentPos;


       @Override
    public boolean dispatchTouchEvent(MotionEvent e) {
        //當(dāng)前正在刷新
        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;//手指移動(dòng)的距離
                //上拉
                if (mLastY - currentY > 0) {//最后的Y坐標(biāo)大于當(dāng)前的Y坐標(biāo)
                    if (mCurrentPos != 0) {
                        mCurrentPos = dy * mResistance;
                        mCurrentPos = Math.max(0, mCurrentPos);
                        changeView(mCurrentPos);
                    } else {
                        return super.dispatchTouchEvent(e);
                    }
                 //下拉
                } else {
                    if (!canChildScrollUp()) { //是否滑動(dòng)到頂部
                        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達(dá)到刷新高度
                        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();
    }

代碼沒有優(yōu)化請見諒(實(shí)在太懶),從上往下看,首先在ACTION_DOWN的是適合我們記錄下了當(dāng)前按下的Y坐標(biāo),重置了head移動(dòng)的距離。在ACTION_MOVE中分兩種情況

  • ACTION_MOVE的方向向下,canChildScrollUp() 返回值為 true,則可以移動(dòng), headerChildView 向下移動(dòng),否則,事件交由父類處理。
  • ACTION_MOVE 的方向向上,如果當(dāng)前位置大于起始位置,則可以移動(dòng),HeaderChildView 向上移動(dòng),否則,事件交由父類處理。

這里我們用一個(gè)mCurrentPos字段來記錄當(dāng)前header的移動(dòng)距離。當(dāng)headerChildView可以移動(dòng)的時(shí)候我們默認(rèn)返回了true也就是消費(fèi)了事件,事件將不再傳遞。ChildView是可以滑動(dòng)的控件他們就將拿到事件也就不會(huì)在滑動(dòng)。反之我們調(diào)用了super.dispatchTouchEvent(e)上面為我們說了這個(gè)方法就是讓事件繼續(xù)向下傳遞。

這里我們提供一個(gè)完成刷新的方法finishRefreshing

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

接著新建一個(gè)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);
            }
        });

    }
    
}

初始化了一個(gè)自定義view也就是我們的header并把他添加到RefreshLayout中通過回調(diào)方法去開始header的刷新動(dòng)畫。2秒后停止動(dòng)畫并調(diào)用finishRefreshing()完成刷新。

控件就基本完成了,添加一個(gè)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

哎喲,不錯(cuò)哦!這時(shí)候有好(搞)奇(事)的又說了不是可以刷新任何控件么?好吧,我們添加一個(gè)TextView讓你見識(shí)一下哥的厲害!

   <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

你會(huì)發(fā)現(xiàn)怎么下拉都沒有反應(yīng),搞事啊兄弟!既然你誠心誠意的發(fā)問了,那我也就老老實(shí)實(shí)的來解決了。

分析問題原因

既然它沒有下拉刷新說明dispatchTouchEvent中的代碼根本就沒有執(zhí)行!我們在dispatchTouchEvent中打印日志會(huì)發(fā)現(xiàn)只有ACTION_DOWN的時(shí)候進(jìn)入了dispatchTouchEvent,ACTION_MOVEACTION_UP時(shí)候并沒有到dispatchTouchEvent中。那么說明ACTION_DOWNACTION_MOVE,ACTION_UP事件傳遞的路徑不是一樣的。

那么我們來設(shè)置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;
            }
        });

運(yùn)行后我們在TextView上拖動(dòng)發(fā)現(xiàn)打印日志如下

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

發(fā)現(xiàn)只有ACTION_DOWN事件傳遞了下來。這樣根本找不到問題所在,我們新建一個(gè)類繼承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中拖動(dòng)發(fā)現(xiàn)日志打印如下

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------

發(fā)現(xiàn)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------

可以看到部分日志如上所示??梢钥吹蕉紨r截都了ACTION_MOVE事件,現(xiàn)在我們把自定義的這TextView放入我們剛自定的下拉刷新控件中,并且在TextViewdispatchTouchEvent的攔截事件。運(yùn)行如何呢?

    <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中沒有消費(fèi)事件,默認(rèn)的它只有ACTION_DOWN這也就是我為什么上面的setOnTouchListener()事件中只能拿到ACTION_DOWN,那怎么才能讓TextView自己消費(fèi)事件呢?

查看TextView發(fā)現(xiàn)它沒有重寫dispatchTouchEvent,那就去它的父類View去找,果然在里面找到了。怎么能才能讓View去消費(fèi)事件呢?最后去看這個(gè)View源碼真的是看的我身體不適,渾身難受,具體過程我就不吐槽了,最后發(fā)現(xiàn)View的方法setClickable(),默認(rèn)像TextView這些控件的isClickable()方法是返回false的,也就是不會(huì)去處理ACTION_MOVE,ACTION_UP事件,換句話說就是不去消費(fèi)這個(gè)事件。知道這個(gè)就簡單了啊,我們只需要一行代碼就可以解決這個(gè)問題了

mChildView.setClickable(true);

RefreshLayout中拿到ChildView的時(shí)候設(shè)置他去消費(fèi)這個(gè)事件,那么我們下拉刷新控件里面就能拿到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

其實(shí)下拉刷新也沒有那么難嘛!

總結(jié)

ACTION_MOVE,ACTION_UP事件它們的傳遞過程如下

  • 在哪個(gè)View的 onTouchEvent 返回true,或者dispatchTouchEvent返回true,那么ACTION_MOVEACTION_UP的事件從上往下傳到這個(gè)View后就不再往下傳遞了,當(dāng)然父View也將收到。在 onTouchEvent中消費(fèi)那么直接傳給自己和父View的dispatchTouchEventonTouchEvent。在 dispatchTouchEvent消費(fèi)就只傳遞給dispatchTouchEvent和父View的dispatchTouchEvent,onTouchEvent。并結(jié)束本次事件傳遞過程。 如果沒有任何View消費(fèi)事件那么ACTION_MOVE,ACTION_UP將不會(huì)向下傳遞 。

ACTION_DOWN 事件則是遵循文首中的圖片傳遞流程,事件走到哪那么ACTION_DOWN就會(huì)傳遞到哪。

最后希望大家都有這種“搞事”的精神,不懂就問,看資料。不行就試,一次不行兩次三次。知行合一!有什么不對的地方希望大家多指教。

The End!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容