Android打造通用的下拉刷新組件

還記得上一篇 blog 的內容嗎?如果不記得建議先去了解一下,Android 事件處理全面剖析 ,因爲下拉刷新需要用到手勢的處理,而上一篇文章中,對事件處理做了很詳細的說明,瞭解了事件的處理機制,對理解本篇文章有很大的幫助。好了,這裏就當大家都已經對事件處理有了一定的瞭解,開始我們的下拉刷新徵程。

還是老規矩,先上效果圖,再根據效果圖來分析實現的原理;
這裏寫圖片描述

一 、分析原理
我們都知道,listView 控件爲我們提供了 addHeaderView、和 addFootView 的方法,我們通過此方法可以很方便的實現下拉刷新效果;但不是所有的控件都有 addHeaderView 方法,比如,scrollView、TextView 等都沒有addHeaderView 方法,所以這些控件就需要我們自己通過其他方式實現下拉刷新的效果,一個項目中,爲了通用性和複用性,往往也不會把 listView 控件單獨分離出來實現下拉刷新的效果,這時,就需要一個能對所有的控件達到通用的下拉刷新效果。
這裏很容易想到用自定義 ViewGroup 來實現,讓自定義的 ViewGroup 包含兩個控件,一個是下拉刷新的headerView、 另一個是需要展示數據的控件contentView,contentView可以是任何控件;headerView 和 contentView 垂直佈局,並且初始狀態讓 headerView 滾動到看不到的位置。基本的思路就是這樣,接下來就是對事件處理。

二、代碼實現
代碼實現可以分爲四個小點:
1、自定義 ViewGroup 的實現
在前面我的 blog 中有一篇寫的是關於自定義 View 的內容,Android自定義View,你必須知道的幾點 如果對這篇 blog 瞭解的同學相信對你來說,自定義 ViewGroup 也沒什麼難度,自定義 ViewGroup 相對自定義 View 還是較容易的。
自定義 ViewGroup 需要重寫的兩個方法是

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
protected void onLayout(boolean changed, int l, int t, int r, int b)

onMeasure方法相對較簡單,只需要對子 View 進行測量即可,這裏貼出onMeasure的代碼,註解也比較詳細。

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        /*獲取 ViewGroup 的寬度*/
        int width = MeasureSpec.getSize(widthMeasureSpec) ;
        /*獲取 ViewGroup 的高度*/
        int height = MeasureSpec.getSize(heightMeasureSpec) ;
        /*這裏不懂的同學可以去參考我前面寫的一篇blog 自定義View*/
        /*測量 refreshView 的寬高,這裏把高度設爲固定值*/
        measureChild(mHeaderView,widthMeasureSpec,MeasureSpec.makeMeasureSpec(mHeaderHeight ,MeasureSpec.EXACTLY));
        Log.v("zgy","==========mHeaderView============"+mHeaderView.getMeasuredHeight()) ;
        /*測量 mContentView 的寬高,高度爲最大值只能爲ViewGroup 的高度*/
        measureChild(mContentView, widthMeasureSpec, MeasureSpec.makeMeasureSpec(height, MeasureSpec.AT_MOST));
        mRefreshHeight = mTextView.getMeasuredHeight() ;
        /*千萬別忘記調用測量方法*/
        setMeasuredDimension(width,height);
    }

onLayout方法就是根據我們測量子 View 的寬高,來佈局子 View,前面我們分析原理的時候說到了,這裏需要用到垂直佈局,也就是先佈局 headerVeiw,再在 headerView 下面佈局 contentView,這裏 headerView 的隱藏操作也放在 onLayout 方法中,所以就得判斷是否是第一次,防止重複隱藏。具體實現代碼如下

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        /*佈局刷新的頭部 headerView*/
mHeaderView.layout(0,0,mHeaderView.getMeasuredWidth(),mHeaderView.getMeasuredHeight());
        /*佈局內容區域 contentView*/   mContentView.layout(0,mHeaderView.getMeasuredHeight(),mContentView.getMeasuredWidth(),             mHeaderView.getMeasuredHeight()+mContentView.getMeasuredHeight());
        if (isFirst){ 
            /*第一次把 headerView隱藏*/
            scrollTo(0,mHeaderView.getMeasuredHeight());
        }
        isFirst = false ;
    }

