1. 程式人生 > >RecyclerView 粘性(懸浮)頭部

RecyclerView 粘性(懸浮)頭部

圖片來自於網路

感謝

【Android】RecyclerView:打造懸浮效果

RecyclerView分組懸浮列表

上圖來自於網路,上圖的列表中有一個懸浮的粘性頭部的效果,現在這種效果的需求比較常見了,像通訊錄,展示城市列表,還有一些諮詢類 App 分類時都會見到這種效果。如果用 ListView 來實現,可謂是十分麻煩,而且查了查相關資料並不多,如果用 RecyclerView 的話,實現這種效果簡直是分分鐘的事。

一、ItemDecoration

要實現這種效果,首先我們需要了解一個 RecyclerView 的內部類 ItemDecoration,之前我在學習 RecyclerView 有記錄過,

從零開始學習RecyclerView(三),重溫一下。

ItemDecoration 是一個抽象類,字面意思是 Item 的裝飾,我們可以通過內部的繪製方法繪製裝飾,它有三個需要實現的抽象方法(過時的方法不管):

onDraw() :該方法在 Canvas 上繪製內容作為 RecyclerView 的 Item 的裝飾,會在 Item 繪製之前繪製,也就是說,如果該 Decoration 沒有設定偏移的話,Item 的內容會覆蓋該 Decoration。

onDrawOver() :在 Canvas 上繪製內容作為 RecyclerView 的 Item 的裝飾,會在 Item 繪製之後繪製 ,也就是說,如果該 Decoration 沒有設定偏移的話,該 Decoration 會覆蓋 Item 的內容。

getItemOffsets() :為 Decoration 設定偏移。

首先我們寫一個 Decoration,只是一個簡單的分隔線效果,分隔線中間有文字:

public class StickyDecoration extends RecyclerView.ItemDecoration {
    private int mHeight;
    private Paint mPaint;
    private TextPaint mTextPaint;
    private Rect mTextBounds;

    public StickyDecoration() {
        mHeight = 100;
        mPaint = new Paint();
        mPaint.setAntiAlias(true);
        mPaint.setColor(Color.GRAY);
        mTextPaint = new TextPaint();
        mTextPaint.setAntiAlias(true);
        mTextPaint.setColor(Color.parseColor("#FF000000"));
        mTextPaint.setTextSize(48f);
        mTextBounds = new Rect();
    }

    /**
     * Description:在 Canvas 上繪製內容作為 RecyclerView 的 Item 的裝飾,會在 Item 繪製之前繪製
     * 也就是說,如果該 Decoration 沒有設定偏移的話,Item 的內容會覆蓋該 Decoration。
     * Date:2018/9/14
     */
    @Override
    public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDraw(c, parent, state);  //Decoration 的左邊位置
    }

    /**
     * Description:在 Canvas 上繪製內容作為 RecyclerView 的 Item 的裝飾,會在 Item 繪製之後繪製
     * 也就是說,如果該 Decoration 沒有設定偏移的話,該 Decoration 會覆蓋 Item 的內容。
     * Date:2018/9/14
     */
    @Override
    public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDrawOver(c, parent, state);
        String stickyHeaderName = "我是分隔線";
        int left = parent.getLeft();
        //Decoration 的右邊位置
        int right = parent.getRight();
        //獲取 RecyclerView 的 Item 數量
        int childCount = parent.getChildCount();
        for (int i = 0; i < childCount; i++) {
            View childView = parent.getChildAt(i);
            //Decoration 的底邊位置
            int bottom = childView.getTop();
            //Decoration 的頂邊位置
            int top = bottom - mHeight;
            c.drawRect(left, top, right, bottom, mPaint);

            //繪製文字
            mTextPaint.getTextBounds(stickyHeaderName, 0, stickyHeaderName.length(), mTextBounds);
            c.drawText(stickyHeaderName, left, bottom - mHeight / 2 + mTextBounds.height() / 2, mTextPaint);
        }
    }

    /**
     * Description:為 Decoration 設定偏移
     * Date:2018/9/14
     */
    @Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
        super.getItemOffsets(outRect, view, parent, state);
        //outRect 相當於 Item 的整體繪製區域,設定 left、top、right、bottom 相當於設定左上右下的內間距
        //如設定 outRect.top = 5 則相當於設定 paddingTop 為 5px。
        outRect.top = mHeight;
    }
}

