1. 程式人生 > >android 打造真正的下拉重新整理上拉載入recyclerview(三):下拉重新整理上拉載入

android 打造真正的下拉重新整理上拉載入recyclerview(三):下拉重新整理上拉載入

前言

在開發過程中,我想了兩種方案。一是使用LinearLayout巢狀頭部、recyclerview、尾部的方式,如下圖:

第一種方案

  • 當recyclerview滑動到頂部時,移動LinearLayout露出頭部;
  • 當recyclerview滑動到底部時,移動LinearLayout露出尾部;

著名的PullToRefreshListView採用的就是這種方式。

但後來,我放棄了這個方案,為什麼呢?

因為多次嘗試對recyclerview內部的fling事件進行處理,總是達不到自己想要的效果,我想要的是:
比如當前正在重新整理,我向下fling RecyclerView,這時候RecyclerView向上滾動到頂部後,剩餘速度繼續露出RefreshHeader,而且我不喜歡每次都全露出來,而是要該露多少就露多少。簡單地說,就是我想要給人一種重新整理頭部就是隸屬於RecyclerView的、不存在斷層的感覺。

恩,懂我意思嗎?(剛剛怕表達不清楚,特地把同事叫來看他懂不懂)

總之,這種方案處理的效果我不滿意!那怎麼辦呢?重來吧,刪程式碼(心在滴血)。

於是有了第二種方案:給RecyclerView新增兩個頭部,分別是:用於造成下拉效果的輔助頭部、重新整理頭部;新增兩個尾部,分別是:載入尾部,用於造成上拉效果的輔助尾部。當滑動到頂部時,改變輔助頭部的高度,把其他item往下推,造成下拉的感覺;上拉同理。

我還是再畫個圖吧:

第二種方案

  • 在onLayout中,通過設定RecyclerView的margin,將頭部和尾部偏移出螢幕;
  • 輔助頭部:初始高度為1px;當RecyclerView滑動到頂部時,通過改變高度,造成下拉效果;
  • 輔助尾部:初始高度為1px;當RecyclerView滑動到底部時,通過改變高度,造成上拉的效果

思路就是這樣,但在實際的開發過程中,下拉還好,而上拉會遇到各種各樣的問題,不過好在解決了這些問題後,實際的效果完美符合我的要求,所以PTLRecyclerView採用了這個方案進行實現。

接下來我們來依次介紹下拉和上拉,以及開發過程中遇到的問題。

下拉重新整理

其實下拉重新整理是比較簡單的,PullToRefreshRecyclerView繼承於HeaderAndFooterRecyclerView,我們按順序來一一介紹PullToRefreshRecyclerView中的幾個主要方法:

  1. 首先介紹下全域性變數,免得看程式碼的時候吃力:
//    當前狀態
private int mState = STATE_DEFAULT;
//    初始
public final static int STATE_DEFAULT = 0;
//    正在下拉
public final static int STATE_PULLING = 1;
//    鬆手重新整理
public final static int STATE_RELEASE_TO_REFRESH = 2;
//    重新整理中
public final static int STATE_REFRESHING = 3;

//    下拉阻尼係數
private float mPullRatio = 0.5f;

//    輔助頭部
private View topView;

//    重新整理頭部
private View mRefreshView;
//    重新整理頭部的高度
private int mRefreshViewHeight = 0;

//    觸控事件輔助,當RecyclerView滑動到頂部時,記錄觸控事件的y軸座標
private float mFirstY = 0;
//    當前是否正在下拉
private boolean mPulling = false;

//    是否可以下拉重新整理
private boolean mRefreshEnable = true;

//    回彈動畫
private ValueAnimator valueAnimator;

//    重新整理監聽
private OnRefreshListener mOnRefreshListener;

//    重新整理頭部構造器
private RefreshHeaderCreator mRefreshHeaderCreator;
  1. 在建構函式中初始化,獲得預設的重新整理頭部:
private void init(Context context) {
    if (topView == null) {
        topView = new View(context);
//        該view的高度不能為0,否則將無法判斷是否已滑動到頂部
        topView.setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, 1));
//        設定預設LayoutManager
        setLayoutManager(new LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false));
//        初始化預設的重新整理頭部
        mRefreshHeaderCreator = new DefaultRefreshHeaderCreator();
        mRefreshView = mRefreshHeaderCreator.getRefreshView(context,this);
    }
}
  1. 在onLayout方法中,獲得重新整理頭部的高度,並偏移RecyclerView:
/**
 * 在measure的時候,隱藏重新整理頭部
 */
@Override
    protected void onMeasure(int widthSpec, int heightSpec) {
        if (mRefreshView != null && mRefreshViewHeight == 0) {
            mRefreshView.measure(0,0);
            mRefreshViewHeight = mRefreshView.getLayoutParams().height;
            ViewGroup.MarginLayoutParams marginLayoutParams = (ViewGroup.MarginLayoutParams) getLayoutParams();
            marginLayoutParams.setMargins(marginLayoutParams.leftMargin, marginLayoutParams.topMargin-mRefreshViewHeight-1, marginLayoutParams.rightMargin, marginLayoutParams.bottomMargin);
            setLayoutParams(marginLayoutParams);
        }
        super.onMeasure(widthSpec, heightSpec);
    }
  1. 觸控事件:
@Override
public boolean onTouchEvent(MotionEvent e) {
//    若是不可以下拉
    if (!mRefreshEnable) return super.onTouchEvent(e);
//    若重新整理頭部為空,不處理
    if (mRefreshView == null)
        return super.onTouchEvent(e);
//    若回彈動畫正在進行,不處理
    if (valueAnimator != null && valueAnimator.isRunning())
        return super.onTouchEvent(e);

    switch (e.getAction()) {
        case MotionEvent.ACTION_MOVE:
            if (!mPulling) {
                if (isTop()) {
//                    當listview滑動到最頂部時,記錄當前y座標
                    mFirstY = e.getRawY();
                }
//                若listview沒有滑動到最頂部,不處理
                else
                    break;
            }
            float distance = (int) ((e.getRawY() - mFirstY)*mPullRatio);
//            若向上滑動(此時重新整理頭部已隱藏),不處理
            if (distance < 0) break;
            mPulling = true;
//            若重新整理中,距離需加上頭部的高度
            if (mState == STATE_REFRESHING) {
                distance += mRefreshViewHeight;
            }
//            下拉
            setState(distance);
            return true;
        case MotionEvent.ACTION_UP:
//            回彈
            replyPull();
            break;
    }
    return super.onTouchEvent(e);
}
  1. 判斷是否滑動到了頂部:
private boolean isTop() {
    return !ViewCompat.canScrollVertically(this, -1);
}
  1. 設定當前下拉狀態:
private void setState(float distance) {
//    重新整理中,狀態不變
    if (mState == STATE_REFRESHING) {
    }
    else if (distance == 0) {
        mState = STATE_DEFAULT;
    }
//    鬆手重新整理
    else if (distance >= mRefreshViewHeight) {
        int lastState = mState;
        mState = STATE_RELEASE_TO_REFRESH;
        if (mRefreshHeaderCreator != null)
            if (!mRefreshHeaderCreator.onReleaseToRefresh(distance,lastState))
                return;
    }
//    正在拖動
    else if (distance < mRefreshViewHeight) {
        int lastState = mState;
        mState = STATE_PULLING;
        if (mRefreshHeaderCreator != null)
            if (!mRefreshHeaderCreator.onStartPull(distance,lastState))
                return;
    }
//    開始下拉
    startPull(distance);
}

這裡可以看到,當頭部構造器的onStartPull和onReleaseToRefresh返回false時,便不再下拉,其實這裡也是為了應對類似“超過多少就不再下拉了”這種需求。

  1. 改變輔助頭部的高度,造成下拉的效果:
private void startPull(float distance) {
//        輔助頭部的高度不能為0,否則將無法判斷是否已滑動到頂部
    if (distance < 1)
        distance = 1;
    if (topView != null) {
        LayoutParams layoutParams = (LayoutParams) topView.getLayoutParams();
        layoutParams.height = (int) distance;
        topView.setLayoutParams(layoutParams);
    }
}
  1. 鬆手回彈,在這個方法中,我們需要判斷是直接重新整理,還是直接回彈到原來位置:
private void replyPull() {
    mPulling = false;
//    回彈位置
    float destinationY = 0;
//    判斷當前狀態
//    若是重新整理中,回彈
    if (mState == STATE_REFRESHING) {
        destinationY = mRefreshViewHeight;
    }
//    若是鬆手重新整理,重新整理,回彈
    else if (mState == STATE_RELEASE_TO_REFRESH) {
//        改變狀態
        mState = STATE_REFRESHING;
//        重新整理
        if (mRefreshHeaderCreator != null)
            mRefreshHeaderCreator.onStartRefreshing();
        if (mOnRefreshListener != null)
            mOnRefreshListener.onStartRefreshing();
//        若在onStartRefreshing中呼叫了completeRefresh方法,將不會滾回初始位置,因此這裡需加個判斷
        if (mState != STATE_REFRESHING) return;
        destinationY = mRefreshViewHeight;
    } else if (mState == STATE_DEFAULT || mState == STATE_PULLING) {
        mState = STATE_DEFAULT;
    }

    LayoutParams layoutParams = (RecyclerView.LayoutParams) topView.getLayoutParams();
    float distance = layoutParams.height;
    if (distance <= 0) return;

    valueAnimator = ObjectAnimator.ofFloat(distance, destinationY).setDuration((long) (distance * 0.5));
    valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            float nowDistance = (float) animation.getAnimatedValue();
            startPull(nowDistance);
        }
    });
    valueAnimator.start();
}
  1. 完成重新整理:
public void completeRefresh() {
    if (mRefreshHeaderCreator != null)
        mRefreshHeaderCreator.onStopRefresh();
    mState = STATE_DEFAULT;
    replyPull();
    mRealAdapter.notifyDataSetChanged();
}
  1. 在設定介面卡的時候,新增輔助頭部和重新整理頭部:
@Override
public void setAdapter(Adapter adapter) {
    super.setAdapter(adapter);
    if (mRefreshView != null) {
        addHeaderView(topView);
        addHeaderView(mRefreshView);
    }
}
  1. 設定自定義的頭部:
public void setRefreshViewCreator(RefreshHeaderCreator refreshHeaderCreator) {
    this.mRefreshHeaderCreator = refreshHeaderCreator;
    mRefreshView = refreshHeaderCreator.getRefreshView(getContext(),this);
//    若有介面卡,新增到頭部
    if (mAdapter != null) {
        addHeaderView(topView);
        addHeaderView(mRefreshView);
    }
    mRealAdapter.notifyDataSetChanged();
}

以上就是PullToRefreshRecyclerView主要的幾個方法了,介紹得算比較清楚吧,再加上程式碼中已經有註釋了,就不再累贅了。核心就一句話:攔截觸控事件,改變輔助頭部的高度。 就是這麼easy~~~~

上拉載入

本來上拉載入我想單獨用一篇文章來介紹的,但其實上拉載入的處理和下拉重新整理的處理邏輯是一致的,因此在這裡便一起介紹了吧,雙飛更開心呦客官~~

咳咳,說正經的,上面我們說過上拉載入會遇到各種問題,具體有哪些呢?

  1. 滑動到底部時,繼續上拉,改變輔助底部的高度造成上拉的效果,然後現實很骨感,你會發現(通過除錯或列印)輔助底部的高度是在改變,但RecyclerView中的item並沒有擠上去啊,根本就沒有上拉的效果出現。
  2. 當你新增FooterView的時候,發現你新增的FooterView居然跑到重新整理底部的下面去了,坑了個爹…..
  3. 哎,怎麼好像沒了,我記得碰到了很多問題呀…….

以下是我的解決方法:
1. 這個問題我實在沒想到什麼好辦法,因此用了最粗暴的方式:在改變高度後直接呼叫scrollToPosition滾動到最底部。這樣做有什麼後果呢?效率肯定是不高的,但為了效果,我可以忍….經過測試,StaggredLayoutManager不會有任何影響,效果溜溜噠。但是但是,LinearLayoutManager上拉時會出現卡頓的現象,這個怎麼忍!當然GridLayoutManager也會卡頓,畢竟他是LinearLayoutManager的兒子啊,遺傳病。為什麼呢?因為LinearLayoutManager對item的layout和StaggredLayoutManager的是不一樣的,既然StaggredLayoutManager沒問題,那麼我們用只有一列的StaggredLayoutManager替代LinearLayoutManager就是最粗暴的方法。當然,更好的方式是直接繼承LayoutManager寫一個自己的LinearLayoutManager,但由於時間和水平的限制,就……採用StaggredLayoutManager吧。這就是為什麼我之前說使用PullToLoadRecyclerView的時候,要用PTLLinearLayout和PTLGridLayoutManager。
2. 這個問題其實最好解決,繼承HeaderAndFooterAdapter寫一個PullToLoadAdapter就可以啦。

雖然解決方法比較坑爹,但不管黑貓還是白貓,能抓老鼠的就是好貓。當然,這麼說有點過分了,所以在這裡,希望有大牛有更好的方法,歡迎到github上提交您的程式碼,共同構建這個專案。

