自定義View實現上拉/下拉刷新

1 概述

最近在做項目的時候,需要實現列表的下拉刷新和上拉加載更多的功能,由于項目周期問題,下拉刷新就直接使用了系統提供的SwipeRefreshLayout類,但是SwipeRefreshLayout的實現效果真的是太low了而且無法達到視覺工程師的要求;上拉加載更多則通過在列表的最后添加一個提示上拉加載的item來實現,雖然實現效果達到了視覺工程師的要求但是會使列表的實現變得復雜。最近又被一個同學問起上面的功能有沒有簡單的實現方式,趁著現在閑暇就通過自定義View(RefreshLayout)實現了上面的功能。

2 RefreshLayout的使用

首先看一下實現效果:


圖1

下圖是對上圖中的下拉刷新和上拉加載更多的流程的概括:


圖2

上圖中的列表是通過RecyclerView實現的,實現該列表的下拉刷新和上拉加載更多的功能是通過在RecyclerView之上嵌套我自定義的RefreshLayout實現的,使用RefreshLayout的代碼如下:

布局activity_test_refresh.xml:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <com.cytmxk.customview.refresh.RefreshLayout
        android:id="@+id/refreshlayout_test"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <android.support.v7.widget.RecyclerView
            android:id="@+id/recyclerview_test_refresh"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"/>

    </com.cytmxk.customview.refresh.RefreshLayout>

</LinearLayout>

public class TestRefreshActivity extends AppCompatActivity implements RefreshLayout.OnRefreshStatusListener {

    private RefreshLayout testRefreshLayout;
    private RecyclerView testRefreshRV;
    private MyAdapter adapter;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_test_refresh);
        testRefreshLayout = (RefreshLayout) findViewById(R.id.refreshlayout_test);
        testRefreshLayout.setPullDownPromptLayout(new SamplePullDownPromptLayout(this));
        testRefreshLayout.setPullUpPromptLayout(new SamplePullUpPromptLayout(this));
        testRefreshLayout.setSupportPullUp(true);
        testRefreshLayout.setOnRefreshStatusListener(this);

        testRefreshRV = (RecyclerView) findViewById(R.id.recyclerview_test_refresh);
        testRefreshRV.setLayoutManager(new LinearLayoutManager(getApplicationContext(), LinearLayoutManager.VERTICAL, false));
        adapter = new MyAdapter();
        testRefreshRV.setAdapter(adapter);
    }

    @Override
    public void onPullDownRefresh() {
        testRefreshLayout.postDelayed(new Runnable() {
            @Override
            public void run() {
                adapter.temp = "人間正道是滄桑";
                adapter.notifyDataSetChanged();
                testRefreshLayout.refreshFinish(0);
            }
        }, 2000);
    }

    @Override
    public void onPullUpRefresh() {
        testRefreshLayout.postDelayed(new Runnable() {
            @Override
            public void run() {
                adapter.itemCount += 10;
                adapter.notifyDataSetChanged();
                testRefreshLayout.refreshFinish(1);
            }
        }, 2000);
    }

    public class MyAdapter extends RecyclerView.Adapter {

        private String temp = "天若有情天亦老";
        private int itemCount = 20;

        @Override
        public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
            return new MyViewHolder(new TextView(parent.getContext()));
        }

        @Override
        public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
            ((MyViewHolder)holder).update(temp + position);
        }

        @Override
        public int getItemCount() {
            return itemCount;
        }
    }

    public class MyViewHolder extends RecyclerView.ViewHolder {

        public MyViewHolder(View itemView) {
            super(itemView);
        }

        public void update(String data) {
            ((TextView)itemView).setTextSize(30);
            ((TextView)itemView).setText(data);
        }
    }
}

可以看到通過RefreshLayout實現RecyclerView下拉刷新和上拉加載更多的功能是很簡單的。

3 RefreshLayout的實現

首先通過下圖理解RefreshLayout的層次結構:


圖3

可以看到RefreshLayout是由三部分組成的(下拉刷新提示項、列表和上拉加載更多提示項)并且這三部分是垂直的線性布局,因此RefreshLayout直接就繼承LinearLayout;上圖是app運行的初始狀態,手機屏幕默認只會顯示列表部分,下拉刷新提示項和上拉加載更多提示項部分超出了屏幕的顯示區域,因此在RefreshLayout的onMeasure回調方法中我會將下拉刷新提示項部分向上移動該部分的高度,代碼如下:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    if (null != this.pullDownPromptLayout) {
        measureChildWithMargins(this.pullDownPromptLayout, widthMeasureSpec, 0, heightMeasureSpec, 0);
        MarginLayoutParams layoutParams = (MarginLayoutParams) this.pullDownPromptLayout.getLayoutParams();
        layoutParams.topMargin = -this.pullDownPromptLayout.getMeasuredHeight();
        RELEASE_TO_REFRESH_DOWN_HEIGHT = this.pullDownPromptLayout.getHeight();
    }
    if (null != this.pullUpPromptLayout) {
        measureChildWithMargins(this.pullUpPromptLayout, widthMeasureSpec, 0, heightMeasureSpec, 0);
        RELEASE_TO_REFRESH_UP_HEIGHT = this.pullUpPromptLayout.getMeasuredHeight();
    }
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}