上面講到了兩個 View,一個是 headerView ,另一個是 contentView,講了這麼久,相信大家都會問,這兩個 View 從何而來?先來分析 headerView, headerView 它是一個固定的、不會變的。所以這裏我們可以直接通過 xml 來定義,然後再代碼中通過 addView 方法把 headerView 添加進去。
headerView xml 中的代碼

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
                android:layout_width="match_parent"
                android:layout_height="130dp"
                android:background="@mipmap/refresh_bg">

    <TextView
        android:id="@+id/id_txt_header"
        android:gravity="center"
        android:text="下拉可以刷新"
        android:layout_width="match_parent"
        android:layout_marginTop="70dp"
        android:layout_height="60dp"/>

    <ImageView
        android:id="@+id/id_anim_header"
        android:layout_width="match_parent"
        android:scaleType="centerCrop"
        android:layout_height="60dp"
        android:layout_marginTop="70dp"
        android:src="@drawable/refresh_anim"
        />
</RelativeLayout>

在初始化 ViewGroup 的時候調用 addView

        mHeaderView = mInflater.inflate(R.layout.refresh_header_view,null) ;
        mTextView = mHeaderView.findViewById(R.id.id_txt_header) ;
        mAnimView = (ImageView) mHeaderView.findViewById(R.id.id_anim_header);
        mAnimDrawable = (AnimationDrawable) mAnimView.getDrawable();
        mHeaderHeight = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,130,
                getResources().getDisplayMetrics()) ;
        addView(mHeaderView);

再來分析 contentView,我們知道 contentView 是變化的,根據不同的界面展示不同的 contentView,所以可以在界面的 xml 中通過 把需要展示的 View 放入自定義的容器 ViewGroup 中。

    <moon.pullrefresh.RefreshView
        android:layout_width="match_parent"
        android:layout_height="wrap_content">
        <ListView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:id="@+id/id_listview"></ListView>
    </moon.pullrefresh.RefreshView>

那麼問題來了,怎麼在自定義的 ViewGroup 中獲取 contentView 呢?可以自己先考慮下,我們都知道,ViewGroup 有這樣一個方法 addView;

    public void addView(View child) {
        addView(child, -1);
    }

    public void addView(View child, int index) {
        LayoutParams params = child.getLayoutParams();
        if (params == null) {
            params = generateDefaultLayoutParams();
            if (params == null) {
                throw new IllegalArgumentException("generateDefaultLayoutParams() cannot return null");
            }
        }
        addView(child, index, params);
    }

    public void addView(View child, int width, int height) {
        final LayoutParams params = generateDefaultLayoutParams();
        params.width = width;
        params.height = height;
        addView(child, -1, params);
    }

    public void addView(View child, LayoutParams params) {
        addView(child, -1, params);
    }

    public void addView(View child, int index, LayoutParams params) {
        requestLayout();
        invalidate(true);
        addViewInner(child, index, params, false);
    }

所以我們可以在 addView 的時候,獲取 contentView,但又有一個問題,參數個數不同,addView 的調用也不同,我們在add content的時候已經先 add 了一個 headerView,所以這裏肯定是調用含有一個 int index 參數的方法,再看 xml 中 viewGroup 包含

android:layout_width="match_parent"
android:layout_height="wrap_content"

所以可以斷定這裏調用的是

public void addView(View child, int index, LayoutParams params) {
        requestLayout();
        invalidate(true);
        addViewInner(child, index, params, false);
    }

所以我們可以通過重寫帶有三個參數的 addView 方法來獲取 contentView

    @Override
    public void addView(View child, int index, LayoutParams params) {
        mContentView = child ;
        /*
        * 這裏判斷是否是 listView 的 AdapterView
        * 如果是 scrollView,也需要在此判斷,
        * 這裏可以擴展任意的contentView
        *  這也是關鍵代碼之一
        * */
        if (mContentView instanceof AdapterView){
            mAdapter = (AdapterView)  mContentView;
        }
        super.addView(child, index, params);
    }