PullToLoadRecyclerView和PullToRefreshRecyclerView的程式碼邏輯其實基本一致,而PullToLoadAdapter的程式碼和HeaderAndFooterAdapter也比較像,因此這裡就不再展開了,有興趣的同學可以去github上把專案clone下來看看。

自定義的重新整理頭部和載入尾部

有沒有遇到過這種情況,當你辛辛苦苦找到一個需要的庫時,卻發現他的UI居然不支援自定義!摔!在實際開發中,產品和設計怎麼會允許你使用那個庫預設的UI設計,這是基本不可能的事。因此,支援自定義的重新整理頭部和載入尾部是非常非常重要的事!!

之前在介紹使用方法時,我們就已經介紹瞭如何使用自定義的重新整理頭部和載入尾部,而通過上面的程式碼,你應該也已經理解了RefreshHeaderCreator和LoadFooterCreator的工作方式。

其實就是使用這兩個抽象類,把重新整理頭部和載入尾部的UI與RecyclerView進行解耦,交給使用者自己去實現,專案中的預設重新整理頭部和載入尾部就是很好的例子,相信你看完應該就知道怎麼去構造自己的重新整理頭部和載入尾部了。

直接上DefaultRefreshHeaderCreator的程式碼:

public class DefaultRefreshHeaderCreator extends RefreshHeaderCreator {

    private View mRefreshView;
    private ImageView iv;
    private TextView tv;

    private int rotationDuration = 200;

    private int loadingDuration = 1000;
    private ValueAnimator ivAnim;

    @Override
    public boolean onStartPull(float distance,int lastState) {
        if (lastState == PullToRefreshRecyclerView.STATE_DEFAULT ) {
            iv.setImageResource(R.drawable.arrow_down);
            iv.setRotation(0f);
            tv.setText("下拉重新整理");
        } else if (lastState == PullToRefreshRecyclerView.STATE_RELEASE_TO_REFRESH) {
            startArrowAnim(0);
            tv.setText("下拉重新整理");
        }
        return true;
    }

    @Override
    public void onStopRefresh() {
        if (ivAnim != null) {
            ivAnim.cancel();
        }
    }

    @Override
    public boolean onReleaseToRefresh(float distance,int lastState) {
        if (lastState == PullToRefreshRecyclerView.STATE_DEFAULT ) {
            iv.setImageResource(R.drawable.arrow_down);
            iv.setRotation(-180f);
            tv.setText("鬆手立即重新整理");
        } else if (lastState == PullToRefreshRecyclerView.STATE_PULLING) {
            startArrowAnim(-180f);
            tv.setText("鬆手立即重新整理");
        }
        return true;
    }

    @Override
    public void onStartRefreshing() {
        iv.setImageResource(R.drawable.loading);
        startLoadingAnim();
        tv.setText("正在重新整理...");
    }

    @Override
    public View getRefreshView(Context context, RecyclerView recyclerView) {
        mRefreshView = LayoutInflater.from(context).inflate(R.layout.layout_ptr_ptl,recyclerView,false);
        iv = (ImageView) mRefreshView.findViewById(R.id.iv);
        tv = (TextView) mRefreshView.findViewById(R.id.tv);
        return mRefreshView;
    }

    private void startArrowAnim(float roration) {
        if (ivAnim != null) {
            ivAnim.cancel();
        }
        float startRotation = iv.getRotation();
        ivAnim = ObjectAnimator.ofFloat(startRotation,roration).setDuration(rotationDuration);
        ivAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                iv.setRotation((Float) animation.getAnimatedValue());
            }
        });
        ivAnim.start();
    }

    private void startLoadingAnim() {
        if (ivAnim != null) {
            ivAnim.cancel();
        }
        ivAnim = ObjectAnimator.ofFloat(0,360).setDuration(loadingDuration);
        ivAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                iv.setRotation((Float) animation.getAnimatedValue());
            }
        });
        ivAnim.setRepeatMode(ObjectAnimator.RESTART);
        ivAnim.setRepeatCount(ObjectAnimator.INFINITE);
        ivAnim.setInterpolator(new LinearInterpolator());
        ivAnim.start();
    }

}

系不繫很簡單?

照例上兩張用爛了的效果圖:

重新整理載入

Grid重新整理載入

Staggred重新整理載入

有意見或建議或疑問等等,歡迎提出~~