自個兒寫Android的下拉刷新/上拉加載控件

前段時間自己寫了一個能夠“通用”的,支持下拉刷新和上拉加載的自定義控件??赡墁F(xiàn)如今這已經(jīng)不新鮮了,但有興趣的朋友還是可以一起來看看的。

  • 與通常的View配合使用(比如ImageView)
ImageView下拉刷新
ImageView下拉刷新
  • 與ListView配合使用
ListView下拉刷新、上拉加載
ListView下拉刷新、上拉加載
  • 與RecyclerView配合使用
RecyclerView下拉刷新、上拉加載
RecyclerView下拉刷新、上拉加載
  • 與SrcollView配合使用
SrcollView下拉刷新
SrcollView下拉刷新
  • 局部刷新(但想必這種需要實(shí)際應(yīng)該還是不多的....)
作為局部View刷新
作為局部View刷新

好啦,效果大概就是這樣。如果您看后覺得有一點(diǎn)興趣。那么,以下是相關(guān)的信息:


好了,閑話就到這里了?,F(xiàn)在正式切入正題,于此逐步簡單的記錄和總結(jié)一下實(shí)現(xiàn)這個自定義View的思路以及實(shí)現(xiàn)過程。

首先,我們分析一下:假設(shè)我們現(xiàn)在的需求是需要讓ListView支持下拉刷新和上拉加載,那么其實(shí)我們選擇去擴(kuò)展系統(tǒng)自身的ListView是最好的。
但我們這里的初衷是創(chuàng)造一個通用的Pullable的控件,也就是說它可以配合Android中各種View使用。所以,顯然我們需要的是一個ViewGroup。
那么,既然有了思路就可以開動了:第一步我們先去創(chuàng)建我們自己的View,并讓其繼承自ViewGroup。例如就像下面這樣:

public class PullableLayout extends ViewGroup{

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

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

接下來,我們靜靜的思考一下所謂的下拉刷新,上拉加載的本質(zhì)何如。就會發(fā)現(xiàn),其實(shí)歸根結(jié)底原理仍舊是“視圖的滾動”而已。
那么,我們來分析下我們?yōu)槭裁磿@么說呢?假設(shè)現(xiàn)在先在腦海中簡單構(gòu)畫一下如下所示的這樣一個ViewGroup的結(jié)構(gòu)圖:

假設(shè)上圖中藍(lán)色的部分就是屏幕區(qū)域,也就是我們想要呈現(xiàn)內(nèi)容的區(qū)域(比如我們在這里放一個ListView)。而我們的ViewGroup所需要做的工作就是:
為Content部分加上一個Header(頭視圖)與Footer(尾視圖),并且顯然Header的位置應(yīng)該位于Content之上,同理Footer則位于其之下。

那么,在這個基礎(chǔ)上,如果我們讓整個Viewgroup支持滾動,那么就得以實(shí)現(xiàn)一種效果了,即:初始情況下,屏幕上將正常呈現(xiàn)我們的Content視圖。
與此同時:當(dāng)我們上下滑動屏幕,那么當(dāng)滑動到Content視圖的頂部時,就會出現(xiàn)Header視圖;當(dāng)滑動到Content的底部時,則會出現(xiàn)Footer視圖。

當(dāng)然,這種紙上談兵式的原理性的東西,永遠(yuǎn)都讓人感到無聊。所以,現(xiàn)在我們實(shí)際的來“兌換”一下我們目前為止談到的這種效果。看以下布局文件:

左邊的布局非常簡單和熟悉,就是顯示一個寬高填滿父窗口的ImageView。而在右邊我們則是把父布局替換成了我們自定義的PullableLayout。

好的,現(xiàn)在我們就一起來看看,我們應(yīng)該怎么樣逐步完善PullableLayout讓它實(shí)現(xiàn)我們說到的效果。
首先,既然我們說到需要一個Header與Footer。那么,我們就先來定義好這兩個東東的布局。比如說,我們定義一個如下的Header布局:

這個布局還是非常簡單明了的。同樣的,F(xiàn)ooter布局的定義其實(shí)與Header是非常類似的,所以就不再貼一次代碼了。
準(zhǔn)備好Header與Footer布局后,我們應(yīng)該考慮的工作,就是怎么把它們按照我們的需要給“放進(jìn)”我們自己的PullableLayout當(dāng)中了,其實(shí)這并不難。

