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

LorraineMcG 8年前發布 | 25K 次閱讀 安卓開發 Android開發 移動開發

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

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

ImageView下拉刷新

  • 與ListView配合使用

ListView下拉刷新、上拉加載

  • 與RecyclerView配合使用

RecyclerView下拉刷新、上拉加載

  • 與SrcollView配合使用

SrcollView下拉刷新

  • 局部刷新(但想必這種需要實際應該還是不多的....)

作為局部View刷新

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

了,閑話就到這里了。現在正式切入正題,于此逐步簡單的記錄和總結一下實現這個自定義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;
    }

好了,差不多就是這樣了。再次說明這里主要旨在總結和分享一下個人對于此類需求的實現思路。當然大家可能會有更加優秀的實現方式,請多多指教。

 

 

 

來自:http://www.jianshu.com/p/d6a80e2c51dc

 

 本文由用戶 LorraineMcG 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!