1. 程式人生 > >自個兒寫Android的下拉重新整理/上拉載入控制元件

自個兒寫Android的下拉重新整理/上拉載入控制元件

前段時間自己寫了一個能夠“通用”的,支援下拉重新整理和上拉載入的自定義控制元件。可能現如今這已經不新鮮了,但有興趣的朋友還是可以一起來看看的。

  • 與通常的View配合使用(比如ImageView)

ImageView下拉重新整理

  • 與ListView配合使用

ListView下拉重新整理、上拉載入

  • 與RecyclerView配合使用

RecyclerView下拉重新整理、上拉載入

  • 與SrcollView配合使用

SrcollView下拉重新整理

  • 區域性重新整理(但想必這種需要實際應該還是不多的….)

作為區域性View重新整理

好啦,效果大概就是這樣。如果您看後覺得有一點興趣。那麼,以下是相關的資訊:

  • Gradle依賴:
    compile ‘me.rawnhwang.library:smart-refresh-layout:1.0.0-rc’

好了,閒話就到這裡了。現在正式切入正題,於此逐步簡單的記錄和總結一下實現這個自定義View的思路以及實現過程。

首先,我們分析一下:假設我們現在的需求是需要讓ListView支援下拉重新整理和上拉載入,那麼其實我們選擇去擴充套件系統自身的ListView是最好的。
但我們這裡的初衷是創造一個通用的Pullable的控制元件,也就是說它可以配合Android中各種View使用。所以,顯然我們需要的是一個ViewGroup。
那麼,既然有了思路就可以開動了:第一步我們先去建立我們自己的View,並讓其繼承自ViewGroup。例如就像下面這樣:

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

接下來,我們靜靜的思考一下所謂的下拉重新整理,上拉載入的本質何如。就會發現,其實歸根結底原理仍舊是“檢視的滾動”而已。
那麼,我們來分析下我們為什麼會這麼說呢?假設現在先在腦海中簡單構畫一下如下所示的這樣一個ViewGroup的結構圖:

假設上圖中藍色的部分就是螢幕區域,也就是我們想要呈現內容的區域(比如我們在這裡放一個ListView)。而我們的ViewGroup所需要做的工作就是:
為Content部分加上一個Header(頭檢視)與Footer(尾檢視),並且顯然Header的位置應該位於Content之上,同理Footer則位於其之下。

那麼,在這個基礎上,如果我們讓整個Viewgroup支援滾動,那麼就得以實現一種效果了,即:初始情況下,螢幕上將正常呈現我們的Content檢視。
與此同時:當我們上下滑動螢幕,那麼當滑動到Content檢視的頂部時,就會出現Header檢視;當滑動到Content的底部時,則會出現Footer檢視。

當然,這種紙上談兵式的原理性的東西,永遠都讓人感到無聊。所以,現在我們實際的來“兌換”一下我們目前為止談到的這種效果。看以下佈局檔案:

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

好的,現在我們就一起來看看,我們應該怎麼樣逐步完善PullableLayout讓它實現我們說到的效果。
首先,既然我們說到需要一個Header與Footer。那麼,我們就先來定義好這兩個東東的佈局。比如說,我們定義一個如下的Header佈局:

這個佈局還是非常簡單明瞭的。同樣的,Footer佈局的定義其實與Header是非常類似的,所以就不再貼一次程式碼了。
準備好Header與Footer佈局後,我們應該考慮的工作,就是怎麼把它們按照我們的需要給“放進”我們自己的PullableLayout當中了,其實這並不難。

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所有內容檢視之後
                child.layout(0, mLayoutContentHeight, child.getMeasuredWidth(), mLayoutContentHeight + child.getMeasuredHeight());
            } else { // 內容檢視根據定義(插入)順序,按由上到下的順序在垂直方向進行排列
                child.layout(0, mLayoutContentHeight, child.getMeasuredWidth(), mLayoutContentHeight + child.getMeasuredHeight());
                mLayoutContentHeight += child.getMeasuredHeight();
            }
        }
    }

以上的程式碼也並不複雜,核心的工作就是填充Header與Footer檢視,並且按需要進行測量和置位的工作。如果作為新手來說,值得注意的可能就是:

  • Header與Footer的addView()工作:如果放在Constructor中,那麼因為此時佈局檔案中的內容都還未進行裝載和填充,就可能會在後續的程式碼中因為某些程式碼邏輯出現意料之外的異常錯誤;而如果放在onMeasure,則會因為onMeasure的內部機制造成重複add。所以放在onFinishInflate算是一個比較合適的選擇。

  • 個人在這裡定義了一個變數mLayoutContentHeight用來記錄內容檢視部分的實際總高度。需要注意的是,要在onLayout開頭的地方將其置零,否則同樣會因為重複累加得到錯誤的結果。

現在,當我們執行程式,就會在螢幕上呈現一個寬高佔滿螢幕的圖片。目前看起來是與把ImageView放在其它常用的Layout中的效果是沒有區別的。

所以,顯然我們接下來要做的工作就是讓檢視能夠跟隨著我們的手指滾動起來。那麼,還有什麼好想的呢?自然就是覆寫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;
    }