效果是這樣:

現在就將這個分隔線一步一步實現成粘性頭部。

二、同一組顯示只顯示一個分隔線

什麼分隔線中的文字是寫死的,所以需要提供給外部一個可以設定每一個分隔線文字的方法,然後在繪製分隔線的時候判斷如果繪製的 position 的分割線的文字與上一個 position 的分隔線的文字一樣的話就不繪製。

    @Override
    public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDrawOver(c, parent, state);
        String previousStickyHeaderName = null;
        String currentStickyHeaderName = null;
        int left = parent.getLeft();
        //Decoration 的右邊位置
        int right = parent.getRight();
        //獲取 RecyclerView 的 Item 數量
        int childCount = parent.getChildCount();
        for (int i = 0; i < childCount; i++) {
            View childView = parent.getChildAt(i);
            //判斷上一個 position 粘性頭部的文字與當前 position 的粘性頭部文字是否相同,如果相同則跳過繪製
            int position = parent.getChildAdapterPosition(childView);
            currentStickyHeaderName = getStickyHeaderName(position);
            if (TextUtils.isEmpty(currentStickyHeaderName)) {
                continue;
            }
            if (position == 0) {
                //Decoration 的底邊位置
                int bottom = childView.getTop();
                //Decoration 的頂邊位置
                int top = bottom - mHeight;
                c.drawRect(left, top, right, bottom, mPaint);
                //繪製文字
                mTextPaint.getTextBounds(currentStickyHeaderName, 0, currentStickyHeaderName.length(), mTextBounds);
                c.drawText(currentStickyHeaderName, left, bottom - mHeight / 2 + mTextBounds.height() / 2, mTextPaint);
                continue;
            }
            previousStickyHeaderName = getStickyHeaderName(position - 1);
            if (!TextUtils.equals(previousStickyHeaderName, currentStickyHeaderName)) {
                //Decoration 的底邊位置
                int bottom = childView.getTop();
                //Decoration 的頂邊位置
                int top = bottom - mHeight;
                c.drawRect(left, top, right, bottom, mPaint);
                //繪製文字
                mTextPaint.getTextBounds(currentStickyHeaderName, 0, currentStickyHeaderName.length(), mTextBounds);
                c.drawText(currentStickyHeaderName, left, bottom - mHeight / 2 + mTextBounds.height() / 2, mTextPaint);
            }
        }
    }

    /**
     * Description:為 Decoration 設定偏移
     * Date:2018/9/14
     */
    @Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
        super.getItemOffsets(outRect, view, parent, state);
        //outRect 相當於 Item 的整體繪製區域,設定 left、top、right、bottom 相當於設定左上右下的內間距
        //如設定 outRect.top = 5 則相當於設定 paddingTop 為 5px。
        int position = parent.getChildAdapterPosition(view);
        String stickyHeaderName = getStickyHeaderName(position);
        if (TextUtils.isEmpty(stickyHeaderName)) {
            return;
        }
        if (position == 0) {
            outRect.top = mHeight;
            return;
        }
        String previousStickyHeaderName = getStickyHeaderName(position - 1);
        if (!TextUtils.equals(stickyHeaderName, previousStickyHeaderName)) {
            outRect.top = mHeight;
        }
    }

    /**
     * author:MrQinshou
     * Description:提供給外部設定每一個 position 的粘性頭部的文字的方法
     * date:2018/10/14 22:14
     * param
     * return
     */
    public abstract String getStickyHeaderName(int position);

