1. 程式人生 > >RecyclerView 懸浮/粘性頭部——StickyHeaderDecoration

RecyclerView 懸浮/粘性頭部——StickyHeaderDecoration

前言

ItemDecoration是recyclerView拓展的一個很好工具,支援我們在recyclerView上面做各種操作,而且耦合性低,容易新增。這篇我們先用ItemDecoration來做懸浮/粘性頭部,後面還可以用ItemDecoration做時間軸,手機通訊錄聯絡人右側字母導航欄


老規矩,先上圖。


這裡寫圖片描述

整合方式

  • 注入依賴
    Step 1. Add the JitPack repository to your build file
    Step 2. Add the dependency
    allprojects {
        repositories {
            ...
maven { url 'https://jitpack.io' } } }
    dependencies {
       compile 'com.github.qdxxxx:StickyHeaderDecoration:1.0.1'
    }

Activity裡面整合程式碼

  • 分組頭部
        NormalDecoration decoration = new NormalDecoration() {
            @Override
            public String getHeaderName
(int pos) { return //返回每個分組頭部名稱; } };
  • 自定義頭部/懸浮頭部layout】【自定義頭部載入圖片請用 loadImage()方法】

        decoration.setOnDecorationHeadDraw(new NormalDecoration.OnDecorationHeadDraw() {
            @Override
            public View getHeaderView(int pos) {
                return //返回自定義頭部view;
            }
        });
  • 頭部點選事件

        decoration.setOnHeaderClickListener(new NormalDecoration.OnHeaderClickListener() {
            @Override
            public void headerClick(int pos) {
            }
        });

GridLayoutManager請配合GridDecoration使用。

方法及屬性介紹

name format 中文解釋
setHeaderHeight integer 分組頭部高度
setTextPaddingLeft integer 普通分組頭部【只含文字】文字左邊距
setTextSize integer 普通分組頭部【只含文字】文字大小
setTextColor integer 普通分組頭部【只含文字】文字顏色
setHeaderContentColor integer 普通分組頭部【只含文字】文字背景顏色
onDestory 清空資料集合/監聽等
*loadImage String,integer,ImageView 用來載入並重新整理圖片到分組頭部【自定義頭部很重要的方法!】

實現解刨

又要開始漫天程式碼的解刨了,非專業戰鬥人員…請務必耐著性子看。
首先我們來劃分幾個主要的功能模組

進擊的ItemDecoration

以下一個段落引用【帶心情去旅行】的簡書,寫的很具體。

先看下RecyclerView.ItemDecoration的原始碼(部分):

public static abstract class ItemDecoration {
    ...
    public void onDraw(Canvas c, RecyclerView parent, State state) {
        onDraw(c, parent);
    }
    public void onDrawOver(Canvas c, RecyclerView parent, State state) {
        onDrawOver(c, parent);
    }
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, State state) {
        getItemOffsets(outRect, ((LayoutParams) view.getLayoutParams()).getViewLayoutPosition(),
                parent);
    }
}

裡面是我們常用的三個方法:

  • getItemOffsets:通過Rect為每個Item設定偏移,用於繪製Decoration。
  • onDraw:通過該方法,在Canvas上繪製內容,在繪製Item之前呼叫。(如果沒有通過getItemOffsets設定偏移的話,Item的內容會將其覆蓋)
  • onDrawOver:通過該方法,在Canvas上繪製內容,在Item之後呼叫。(畫的內容會覆蓋在item的上層)

RecyclerView 的背景、onDraw繪製的內容、Item、onDrawOver繪製的內容,各層級關係如下:

這裡寫圖片描述

預留不同分組的頭部空間

我們為每個不同頭部名稱的第一個item設定頭部高度

根據上面的講解,我們用getItemOffsets()方法設定分組的item頭部,我們只要判斷當前item和上一個item是否屬於同一個group即可。

        /*我們為每個不同頭部名稱的第一個item設定頭部高度*/
        int pos = parent.getChildAdapterPosition(itemView); //獲取當前itemView的位置
        String curHeaderName = getHeaderName(pos);         //根據pos獲取分組頭部名

        if (pos == 0 || !curHeaderName.equals(getHeaderName(pos - 1))) {//如果當前位置為0,或者與上一個item頭部名不同的,都騰出頭部空間
            outRect.top = headerHeight;                                 //設定itemView PaddingTop的距離
        }

繪製不同分組頭部

onDrawOver()來繪製分組頭部,相當於繪製在item的介面之上(因為item已經設定了偏移)
和上述方法一樣,我們先獲得每個分組的位置,然後繪製文字即可(自定義layout亦是如此)

  • 我們先獲取當前螢幕所有recyclerView顯示的item
  • 如果頭部距離頂部==2*headerHeight時,懸浮頭部就要向上偏移(上推效果)
  • 頭部距離頂部==headerHeight時,懸浮頭部偏移headerHeight(推離螢幕效果)
    這裡寫圖片描述
        int childCount = recyclerView.getChildCount();//獲取螢幕上可見的item數量
        for (int i = 0; i < childCount; i++) {
            View childView = recyclerView.getChildAt(i);
            int pos = recyclerView.getChildAdapterPosition(childView); //獲取當前view在Adapter裡的pos
            String curHeaderName = getHeaderName(pos);                 //根據pos獲取要懸浮的頭部名
            int viewTop = childView.getTop() + recyclerView.getPaddingTop();
            if (pos == 0 || !curHeaderName.equals(getHeaderName(pos - 1))) {//如果當前位置為0,或者與上一個item頭部名不同的,都騰出頭部空間
                //繪製每個組頭【奧迪上頭的a(阿爾法羅密歐上頭就不用繪製a),本田上頭的b】

            canvas.drawRect(left, viewTop - headerHeight, right, viewTop, mHeaderContentPaint);//繪製頭部背景
            canvas.drawText(curHeaderName, left + textPaddingLeft, viewTop - headerHeight / 2 + txtYAxis, mHeaderTxtPaint);//繪製文字,文字的基線可以看我的自定義選單,有說到

                if (headerHeight < viewTop && viewTop <= 2 * headerHeight) { //此判斷是剛好2個頭部碰撞,懸浮頭部就要偏移
                    translateTop = viewTop - 2 * headerHeight;//懸浮頭部需要偏移的距離(y軸方向)
                }

stickyHeaderPosArray.put(pos, viewTop);//將頭部資訊放進array,【頭部點選處理有講解】
            }
        }

繪製懸浮頭部

通過上面的方法,我們就能繪製出每個分組的頭部。最後我們繪製一次懸浮的頭部

        canvas.save();
        canvas.translate(0, translateTop);
        canvas.drawRect(left, 0, right, headerHeight, mHeaderContentPaint);
        canvas.drawText(firstHeaderName, left + textPaddingLeft, headerHeight / 2 + txtYAxis, mHeaderTxtPaint);
//      canvas.drawLine(0, headerHeight / 2, right, headerHeight / 2, mHeaderTxtPaint);//畫條線看看文字居中不
        canvas.restore();

頭部點選處理

頭部點選這個一開始的確有點棘手,因為這個分組的頭部是我們額外繪製上的,就必須要通過自己的計算和儲存頭部資訊。
我們在繪製頭部的時候,通過SparseArray將頭部資訊儲存集合裡,但是每onDrawOver的時候都要clear一下,確保頭部資料正確。
最後通過GestureDetector來處理使用者觸控事件,根據使用者觸控的y軸位置來判斷SparseArray是否包含該位置。

        @Override//單擊事件
        public boolean onSingleTapUp(MotionEvent e) {
            for (int i = 0; i < stickyHeaderPosArray.size(); i++) {
                int value = stickyHeaderPosArray.valueAt(i);
                float y = e.getY();
                if (value - headerHeight <= y && y <= value) {//如果點選到分組頭
                    if (headerClickEvent != null) {
                        headerClickEvent.headerClick(stickyHeaderPosArray.keyAt(i));
                    }
                    return true;
                }
            }
            return false;
        }

自定義layout的頭部

繪製自定義layout的頭部有2個要點

  • 如何將layout佈局繪製到canvas上
  • 如果layout裡有圖片,圖片載入完成後需要通知canvas重新整理,以顯示頭部圖片(否則需要使用者滑動才能更新圖片)
繪製view到canvas

我們可以通過view.setDrawingCacheEnabled(true)方法,通過cache將view轉化為bitmap,在用headerView.getDrawingCache()獲取bitmap物件。

    View headerView = headerDrawEvent.getHeaderView(firstPos);
    headerView.measure(//measure佈局
            View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
            View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED));
    headerView.setDrawingCacheEnabled(true);
    headerView.layout(0, 0, right, headerHeight);//佈局layout
    canvas.drawBitmap(headerView.getDrawingCache(), left, 0, null);