2、事件攔截的實現
通過上一篇blog我們知道了事件傳遞的順序,所以想要在 ViewGroup 中相應 onTouchEvent 事件則需要在 onInterceptTouchEvent中對事件進行攔截。 具體攔截代碼如下

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        Log.v("zgy","====onInterceptTouchEvent====");
        /*是否已經拖拽,也就是是否已經攔截的意思,如果還在攔截中,繼續攔截*/
        if (mIsBeginDrag){
            return true ;
        }
        if(ev.getAction() == MotionEvent.ACTION_DOWN){
            mDownY = (int) ev.getY();
        }
        if (ev.getAction() == MotionEvent.ACTION_MOVE){
            int currentY = (int) ev.getY();
            if (isIntercept(currentY-mDownY)){
                ev.setAction(MotionEvent.ACTION_DOWN);
                onTouchEvent(ev) ;
                requestDisallowInterceptTouchEvent(true);
                mIsBeginDrag = true ;
                return true ;
            }
        }
        return super.onInterceptTouchEvent(ev);
    }

具體判斷攔截操作是在isIntercept方法中,進入此方法看看

    private boolean isIntercept(int distance){
        if(distance > 0){
            Log.v("zgy","====mAdapter===="+mAdapter);
            if(mAdapter != null){
                Log.v("zgy","====mAdapter===="+mAdapter);
                View firstChild =  mAdapter.getChildAt(0);
                if(firstChild != null){
                    if (firstChild.getTop() == 0){
                        return true ;
                    }
                }
            }
        }
        return false ;
    }

代碼也很簡單,因爲這裏只處理了一種控件,爲了達到通用,則需要在此方法中加入判斷,判斷 contentView是否是 scrollView、TextView 等,根據不同的控件設置不同的攔截條件。

3、事件處理的實現
這裏爲了方便起見,我把事件轉化成了GestureDetector的 onTouchEvent 來處理,這裏面只要對一下幾個方法操作即可

    @Override
    public boolean onDown(MotionEvent e) {
        return false;
    }
    @Override
    public void onShowPress(MotionEvent e) {
    }
    @Override
    public boolean onSingleTapUp(MotionEvent e) {
        return false;
    }
    @Override
    public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
        return false;
    }
    @Override
    public void onLongPress(MotionEvent e) {
    }
    @Override
    public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
        return false;
    }

還是根據上一篇 blog 的知識可以知道,在onDown方法中,必須方法 true

    @Override
    public boolean onDown(MotionEvent e) {
         /*根據我前面所講的Android事件處理全面剖析可知,這裏應該返回true*/
        return true;
    }

然後就是對onScroll的處理

    @Override
    public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
        /*這裏是讓下拉的View 越拉越緊,給人的感覺時越要用力*/
        distanceY = distanceY *  (0.8f * (getScrollY() * 1.0f / mHeaderHeight));
        /*設置界限,滾動的距離不能低於0,也不能高於 headerView 的高度*/
        int scrollY = cling(0, mHeaderHeight, getScrollY()+(int) distanceY) ;
        Log.v("zgy","=======onScroll===="+distanceY+",scrollY=="+scrollY+",getScrollY()="+getScrollY());
        scrollTo(0,scrollY);
        /*如果達到了下拉刷新的界限,值改變下拉刷新的狀態*/
        if (scrollY < mHeaderHeight-mRefreshHeight){
            ((TextView)mTextView).setText("鬆開可以刷新");
            STATUS = STATUS_REFRESH ;
        }else{
            ((TextView)mTextView).setText("下拉可以刷新");
            STATUS = STATUS_HIDE ;
        }
        return true;
    }

在手指擡起的時候,需要釋放攔截事件,並且根據當前狀態來執行相應的操作,如果可以刷新則刷新,未達到刷新的條件這回復原位

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if(event.getAction() == MotionEvent.ACTION_UP||event.getAction() == MotionEvent.ACTION_CANCEL){
            mIsBeginDrag = false ;
            scrollNormal() ;
        }
        return mGesture.onTouchEvent(event);
    }
    private void scrollNormal(){
        if (STATUS == STATUS_REFRESH){
            STATUS = STATUS_HIDE ;
            int scroll = mHeaderHeight - mRefreshHeight -getScrollY() ;
            int currentDuration = (int) (mDuration*0.6f* scroll/(mHeaderHeight - mRefreshHeight));
            mScroller.startScroll(0,getScrollY(),0,scroll,currentDuration);
            /*測試*/
            postDelayed(new Runnable() {
                @Override
                public void run() {
                    stopRefresh() ;
                }
            },1000) ;
            if(mListener != null){
                mListener.onRefresh();
            }
            mAnimView.setVisibility(VISIBLE);
            mAnimDrawable.start();
            invalidate();
        }else if(STATUS == STATUS_HIDE){
            STATUS = STATUS_NORMAL ;
            int scroll = mHeaderHeight - getScrollY() ;
            int currentDuration = mDuration* scroll/mHeaderHeight ;
            mScroller.startScroll(0,getScrollY(),0,scroll,currentDuration);
            mAnimView.setVisibility(View.INVISIBLE);
            mAnimDrawable.stop();
            invalidate();
        }
    }