上面代碼中pullDownPromptLayout代表下拉刷新提示項,pullUpPromptLayout代表上拉加載更多提示項,RELEASE_TO_REFRESH_DOWN_HEIGHT代表下拉刷新時可以釋放刷新的最低高度,RELEASE_TO_REFRESH_UP_HEIGHT代表上拉加載更多時可以釋放加載的最低高度,代碼很簡單不在贅敘了。

上面完成了RefreshLayout的布局,接下來就是要完成RefreshLayout的滑動,這就會涉及到事件傳遞和處理流程,大家可以參考Android中的事件傳遞與事件處理機制

當手指滑動屏幕時,產生的滑動事件要么傳遞給RefreshLayout處理,要么傳遞給RefreshLayout中的列表處理,根據圖2中可以得出在如下4種情況下滑動事件應該傳遞給RefreshLayout處理(即對滑動事件進行攔截),反之傳遞給RefreshLayout中的列表處理:
1> 當app處于圖2中第1幅圖的狀態、RefreshLayout中的列表不能向下滑動并且手指將要向下滑動
2> 當app處于圖2中第4幅圖的狀態并且手指將要向上滑動
3> 當app處于圖2中第6幅圖的狀態、RefreshLayout中的列表不能向上滑動并且手指將要向上滑動
4> 當app處于圖2中第9幅圖的狀態并且手指將要向下滑動
滑動事件是從外層向內層傳遞的,即滑動事件會先傳遞給RefreshLayout,如果RefreshLayout不攔截,就會傳遞給RefreshLayout中的列表,RefreshLayout繼承至LinearLayout而LinearLayout默認不會攔截滑動事件,因此要想將滑動事件傳遞給RefreshLayout就必須重寫onInterceptTouchEvent方法對滑動事件進行攔截,代碼如下:

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    boolean intercepted = false;
    int action = ev.getAction();
    float y = ev.getY();
    switch (action) {
        case MotionEvent.ACTION_DOWN: {
            lastY = lastYIntercept = y;
            intercepted = false;
            break;
        }
        case MotionEvent.ACTION_MOVE: {
            float offsetY = y - lastYIntercept;
            if (null == onRefreshStatusListener) {
                intercepted = false;
                break;
            }
            if (0 == getScrollY()) {
                intercepted = (offsetY >= 0 && !childScrollView.canScrollVertically(-1) && null!= this.pullDownPromptLayout && supportPullDown)
                        || (offsetY < 0 && !childScrollView.canScrollVertically(1) && null != this.pullUpPromptLayout && supportPullUp);
            } else if (getScrollY() < 0) {
                intercepted = (offsetY < 0);
            } else if (getScrollY() > 0) {
                intercepted = (offsetY > 0);
            }
            break;
        }
        case MotionEvent.ACTION_UP: {
            intercepted = false;
            break;
        }
    }
    lastYIntercept = y;
    return intercepted;
}

上面代碼中childScrollView代表RefreshLayout中的列表,在MotionEvent.ACTION_MOVE的case中,getScrollY()等于0代表RefreshLayout中的內容沒有發生滑動(即當前app處于圖2中第1、6幅圖的狀態),getScrollY()小于0代表RefreshLayout中的內容向下發生了滑動(即當前app處于圖2中第2、3、4幅圖的狀態),getScrollY()大于0代表RefreshLayout中的內容向上發生了滑動(即當前app處于圖2中第7、8、9幅圖的狀態);offsetY大于等于0表示手指向下滑動,反之手指向上滑動;childScrollView.canScrollVertically(-1)為true代表childScrollView可以向下滑動,反之不行,childScrollView.canScrollVertically(1)為true代表childScrollView可以向上滑動,反之不行;再來看一下上面的代碼,其實就是當滿足上面提到的滑動事件傳遞給RefreshLayout處理的4種情況的某一條,就對滑動事件進行攔截交由RefreshLayout處理,否則就傳遞給RefreshLayout中的列表處理。