但是如果view裡面包含圖片的話,圖片不太可能是我們事先儲存好的,而是通過網路請求獲得的圖片url,然後再載入。所以這也是一個難點之一。

通過url繪製圖片到頭部
  • 如果圖片暫未載入完成,通過Glide載入,載入完成後通過map集合來儲存圖片。
  • 圖片載入完成後用mRecyclerView.postInvalidate(),從而間接性的手動呼叫onDrawOver()方法,重新繪製已經載入好的圖片。
public void loadImage(final String url, final int pos, ImageView imageView) {
        if (imgDrawableMap.get(url) != null) {//如果圖片已經載入過了,並且已經儲存
            imageView.setImageDrawable(imgDrawableMap.get(url));
        } else {
            Glide.with(mRecyclerView.getContext()).load(url).into(new SimpleTarget<Drawable>() {
                @Override
                public void onResourceReady(Drawable resource, Transition<? super Drawable> transition) {

                    headViewMap.remove(pos);//刪除,重新更新
                    imgDrawableMap.put(url, resource);
                    mRecyclerView.postInvalidate();
                }
            });
        }

    }

更多詳細功能請移步NormalDecoration,並配合onDrawOver()解析。
所以自定義layout有圖片請務必使用loadImage()方法,以便及時講載入完的圖片繪製到介面上。