private View mHeader,mFooter;

    public PullableLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
        mHeader = LayoutInflater.from(context).inflate(R.layout.header_pullable_layout,null);
        mFooter = LayoutInflater.from(context).inflate(R.layout.footer_pullable_layout,null);
    }

    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        // 看這里哦,親
        RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams
                (RelativeLayout.LayoutParams.MATCH_PARENT,RelativeLayout.LayoutParams.MATCH_PARENT);
        mHeader.setLayoutParams(params);
        mFooter.setLayoutParams(params);
        addView(mHeader);
        addView(mFooter);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        // 測量
        for (int i = 0; i < getChildCount(); i++){
            View child = getChildAt(i);
            measureChild(child,widthMeasureSpec,heightMeasureSpec);
        }
    }

    private int mLayoutContentHeight;
    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        mLayoutContentHeight = 0;
        // 置位
        for (int i = 0; i < getChildCount(); i++){
            View child = getChildAt(i);
            if (child == mHeader) { // 頭視圖隱藏在頂端
                child.layout(0, 0 - child.getMeasuredHeight(), child.getMeasuredWidth(), 0);
            } else if (child == mFooter) { // 尾視圖隱藏在layout所有內(nèi)容視圖之后
                child.layout(0, mLayoutContentHeight, child.getMeasuredWidth(), mLayoutContentHeight + child.getMeasuredHeight());
            } else { // 內(nèi)容視圖根據(jù)定義(插入)順序,按由上到下的順序在垂直方向進(jìn)行排列
                child.layout(0, mLayoutContentHeight, child.getMeasuredWidth(), mLayoutContentHeight + child.getMeasuredHeight());
                mLayoutContentHeight += child.getMeasuredHeight();
            }
        }
    }

以上的代碼也并不復(fù)雜,核心的工作就是填充Header與Footer視圖,并且按需要進(jìn)行測量和置位的工作。如果作為新手來說,值得注意的可能就是:

  • Header與Footer的addView()工作:如果放在Constructor中,那么因?yàn)榇藭r布局文件中的內(nèi)容都還未進(jìn)行裝載和填充,就可能會在后續(xù)的代碼中因?yàn)槟承┐a邏輯出現(xiàn)意料之外的異常錯誤;而如果放在onMeasure,則會因?yàn)閛nMeasure的內(nèi)部機(jī)制造成重復(fù)add。所以放在onFinishInflate算是一個比較合適的選擇。

  • 個人在這里定義了一個變量mLayoutContentHeight用來記錄內(nèi)容視圖部分的實(shí)際總高度。需要注意的是,要在onLayout開頭的地方將其置零,否則同樣會因?yàn)橹貜?fù)累加得到錯誤的結(jié)果。

現(xiàn)在,當(dāng)我們運(yùn)行程序,就會在屏幕上呈現(xiàn)一個寬高占滿屏幕的圖片。目前看起來是與把ImageView放在其它常用的Layout中的效果是沒有區(qū)別的。

所以,顯然我們接下來要做的工作就是讓視圖能夠跟隨著我們的手指滾動起來。那么,還有什么好想的呢?自然就是覆寫onTouchEvent了。

  private int mLastMoveY;
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int y = (int) event.getY();

        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:
                mLastMoveY = y;
                break;
            case MotionEvent.ACTION_MOVE:
                int dy = mLastMoveY - y;
                scrollBy(0, dy);
                break;
        }

        mLastMoveY = y;
        return true;
    }

我們看到現(xiàn)在似乎已經(jīng)有點(diǎn)意思了,但其實(shí)顯然是遠(yuǎn)遠(yuǎn)不夠的?,F(xiàn)在說穿了就只是一個支持滾動的視圖而已,看上去非常呆板,更別提下拉刷新此類了。

那么,我們想一下應(yīng)該怎么改進(jìn)呢?有了,我們可以給每次的拉動設(shè)置一些相關(guān)信息,比如“最大滾動距離,有效距離”等等。這是什么意思呢?
打個比方:當(dāng)拉動的距離超過了最大距離,我們就不允許視圖繼續(xù)滾動了;而當(dāng)此次拉動的距離超過有效距離我們就認(rèn)為這是一次有效的行為。
那么現(xiàn)在我們先做點(diǎn)小改進(jìn),當(dāng)拉動的距離超過有效距離,我們就將文字信息改為“松開刷新”,以提示用戶你現(xiàn)在松開手指就會執(zhí)行刷新的行為了。

            case MotionEvent.ACTION_MOVE:
                int dy = mLastMoveY - y;
                // dy < 0代表是針對下拉刷新的操作
                if(dy < 0) {
                    if(Math.abs(getScrollY()) <= mHeader.getMeasuredHeight() / 2) {
                        scrollBy(0, dy);
                        if(Math.abs(getScrollY()) >= effectiveScrollY){
                            tvPullHeader.setText("松開刷新");
                        }
                    }
                }
                break;

這里我們所做的改動實(shí)際就是:當(dāng)進(jìn)行下拉操作的時候,如果下拉距離已經(jīng)達(dá)到header的一半高度,就不允許繼續(xù)下拉了。
同時來說,如果當(dāng)我們的拉動行為超過了有效距離effectiveScrollY,就提示用戶可以“松開刷新”了。同樣的,看看效果如何:

顯然,我們又向前邁進(jìn)了小小的一步。但最終的效果依舊有些呆板。因?yàn)殡m然提示了可以“松開刷新”,但現(xiàn)在即使我們松開,也不會有任何效果。
松開手指卻沒有對應(yīng)效果,顯然是因?yàn)槲覀冞€沒有在Action_Up的時候做對應(yīng)的操作,那么現(xiàn)在就來進(jìn)一步的修改吧:

            case MotionEvent.ACTION_UP:
                if(Math.abs(getScrollY()) >= effectiveScrollY){
                    mLayoutScroller.startScroll(0, getScrollY(), 0, -(getScrollY() + effectiveScrollY));
                    tvPullHeader.setVisibility(View.GONE);
                    pbPullHeader.setVisibility(View.VISIBLE);
                }else{
                    mLayoutScroller.startScroll(0, getScrollY(), 0, -getScrollY());
                }
                break;

因?yàn)閮H僅是為了說明原理,所以這一步的改動代碼也非常的簡單。簡單來說就是:如果松開手指時,滑動的距離并未超過有效距離,我們就認(rèn)為這并不是一次成功有效的刷新行為,那么讓view的位置變動恢復(fù)就行了。而如果手指離開時,已經(jīng)滑動超過了有效驅(qū)離,則將view滑動到剛好能夠讓Header顯示出有效距離的部分的位置,來提示用戶正處于刷新的狀態(tài)下。對應(yīng)下面的效果圖就更容易理解我們所做的工作是什么了:

讓人高興的是,到了這里看上去效果就很不錯了。但雖然效果是有了,看上去像是在刷新,實(shí)際卻沒有執(zhí)行任何實(shí)際用于刷新的操作。
所以說,顯然我們還需要提供一個回調(diào)接口,讓client端在使用的時候能夠順利在合適的時機(jī)執(zhí)行需要的操作(刷新/加載)。

 public interface onRefreshListener{
        void onRefresh();
    }

    private onRefreshListener mRefreshListener;

    public void setRefreshListener(onRefreshListener listener){
        mRefreshListener = listener;
    }

    public void refreshDone(){
        mLayoutScroller.startScroll(0, getScrollY(), 0, -getScrollY());
        pbPullHeader.setVisibility(View.GONE);
        tvPullHeader.setText("繼續(xù)向下拉");
        tvPullHeader.setVisibility(View.VISIBLE);
    }

case MotionEvent.ACTION_UP:
if(Math.abs(getScrollY()) >= effectiveScrollY){
   // 省略之前的代碼......
                    
   // 執(zhí)行回調(diào)
   mRefreshListener.onRefresh();
}else{
   mLayoutScroller.startScroll(0, getScrollY(), 0, -getScrollY());
}
break;

public class MainActivity extends AppCompatActivity {
    private PullableLayout plMain;
    private ImageView iv;

    private Handler mHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            iv.setBackgroundResource(R.drawable.ace);
            plMain.refreshDone();
        }
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        iv = (ImageView) findViewById(R.id.iv);
        plMain = (PullableLayout) findViewById(R.id.pl_main);
        plMain.setRefreshListener(new PullableLayout.onRefreshListener() {
            @Override
            public void onRefresh() {
                 new Thread(new Runnable() {
                     @Override
                     public void run() {
                         try {
                             Thread.sleep(3000);
                         } catch (InterruptedException e) {
                             e.printStackTrace();
                         }

                         mHandler.sendEmptyMessage(0);
                     }
                 }).start();
            }
        });
    }
}

OK,大功告成,現(xiàn)在我們在來看一看效果如何:

可以看到,到這里我們就已經(jīng)完全實(shí)現(xiàn)了“下拉刷新”這一功能了。當(dāng)然這里只是為了演示原理的demo,所以很多代碼都沒有那么的追求嚴(yán)謹(jǐn)。
當(dāng)然,這里要總結(jié)的重點(diǎn)其實(shí)也只是個人的思路和實(shí)現(xiàn)原理而已。所以同理,只要理解了這種思路,“上拉加載”也同樣就能夠?qū)崿F(xiàn)了,故不再贅述。

那么,是不是到了這里,我們就可以結(jié)束了呢?當(dāng)然不是,因?yàn)橹拔覀冋f過需要讓我們的PullableLayout是通用的。而以目前來說:
我們絕大多數(shù)普通的常用控件,是能夠通用的。但是呢?對另一類以ListView,GridView,RecyclerView,ScrollView為代表的控件就不靈了。
顯然,這類控件與普通的View相比,最大的特點(diǎn)就是:它們自身就是支持滾動的。所以無法避免的,就會與我們的控件出現(xiàn)“滑動沖突”。