這裏還用到了mScroller.startScroll(0,getScrollY(),0,scroll,currentDuration);知識點,可以參考我的 blog Scroller 的運用案例(一)
4、定義刷新回調接口

    /**
     * 定義下拉刷新刷新回調接口
     */
    public interface OnRefreshListener{
        /**
         * 開始刷新
         */
        void onRefresh() ;
    }

在開始刷新的時候執行回調函數

       if (STATUS == STATUS_REFRESH){
            STATUS = STATUS_HIDE ;
            int scroll = mHeaderHeight - mRefreshHeight -getScrollY() ;
            int currentDuration = (int) (mDuration*0.6f* scroll/(mHeaderHeight - mRefreshHeight));
            mScroller.startScroll(0,getScrollY(),0,scroll,currentDuration);
            /*測試*/
            postDelayed(new Runnable() {
                @Override
                public void run() {
                    stopRefresh() ;
                }
            },1000) ;
            if(mListener != null){
                mListener.onRefresh();
            }
            mAnimView.setVisibility(VISIBLE);
            mAnimDrawable.start();
            invalidate();
        }

以上就是通用型下拉刷新的實現過程。

三、總結
我喜歡在寫blog 的後面加些總結,可以說是對本篇 blog 所涉及到的知識的一次鞏固、並對內容的提煉,從而對本篇 blog 有一個較深的理性認知,希望大家通過我的 blog 不單單能掌握blog 中所實現的內容,更應該掌握實現內容所用到的知識點,從而擴展到其他功能。
1、自定義 ViewGroup 的實現
a,重寫 onMeasure 方法,主要是測量子 View的大小
b、重寫 onLayout 方法,根據需求佈局子 View
2、事件攔截onInterceptTouchEvent,請參考Android 事件處理全面剖析
3、事件處理 onTouchEvent,請參考Android 事件處理全面剖析
4、GestureDetector類事件轉換
5、Scroller 的運用,請參考Scroller 的運用案例(一)

說好了是通用型的下拉刷新,但是好像沒實現啊,那麼再來看一張TextView 的下拉刷新效果,contentView 只是一個 TextView
先看 xml

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
                android:layout_width="match_parent"
                android:layout_height="match_parent">

    <moon.pullrefresh.RefreshView
    android:layout_width="match_parent"
    android:layout_height="wrap_content">
       <TextView
           android:gravity="center"
           android:layout_width="match_parent"
           android:layout_height="wrap_content"
           android:text="@string/hello_world"/>
    <!--<ListView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:id="@+id/id_listview"></ListView>-->
</moon.pullrefresh.RefreshView>
</RelativeLayout>

再修改 RefreshView

    private boolean isIntercept(int distance){
        if(distance > 0){
            Log.v("zgy","====mAdapter===="+mAdapter);
            if(mAdapter != null){
                Log.v("zgy","====mAdapter===="+mAdapter);
                View firstChild =  mAdapter.getChildAt(0);
                if(firstChild != null){
                    if (firstChild.getTop() == 0){
                        return true ;
                    }
                }
            }else {
                if (mContentView.getTop() == 0){
                    return true ;
                }
            }
        }
        return false ;
    }

只是在原來的基礎上加了兩句話

else {
      if (mContentView.getTop() == 0){
          return true ;
      }
}

效果圖
這裏寫圖片描述

源碼下載地址 Android打造通用的下拉刷新組件

發佈了46 篇原創文章 · 獲贊 9 · 訪問量 33萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章