上面完成了對滑動事件的傳遞,下面就來看看滑動事件是如何處理的,RefreshLayout繼承至LinearLayout而LinearLayout默認不會處理滑動事件,因此要處理滑動事件就必須重寫onTouchEvent方法對滑動事件進行處理,代碼如下:

// RefreshLayout中當前的刷新類型,要么是下拉刷新(0),要么是上拉刷新(1)
private int currentRefreshType = 0;
private float lastY;

@Override
public boolean onTouchEvent(MotionEvent event) {
    int action = event.getAction();
    float y = event.getY();
    switch (action) {
        case MotionEvent.ACTION_DOWN: {
            break;
        }
        case MotionEvent.ACTION_MOVE: {
            float offsetY = (y - lastY) / 2;
            if (null == onRefreshStatusListener) {
                break;
            }
            if (0 == getScrollY()) {
                if (offsetY >= 0) {
                    if (childScrollView.canScrollVertically(-1)) {
                        childScrollView.scrollBy(0, (int) -offsetY);
                    } else {
                        scrollBy(0, (int) -offsetY);
                        currentRefreshType = 0;
                    }
                } else {
                    if (childScrollView.canScrollVertically(1)) {
                        childScrollView.scrollBy(0, (int) -offsetY);
                    } else {
                        scrollBy(0, (int) -offsetY);
                        currentRefreshType = 1;
                    }
                }
            } else if (getScrollY() < 0) {
                if (offsetY >= 0) {
                    scrollBy(0, (int) -offsetY);
                    tryChangeRefreshStatus();
                } else {
                    if ((getScrollY() - offsetY) > 0) {
                        scrollTo(0, 0);
                        if (childScrollView.canScrollVertically(-1)) {
                            childScrollView.scrollBy(0, (int) (getScrollY() - offsetY));
                        }
                    } else {
                        scrollBy(0, (int) -offsetY);
                        tryChangeRefreshStatus();
                    }
                }
            } else if (getScrollY() > 0) {
                if (offsetY >= 0) {
                    if ((getScrollY() - offsetY) > 0) {
                        scrollBy(0, (int) -offsetY);
                        tryChangeRefreshStatus();
                    } else {
                        scrollTo(0, 0);
                        if (childScrollView.canScrollVertically(1)) {
                            childScrollView.scrollBy(0, (int) (getScrollY() - offsetY));
                        }
                    }
                } else {
                    scrollBy(0, (int) -offsetY);
                    tryChangeRefreshStatus();
                }
            }
            break;
        }
        case MotionEvent.ACTION_UP: {
            tryEnterRefreshStatus();
            break;
        }
    }
    lastY = y;
    return true;
}

處理滑動事件的過程也就是實現滑動的過程,當一個事件序列(關于事件序列大家可以參考Android中的事件傳遞與事件處理機制)滿足了上面提到的4種情況的一種,整個事件序列就都會被傳遞給上面代碼中的onTouchEvent方法處理,上面代碼中用到的scrollTo和scrollBy方法大家可以參考Android中實現滑動效果

上面代碼中主要針對ACTION_MOVE和ACTION_UP類型的滑動事件進行處理,因為ACTION_DOWN類型的滑動事件沒有攔截
1> 對于ACTION_UP類型的滑動事件,代表手指離開屏幕,此時當滑動的距離低于最低高度時就會關閉下拉刷新提示項或者上拉加載更多提示項,否則進入加載狀態,tryEnterRefreshStatus方法源碼:

private void tryEnterRefreshStatus() {
    int currentOffset = Math.abs(getScrollY());
    switch (currentRefreshType) {
        case 0: {
            if (currentOffset >= RELEASE_TO_REFRESH_DOWN_HEIGHT) {
                this.pullDownIRPL.changeRefreshStatus(IRefreshPromptLayout.RefreshStatus.REFRESHING);
                if (null != onRefreshStatusListener) {
                    onRefreshStatusListener.onPullDownRefresh();
                }
                scroller.startScroll(0, getScrollY(), 0, -getScrollY() - RELEASE_TO_REFRESH_DOWN_HEIGHT);
            } else {
                scroller.startScroll(0, getScrollY(), 0, -getScrollY());
            }
            invalidate();
            break;
        }
        case 1: {
            if (currentOffset >= RELEASE_TO_REFRESH_UP_HEIGHT) {
                this.pullUpIPRL.changeRefreshStatus(IRefreshPromptLayout.RefreshStatus.REFRESHING);
                if (null != onRefreshStatusListener) {
                    onRefreshStatusListener.onPullUpRefresh();
                }
                scroller.startScroll(0, getScrollY(), 0, -getScrollY() + RELEASE_TO_REFRESH_UP_HEIGHT);
            } else {
                scroller.startScroll(0, getScrollY(), 0, -getScrollY());
            }
            invalidate();
            break;
        }
    }
}