那么,關(guān)于“滑動沖突”的解決方案,可以參考《Android開發(fā)藝術(shù)探索》,作者針對各種常見的滑動沖突都給出了非常實(shí)用的干貨方案。
OK,這里我們假設(shè)以ListView與我們自定義的Layout配合使用為例。那么出現(xiàn)的滑動沖突就是,雙方都需要處理上下滑動的行為。
《Android開發(fā)藝術(shù)探索》中已經(jīng)說過,這種沖突往往都可以從業(yè)務(wù)邏輯上找到突破口。那么,我們來思考一下這個所謂的“突破口”:
顯然,如果我們的ListView需要下拉刷新或者上拉加載,那么刷新行為的發(fā)生時機(jī)就是在ListView的內(nèi)容已經(jīng)到達(dá)最現(xiàn)有的最頂部時,再繼續(xù)下拉。
同理,加載的行為發(fā)生的時機(jī)就是內(nèi)容已經(jīng)到達(dá)最現(xiàn)有的最底部時,繼續(xù)上拉。所以,如此一分析,這個突破口就已經(jīng)出現(xiàn)了:
以下拉行為為例,我們就應(yīng)該在ListView未到達(dá)頂部的情況下,將滑動事件交給ListView處理。而如果已經(jīng)到達(dá)頂部,就將事件攔截,自己處理

現(xiàn)在我們的思路已經(jīng)明確了,接著要做的,自然就是將思路轉(zhuǎn)化到代碼上面了。其實(shí),所謂的“滑動沖突”的處理,最終實(shí)際就是回歸到在ViewGroup的onInterceptTouchEvent方法上根據(jù)業(yè)務(wù)邏輯處理事件的攔截。對應(yīng)我們這里的需求來說,以ListView的下拉操作為例,就可以這樣做:

    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {

        boolean intercept = false;
        // 記錄此次觸摸事件的y坐標(biāo)
        int y = (int) event.getY();

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN: {
                intercept = false;
                break;
            }

            case MotionEvent.ACTION_MOVE: {
                if (y > mLastMoveY) { // 下滑操作
                    View child = getChildAt(0);
                    if (child instanceof AdapterView) {
                        AdapterView adapterChild = (AdapterView) child;
                        // 判斷AbsListView是否已經(jīng)到達(dá)內(nèi)容最頂部(如果已經(jīng)到達(dá)最頂部,就攔截事件,自己處理滑動)
                        if (adapterChild.getFirstVisiblePosition() == 0
                                || adapterChild.getChildAt(0).getTop() == 0) {
                            intercept = true;
                        }
                    }
                }

                break;
            }
            // Up事件
            case MotionEvent.ACTION_UP: {
                intercept = false;
                break;
            }
        }

        mLastMoveY = y;
        return intercept;
    }

好了,差不多就是這樣了。再次說明這里主要旨在總結(jié)和分享一下個人對于此類需求的實(shí)現(xiàn)思路。當(dāng)然大家可能會有更加優(yōu)秀的實(shí)現(xiàn)方式,請多多指教。
另外,也可能有朋友注意到在最初的演示圖中,使用了兩個比較有趣的Loading動畫。一個是下拉時的小幽靈,一個時上拉時的吃豆子的形象。
同樣再次申明:這兩種效果都來自Github上一位作者開源的庫:https://github.com/ldoublem/LoadingView,里面有很多有意思的Loading效果。
個人而言,對那個小幽靈的形象比較有興趣,所以也簡單研究了下作者的源碼。如果您也有興趣,那也可以看一看我之前寫的:用Canvas和屬性動畫造一只萌蠢的“小鬼”。

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

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

  • 本文算是對之前的一篇博文《自個兒寫Android的下拉刷新/上拉加載控件》的續(xù)章,如果有興趣了解更多的朋友可以先看...
    Machivellia閱讀 3,631評論 8 70
  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 178,741評論 25 709
  • 一、Android開發(fā)初體驗(yàn) 二、Android與MVC設(shè)計模式模型對象存儲著應(yīng)用的數(shù)據(jù)和業(yè)務(wù)邏輯。模型類通常用來...
    為夢想戰(zhàn)斗閱讀 1,064評論 0 3
  • 一級標(biāo)題 二級標(biāo)題 三級標(biāo)題 四級標(biāo)題 我很帥 很陽光 美死你 下邊是有序列表 顯示數(shù)字了 剛才是缺少回車鍵吧df...
    空閑自閉癥閱讀 269評論 0 1
  • 今天體檢。 當(dāng)然不收錢,所以從這點(diǎn)來說比北師的好。檢的項(xiàng)目也比較關(guān)鍵,尿樣血樣心電圖血壓等。沒有查視力太好了。抽血...
    GSES94閱讀 291評論 0 0

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