我們看到現在似乎已經有點意思了,但其實顯然是遠遠不夠的。現在說穿了就只是一個支援滾動的檢視而已,看上去非常呆板,更別提下拉重新整理此類了。

那麼,我們想一下應該怎麼改進呢?有了,我們可以給每次的拉動設定一些相關資訊,比如“最大滾動距離,有效距離”等等。這是什麼意思呢?
打個比方:當拉動的距離超過了最大距離,我們就不允許檢視繼續滾動了;而當此次拉動的距離超過有效距離我們就認為這是一次有效的行為。
那麼現在我們先做點小改進,當拉動的距離超過有效距離,我們就將文字資訊改為“鬆開重新整理”,以提示使用者你現在鬆開手指就會執行重新整理的行為了。

            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;

這裡我們所做的改動實際就是:當進行下拉操作的時候,如果下拉距離已經達到header的一半高度,就不允許繼續下拉了。
同時來說,如果當我們的拉動行為超過了有效距離effectiveScrollY,就提示使用者可以“鬆開重新整理”了。同樣的,看看效果如何:

顯然,我們又向前邁進了小小的一步。但最終的效果依舊有些呆板。因為雖然提示了可以“鬆開重新整理”,但現在即使我們鬆開,也不會有任何效果。
鬆開手指卻沒有對應效果,顯然是因為我們還沒有在Action_Up的時候做對應的操作,那麼現在就來進一步的修改吧:

            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;

因為僅僅是為了說明原理,所以這一步的改動程式碼也非常的簡單。簡單來說就是:如果鬆開手指時,滑動的距離並未超過有效距離,我們就認為這並不是一次成功有效的重新整理行為,那麼讓view的位置變動恢復就行了。而如果手指離開時,已經滑動超過了有效驅離,則將view滑動到剛好能夠讓Header顯示出有效距離的部分的位置,來提示使用者正處於重新整理的狀態下。對應下面的效果圖就更容易理解我們所做的工作是什麼了:

讓人高興的是,到了這裡看上去效果就很不錯了。但雖然效果是有了,看上去像是在重新整理,實際卻沒有執行任何實際用於重新整理的操作。
所以說,顯然我們還需要提供一個回撥介面,讓client端在使用的時候能夠順利在合適的時機執行需要的操作(重新整理/載入)。

 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("繼續向下拉");
        tvPullHeader.setVisibility(View.VISIBLE);
    }
case MotionEvent.ACTION_UP:
if(Math.abs(getScrollY()) >= effectiveScrollY){
   // 省略之前的程式碼......

   // 執行回撥
   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,大功告成,現在我們在來看一看效果如何:

可以看到,到這裡我們就已經完全實現了“下拉重新整理”這一功能了。當然這裡只是為了演示原理的demo,所以很多程式碼都沒有那麼的追求嚴謹。
當然,這裡要總結的重點其實也只是個人的思路和實現原理而已。所以同理,只要理解了這種思路,“上拉載入”也同樣就能夠實現了,故不再贅述。

那麼,是不是到了這裡,我們就可以結束了呢?當然不是,因為之前我們說過需要讓我們的PullableLayout是通用的。而以目前來說:
我們絕大多數普通的常用控制元件,是能夠通用的。但是呢?對另一類以ListView,GridView,RecyclerView,ScrollView為代表的控制元件就不靈了。
顯然,這類控制元件與普通的View相比,最大的特點就是:它們自身就是支援滾動的。所以無法避免的,就會與我們的控制元件出現“滑動衝突”。

那麼,關於“滑動衝突”的解決方案,可以參考《Android開發藝術探索》,作者針對各種常見的滑動衝突都給出了非常實用的乾貨方案。
OK,這裡我們假設以ListView與我們自定義的Layout配合使用為例。那麼出現的滑動衝突就是,雙方都需要處理上下滑動的行為。
《Android開發藝術探索》中已經說過,這種衝突往往都可以從業務邏輯上找到突破口。那麼,我們來思考一下這個所謂的“突破口”:
顯然,如果我們的ListView需要下拉重新整理或者上拉載入,那麼重新整理行為的發生時機就是在ListView的內容已經到達最現有的最頂部時,再繼續下拉。
同理,載入的行為發生的時機就是內容已經到達最現有的最底部時,繼續上拉。所以,如此一分析,這個突破口就已經出現了:
以下拉行為為例,我們就應該在ListView未到達頂部的情況下,將滑動事件交給ListView處理。而如果已經到達頂部,就將事件攔截,自己處理

現在我們的思路已經明確了,接著要做的,自然就是將思路轉化到程式碼上面了。其實,所謂的“滑動衝突”的處理,最終實際就是迴歸到在ViewGroup的onInterceptTouchEvent方法上根據業務邏輯處理事件的攔截。對應我們這裡的需求來說,以ListView的下拉操作為例,就可以這樣做:

    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {

        boolean intercept = false;
        // 記錄此次觸控事件的y座標
        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是否已經到達內容最頂部(如果已經到達最頂部,就攔截事件,自己處理滑動)
                        if (adapterChild.getFirstVisiblePosition() == 0
                                || adapterChild.getChildAt(0).getTop() == 0) {
                            intercept = true;
                        }
                    }
                }

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

        mLastMoveY = y;
        return intercept;
    }

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