​​getItemOffsets() 與 onDrawOver() 中的判斷差不多,都是如果當前位置的 stickyHeaderName 為空,則不預留粘性頭部空間和不繪製粘性頭部,然後如果是第 0 個位置,則直接給空間和繪製,在之後的 position 的 Item,需要拿到上一個 position 的粘性頭部的文字與當前 position 的相比,如果不同才繪製。outRect.top 如果不給值的話,預設是 0。下面測試一下:

MainActivity 中就根據填充的資料來設定一下粘性頭部的文字:

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        final List<String> list = getList(120);
        RecyclerView rvTest = (RecyclerView) findViewById(R.id.rv_test);
        TestAdapter testAdapter = new TestAdapter();
        rvTest.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false));
        rvTest.addItemDecoration(new StickyDecoration() {
            @Override
            public String getStickyHeaderName(int position) {
                return list.get(position);
            }
        });
        rvTest.setAdapter(testAdapter);
        testAdapter.setDataList(list);
    }

    private List<String> getList(int size) {
        List<String> list = new ArrayList<>();
        for (int i = 0; i < 120; i++) {
            if (i < size / 3) {
                list.add("力量英雄");
            } else if (i < size / 3 * 2) {
                list.add("敏捷英雄");
            } else {
                list.add("智力英雄");
            }
        }
        return list;
    }
}

效果是這樣的:

三、同一組的一直顯示

接下來只需要將上面的同一組的頭部一直顯示在頂端,形成粘性效果,直到下一組的頭部滑動上來時,才慢慢替換掉上一個頭部,有一個推動效果。這個其實也很簡單,因為 RecyclerView 在滑動時一直在回撥 onDrawOver() 方法,所以我們該方法中繪製每一個 Item 的粘性頭部時不斷計算 Decoration 的位置,使其不會隨著 Item 的滑動一起往上移動,即一直處於 RecyclerView 的位置。

    @Override
    public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDrawOver(c, parent, state);
        String previousStickyHeaderName = null;
        String currentStickyHeaderName = null;
        int left = parent.getLeft();
        //Decoration 的右邊位置
        int right = parent.getRight();
        //獲取 RecyclerView 的 Item 數量
        int childCount = parent.getChildCount();
        for (int i = 0; i < childCount; i++) {
            View childView = parent.getChildAt(i);
            //判斷上一個 position 粘性頭部的文字與當前 position 的粘性頭部文字是否相同,如果相同則跳過繪製
            int position = parent.getChildAdapterPosition(childView);
            currentStickyHeaderName = getStickyHeaderName(position);
            if (TextUtils.isEmpty(currentStickyHeaderName)) {
                continue;
            }
            if (position == 0 || i == 0) {
                //Decoration 的底邊位置
                int bottom = Math.max(childView.getTop(), mHeight);
                //噹噹前 Decoration 的 Bottom 比下一個 View 的 Decoration 的 Top (即下一個 View 的 getTop() - mHeight)大時
                //就應該使當前 Decoration 的 Bottom 等於下一個 Decoration 的 Top,形成推動效果
                View nextChildView = parent.getChildAt(i + 1);
                String nextStickyHeaderName = getStickyHeaderName(position + 1);
                if (nextChildView != null && !TextUtils.equals(currentStickyHeaderName, nextStickyHeaderName) && bottom > (nextChildView.getTop() - mHeight)) {
                    bottom = nextChildView.getTop() - mHeight;
                }
                //Decoration 的頂邊位置
                int top = bottom - mHeight;
                c.drawRect(left, top, right, bottom, mPaint);
                //繪製文字
                mTextPaint.getTextBounds(currentStickyHeaderName, 0, currentStickyHeaderName.length(), mTextBounds);
                c.drawText(currentStickyHeaderName, left, bottom - mHeight / 2 + mTextBounds.height() / 2, mTextPaint);
                continue;
            }
            previousStickyHeaderName = getStickyHeaderName(position - 1);
            if (!TextUtils.equals(previousStickyHeaderName, currentStickyHeaderName)) {
                //Decoration 的底邊位置
                int bottom = Math.max(childView.getTop(), mHeight);
                //噹噹前 Decoration 的 Bottom 比下一個 View 的 Decoration 的 Top (即下一個 View 的 getTop() - mHeight)大時
                //就應該使當前 Decoration 的 Bottom 等於下一個 Decoration 的 Top,形成推動效果
                View nextChildView = parent.getChildAt(i + 1);
                String nextStickyHeaderName = getStickyHeaderName(position + 1);
                if (nextChildView != null && !TextUtils.equals(currentStickyHeaderName, nextStickyHeaderName) && bottom > (nextChildView.getTop() - mHeight)) {
                    bottom = nextChildView.getTop() - mHeight;
                }
                //Decoration 的頂邊位置
                int top = bottom - mHeight;
                c.drawRect(left, top, right, bottom, mPaint);
                //繪製文字
                mTextPaint.getTextBounds(currentStickyHeaderName, 0, currentStickyHeaderName.length(), mTextBounds);
                c.drawText(currentStickyHeaderName, left, bottom - mHeight / 2 + mTextBounds.height() / 2, mTextPaint);
            }
        }
    }