@Override
public void computeScroll() {
    super.computeScroll();
    if (scroller.computeScrollOffset()) {
        scrollTo(0, scroller.getCurrY());
        invalidate();
    }
}

上面代碼中用到的scroller大家可以參考Android中實現滑動效果

2> 對于ACTION_MOVE類型的滑動事件,代表手指在屏幕上移動,在ACTION_MOVE case中,首先通過getScrollY()、offsetY和childScrollView.canScrollVertically得出什么時候RefreshLayout的內容滑動,什么時候RefreshLayout中的列表(childScrollView)滑動,然后通過scrollBy或者scrollTo實現滑動,在滑動的時候,當滑動距離在最低距離上下波動時,下拉刷新提示項或者上拉加載更多提示項中的箭頭和提示語句就會發生變化,該變化就是通過tryChangeRefreshStatus方法實現的:

// 代表下拉刷新提示項
private IRefreshPromptLayout pullDownIRPL= null;
private View pullDownPromptLayout = null;

// 代表上拉加載更多提示項
private IRefreshPromptLayout pullUpIPRL = null;
private View pullUpPromptLayout = null;

public void setPullDownPromptLayout(IRefreshPromptLayout iRefreshPromptLayout) {
    if (null == iRefreshPromptLayout) {
        return;
    }
    if (null != this.pullDownPromptLayout) {
        this.removeView(this.pullDownPromptLayout);
    }
    this.pullDownIRPL = iRefreshPromptLayout;
    this.pullDownPromptLayout = iRefreshPromptLayout.getRefreshPromptLayout();
    this.addView(this.pullDownPromptLayout, 0);
}

public void setPullUpPromptLayout(IRefreshPromptLayout iRefreshPromptLayout) {
    if (null == iRefreshPromptLayout) {
        return;
    }
    if (null != this.pullUpPromptLayout) {
        this.removeView(this.pullUpPromptLayout);
    }
    this.pullUpIPRL = iRefreshPromptLayout;
    this.pullUpPromptLayout = iRefreshPromptLayout.getRefreshPromptLayout();
    this.addView(this.pullUpPromptLayout);
}

private void tryChangeRefreshStatus () {
    int currentOffset = Math.abs(getScrollY());
    switch (currentRefreshType) {
        case 0: {
            if (currentOffset >= RELEASE_TO_REFRESH_DOWN_HEIGHT) {
                this.pullDownIRPL.changeRefreshStatus(IRefreshPromptLayout.RefreshStatus.RELEASE_TO_REFRESH);
            } else {
                this.pullDownIRPL.changeRefreshStatus(IRefreshPromptLayout.RefreshStatus.PULL_TO_REFRESH);
            }
            break;
        }
        case 1: {
            if (currentOffset >= RELEASE_TO_REFRESH_UP_HEIGHT) {
                this.pullUpIPRL.changeRefreshStatus(IRefreshPromptLayout.RefreshStatus.RELEASE_TO_REFRESH);
            } else {
                this.pullUpIPRL.changeRefreshStatus(IRefreshPromptLayout.RefreshStatus.PULL_TO_REFRESH);
            }
            break;
        }
    }
}

上面代碼中的IRefreshPromptLayout是一個提供下拉刷新提示項或者上拉加載更多提示項的接口,如下所示:

public interface IRefreshPromptLayout {
    enum RefreshStatus {
        PULL_TO_REFRESH(0), // 表示下拉或者上拉可以刷新的狀態
        RELEASE_TO_REFRESH(1), // 表示釋放立即刷新的狀態
        REFRESHING(2);  // 代表正在刷新狀態

        private int value;

        RefreshStatus(int value) {
            this.value = value;
        }

        public int getValue() {
            return value;
        }

        public static RefreshStatus valueOf(int value) {
            RefreshStatus ret = PULL_TO_REFRESH;

            for (RefreshStatus refreshStatus : RefreshStatus.values()) {
                if (refreshStatus.getValue() == value) {
                    ret = refreshStatus;
                    break;
                }
            }

            return ret;
        }
    }

    void changeRefreshStatus(RefreshStatus refreshStatus);
    View getRefreshPromptLayout();
}
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,923評論 6 535
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,740評論 3 420
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,856評論 0 380
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,175評論 1 315
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,931評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,321評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,383評論 3 443
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,533評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,082評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,891評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,067評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,618評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,319評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,732評論 0 27
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,987評論 1 289
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,794評論 3 394
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,076評論 2 375

推薦閱讀更多精彩內容