GridLayoutManager的適配

GridGridDecoration也有2個難點突破

  • 設定item的getItemOffsets,不僅僅是分組頭
  • 設定當前分組的最後一個item的Span.
  • 其它的就不需要我們設定了,normalDecoration已經幫我們完成了【自信回頭】

這裡寫圖片描述

public abstract class GridDecoration extends NormalDecoration {
    private int itemTotalCount;

    public GridDecoration(int itemTotalCount, int span) {
        this.itemTotalCount = itemTotalCount;
        for (int pos = 0; pos < itemTotalCount; pos++) {
            /*我們為每個不同頭部名稱的第一個item設定頭部高度*/
            String curHeaderName = getRealHeaderName(pos);         //根據j獲取要懸浮的頭部名
            if (!headerPaddingSet.contains(pos) && (pos == 0 || !curHeaderName.equals(getRealHeaderName(pos - 1)))) {//如果是分組頭部
                groupHeadPos.add(pos);
                for (int i = 0; i < span; i++) {
                    headerPaddingSet.add(pos + i);
                    if (!curHeaderName.equals(getRealHeaderName(pos + i + 1))) {//如果下一個分組名稱不一致,pass
                        break;
                    }
                }
            }
            if (!curHeaderName.equals(getRealHeaderName(pos + 1)) && groupHeadPos.size() > 0) {
                int preHeadPos = (int) ((TreeSet) (groupHeadPos)).last();
                int padSpan = span - (pos - preHeadPos) % span;
                headerSpanArray.put(pos, padSpan);
            }
        }
    }

    private Set<Integer> headerPaddingSet = new TreeSet<>();                //用來記錄每個頭部的paddintTop資訊
    private Set<Integer> groupHeadPos = new TreeSet<>();                    //記錄每個分組第一個頭部的pos【用於計算當前組最後一個item的span】
    private SparseArray<Integer> headerSpanArray = new SparseArray<>();     //用來記錄每個分組最後一個item的span
    private GridLayoutManager.SpanSizeLookup lookup;

    @Override
    public void getItemOffsets(Rect outRect, View itemView, RecyclerView parent, RecyclerView.State state) {
        super.getItemOffsets(outRect, itemView, parent, state);
        if (lookup == null) {
            lookup = new GridLayoutManager.SpanSizeLookup() {//相當於weight
                @Override
                public int getSpanSize(int position) {
                    int returnSpan = 1;
                    int index = headerSpanArray.indexOfKey(position);
                    if (index >= 0) {
                        returnSpan = headerSpanArray.valueAt(headerSpanArray.indexOfKey(position));   //設定itemView PaddingTop的距離
                    }

                    return returnSpan;
                }
            };
            final GridLayoutManager gridLayoutManager = (GridLayoutManager) parent.getLayoutManager();
            gridLayoutManager.setSpanSizeLookup(lookup);
        }


        /*我們為每個不同頭部名稱的第一個item設定頭部高度*/
        int pos = parent.getChildAdapterPosition(itemView); //獲取當前itemView的位置
        if (headerPaddingSet.contains(pos)) {
            outRect.top = headerHeight;   //設定itemView PaddingTop的距離
        }
    }

}

總結

至此我們的功能都已經描述結束,做了這個小功能的確收貨不少,比較多的耗時在GridDecoration的設計,因為不清楚能夠動態的設定Span,一開始是通過設定itemOffsets的paddingRight去計算的,然後還要計算下一個分組的頭部,各種問題,所以以後做功能時候先看看有沒有api可以操作的,這樣來的更方便和容易。最後附上github望小夥伴們多多點贊哈。有建議和意見還望在評論出提出~~


https://github.com/qdxxxx/StickyHeaderDecoration