主要是 bottom 的計算,這樣就完成了粘性頭部的效果,而且下一組不同的頭部到來時,也會有一個推動的過渡效果,不會很生硬:

四、GridLayoutManager

上面的粘性頭部 Decoration 只適用 LinearLayoutManager,而且是垂直方向的 LinearLayoutManager,不過考慮到一般水平方向的 LayoutManager 對粘性頭部的需求很少,所以暫時沒去實現它。如果將上面的粘性頭部 Decoration 用在 GridLayoutManager 上會怎樣呢?

MainActivity 中將 LayoutManager 改為 spanCount 為 4 的 GridLayoutManager:

rvTest.setLayoutManager(new GridLayoutManager(this, 4));

可以看到有分隔線的那一行,除了第一個,其他的都往上”移“了。其實並不是 Item 往上移了,只是在 getItemOffsets() 中只給第一個 Item 設定了偏移,所以我們設定偏移的時候不是隻給 position==0 的 Item 設定了,而要給第一行,即 position<spanCount 的 Item 都設定偏移。在其他比較粘性頭部的文字是否相等的地方也不是和上一個 Item 比較了,而是和上一行的比較,說穿了也就是 position-spanCount 的那一個比較。

在 StickyDecoration 中給一個變數 spanCount,預設為 1,當外部使用的是 GridLayoutManager 時可以傳入 GridLayoutManager 的 spanCount 供我們計算 Decoration 的繪製,修改 onDrawOver() 和 getItemOffsets() 方法:

    @Override
    public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDrawOver(c, parent, state);
        String previousStickyHeaderName = null;
        String currentStickyHeaderName = null;
        int left = parent.getLeft();
        //Decoration 的右邊位置
        int right = parent.getRight();
        //獲取 RecyclerView 的 Item 數量
        int childCount = parent.getChildCount();
        for (int i = 0; i < childCount; i++) {
            View childView = parent.getChildAt(i);
            //判斷上一個 position 粘性頭部的文字與當前 position 的粘性頭部文字是否相同,如果相同則跳過繪製
            int position = parent.getChildAdapterPosition(childView);
            currentStickyHeaderName = getStickyHeaderName(position);
            if (TextUtils.isEmpty(currentStickyHeaderName)) {
                continue;
            }
            if (position < mSpanCount || i < mSpanCount) {
                //Decoration 的底邊位置
                int bottom = Math.max(childView.getTop(), mHeight);
                //噹噹前 Decoration 的 Bottom 比下一個 View 的 Decoration 的 Top (即下一個 View 的 getTop() - mHeight)大時
                //就應該使當前 Decoration 的 Bottom 等於下一個 Decoration 的 Top,形成推動效果
                View nextChildView = parent.getChildAt(i + mSpanCount);
                String nextStickyHeaderName = getStickyHeaderName(position + mSpanCount);
                if (nextChildView != null && !TextUtils.equals(currentStickyHeaderName, nextStickyHeaderName) && bottom > (nextChildView.getTop() - mHeight)) {
                    bottom = nextChildView.getTop() - mHeight;
                }
                //Decoration 的頂邊位置
                int top = bottom - mHeight;
                c.drawRect(left, top, right, bottom, mPaint);
                //繪製文字
                mTextPaint.getTextBounds(currentStickyHeaderName, 0, currentStickyHeaderName.length(), mTextBounds);
                c.drawText(currentStickyHeaderName, left, bottom - mHeight / 2 + mTextBounds.height() / 2, mTextPaint);
                continue;
            }
            previousStickyHeaderName = getStickyHeaderName(position - mSpanCount);
            if (!TextUtils.equals(previousStickyHeaderName, currentStickyHeaderName)) {
                //Decoration 的底邊位置
                int bottom = Math.max(childView.getTop(), mHeight);
                //噹噹前 Decoration 的 Bottom 比下一個 View 的 Decoration 的 Top (即下一個 View 的 getTop() - mHeight)大時
                //就應該使當前 Decoration 的 Bottom 等於下一個 Decoration 的 Top,形成推動效果
                View nextChildView = parent.getChildAt(i +  mSpanCount);
                String nextStickyHeaderName = getStickyHeaderName(position + mSpanCount);
                if (nextChildView != null && !TextUtils.equals(currentStickyHeaderName, nextStickyHeaderName) && bottom > (nextChildView.getTop() - mHeight)) {
                    bottom = nextChildView.getTop() - mHeight;
                }
                //Decoration 的頂邊位置
                int top = bottom - mHeight;
                c.drawRect(left, top, right, bottom, mPaint);
                //繪製文字
                mTextPaint.getTextBounds(currentStickyHeaderName, 0, currentStickyHeaderName.length(), mTextBounds);
                c.drawText(currentStickyHeaderName, left, bottom - mHeight / 2 + mTextBounds.height() / 2, mTextPaint);
            }
        }
    }

    @Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
        super.getItemOffsets(outRect, view, parent, state);
        //outRect 相當於 Item 的整體繪製區域,設定 left、top、right、bottom 相當於設定左上右下的內間距
        //如設定 outRect.top = 5 則相當於設定 paddingTop 為 5px。
        int position = parent.getChildAdapterPosition(view);
        String stickyHeaderName = getStickyHeaderName(position);
        if (TextUtils.isEmpty(stickyHeaderName)) {
            return;
        }
        if (position < mSpanCount) {
            outRect.top = mHeight;
            return;
        }
        String previousStickyHeaderName = getStickyHeaderName(position - mSpanCount);
        if (!TextUtils.equals(stickyHeaderName, previousStickyHeaderName)) {
            outRect.top = mHeight;
        }
    }

OK,這樣就可以適用於 GridLayoutManager 了,MainActivity 中修改一下測試程式碼:

        rvTest.setLayoutManager(new GridLayoutManager(this, 4));
        rvTest.addItemDecoration(new StickyDecoration(4) {
            @Override
            public String getStickyHeaderName(int position) {
                return list.get(position);
            }
        });

效果如下:

五、遺留問題

這樣其實還有一個問題,在 GridLayoutManager 中上面的測試資料都是每一組資料的個數都是 spanCount 的整數倍,如果不是整數倍的時候就會出現分隔線錯亂,這個情況暫時還沒有想到好的辦法解決,如有好的思路還望不吝賜教。

六、github 傳送門

完整 Demo